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:
@@ -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; },
|
||||
|
||||
Reference in New Issue
Block a user