// Domain vocabulary for /invoices — the Officer front end over the officer-invoiceshelf sidecar. // // Types are written against the LIVE 2.4.2 instance's actual responses, not the 3.0.0-alpha.1 checkout in // _references. Two things about those responses drive most of the odd choices here: // // 1. Money is an integer count of minor units (cents), and the API is inconsistent about whether it hands // it over as a number or a decimal string — `invoice.total` is 450000 but `customer.due_amount` is // "1830000". Hence the `Money` alias and `toMinor()` in format.ts. Never do arithmetic on these // without going through it. // 2. Every record embeds its whole company, including the role/ability list — a single invoice row carries // ~40KB of super-admin abilities nobody asked for. That is upstream's shape and we cannot change it, // so the types below deliberately declare `company?: unknown`: naming those fields would invite code // that reads them, and the interesting data is never in there. import type { Tone } from '@/components/Data/tone'; export const INVOICES_SECTIONS = [ { id: 'dashboard', label: 'Dashboard' }, { id: 'invoices', label: 'Invoices' }, { id: 'estimates', label: 'Estimates' }, { id: 'recurring', label: 'Recurring' }, { id: 'payments', label: 'Payments' }, { id: 'expenses', label: 'Expenses' }, { id: 'customers', label: 'Customers' }, { id: 'items', label: 'Items' }, { id: 'reports', label: 'Reports' }, { id: 'settings', label: 'Connection' }, ] as const; export type InvoicesSectionId = (typeof INVOICES_SECTIONS)[number]['id']; export const DEFAULT_INVOICES_SECTION: InvoicesSectionId = 'dashboard'; export const isInvoicesSection = (value: string | undefined): value is InvoicesSectionId => INVOICES_SECTIONS.some((s) => s.id === value); export const invoicesSectionPath = (id: InvoicesSectionId) => `/invoices/${id}`; /** * A record open inside a list section — `/invoices/invoices?selected=12`. `useInvoicesSection` reads * `selected` out of the query string, so this is the addressable form of "this row is open" and the * thing a row anchor points at. */ export const invoicesRecordPath = (section: InvoicesSectionId, id: number) => `${invoicesSectionPath(section)}?selected=${id}`; /** A list section filtered to one customer — `useListFilters` reads `customer`. */ export const invoicesCustomerPath = (section: InvoicesSectionId, customerId: number) => `${invoicesSectionPath(section)}?customer=${customerId}`; /** * The payment editor, opened blank against an invoice — `/invoices/payments?edit=new&pay=12`. * `InvoicesView` reads both params (`edit` → which editor, `pay` → which invoice it prefills from), so * "record a payment for this invoice" is a whole address and not a callback. */ export const invoicesNewPaymentPath = (invoiceId: number) => `${invoicesSectionPath('payments')}?edit=new&pay=${invoiceId}`; /** The sidecar resource slug behind each list section. `dashboard` and `reports` have none. */ export const SECTION_RESOURCE = { invoices: 'invoices', estimates: 'estimates', recurring: 'recurring-invoices', payments: 'payments', expenses: 'expenses', customers: 'customers', items: 'items', } as const satisfies Partial>; export type ListSectionId = keyof typeof SECTION_RESOURCE; export const isListSection = (id: InvoicesSectionId): id is ListSectionId => id in SECTION_RESOURCE; // ── money ──────────────────────────────────────────────────────────────────────────────────────── /** An integer count of minor units, which upstream may hand over as either a number or a decimal string. */ export type Money = number | string | null | undefined; export type Currency = { id: number; name: string; code: string; symbol: string; precision: number | string; thousand_separator: string; decimal_separator: string; /** true → "1.234,56 €"; false → "€1.234,56". */ swap_currency_symbol: boolean | number; }; // ── status vocabulary ──────────────────────────────────────────────────────────────────────────── // The real persisted statuses. DUE and OVERDUE are NOT among them — upstream computes those for filters // and badges (`overdue` is a derived boolean on the invoice, `DUE` is a filter-only token), so they appear // in the tone map and the filter vocabulary but never as a value you can write back. export type InvoiceStatus = 'DRAFT' | 'SENT' | 'VIEWED' | 'COMPLETED'; export type PaidStatus = 'UNPAID' | 'PARTIALLY_PAID' | 'PAID'; export type EstimateStatus = 'DRAFT' | 'SENT' | 'VIEWED' | 'EXPIRED' | 'ACCEPTED' | 'REJECTED'; export type RecurringStatus = 'ACTIVE' | 'ON_HOLD' | 'COMPLETED'; /** * The one place a status token becomes a colour. Every badge, tab and detail header reads it, so a status * cannot mean success in the list and warning in the preview. * * Mapped by MEANING onto the platform's closed six-tone set (`components/Data/tone.ts`), not hue-for-hue * onto upstream's palette. Two consequences worth knowing: * * - SENT and VIEWED both resolve to `info`. Upstream separates them (blue vs indigo); the tone set has no * sixth shade of "in flight, not yet actioned" and inventing one would put the plugin back on palette * classes. The words still differ in the badge — only the colour is shared. * - PARTIALLY_PAID is `busy` rather than `warning`, which keeps it distinct from UNPAID in the same * column and is what it actually means: payment underway. Likewise EXPIRED is `warning` (an offer that * lapsed and can be re-issued) where REJECTED is `danger` (declined outright). * * Unknown tokens are the caller's problem — `StatusBadge` falls back to `neutral`. */ export const STATUS_TONE: Record = { DRAFT: 'neutral', SENT: 'info', VIEWED: 'info', COMPLETED: 'success', DUE: 'warning', OVERDUE: 'danger', UNPAID: 'warning', PARTIALLY_PAID: 'busy', PAID: 'success', EXPIRED: 'warning', ACCEPTED: 'success', REJECTED: 'danger', ACTIVE: 'info', ON_HOLD: 'warning', }; /** Status filter vocabulary per list, in upstream's own order. */ export const INVOICE_STATUS_FILTERS = ['DRAFT', 'DUE', 'SENT', 'VIEWED', 'COMPLETED'] as const; export const INVOICE_PAID_FILTERS = ['UNPAID', 'PAID', 'PARTIALLY_PAID'] as const; export const ESTIMATE_STATUS_FILTERS = ['DRAFT', 'SENT', 'VIEWED', 'EXPIRED', 'ACCEPTED', 'REJECTED'] as const; export const RECURRING_STATUS_FILTERS = ['ACTIVE', 'ON_HOLD', 'COMPLETED'] as const; /** Tabs above each list — `null` is "All". Mirrors upstream's tab sets exactly. */ export const INVOICE_TABS = [ { id: null, label: 'All' }, { id: 'DRAFT', label: 'Draft' }, { id: 'SENT', label: 'Sent' }, { id: 'DUE', label: 'Due' }, ] as const; export const ESTIMATE_TABS = [ { id: null, label: 'All' }, { id: 'DRAFT', label: 'Draft' }, { id: 'SENT', label: 'Sent' }, ] as const; export const RECURRING_TABS = [ { id: null, label: 'All' }, { id: 'ACTIVE', label: 'Active' }, { id: 'ON_HOLD', label: 'On hold' }, ] as const; /** Upstream paginates every list at 10. Matched so page numbers line up between the two UIs. */ export const PAGE_SIZE = 10; export const humanStatus = (value: string | null | undefined): string => (value ?? '') .replace(/_/g, ' ') .toLowerCase() .replace(/^./, (c) => c.toUpperCase()); // ── records ────────────────────────────────────────────────────────────────────────────────────── export type Address = { id: number; name: string | null; address_street_1: string | null; address_street_2: string | null; city: string | null; state: string | null; zip: string | null; phone: string | null; country_id: number | null; country?: { id: number; code: string; name: string } | null; }; export type Customer = { id: number; name: string; email: string | null; phone: string | null; contact_name: string | null; company_name: string | null; website: string | null; prefix: string | null; tax_id: string | null; currency_id: number | null; enable_portal: boolean; due_amount: Money; formatted_created_at: string | null; created_at: string | null; billing?: Address | null; shipping?: Address | null; // Deliberately unnamed — see the header note about embedded company payloads. company?: unknown; }; export type Unit = { id: number; name: string }; export type TaxType = { id: number; name: string; percent: number | string; compound_tax: boolean | number }; export type Category = { id: number; name: string }; export type PaymentMethod = { id: number; name: string }; export type Item = { id: number; name: string; description: string | null; price: Money; unit_id: number | null; currency_id: number | null; tax_per_item: boolean; formatted_created_at: string | null; unit?: Unit | null; }; /** A line on an invoice or estimate. Same shape either way. */ export type DocumentItem = { id: number; item_id: number | null; name: string; description: string | null; quantity: number | string; price: Money; discount_type: 'fixed' | 'percentage'; discount: number | string; discount_val: Money; tax: Money; total: Money; unit_name: string | null; taxes?: DocumentTax[]; }; export type DocumentTax = { id?: number; tax_type_id: number | null; name?: string; percent: number | string; amount: Money; compound_tax?: boolean | number; }; /** Shared spine of invoices, estimates and recurring invoices — they differ only at the edges. */ type DocumentBase = { id: number; status: string; notes: string | null; reference_number: string | null; customer_id: number; currency_id: number; unique_hash: string; template_name: string | null; sub_total: Money; total: Money; tax: Money; discount: number | string; discount_type: 'fixed' | 'percentage'; discount_val: Money; tax_per_item: 'YES' | 'NO'; discount_per_item: 'YES' | 'NO'; sent: boolean; viewed: boolean; items: DocumentItem[]; taxes?: DocumentTax[]; customer?: Customer | null; formatted_created_at: string | null; }; export type Invoice = DocumentBase & { invoice_number: string; invoice_date: string; due_date: string | null; paid_status: PaidStatus; due_amount: Money; overdue: boolean; allow_edit: boolean; invoice_pdf_url: string | null; formatted_invoice_date: string | null; formatted_due_date: string | null; recurring_invoice_id: number | null; }; export type Estimate = DocumentBase & { estimate_number: string; estimate_date: string; expiry_date: string | null; estimate_pdf_url: string | null; formatted_estimate_date: string | null; formatted_expiry_date: string | null; /** Set once converted; the convert action is refused a second time. */ invoice_id?: number | null; }; export type RecurringInvoice = DocumentBase & { starts_at: string | null; next_invoice_at: string | null; /** A five-field cron expression, not a keyword — see FREQUENCY_PRESETS. */ frequency: string | null; limit_by: 'NONE' | 'COUNT' | 'DATE' | string | null; limit_count: number | null; limit_date: string | null; send_automatically: boolean; due_amount: Money; formatted_starts_at: string | null; formatted_next_invoice_at: string | null; formatted_limit_date: string | null; /** Present only once the schedule has generated something. */ invoices?: Invoice[]; }; /** * A recurring invoice's `frequency` is a raw cron expression, which is unreadable in a table column. * * These are upstream's own presets, cron string for cron string (`recurring-invoices/store.ts` → * `initFrequencies`), in its order — common business intervals first, then the short ones. A schedule * created outside these presets keeps a cron expression we do not recognise, and `frequencyLabel` shows * it verbatim rather than guessing. */ export const FREQUENCY_PRESETS = [ { cron: '0 0 * * 0', label: 'Every week' }, { cron: '0 0 */14 * *', label: 'Every 2 weeks' }, { cron: '0 0 1 * *', label: 'Every month' }, { cron: '0 0 1 */2 *', label: 'Every 2 months' }, { cron: '0 0 1 */3 *', label: 'Every quarter' }, { cron: '0 0 1 */6 *', label: 'Every 6 months' }, { cron: '0 0 1 1 *', label: 'Every year' }, { cron: '0 0 * * *', label: 'Every day' }, { cron: '0 5 */15 * *', label: 'Every 15 days' }, { cron: '0 * * * *', label: 'Every hour' }, { cron: '* * * * *', label: 'Every minute' }, ] as const; export const frequencyLabel = (cron: string | null | undefined): string => FREQUENCY_PRESETS.find((f) => f.cron === cron)?.label ?? cron ?? '—'; export type Payment = { id: number; payment_number: string; payment_date: string; amount: Money; notes: string | null; unique_hash: string; invoice_id: number | null; customer_id: number; payment_method_id: number | null; currency_id: number; transaction_id: string | null; payment_pdf_url: string | null; formatted_payment_date: string | null; formatted_created_at: string | null; customer?: Customer | null; invoice?: Invoice | null; payment_method?: PaymentMethod | null; }; export type Expense = { id: number; expense_date: string; expense_number: string | null; amount: Money; notes: string | null; expense_category_id: number | null; payment_method_id: number | null; customer_id: number | null; currency_id: number; /** Absolute, rendered from the instance's APP_URL — usable in a browser, not from this process. */ attachment_receipt_url: string | null; formatted_expense_date: string | null; formatted_created_at: string | null; /** `expense_category`, not `category` — the column is `expense_category_id` but the relation keeps the prefix. */ expense_category?: Category | null; customer?: Customer | null; payment_method?: PaymentMethod | null; }; // ── envelopes ──────────────────────────────────────────────────────────────────────────────────── export type PageMeta = { current_page: number; last_page: number; per_page: number; from: number | null; to: number | null; total: number; }; export type Paginated = { data: T[]; meta?: PageMeta }; export type Summary = { me: { id: number; name: string; email: string } | null; company: { id: number; name: string; unique_hash: string; address?: Address | null; logo?: string | null } | null; currency: Currency | null; settings: Record | null; dashboard: Dashboard | null; }; export type Dashboard = { total_amount_due: Money; total_customer_count: number; total_invoice_count: number; total_estimate_count: number; total_sales: Money; total_receipts: Money; total_expenses: Money; total_net_income: Money; recent_due_invoices: Invoice[]; recent_estimates: Estimate[]; chart_data: { months: string[]; invoice_totals: Money[]; expense_totals: Money[]; receipt_totals: Money[]; net_income_totals: Money[]; } | null; }; /** * `GET /customers/{id}/stats` — the customer plus a twelve-month chart. * * The chart keys are camelCase here (`invoiceTotals`) where the company dashboard's are snake_case * (`invoice_totals`). That is upstream's inconsistency, not a transcription slip; the two shapes cannot share * a type. */ export type CustomerStats = { data?: Customer; meta?: { chartData?: { months: string[]; invoiceTotals: Money[]; expenseTotals: Money[]; receiptTotals: Money[]; netProfits: Money[]; netProfit: Money; salesTotal: Money; totalReceipts: Money; totalExpenses: Money; }; }; }; export type Lookups = { customers: Customer[]; items: Item[]; units: Unit[]; taxTypes: TaxType[]; categories: Category[]; paymentMethods: PaymentMethod[]; currencies: Currency[]; }; export const REPORT_KINDS = [ { id: 'sales-customers', label: 'Sales by customer' }, { id: 'sales-items', label: 'Sales by item' }, { id: 'tax-summary', label: 'Tax summary' }, { id: 'profit-loss', label: 'Profit & loss' }, { id: 'expenses', label: 'Expenses' }, ] as const; export type ReportKind = (typeof REPORT_KINDS)[number]['id'];