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
+14 -2
View File
@@ -3520,6 +3520,7 @@
"saveCategorize": "Speichern",
"saveCategorizePay": "Speichern & als bezahlt markieren",
"categorize": "Kategorisieren",
"recategorize": "Neu kategorisieren",
"view": "Ansehen",
"empty": "Noch keine Dokumente — oben eines erfassen.",
"untitled": "Unbenanntes Dokument",
@@ -3551,7 +3552,10 @@
"eventId": "Event-ID (optional)",
"markup": "Zuschlag",
"reference": "Zahlungsreferenz",
"referenceHint": "QR-/ESR-Referenz oder Mitteilung"
"referenceHint": "QR-/ESR-Referenz oder Mitteilung",
"note": "Notiz",
"noteHint": "Interne Notiz zu dieser Rechnung (optional)",
"passthroughCustomerHint": "Optional — einen Kunden zuordnen, um diese durchlaufende Position weiterzuverrechnen; leer lassen, um sie nur auf das Event zu buchen."
}
},
"expenseStatus": {
@@ -3584,7 +3588,15 @@
"paid": "Bezahlt",
"paidToast": "Als bezahlt markiert.",
"categorizedToast": "Kategorisiert.",
"categorizedPaidToast": "Kategorisiert und als bezahlt markiert."
"categorizedPaidToast": "Kategorisiert und als bezahlt markiert.",
"pendingRebill": "Weiterverrechnung offen",
"pendingTitle": "Offene Weiterverrechnungen",
"pendingBody": "Kategorisierte Rechnungen, die auf die Weiterverrechnung warten. Posten eines Kunden zu einer Rechnung bündeln.",
"pendingCount": "{{count}} Posten",
"pendingCount_other": "{{count}} Posten",
"billPending": "Verrechnen",
"bundledToast": "{{count}} Weiterverrechnung zu einer Rechnung gebündelt.",
"bundledToast_other": "{{count}} Weiterverrechnungen zu einer Rechnung gebündelt."
},
"expense": {
"kind": "Art",
+14 -2
View File
@@ -3520,6 +3520,7 @@
"saveCategorize": "Save",
"saveCategorizePay": "Save & mark paid",
"categorize": "Categorize",
"recategorize": "Re-categorize",
"view": "View",
"empty": "No documents yet — capture one above.",
"untitled": "Untitled document",
@@ -3551,7 +3552,10 @@
"eventId": "Event ID (optional)",
"markup": "Markup",
"reference": "Payment reference",
"referenceHint": "QR / ESR reference or message"
"referenceHint": "QR / ESR reference or message",
"note": "Note",
"noteHint": "Internal note for this invoice (optional)",
"passthroughCustomerHint": "Optional — attach a client to re-bill this passthrough; leave empty to only book it to the event."
}
},
"expenseStatus": {
@@ -3584,7 +3588,15 @@
"paid": "Paid",
"paidToast": "Marked as paid.",
"categorizedToast": "Categorized.",
"categorizedPaidToast": "Categorized and marked paid."
"categorizedPaidToast": "Categorized and marked paid.",
"pendingRebill": "Pending re-bill",
"pendingTitle": "Pending re-bills",
"pendingBody": "Categorized invoices waiting to be re-billed. Bundle a clients items into one invoice.",
"pendingCount": "{{count}} item",
"pendingCount_other": "{{count}} items",
"billPending": "Bill these",
"bundledToast": "Bundled {{count}} re-bill into one invoice.",
"bundledToast_other": "Bundled {{count}} re-bills into one invoice."
},
"expense": {
"kind": "Type",
@@ -6,10 +6,11 @@
* client. PDFs are previewed as server-rasterised page images (never raw).
*/
import React, { useRef, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Camera, Upload, Inbox, X, Circle, Eye, RotateCcw } from 'lucide-react';
import { Camera, Upload, Inbox, X, Circle, Eye, RotateCcw, Send, Pencil } from 'lucide-react';
import { Button, Card, CardContent, Input, LocalizedDateInput, Loading } from '../../../components/common';
import { DecimalInput } from '../../../components/common/DecimalInput';
import { CustomerAccountPicker, type SelectedCustomer } from '../../../components/admin/CustomerAccountPicker';
@@ -150,10 +151,14 @@ const ViewModal: React.FC<{ doc: InboundDocument; onClose: () => void }> = ({ do
{field(t('accounting.inbox.field.total', 'Total'), doc.totalAmountMinor != null ? formatMoneyMinor(doc.totalAmountMinor, doc.currency || 'CHF') : null)}
{field(t('accounting.inbox.field.invoiceDate', 'Invoice date'), doc.invoiceDate ? format(doc.invoiceDate) : null)}
{field(t('accounting.inbox.field.disposition', 'Disposition'), doc.disposition ? t(`accounting.disposition.${doc.disposition}`, doc.disposition) : null)}
{field(t('accounting.inbox.status.label', 'Status'), t(`accounting.inbox.status.${doc.status}`, doc.status))}
{doc.customerName && field(t('accounting.inbox.field.customer', 'Client'), doc.customerName)}
{field(t('accounting.inbox.status.label', 'Status'), doc.customerAccountId && !doc.billedInvoiceId
? t('accounting.incoming.pendingRebill', 'Pending re-bill')
: t(`accounting.inbox.status.${doc.status}`, doc.status))}
{field(t('accounting.incoming.paid', 'Paid'), doc.supplierPaid
? (doc.supplierPaidAt ? format(doc.supplierPaidAt) : t('common.yes', 'Yes'))
: t('common.no', 'No'))}
{doc.note && field(t('accounting.inbox.field.note', 'Note'), doc.note)}
</div>
</div>
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
@@ -171,12 +176,21 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
const [currency, setCurrency] = useState(doc.currency || 'CHF');
const [invoiceDate, setInvoiceDate] = useState(doc.invoiceDate || '');
const [reference, setReference] = useState(doc.paymentReference || '');
const [disposition, setDisposition] = useState<Disposition>('eigener_aufwand');
const [categoryId, setCategoryId] = useState<number | undefined>(undefined);
const [eventId, setEventId] = useState<number | null>(null);
const [customer, setCustomer] = useState<SelectedCustomer[]>([]);
const [markupType, setMarkupType] = useState<MarkupType>('none');
const [markupValue, setMarkupValue] = useState<number>(NaN);
const [note, setNote] = useState(doc.note || '');
// Pre-fill from the existing disposition so a categorized invoice can be
// re-categorized (#1) — falls back to "company expense" for fresh docs.
const [disposition, setDisposition] = useState<Disposition>(doc.disposition || 'eigener_aufwand');
const [categoryId, setCategoryId] = useState<number | undefined>(doc.categoryId ?? undefined);
const [eventId, setEventId] = useState<number | null>(doc.eventId ?? null);
const [customer, setCustomer] = useState<SelectedCustomer[]>(
doc.customerAccountId ? [{ id: doc.customerAccountId, email: doc.customerEmail || '', displayName: doc.customerName }] : [],
);
const [markupType, setMarkupType] = useState<MarkupType>(doc.markupType || 'none');
const [markupValue, setMarkupValue] = useState<number>(
doc.markupType === 'percent' && doc.markupPercent != null ? doc.markupPercent
: doc.markupType === 'flat' && doc.markupFlatMinor != null ? doc.markupFlatMinor / 100
: NaN,
);
const totalMinor = Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null;
@@ -191,12 +205,13 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
// entered) — no second dialog — so "Save & mark paid" actually pays.
const save = useMutation({
mutationFn: async (pay: boolean) => {
await accountingService.updateInbound(doc.id, { supplierName: supplier || null, totalAmountMinor: totalMinor, currency: currency || null, invoiceDate: invoiceDate || null, paymentReference: reference || null });
await accountingService.updateInbound(doc.id, { supplierName: supplier || null, totalAmountMinor: totalMinor, currency: currency || null, invoiceDate: invoiceDate || null, paymentReference: reference || null, note: note || null });
await accountingService.categorizeInbound(doc.id, {
disposition,
eventId: BOOKING_DISPOSITIONS.includes(disposition) ? eventId : null,
categoryId: disposition === 'eigener_aufwand' ? (categoryId ?? null) : null,
customerAccountId: disposition === 'rebill' && customer[0] ? customer[0].id : null,
// Both rebill and passthrough can attach to a customer (#3).
customerAccountId: BOOKING_DISPOSITIONS.includes(disposition) && customer[0] ? customer[0].id : null,
...markupPayload(),
});
if (pay) {
@@ -229,6 +244,8 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
<div><label className={labelCls}>{t('accounting.inbox.field.currency', 'Currency')}</label><Input value={currency} maxLength={3} onChange={(e) => setCurrency(e.target.value.toUpperCase())} /></div>
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.invoiceDate', 'Invoice date')}</label><LocalizedDateInput value={invoiceDate} onChange={setInvoiceDate} /></div>
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.reference', 'Payment reference')}</label><Input value={reference} onChange={(e) => setReference(e.target.value)} placeholder={t('accounting.inbox.field.referenceHint', 'QR / ESR reference or message') as string} /></div>
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.note', 'Note')}</label>
<textarea value={note} onChange={(e) => setNote(e.target.value)} rows={2} className={selectCls} placeholder={t('accounting.inbox.field.noteHint', 'Internal note for this invoice (optional)') as string} /></div>
</div>
<div><label className={labelCls}>{t('accounting.inbox.field.disposition', 'Disposition')}</label>
@@ -253,10 +270,12 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
</div>
)}
{disposition === 'rebill' && (
{BOOKING_DISPOSITIONS.includes(disposition) && (
<div className="space-y-3 rounded-lg border border-neutral-200 dark:border-neutral-700 p-3">
<div><label className={labelCls}>{t('accounting.inbox.field.customer', 'Client')} *</label>
<CustomerAccountPicker value={customer.slice(0, 1)} onChange={(next) => setCustomer(next.slice(-1))} /></div>
<div><label className={labelCls}>{t('accounting.inbox.field.customer', 'Client')} {disposition === 'rebill' ? '*' : ''}</label>
<CustomerAccountPicker value={customer.slice(0, 1)} onChange={(next) => setCustomer(next.slice(-1))} />
{disposition === 'durchlaufend' && <p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('accounting.inbox.field.passthroughCustomerHint', 'Optional — attach a client to re-bill this passthrough; leave empty to only book it to the event.')}</p>}
</div>
<div><label className={labelCls}>{t('accounting.inbox.field.markup', 'Markup')}</label>
<select value={markupType} onChange={(e) => setMarkupType(e.target.value as MarkupType)} className={selectCls}>
<option value="none">{t('accounting.markup.none', 'None / from contract')}</option>
@@ -283,6 +302,7 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
export const AccountingInboxPage: React.FC = () => {
const { t } = useTranslation();
const qc = useQueryClient();
const navigate = useNavigate();
const { format } = useLocalizedDate();
const cameraRef = useRef<HTMLInputElement>(null);
const uploadRef = useRef<HTMLInputElement>(null);
@@ -294,6 +314,8 @@ export const AccountingInboxPage: React.FC = () => {
// without a manual reload (the poller runs server-side every 60s).
const { data, isLoading } = useQuery({ queryKey: ['accounting-inbound'], queryFn: () => accountingService.listInbound({ pageSize: 100 }), refetchInterval: 30000, refetchOnWindowFocus: true });
const { data: categories } = useQuery({ queryKey: ['expense-categories'], queryFn: () => accountingService.listCategories() });
// Per-event customers carrying pending (categorised, unbilled) re-bills (#3).
const { data: pending } = useQuery({ queryKey: ['accounting-pending-rebills'], queryFn: () => accountingService.listPendingRebills(), refetchInterval: 30000 });
const upload = useMutation({
mutationFn: ({ file, source }: { file: File; source: 'upload' | 'camera' }) => accountingService.uploadInbound(file, source),
@@ -305,11 +327,24 @@ export const AccountingInboxPage: React.FC = () => {
onSuccess: () => { qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); },
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
});
const billPending = useMutation({
mutationFn: (customerAccountId: number) => accountingService.billPendingRebills(customerAccountId),
onSuccess: ({ invoiceId, count }) => {
toast.success(t('accounting.incoming.bundledToast', 'Bundled {{count}} re-bill(s) into one invoice.', { count }));
qc.invalidateQueries({ queryKey: ['accounting-inbound'] });
qc.invalidateQueries({ queryKey: ['accounting-pending-rebills'] });
navigate(`/admin/clients/bills/${invoiceId}/edit`);
},
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
});
const onFile = (source: 'upload' | 'camera') => (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; if (file) upload.mutate({ file, source }); e.target.value = '';
};
const refresh = () => qc.invalidateQueries({ queryKey: ['accounting-inbound'] });
const refresh = () => { qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); qc.invalidateQueries({ queryKey: ['accounting-pending-rebills'] }); };
const pendingItems = pending ?? [];
const customerLabel = (p: typeof pendingItems[number]) => p.displayName || p.companyName || [p.firstName, p.lastName].filter(Boolean).join(' ') || p.email || `#${p.customerAccountId}`;
const handleTriageDone = () => { setTriageDoc(null); refresh(); };
@@ -329,6 +364,31 @@ export const AccountingInboxPage: React.FC = () => {
<Button variant="outline" onClick={() => uploadRef.current?.click()} disabled={upload.isPending}><Upload className="w-4 h-4 mr-2" /> {t('accounting.inbox.uploadFile', 'Upload file')}</Button>
</CardContent></Card>
{/* Pending re-bills (#3): per-event customers accumulate categorised
rebill/passthrough invoices here; bundle them into one invoice like
"Bill these hours". Monthly/manual customers never surface (they
consolidate onto their running draft at categorise time). */}
{pendingItems.length > 0 && (
<Card className="mb-6"><CardContent className="p-5">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100 mb-1">{t('accounting.incoming.pendingTitle', 'Pending re-bills')}</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-3">{t('accounting.incoming.pendingBody', 'Categorized invoices waiting to be re-billed. Bundle a clients items into one invoice.')}</p>
<div className="space-y-2">
{pendingItems.map((p) => (
<div key={p.customerAccountId} className="flex flex-wrap items-center gap-3 rounded-lg border border-neutral-200 dark:border-neutral-700 px-4 py-2">
<div className="flex-1 min-w-[12rem]">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{customerLabel(p)}</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('accounting.incoming.pendingCount', '{{count}} item(s)', { count: p.itemCount })}
{' · '}{formatMoneyMinor(p.openAmountMinor, 'CHF')}
</div>
</div>
<Button size="sm" onClick={() => billPending.mutate(p.customerAccountId)} disabled={billPending.isPending}><Send className="w-3.5 h-3.5 mr-1" /> {t('accounting.incoming.billPending', 'Bill these')}</Button>
</div>
))}
</div>
</CardContent></Card>
)}
{isLoading ? <Loading /> : items.length === 0 ? (
<div className="rounded-xl border border-dashed border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900 p-8 text-center">
<Inbox className="w-10 h-10 mx-auto mb-3 text-neutral-400" />
@@ -362,6 +422,10 @@ export const AccountingInboxPage: React.FC = () => {
{doc.totalAmountMinor != null ? formatMoneyMinor(doc.totalAmountMinor, doc.currency || 'CHF') : t('accounting.inbox.noAmount', 'amount not entered')}
{' · '}{format(doc.createdAt)}
{doc.disposition && <>{' · '}{t(`accounting.disposition.${doc.disposition}`, doc.disposition)}</>}
{/* Pending re-bill = attached to a client but not yet on an invoice. */}
{doc.customerAccountId && !doc.billedInvoiceId && (
<span className="text-indigo-600 dark:text-indigo-400">{' · '}{t('accounting.incoming.pendingRebill', 'Pending re-bill')}{doc.customerName ? `${doc.customerName}` : ''}</span>
)}
</div>
</button>
<Button size="sm" variant="ghost" onClick={() => setViewDoc(doc)}><Eye className="w-3.5 h-3.5 mr-1" /> {t('accounting.inbox.view', 'View')}</Button>
@@ -372,7 +436,11 @@ export const AccountingInboxPage: React.FC = () => {
? <Button size="sm" variant="ghost" onClick={() => unpay.mutate(doc.id)} disabled={unpay.isPending}><RotateCcw className="w-3.5 h-3.5 mr-1" /> {t('accounting.incoming.markUnpaid', 'Mark unpaid')}</Button>
: <Button size="sm" variant="outline" onClick={() => setPayDoc(doc)}><Circle className="w-3.5 h-3.5 mr-1" /> {t('accounting.incoming.markPaid', 'Mark paid')}</Button>
)}
{doc.status === 'unsorted' && <Button size="sm" onClick={() => setTriageDoc(doc)}>{t('accounting.inbox.categorize', 'Categorize')}</Button>}
{/* #1: re-categorize is available after the first triage too, so a
disposition can be changed (e.g. passthrough → company expense). */}
{doc.status === 'unsorted'
? <Button size="sm" onClick={() => setTriageDoc(doc)}>{t('accounting.inbox.categorize', 'Categorize')}</Button>
: <Button size="sm" variant="outline" onClick={() => setTriageDoc(doc)}><Pencil className="w-3.5 h-3.5 mr-1" /> {t('accounting.inbox.recategorize', 'Re-categorize')}</Button>}
</div>
))}
</div>
@@ -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; },