Ninth plugin out, third resurrection: switched off since 2026-08-13 with the permission reduced to api: [], so the server booted clean and every panel request 404'd. invoiceshelf/schema was also commented out of the drizzle barrel. Named `invoices`, not `invoiceshelf`. The appName drives the API prefix, the frontend route, the table prefix and the install key all at once, and before extraction those had drifted: the API was /api/invoiceshelf while the screen was /invoices. The URL is what a person sees and should name what the plugin shows rather than the daemon behind it — the permission key and label were already `invoices`, so the API prefix was the odd one out. Cost: three call sites, and renaming invoiceshelf_accounts to invoices_accounts to satisfy the table-prefix rule. That rename was free ONLY because the table existed on no server, which was checked before deciding. With rows in it, keeping the vendor name would have been the better trade: db:push sees a rename as a drop plus a create and this project has no migration mechanism. The sidecar keeps its Invoiceshelf-named functions deliberately — they name the upstream API it actually speaks. 91 palette classes converted, 52 of them in STATUS_TONE, which was already the single source of status colour but held raw palette strings. SENT and VIEWED now share `info`: the closed six-tone set has no second shade of "in flight, not yet actioned". Recorded in PLUGIN.md as a real loss.
465 lines
16 KiB
TypeScript
465 lines
16 KiB
TypeScript
// 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<Record<InvoicesSectionId, string>>;
|
|
|
|
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<string, Tone> = {
|
|
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<T> = { 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<string, string> | 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'];
|