feat(accounting): re-categorize incoming invoices, note field, pending re-bill pool

Address three incoming-invoice issues:

1. Re-categorization: a categorized invoice can now be changed again (e.g.
   passthrough → company expense). New "Re-categorize" button pre-fills the
   triage modal from the existing disposition/customer/markup/note.
   categorizeInbound is re-runnable — it unwinds any prior re-bill line
   (removes the invoice line + recomputes totals) before applying the new
   disposition, and refuses (INVOICE_LOCKED) when the re-bill is on an
   already-issued invoice.

2. Note field: new `note` column (migration 132 — 126 is already on beta)
   captured in triage and shown in the read-only view.

3. Re-bill like hours: rebill/passthrough now persist customer_account_id.
   Per-event customers accumulate as PENDING items, surfaced in a new
   "Pending re-bills" card and bundled into one invoice via "Bill these"
   (mirrors unbilled-hours billing). Monthly/manual customers keep
   auto-consolidating onto their running draft. Passthrough (durchlaufend)
   can now also attach to a customer with optional markup.

Adds backend unit tests for buildInboundLineItem + isInvoiceMutable and
en/de translations (other locales fall back to English defaults).
This commit is contained in:
Luca
2026-06-18 12:23:13 +02:00
parent 8deb7e0741
commit 36a8e42f90
8 changed files with 553 additions and 76 deletions
@@ -35,6 +35,12 @@ export interface InboundDocument {
markupPercent: number | null;
markupFlatMinor: number | null;
billedInvoiceId: number | null;
/** Client a rebill/passthrough is attached to (migration 132). */
customerAccountId: number | null;
customerName: string | null;
customerEmail: string | null;
/** Free-text categorisation note. */
note: string | null;
supplierPaid: boolean;
supplierPaidAt: string | null;
supplierPaymentMethod: PaymentMethod | null;
@@ -79,6 +85,20 @@ export interface InvoiceExpensePayload {
markupFlatMinor?: number | null;
}
/** One customer with categorised-but-unbilled rebill/passthrough docs. */
export interface PendingRebillSummary {
customerAccountId: number;
companyName: string | null;
displayName: string | null;
firstName: string | null;
lastName: string | null;
email: string | null;
isPassive: boolean;
billingCadence: string | null;
itemCount: number;
openAmountMinor: number;
}
export interface ExpenseCategory { id: number; name: string; color: string | null; is_seed: boolean; display_order: number; }
export interface Paginated<T> { items: T[]; pagination: { page: number; pageSize: number; total: number; totalPages: number }; }
@@ -148,6 +168,10 @@ export const accountingService = {
async updateInbound(id: number, fields: Partial<InboundDocument>): Promise<InboundDocument> { const { data } = await api.patch(`/admin/expenses/inbound/${id}`, fields); return data.document; },
async categorizeInbound(id: number, payload: CategorizePayload): Promise<InboundDocument> { const { data } = await api.post(`/admin/expenses/inbound/${id}/categorize`, payload); return data.document; },
async rebillInbound(id: number, payload: CategorizePayload): Promise<{ document: InboundDocument; invoiceId: number }> { const { data } = await api.post(`/admin/expenses/inbound/${id}/rebill`, payload); return data; },
/** Per-event customers with pending (categorised, unbilled) re-bills. */
async listPendingRebills(): Promise<PendingRebillSummary[]> { const { data } = await api.get('/admin/expenses/inbound/pending-summary'); return data.items; },
/** Bundle one customer's pending re-bills into a single invoice. */
async billPendingRebills(customerAccountId: number): Promise<{ invoiceId: number; count: number }> { const { data } = await api.post('/admin/expenses/inbound/bill-pending', { customerAccountId }); return data; },
async markInboundPaid(id: number, payload: { paid: boolean; paidAt?: string; paymentMethod?: PaymentMethod; paymentReference?: string }): Promise<InboundDocument> { const { data } = await api.post(`/admin/expenses/inbound/${id}/supplier-payment`, payload); return data.document; },
async getInboundFileBlob(id: number): Promise<Blob> { const { data } = await api.get(`/admin/expenses/inbound/${id}/file`, { responseType: 'blob' }); return data; },
async getInboundPageBlob(id: number, page: number): Promise<Blob> { const { data } = await api.get(`/admin/expenses/inbound/${id}/page/${page}`, { responseType: 'blob' }); return data; },