{t('accounting.inbox.field.passthroughCustomerHint', 'Optional — attach a client to re-bill this passthrough; leave empty to only book it to the event.')}
}
+
))}
diff --git a/frontend/src/services/accounting.service.ts b/frontend/src/services/accounting.service.ts
index 7e3ae7c6..90d54e5c 100644
--- a/frontend/src/services/accounting.service.ts
+++ b/frontend/src/services/accounting.service.ts
@@ -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 { items: T[]; pagination: { page: number; pageSize: number; total: number; totalPages: number }; }
@@ -148,6 +168,10 @@ export const accountingService = {
async updateInbound(id: number, fields: Partial): Promise { const { data } = await api.patch(`/admin/expenses/inbound/${id}`, fields); return data.document; },
async categorizeInbound(id: number, payload: CategorizePayload): Promise { 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 { 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 { const { data } = await api.post(`/admin/expenses/inbound/${id}/supplier-payment`, payload); return data.document; },
async getInboundFileBlob(id: number): Promise { const { data } = await api.get(`/admin/expenses/inbound/${id}/file`, { responseType: 'blob' }); return data; },
async getInboundPageBlob(id: number, page: number): Promise { const { data } = await api.get(`/admin/expenses/inbound/${id}/page/${page}`, { responseType: 'blob' }); return data; },
From 9a023c019750ebcd8d21e005aaf9a77a32cb34a3 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Thu, 18 Jun 2026 12:44:18 +0200
Subject: [PATCH 02/11] feat(accounting): explain dispositions inline, drop
markup from pass-through
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add a per-disposition info line under the Disposition dropdown so re-bill
vs pass-through vs company expense is clear in-context (en + de).
- Markup is a re-bill concept only: the control now renders solely for
rebill, and a pass-through always bills at cost. Enforced server-side too
(categorizeInbound applies markup only when disposition === 'rebill').
- Clarify "Book to" with a hint — it attributes the supplier cost to an
event in the tax report / ledger export, separate from who you re-bill to.
---
backend/src/services/expenseService.js | 11 ++++---
frontend/src/i18n/locales/de.json | 12 ++++++--
frontend/src/i18n/locales/en.json | 12 ++++++--
.../admin/accounting/AccountingInboxPage.tsx | 29 +++++++++++++------
4 files changed, 47 insertions(+), 17 deletions(-)
diff --git a/backend/src/services/expenseService.js b/backend/src/services/expenseService.js
index 377143f7..7ee5ccb5 100644
--- a/backend/src/services/expenseService.js
+++ b/backend/src/services/expenseService.js
@@ -375,7 +375,10 @@ async function categorizeInbound(id, payload, adminId) {
// #1: unwind any prior re-bill so the disposition can change.
if (doc.billedInvoiceId) await unwindBilledLine(trx, doc);
- const markup = billsToCustomer
+ // Markup is a re-bill concept only. A pass-through (durchlaufender Posten)
+ // is invoiced at cost / VAT-neutral, so it never carries a markup.
+ const appliesMarkup = disposition === 'rebill';
+ const markup = appliesMarkup
? await resolveMarkup(
{ markupType: payload.markupType, markupPercent: payload.markupPercent, markupFlatMinor: payload.markupFlatMinor },
payload, payload.contractId, trx,
@@ -388,9 +391,9 @@ async function categorizeInbound(id, payload, adminId) {
event_id: BOOKING_DISPOSITIONS.includes(disposition) ? (payload.eventId || null) : null,
category_id: disposition === 'eigener_aufwand' ? (payload.categoryId || null) : null,
customer_account_id: customerAccountId,
- markup_type: billsToCustomer ? markup.type : 'none',
- markup_percent: billsToCustomer && markup.type === 'percent' ? markup.percent : null,
- markup_flat_minor: billsToCustomer && markup.type === 'flat' ? markup.flatMinor : null,
+ markup_type: appliesMarkup ? markup.type : 'none',
+ markup_percent: appliesMarkup && markup.type === 'percent' ? markup.percent : null,
+ markup_flat_minor: appliesMarkup && markup.type === 'flat' ? markup.flatMinor : null,
// Cleared here; re-set by billInboundNow when we bill immediately.
billed_invoice_id: null,
billed_invoice_line_item_id: null,
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 0d341664..6b5e7689 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -3502,7 +3502,14 @@
"durchlaufend": "Durchlaufender Posten",
"eigener_aufwand": "Eigener Aufwand",
"duplikat": "Duplikat",
- "abgelehnt": "Abgelehnt"
+ "abgelehnt": "Abgelehnt",
+ "help": {
+ "rebill": "Eigene Lieferantenkosten, die du an einen Kunden weiterverrechnest — meist mit Zuschlag. Wird als Kosten und als weiterverrechneter Ertrag gebucht.",
+ "durchlaufend": "Ein Betrag, den du nur im Namen des Kunden vorstreckst und exakt durchreichst — kein Zuschlag, MwSt-neutral (durchlaufender Posten). Kunde zuordnen, um ihn zum Selbstkostenpreis weiterzuverrechnen.",
+ "eigener_aufwand": "Eigene Kosten, die nicht weiterverrechnet werden. Kategorie wählen, damit der Posten richtig in der Erfolgsrechnung landet.",
+ "duplikat": "Duplikat einer bereits erfassten Rechnung — wird nicht verbucht.",
+ "abgelehnt": "Dokument ablehnen — wird nicht verbucht."
+ }
},
"markup": {
"none": "Keiner / aus Vertrag",
@@ -3576,7 +3583,8 @@
"label": "Buchen auf",
"company": "Firma",
"event": "Event",
- "eventId": "Event-ID"
+ "eventId": "Event-ID",
+ "inboundHint": "Auf welches Event diese Kosten in Auswertungen & Steuerexport entfallen (Firma = allgemeiner Aufwand). Unabhängig davon, an wen du weiterverrechnest."
},
"incoming": {
"triageTitle": "Eingangsrechnung kategorisieren",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index a1976667..bf7d8828 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -3502,7 +3502,14 @@
"durchlaufend": "Pass-through",
"eigener_aufwand": "Company expense",
"duplikat": "Duplicate",
- "abgelehnt": "Declined"
+ "abgelehnt": "Declined",
+ "help": {
+ "rebill": "Your own supplier cost that you invoice on to a client — usually with a markup. Booked as both a cost and re-billed revenue.",
+ "durchlaufend": "An amount you only front on behalf of a client and pass through at the exact figure — no markup, VAT-neutral (durchlaufender Posten). Attach the client to re-bill it at cost.",
+ "eigener_aufwand": "Your own cost, not re-billed to anyone. Pick a category so it lands in the right place in your P&L.",
+ "duplikat": "A duplicate of an invoice you already captured — excluded from the books.",
+ "abgelehnt": "Decline this document — excluded from the books."
+ }
},
"markup": {
"none": "None / from contract",
@@ -3576,7 +3583,8 @@
"label": "Book to",
"company": "Company",
"event": "Event",
- "eventId": "Event ID"
+ "eventId": "Event ID",
+ "inboundHint": "Which event carries this cost in your reports & tax export (Company = general overhead). This is separate from who you re-bill it to."
},
"incoming": {
"triageTitle": "Categorize incoming invoice",
diff --git a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx
index a1397a7b..ff95c061 100644
--- a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx
+++ b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx
@@ -212,7 +212,8 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
categoryId: disposition === 'eigener_aufwand' ? (categoryId ?? null) : null,
// Both rebill and passthrough can attach to a customer (#3).
customerAccountId: BOOKING_DISPOSITIONS.includes(disposition) && customer[0] ? customer[0].id : null,
- ...markupPayload(),
+ // Markup is a re-bill concept only — a pass-through bills at cost.
+ ...(disposition === 'rebill' ? markupPayload() : { markupType: 'none', markupPercent: null, markupFlatMinor: null }),
});
if (pay) {
await accountingService.markInboundPaid(doc.id, { paid: true, paymentReference: reference || undefined });
@@ -252,12 +253,18 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
+ {/* Explain the selected disposition — re-bill vs pass-through vs
+ company expense aren't obvious from the labels alone. */}
+
{t('accounting.booking.inboundHint', 'Which event carries this cost in your reports & tax export (Company = general overhead). This is separate from who you re-bill it to.')}
{t('accounting.inbox.field.passthroughCustomerHint', 'Optional — attach a client to re-bill this passthrough; leave empty to only book it to the event.')}
}
-
-
-
- {markupType !== 'none' && }
+ {/* Markup is a re-bill concept only. A pass-through is invoiced
+ at cost (VAT-neutral), so no markup control here. */}
+ {disposition === 'rebill' && (<>
+
+
+
+ {markupType !== 'none' && }
+ >)}
)}
From 315d15afd46f3bf70dd31c8ce1f871f8e7f6a169 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Thu, 18 Jun 2026 14:11:08 +0200
Subject: [PATCH 03/11] test(accounting): incoming-invoice integration test +
fix vat_code reload & SQLite logActivity deadlock
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add backend/__tests__/integration/incomingInvoiceRebill.test.js (8 tests):
disposition state machine, per-event PENDING pool, passthrough-no-markup,
unwindBilledLine recompute, INVOICE_LOCKED on an issued invoice, and
re-categorisation transitions. The invoice-MINTING paths can't run inside an
outer transaction on SQLite (createInvoice's sequence claim deadlocks on the
held write lock) — covered by buildInboundLineItem unit tests + discountLineItems
instead; documented in the test.
- Move logActivity out of the categorize/rebill/bundle transactions. It writes
via the global db; inside a transaction a second write connection deadlocks on
a SQLite-backed install (also affected SQLite-prod, not just tests).
- Fix bill-editor vat_code reload: transformInvoice (adminInvoices.js) dropped
vatCode, so the editor fell back to rate-matching and lost a custom-rate code
on edit. Now returns vatCode: i.vat_code.
- Rewrite docs/accounting-inbound-invoices.md to the current implementation
(IR-vs-Expenses split, re-categorise + unwind, cadence-aware re-bill / pending
pool, passthrough-at-cost, migrations 122-132, rasterised preview, tax/ledger/VAT).
---
.../integration/incomingInvoiceRebill.test.js | 211 ++++++++++++++++++
backend/src/routes/adminInvoices.js | 4 +
backend/src/services/expenseService.js | 22 +-
docs/accounting-inbound-invoices.md | 132 ++++++-----
4 files changed, 313 insertions(+), 56 deletions(-)
create mode 100644 backend/__tests__/integration/incomingInvoiceRebill.test.js
diff --git a/backend/__tests__/integration/incomingInvoiceRebill.test.js b/backend/__tests__/integration/incomingInvoiceRebill.test.js
new file mode 100644
index 00000000..9d9312c7
--- /dev/null
+++ b/backend/__tests__/integration/incomingInvoiceRebill.test.js
@@ -0,0 +1,211 @@
+/**
+ * Incoming-invoice categorisation + re-bill chain (expenseService) against a
+ * real SQLite schema. Covers the bits unit tests can't: the disposition state
+ * machine, re-categorisation unwind, the per-event PENDING pool + bundling, and
+ * the monthly accumulator immediate-bill — i.e. that categorizeInbound /
+ * billPendingRebills actually mint / amend invoice rows correctly.
+ *
+ * No date-range comparisons are exercised here, so it's safe on SQLite (the
+ * usual PG-vs-SQLite date pitfall — [[feedback_pg_date_columns_serialize]] —
+ * doesn't apply to this path).
+ */
+const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
+
+// Service-level CRM calls cold-require heavy modules (pdfService, nodemailer)
+// on first use; bump the budget for this file.
+jest.setTimeout(60000);
+
+describe('incoming-invoice categorise / re-bill chain', () => {
+ let db;
+ let cleanup;
+ let adminId;
+ let expenseService;
+
+ beforeAll(async () => {
+ ({ db, cleanup } = await bootCrmDb());
+ // logActivity writes to activity_logs via the GLOBAL db. createInvoice (and
+ // appendToMonthlyDraft) call it INSIDE the transaction we pass them, and a
+ // second write connection deadlocks against the held write lock on
+ // SQLite. It's fire-and-forget audit noise, irrelevant to these
+ // assertions, so stub it BEFORE the services destructure it at require
+ // time. (Production runs Postgres, where the concurrent write is fine.)
+ const dbModule = require('../../src/database/db');
+ dbModule.logActivity = async () => {};
+ ({ adminId } = await seedMinimal(db));
+ expenseService = require('../../src/services/expenseService');
+ }, 120000);
+
+ afterAll(async () => {
+ if (cleanup) await cleanup();
+ });
+
+ const unwrapId = (ins) => (typeof ins[0] === 'object' ? ins[0].id : ins[0]);
+
+ async function captureDoc(overrides = {}) {
+ const ins = await db('inbound_documents').insert({
+ source: 'upload',
+ status: 'unsorted',
+ parse_status: 'pending',
+ parse_method: 'none',
+ supplier_name: 'ACME AG',
+ currency: 'CHF',
+ total_amount_minor: 10000,
+ invoice_date: '2026-06-01',
+ created_at: new Date(),
+ updated_at: new Date(),
+ ...overrides,
+ }).returning('id');
+ return unwrapId(ins);
+ }
+
+ let customerSeq = 0;
+ async function makeCustomer(billingCadence) {
+ customerSeq += 1;
+ const ins = await db('customer_accounts').insert({
+ email: `rebill-${billingCadence || 'event'}-${customerSeq}@example.com`,
+ display_name: `Rebill ${billingCadence || 'event'} ${customerSeq}`,
+ password_hash: 'x',
+ preferred_language: 'de',
+ is_active: 1,
+ billing_cadence: billingCadence || null,
+ created_at: new Date(),
+ }).returning('id');
+ return unwrapId(ins);
+ }
+
+ it('company expense (eigener_aufwand) categorises with no invoice + no customer', async () => {
+ const id = await captureDoc();
+ const doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId);
+ expect(doc.disposition).toBe('eigener_aufwand');
+ expect(doc.status).toBe('categorized');
+ expect(doc.billedInvoiceId).toBeNull();
+ expect(doc.customerAccountId).toBeNull();
+ });
+
+ it('rebill REQUIRES a customer', async () => {
+ const id = await captureDoc();
+ await expect(expenseService.categorizeInbound(id, { disposition: 'rebill' }, adminId))
+ .rejects.toMatchObject({ code: 'CUSTOMER_REQUIRED' });
+ });
+
+ it('per-event rebill stays PENDING (customer + markup stored, no invoice yet)', async () => {
+ const customerId = await makeCustomer('per_event');
+ const id = await captureDoc({ total_amount_minor: 10000 });
+ const doc = await expenseService.categorizeInbound(id, {
+ disposition: 'rebill', customerAccountId: customerId,
+ markupType: 'percent', markupPercent: 10,
+ }, adminId);
+ expect(doc.disposition).toBe('rebill');
+ expect(doc.customerAccountId).toBe(customerId);
+ expect(doc.billedInvoiceId).toBeNull(); // pending — not billed until bundled
+ expect(doc.markupType).toBe('percent');
+ expect(Number(doc.markupPercent)).toBe(10);
+ });
+
+ it('passthrough never carries a markup, even if one is sent', async () => {
+ const customerId = await makeCustomer('per_event');
+ const id = await captureDoc();
+ const doc = await expenseService.categorizeInbound(id, {
+ disposition: 'durchlaufend', customerAccountId: customerId,
+ markupType: 'percent', markupPercent: 25, // should be ignored
+ }, adminId);
+ expect(doc.disposition).toBe('durchlaufend');
+ expect(doc.customerAccountId).toBe(customerId);
+ expect(doc.markupType).toBe('none');
+ expect(doc.markupPercent).toBeNull();
+ expect(doc.billedInvoiceId).toBeNull();
+ });
+
+ it('billPendingRebills refuses monthly/manual customers (they auto-consolidate)', async () => {
+ const customerId = await makeCustomer('monthly');
+ await expect(expenseService.billPendingRebills(customerId, adminId))
+ .rejects.toMatchObject({ code: 'CADENCE_MISMATCH' });
+ });
+
+ // ── The actual invoice-MINTING paths (billPendingRebills bundling a per-event
+ // customer's pool; monthly-customer immediate-bill onto the running draft)
+ // both call invoiceService.createInvoice INSIDE a db.transaction. createInvoice
+ // claims its sequence number via the global db, which DEADLOCKS against the
+ // held write lock on a SQLite-backed harness (a second write connection blocks
+ // — verified). Production runs Postgres where the concurrent write is fine, so
+ // this is a harness limitation, not a product bug. The line-amount math is
+ // covered by the buildInboundLineItem unit tests, and createInvoice itself by
+ // discountLineItems.test.js. Below we test the UNWIND path against a
+ // hand-crafted billed state so we don't have to mint through createInvoice. ──
+
+ // Build a billed state directly: an invoice with two lines, with the inbound
+ // doc stamped onto the first line as a prior re-bill.
+ async function makeBilledDoc(customerId, { status = 'scheduled', scheduledSendAt = null, isMonthlyDraft = false } = {}) {
+ const invIns = await db('invoices').insert({
+ invoice_number: `R-TEST-${customerSeq}-${Math.floor(Math.random() * 1e9)}`,
+ customer_account_id: customerId,
+ status,
+ scheduled_send_at: scheduledSendAt,
+ is_monthly_draft: isMonthlyDraft,
+ currency: 'CHF',
+ issue_date: '2026-06-01',
+ due_date: '2026-07-01',
+ vat_rate: 0,
+ net_amount_minor: 7000, // 4000 (rebill line) + 3000 (sibling)
+ vat_amount_minor: 0,
+ total_amount_minor: 7000,
+ created_at: new Date(),
+ updated_at: new Date(),
+ }).returning('id');
+ const invoiceId = unwrapId(invIns);
+ const rebillLineIns = await db('invoice_line_items').insert({
+ invoice_id: invoiceId, position: 1, quantity: 1, description: 'Rebill Co (Weiterverrechnung)',
+ unit_price_minor: 4000, discount_percent: 0, line_total_minor: 4000,
+ }).returning('id');
+ const rebillLineId = unwrapId(rebillLineIns);
+ await db('invoice_line_items').insert({
+ invoice_id: invoiceId, position: 2, quantity: 1, description: 'Other line',
+ unit_price_minor: 3000, discount_percent: 0, line_total_minor: 3000,
+ });
+ const id = await captureDoc({ total_amount_minor: 4000, supplier_name: 'Rebill Co' });
+ await db('inbound_documents').where({ id }).update({
+ disposition: 'rebill', status: 'categorized', customer_account_id: customerId,
+ billed_invoice_id: invoiceId, billed_invoice_line_item_id: rebillLineId,
+ });
+ return { id, invoiceId, rebillLineId };
+ }
+
+ it('re-categorising a billed doc UNWINDS its re-bill line + recomputes the (mutable) invoice', async () => {
+ const customerId = await makeCustomer('per_event');
+ const { id, invoiceId, rebillLineId } = await makeBilledDoc(customerId); // scheduled, no send-at → mutable
+
+ const recat = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId);
+ expect(recat.disposition).toBe('eigener_aufwand');
+ expect(recat.billedInvoiceId).toBeNull();
+ expect(recat.customerAccountId).toBeNull();
+
+ // The re-bill line is gone; the sibling line remains and net recomputes.
+ expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeUndefined();
+ const after = await db('invoices').where({ id: invoiceId }).first();
+ expect(Number(after.net_amount_minor)).toBe(3000);
+ });
+
+ it('re-categorising a doc billed on an ISSUED invoice is refused (Storno required)', async () => {
+ const customerId = await makeCustomer('per_event');
+ const { id, rebillLineId } = await makeBilledDoc(customerId, { status: 'sent' });
+
+ await expect(expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId))
+ .rejects.toMatchObject({ code: 'INVOICE_LOCKED' });
+ // Nothing was touched — the line survives.
+ expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeDefined();
+ });
+
+ it('re-categorisation moves a pending item between dispositions without a stray invoice', async () => {
+ const customerId = await makeCustomer('per_event');
+ const id = await captureDoc();
+ // passthrough → pending
+ let doc = await expenseService.categorizeInbound(id, { disposition: 'durchlaufend', customerAccountId: customerId }, adminId);
+ expect(doc.customerAccountId).toBe(customerId);
+ expect(doc.billedInvoiceId).toBeNull();
+ // → company expense: customer cleared, still no invoice
+ doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId);
+ expect(doc.disposition).toBe('eigener_aufwand');
+ expect(doc.customerAccountId).toBeNull();
+ expect(doc.billedInvoiceId).toBeNull();
+ });
+});
diff --git a/backend/src/routes/adminInvoices.js b/backend/src/routes/adminInvoices.js
index 23d8f2bf..997d7366 100644
--- a/backend/src/routes/adminInvoices.js
+++ b/backend/src/routes/adminInvoices.js
@@ -130,6 +130,10 @@ function transformInvoice(i) {
sentAt: i.sent_at,
netAmountMinor: i.net_amount_minor,
vatRate: i.vat_rate == null ? null : Number(i.vat_rate),
+ // Snapshotted VAT code (migration 130) — the editor needs it to repopulate
+ // VatRateSelect on edit; without it the dropdown falls back to rate-matching
+ // and a custom-rate code is silently lost.
+ vatCode: i.vat_code || null,
vatAmountMinor: i.vat_amount_minor,
shippingAmountMinor: i.shipping_amount_minor,
totalAmountMinor: i.total_amount_minor,
diff --git a/backend/src/services/expenseService.js b/backend/src/services/expenseService.js
index 7ee5ccb5..4fb5a648 100644
--- a/backend/src/services/expenseService.js
+++ b/backend/src/services/expenseService.js
@@ -343,7 +343,9 @@ async function billInboundNow(trx, id, customerAccountId, eventId, disposition,
billed_invoice_line_item_id: line ? line.id : null,
updated_at: new Date(),
});
- await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId }, adminId);
+ // NOTE: no logActivity here — it writes via the GLOBAL db, which deadlocks
+ // when called inside this transaction on a SQLite-backed install (a second
+ // write connection blocks on the held write lock). Callers log AFTER commit.
return invoiceId;
}
@@ -367,6 +369,7 @@ async function categorizeInbound(id, payload, adminId) {
throw new AppError('customerAccountId is required to re-bill', 400, 'CUSTOMER_REQUIRED');
}
+ let billedInvoiceId = null;
await db.transaction(async (trx) => {
const row = await trx('inbound_documents').where({ id }).first();
if (!row) throw new AppError('Incoming invoice not found', 404, 'INBOUND_NOT_FOUND');
@@ -409,12 +412,14 @@ async function categorizeInbound(id, payload, adminId) {
// Monthly/manual = accumulator → bill now onto the running draft.
// Per-event → leave PENDING for bundling via billPendingRebills.
if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') {
- await billInboundNow(trx, id, customerAccountId, payload.eventId || null, disposition, markup, adminId);
+ billedInvoiceId = await billInboundNow(trx, id, customerAccountId, payload.eventId || null, disposition, markup, adminId);
}
}
-
- await logActivity('incoming_invoice_categorized', { inboundDocumentId: id, disposition }, adminId);
});
+ // Audit logging AFTER commit — logActivity writes via the global db and would
+ // deadlock if run inside the transaction above on a SQLite-backed install.
+ await logActivity('incoming_invoice_categorized', { inboundDocumentId: id, disposition }, adminId);
+ if (billedInvoiceId) await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId: billedInvoiceId }, adminId);
return getInbound(id);
}
@@ -447,6 +452,9 @@ async function rebillInbound(id, payload, adminId, trx0) {
return billInboundNow(trx, id, payload.customerAccountId, payload.eventId || doc.eventId || null, 'rebill', markup, adminId);
};
const invoiceId = trx0 ? await run(trx0) : await db.transaction(run);
+ // Log after commit (global-db write — see billInboundNow). When a caller
+ // supplied trx0, that outer transaction owns the audit log instead.
+ if (!trx0) await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId }, adminId);
return { document: await getInbound(id), invoiceId };
}
@@ -518,7 +526,7 @@ async function billPendingRebills(customerId, adminId) {
);
}
- return await db.transaction(async (trx) => {
+ const result = await db.transaction(async (trx) => {
const pending = await trx('inbound_documents')
.where({ customer_account_id: customer.id })
.whereNull('billed_invoice_id')
@@ -556,9 +564,11 @@ async function billPendingRebills(customerId, adminId) {
});
}
- await logActivity('incoming_invoices_rebilled_bundle', { customerId: customer.id, invoiceId, count: pending.length }, adminId);
return { invoiceId, count: pending.length };
});
+ // Audit log after commit (global-db write — see billInboundNow).
+ await logActivity('incoming_invoices_rebilled_bundle', { customerId: customer.id, invoiceId: result.invoiceId, count: result.count }, adminId);
+ return result;
}
/** Mark the supplier paid on the incoming invoice (the payable lives here). */
diff --git a/docs/accounting-inbound-invoices.md b/docs/accounting-inbound-invoices.md
index 8a1625c8..51a9d9a8 100644
--- a/docs/accounting-inbound-invoices.md
+++ b/docs/accounting-inbound-invoices.md
@@ -1,65 +1,97 @@
-# Accounting — Inbound supplier invoices, expenses & re-bill (MVP)
+# Accounting — Incoming invoices, expenses & re-bill
-> **Status:** new feature, in development on `feat/accounting-inbound-invoices` (based on `upstream/beta`).
-> **Maintainer scope decision required** before merge — this introduces a new top-level **Accounting** area, separate from CRM (see "Scope decisions" below).
-> **Legal:** every VAT / tax-treatment surface is an *example only* and must be reviewed with a Treuhänder before relying on it. Jurisdiction scope is **Liechtenstein-first** (Swiss/LI rails — QR-bill, LI MWST), not German DATEV/ELSTER.
+> **Status:** built on `feat/accounting-inbound-invoices` (based on `upstream/beta`); not yet merged to `main`.
+> **Legal:** every VAT / tax-treatment surface is an *example only* and must be reviewed with a Treuhänder before relying on it. Jurisdiction scope is **Liechtenstein-first** (Swiss/LI rails — QR-bill, LI MWST), not German DATEV/ELSTER/ITSG. See `docs/crm-disclaimers.md`.
## Why
-The studio receives supplier invoices/receipts (hotels, equipment, fremdleistungen). Today they live in email/paper and are re-typed. This feature lets an admin **capture an incoming invoice** (upload, or **phone/tablet camera**), have its fields **best-effort extracted**, then give it a **disposition** — most importantly **re-bill it to a client** ("Weiterverrechnung") onto the relevant event's invoice with a contract-driven markup.
+The studio receives supplier invoices/receipts (hotels, equipment, Fremdleistungen). This feature lets an admin **capture** an incoming invoice (upload, **phone/tablet camera**, or **IMAP email intake**), confirm its fields, give it a **disposition**, mark the **supplier payable** paid, and — for client-borne costs — **re-bill it to a client** ("Weiterverrechnung"), consolidated onto the client's bill the same way billable hours are.
-This mirrors the existing **billable-hours** model (`customerHoursService`): an item is parked against a customer/event and folded into an invoice as a line item.
+## Two distinct entities (split in migration 126)
+Incoming invoices and internal expenses are **separate** — one document never appears in both surfaces.
-## Scope decisions (maintainer)
-1. **New top-level "Accounting" area**, gated behind a new `accounting` feature flag (default OFF) and `accounting.view` / `accounting.manage` permissions — *not* bolted onto CRM. The existing tax-export page is a candidate to move here later (not in this MVP).
-2. **picpeak owns documents + books up to the export boundary**; certified external systems (Treuhänder / Abacus / Bexio) own statutory filing.
-3. **No paperless-ngx sidecar** — picpeak is the system of record; files live under `storage/` and are covered by the existing `backup_paths` walker.
+- **Incoming invoices** (`inbound_documents`) — an *external* supplier document. The **row itself is the payable**: it carries the disposition, tax treatment, event booking, re-bill linkage, supplier-payment, note, and (for re-bills) the attached customer. Categorising it **updates the document** — it never derives an `expenses` row. Mark-paid lives here.
+- **Expenses** (`expenses`, `inbound_document_id IS NULL`) — *internal* own-costs: `kind = amount | mileage | per_diem` (amount = quantity × rate, rate from accounting settings with per-entry override), optional proof file, booked to an event or the company. Disposition is always `eigener_aufwand`; no supplier payment.
-## MVP scope (this branch)
-- **Intake**: file upload **and camera capture** (phone/tablet) → `POST /api/admin/expenses/inbound` (accepts PDF + JPEG/PNG). Stored as the system of record; deduped by SHA-256.
-- **Best-effort extraction** (`extractionService`): ladder of Swiss-QR decode → PDF text layer → OCR. *Scaffolded with the interface in place; the heavy extractors (Tesseract OS package, QR decoder, isolated rasterise worker) are a follow-up — see "Deferred".*
-- **Inbox**: list documents as **„Neu / Unsortiert"**; parsed fields are editable/confirmable (parsing is assist, never blind trust). The **QR-encoded amount is stored separately** and surfaced for tamper cross-check — the **authoritative total is the text/line-item value**.
-- **5 dispositions**: `rebill` (Weiterverrechnen) · `durchlaufend` (Durchlaufender Posten) · `eigener_aufwand` (company expense) · `duplikat` · `abgelehnt` (with reason).
-- **Re-bill flow**: event-scoped (one event → one customer). Markup resolved **expense override → contract `Spesen-Zuschlag` clause → 0%** (percent or flat). Mints an editable **scheduled** invoice (admin can add more lines) — same pattern as `billUnbilledEntries`.
-- **Supplier-payment status** (decoupled from categorisation): „Zu zahlen / Bezahlt" with `payment_method` (unified with the outgoing list incl. **bank_transfer**).
-- **Expense categories**: seeded + admin-editable (colored label) — feed the future Erfolgsrechnung.
-- **`tax_treatment` captured from day 1** (`domestic` default) — stored for the books; reclaim/Bezugsteuer math is future (switches on when `business_profile.vat_id` is set).
+This document covers the **incoming-invoices** surface. Expenses share the markup/re-bill helpers but are otherwise independent.
-## Data model (migrations 122–125)
-Numbered from **122** to avoid colliding with the in-flight `feat/crm-improvements` migrations **117–121** (which are expected to merge first). If this lands before that branch, renumber to 117+.
+## Lifecycle
+```
+capture (upload / camera / email)
+ → inbox row, status = unsorted, parse_status = pending
+triage (confirm fields + disposition + note)
+ ├─ eigener_aufwand → company expense (pick category), booked to company
+ ├─ durchlaufend → pass-through; optionally attach a client (billed at cost)
+ ├─ rebill → re-bill to a client (with markup)
+ ├─ duplikat → status = duplicate (excluded from the books)
+ └─ abgelehnt → status = declined (excluded from the books)
+supplier payment (independent axis): markInboundSupplierPayment → supplier_paid
+```
-- **122** — seed `accounting` feature flag (default OFF).
-- **123** — seed `accounting.view` / `accounting.manage` permissions + grant to super_admin/admin.
+### Dispositions
+Five: `rebill` · `durchlaufend` (Durchlaufender Posten) · `eigener_aufwand` (company expense) · `duplikat` · `abgelehnt`.
+
+- **`rebill`** — your own supplier cost, invoiced on to a client, usually with a **markup** (percent or flat). Requires a customer.
+- **`durchlaufend`** — an amount fronted on behalf of a client and passed through **at cost / VAT-neutral**. May optionally attach a client (then it is re-billed like a rebill, but **never carries a markup** — enforced in both the UI and `categorizeInbound`). With no client it is only booked to an event/company.
+- **`eigener_aufwand`** — own cost, not re-billed; pick an expense category for the Erfolgsrechnung.
+
+The triage modal shows an **inline explainer** for the selected disposition (`accounting.disposition.help.*`) and a **note** field on every disposition.
+
+### Re-categorisation
+Categorising is **re-runnable** — a categorised invoice can be changed again (e.g. pass-through → company expense), including after the supplier has been paid (supplier-payment and classification are independent axes). When the document was already re-billed, `categorizeInbound` first **unwinds** the prior re-bill line (removes the invoice line, recomputes the invoice totals) before applying the new disposition. It **refuses** (`INVOICE_LOCKED`) only when the re-bill sits on an already-issued invoice — then a Storno is required (`isInvoiceMutable` mirrors the hour-entry lock rules). The only hard lock is an *issued* invoice, never supplier-payment.
+
+### Re-bill: cadence-aware, like hours
+Re-bill/pass-through-to-a-customer consolidates onto the client's bill exactly like `customerHoursService`:
+
+- **Monthly / manual customers** — the line is appended **immediately** onto the customer's running monthly draft (via `invoiceService.createInvoice`'s accumulator intercept). `billed_invoice_id` is set at categorise time.
+- **Per-event customers** — the item stays **PENDING** in the customer's pool (`customer_account_id` set, `billed_invoice_id` null). The inbox surfaces a **"Pending re-bills"** card grouped by customer; **"Bill these"** (`billPendingRebills`) bundles all of a customer's pending items into **one** invoice (one line per document), then navigates to the bill editor so the admin can add more lines before sending. This mirrors `billUnbilledEntries`.
+
+Markup resolution (rebill only): expense/document override → contract `Spesen-Zuschlag` clause → 0% (`resolveMarkup`). The re-bill line description is `"{supplier} (Weiterverrechnung)"` / `"… (Durchlaufende Position)"`.
+
+## Data model (migrations 122–132)
+All money is integer minor units (`*_amount_minor`). Additive, hasTable/hasColumn-guarded.
+
+- **122** — seed `accounting` master flag (default OFF; preserve-visuals auto-enable where `taxReport` was on).
+- **123** — `accounting.view` / `accounting.manage` permissions.
- **124** — `inbound_documents`, `expenses`, `expense_categories` (+ seed categories).
-- **125** — `contracts.expense_markup_type|_percent|_flat_minor` (the Spesen-Zuschlag clause).
+- **125** — contract `expense_markup_type|_percent|_flat_minor` (Spesen-Zuschlag clause).
+- **126** — split incoming vs expenses: disposition/tax_treatment/event_id/category_id, re-bill markup + `billed_invoice_id`/`billed_invoice_line_item_id`, supplier-payment columns on `inbound_documents`; `kind`/`quantity`/`rate_minor` on `expenses`.
+- **127** — separate `expenses` sub-flag + accounting `app_settings` (km/per-diem rate, require-proof). *(NB: `app_settings` has no `created_at/updated_at` — seed `setting_key/value/type` only.)*
+- **128** — incoming mail (IMAP): `incomingMail` flag + `email_configs.imap_*` + `received_emails`.
+- **129** — `ledger_accounts` + `vat_codes` (Swiss/LI KMU seed) + category→account mapping.
+- **130** — `vat_code` snapshot column on quotes + invoices.
+- **132** — `inbound_documents.note` + `inbound_documents.customer_account_id` (the attached re-bill client; loose link, indexed for the pending-pool lookup).
-Key tables (all money in integer minor units, `*_amount_minor`):
-- `inbound_documents` — raw received doc + parsed/confirmable fields + `qr_amount_minor` (separate, untrusted) + `status` (unsorted/categorized/declined/duplicate).
-- `expenses` — the booking: `disposition`, `tax_treatment`, `event_id`, `customer_account_id`, FX (`original_*` + `chf_amount_minor` + `fx_locked`), `markup_type/_percent/_flat_minor`, `category_id`, `billed_invoice_id`, supplier-payment fields, `status`.
-- `expense_categories` — seeded colored labels.
+`inbound_documents` key columns: parsed fields (`supplier_name`, `invoice_date`, `total/net/vat_amount_minor`, `iban`, `payment_reference`) + separate untrusted `qr_amount_minor` (tamper cross-check — the authoritative total is the text value); `status` (unsorted/categorized/declined/duplicate); `disposition`; `tax_treatment`; `event_id` (NULL = company); `category_id`; `customer_account_id`; `markup_type/_percent/_flat_minor`; `billed_invoice_id` + `_line_item_id`; `supplier_paid` + `_at/_method/_ref`; `note`.
-## API (`/api/admin/expenses`, gated by `accounting` flag + `accounting.*`)
-- `POST /inbound` (multipart) — capture an inbound doc (upload/camera).
-- `GET /inbound` — list (filter by status, paginated).
-- `GET /inbound/:id` — one doc.
-- `PATCH /inbound/:id` — confirm/edit parsed fields.
-- `POST /inbound/:id/categorize` — create an expense with a disposition.
-- `POST / ` — create a manual expense (no document).
-- `GET / ` — list expenses (filter by status/disposition/customer/event).
-- `GET /:id` — one expense.
-- `PATCH /:id` — edit (locked once billed).
-- `POST /:id/rebill` — re-bill to a client (event-scoped, contract markup) → scheduled invoice.
-- `POST /:id/supplier-payment` — toggle supplier paid + method.
-- `GET/POST/PATCH/DELETE /categories` — manage expense categories.
+## API (`/api/admin/expenses`, gated by `incomingInvoices` + `accounting.*`)
+- `POST /inbound` (multipart) — capture (upload/camera). Deduped by SHA-256.
+- `GET /inbound` — list (joins the attached customer name/email).
+- `GET /inbound/pending-summary` — per-customer pending re-bills (registered before `/inbound/:id`).
+- `POST /inbound/bill-pending` — bundle one customer's pending re-bills into one invoice.
+- `GET /inbound/:id` · `PATCH /inbound/:id` (edit/confirm fields incl. `note`).
+- `GET /inbound/:id/page/:n` — rasterised PNG of a page. `GET /inbound/:id/file` — original (PDFs as attachment only, never inline).
+- `POST /inbound/:id/categorize` — set disposition (re-runnable; unwinds prior re-bill).
+- `POST /inbound/:id/rebill` — explicit "re-bill this one now" (forces an immediate single-doc bill).
+- `POST /inbound/:id/supplier-payment` — toggle supplier paid + method/date/reference.
+- Expenses: `GET/POST /`, `GET/PATCH /:id`, `POST /:id/invoice`, `POST /:id/paid`, `GET /:id/proof`.
+- Categories: `GET/POST/PATCH/DELETE /categories` (accounting master).
-## Camera capture (step 3)
-The `POST /inbound` endpoint accepts images, so a **mobile web** widget using
-`` already enables phone/tablet camera capture — **no native app required for v1**. A native document-scanner (edge-detect/dewarp, multi-page) is a later UX upgrade that improves OCR accuracy.
+## Document preview = server-side rasterised images
+Raw PDFs are **never** served inline. `rasterizeService` shells out to poppler `pdftoppm` (OS package in the Docker image — not a Node PDF lib, runs no JS, no egress). Pages cached under `storage/business-docs/inbound/rendered//page-.png`, served with `Content-Security-Policy: default-src 'none'` + `nosniff`. Page count capped at 200. The triage preview defaults to the last page (the Swiss QR-bill usually sits at the bottom).
-## Deferred (follow-ups)
-- Real extraction: Tesseract OCR (OS package in the Docker image, shell-out — *not* a sidecar), Swiss-QR decoder, **network-isolated rasterise worker** (no egress), CSP-locked image preview, never serve the raw PDF.
-- Email intake (`rechnungen@…` IMAP poll, forwarded-message parsing, message-id dedupe).
-- Bank reconciliation, FX auto-lock backstop (30-day), Erfolgsrechnung, customer-account close guard.
-- Frontend: the Accounting tab UI (inbox, disposition actions, re-bill dialog) + the camera widget.
+## Reporting & export
+- **Tax report** (`taxReportService`) — full Einnahmen-Ausgaben: incoming invoices + expenses feed the `costs` side, grouped Company vs Event; re-billed costs are kept (the matching re-bill revenue is also counted, so it nets). `vatPayable` = output VAT − reclaimable input VAT (excludes `foreign_vat_non_reclaimable`); zero when not VAT-registered. Gated on `accounting` + `taxReport` (no longer `bills`).
+- **Treuhänder export** (`ledgerService`) — accrual Buchungssätze → generic/Banana/bexio CSV. Accrual basis only; bank/payment postings are Layer B (deferred). See `project_banana_treuhaender_export_format`.
+- VAT config (codes, rate→code + treatment→code maps, registration & reclaim countries, chart of accounts) lives under **Settings → Accounting**; invoices snapshot the chosen `vat_code`.
+
+## Flag model
+`accounting` is an explicit top-level **master** flag with sub-toggles: `incomingInvoices` (this surface), `expenses` (internal expenses), `taxReport` (moved permanently out of CRM, now independent of `bills`). `incomingMail` (IMAP) is a separate flag, not under accounting. `accounting` off forces `taxReport` + `incomingInvoices` off.
## Conventions followed
-Idempotent migrations (hasTable/hasColumn-guarded); new flag default OFF; flag reads tolerate `true|1|'1'`; money as integer `*_minor`; `requirePermission` guards; camelCase API ↔ snake_case service; multer + `safePath` containment at every file boundary; localized dates on display; tax/legal surfaces carry a "verify with Treuhänder" disclaimer.
+Idempotent migrations; new flags default OFF; flag reads tolerate `true|1|'1'`; money as integer `*_minor`; `requirePermission` guards; camelCase API ↔ snake_case service; multer + `safePath` containment at every file boundary; localized dates via `useLocalizedDate`; money via `utils/money`; every tax/legal surface carries a "verify with your Treuhänder" disclaimer.
+
+## Deferred
+- **OCR / auto-extract** — `extractionService` is a no-op stub (Tesseract + Swiss-QR decode); admin reads the slip and types the fields.
+- **Capture-time VAT reclaim default** — `accounting_vat_reclaim_countries` is stored but not yet consumed; needs a `supplier_country` column to default `tax_treatment`.
+- **Bank reconciliation** — match incoming payments to open invoices / confirm supplier invoices paid (LLB DataFeed / camt.053 / EBICS). Phased, Swiss/LI rails.
+- **Native double-entry (Layer B)** — picpeak stays a feeder/export tool below the CHF 500k threshold; full Erfolgsrechnung/Bilanz is out of scope.
From dc7b87bb874e22ac6902fd2d48531ecdb6108c88 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Thu, 18 Jun 2026 15:10:44 +0200
Subject: [PATCH 04/11] =?UTF-8?q?feat(accounting):=20consolidate=20VAT/fin?=
=?UTF-8?q?ancial=20config=20into=20Settings=20=E2=86=92=20Accounting?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Remove the orphaned "Default VAT rate %" from Business profile; the rates
are the Accounting VAT codes. The invoice/quote VAT picker (VatRateSelect)
is now code-only — options are exactly the Accounting output codes, no
free-text custom rate. Off-list legacy values on existing invoices are
preserved as a read-only "(not configured)" option so issued documents
aren't silently changed.
- Move VAT label + default hourly rate to the Accounting tab (new
AccountingProfileFields card; storage stays on business_profile, own save).
Wire vat_label onto the PDF VAT-line label via the issuer block (covers
invoices + quotes), falling back to the locale default when blank.
- Default currency stays on Business profile but becomes a normalizing
dropdown (an old free-text "chf" auto-selects "CHF"; unknown values
preserved). Add a moved-note callout. Strip the moved fields from the
Business-profile save so it can't clobber an Accounting-tab edit.
---
backend/src/services/_renderContext.js | 4 +
backend/src/services/pdfService.js | 4 +-
.../admin/AccountingProfileFields.tsx | 82 +++++++++++++++++++
.../src/components/admin/VatRateSelect.tsx | 46 +++++------
frontend/src/constants/currencies.ts | 30 +++++++
.../features/settings/tabs/AccountingTab.tsx | 6 ++
.../settings/SettingsBusinessProfilePage.tsx | 69 ++++++++--------
7 files changed, 183 insertions(+), 58 deletions(-)
create mode 100644 frontend/src/components/admin/AccountingProfileFields.tsx
create mode 100644 frontend/src/constants/currencies.ts
diff --git a/backend/src/services/_renderContext.js b/backend/src/services/_renderContext.js
index 750b9475..abd39dea 100644
--- a/backend/src/services/_renderContext.js
+++ b/backend/src/services/_renderContext.js
@@ -73,6 +73,10 @@ function buildIssuerBlock(profile, logoPath, options = {}) {
// PDF issuer block — §14 UStG requires one or both on every
// invoice. Kleinunternehmer without a USt-IdNr. carry only this.
taxId: profile.tax_id || null,
+ // VAT-line label on the totals block (e.g. "MwSt.", "VAT"). Falls back to
+ // the per-locale default in pdfService when blank. Configured under
+ // Settings → Accounting.
+ vatLabel: profile.vat_label || null,
// pre-resolved absolute path; renderer never re-resolves.
logoPath,
pdfFontTtfPath: profile.pdf_font_ttf_path,
diff --git a/backend/src/services/pdfService.js b/backend/src/services/pdfService.js
index 01c58100..0e4cf8d6 100644
--- a/backend/src/services/pdfService.js
+++ b/backend/src/services/pdfService.js
@@ -844,7 +844,9 @@ function drawTotals(doc, ctx, x, y, width) {
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).text(formatMinor(totals.shippingAmountMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' });
y = doc.y + 4;
- doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).text(t(locale, 'totals_vat'), labelX, y, { width: labelCol });
+ // Custom VAT label (Settings → Accounting) overrides the per-locale default.
+ const vatLabel = (ctx.issuer && ctx.issuer.vatLabel) || t(locale, 'totals_vat');
+ doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).text(vatLabel, labelX, y, { width: labelCol });
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).text(`${stripTrailingZeros(totals.vatRate)}%`, rateX, y, { width: rateCol, align: 'right' });
doc.text(formatMinor(totals.vatAmountMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' });
y = doc.y + 4;
diff --git a/frontend/src/components/admin/AccountingProfileFields.tsx b/frontend/src/components/admin/AccountingProfileFields.tsx
new file mode 100644
index 00000000..943ffd0f
--- /dev/null
+++ b/frontend/src/components/admin/AccountingProfileFields.tsx
@@ -0,0 +1,82 @@
+/**
+ * Business-profile financial fields surfaced on the Accounting tab: the
+ * **VAT label** (printed on invoice/quote PDFs) and the **default hourly rate**
+ * (install-wide fallback for hours logging). The values still live on
+ * `business_profile`; this is a self-contained card with its own save (mirrors
+ * VatCodesManager) so it can't clobber the rest of the business profile, and it
+ * shares the `business-profile` query cache so both pages stay in sync.
+ */
+import React, { useEffect, useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { toast } from 'react-toastify';
+import { Save } from 'lucide-react';
+import { Button, Card, CardContent, Input, Loading } from '../common';
+import { DecimalInput } from '../common/DecimalInput';
+import { businessProfileService } from '../../services/businessProfile.service';
+
+const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
+const inputCls = 'w-full max-w-xs rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm';
+
+export const AccountingProfileFields: React.FC = () => {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+ const { data, isLoading } = useQuery({ queryKey: ['business-profile'], queryFn: () => businessProfileService.get() });
+
+ const [vatLabel, setVatLabel] = useState('');
+ const [hourlyMajor, setHourlyMajor] = useState(NaN);
+ const currency = data?.profile?.defaultCurrency || 'CHF';
+
+ useEffect(() => {
+ if (data?.profile) {
+ setVatLabel(data.profile.vatLabel || '');
+ setHourlyMajor(data.profile.defaultHourlyRateMinor != null ? data.profile.defaultHourlyRateMinor / 100 : NaN);
+ }
+ }, [data]);
+
+ const save = useMutation({
+ mutationFn: () => businessProfileService.update({
+ vatLabel: vatLabel || '',
+ defaultHourlyRateMinor: Number.isFinite(hourlyMajor) ? Math.max(0, Math.round(hourlyMajor * 100)) : null,
+ }),
+ onSuccess: () => {
+ toast.success(t('settings.accounting.profileFields.savedToast', 'Saved.'));
+ qc.invalidateQueries({ queryKey: ['business-profile'] });
+ },
+ onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
+ });
+
+ if (isLoading) return ;
+
+ return (
+
+
{t('settings.accounting.profileFields.vatLabelHint', 'Printed as the VAT-line label on invoice / quote PDFs. Leave blank to use the document language default.')}
+
+
+
+
+
+
+ {t('settings.accounting.profileFields.hourlyRateHint', 'Fallback used when a customer has no own rate. In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.', { currency })}
+
+
+
+
+
+ );
+};
+
+export default AccountingProfileFields;
diff --git a/frontend/src/components/admin/VatRateSelect.tsx b/frontend/src/components/admin/VatRateSelect.tsx
index bac73c34..588f3f40 100644
--- a/frontend/src/components/admin/VatRateSelect.tsx
+++ b/frontend/src/components/admin/VatRateSelect.tsx
@@ -1,17 +1,23 @@
/**
- * VAT-rate picker for the invoice/quote editors. A dropdown of the configured
- * OUTPUT VAT codes (Settings → Accounting) plus an "Other (custom rate)" escape
- * hatch. Controlled by `(rate, code)`: selecting a code emits its rate + code
- * string (snapshotted on the document for the accounting export); "Other" emits
- * the typed rate with a null code. Reads the un-gated /admin/vat-codes endpoint,
- * so it works even when the accounting feature is off.
+ * VAT-rate picker for the invoice/quote editors. A dropdown whose ONLY options
+ * are the configured OUTPUT VAT codes (Settings → Accounting) — there is no
+ * free-text custom rate; to use a different rate, add a VAT code in Accounting.
+ * Controlled by `(rate, code)`: selecting a code emits its rate + code string
+ * (snapshotted on the document for the accounting export). Reads the un-gated
+ * /admin/vat-codes endpoint so it works even when the accounting feature is off.
+ *
+ * Legacy preservation: when editing a document whose stored rate/code isn't an
+ * accounting code anymore (an old invoice, or a deleted code), that value is
+ * shown as a read-only "(not configured)" option so it stays selected and is
+ * never silently changed — issued invoices are immutable. The admin can still
+ * switch it to a current code.
*/
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { vatCodesService, type VatCodeOption } from '../../services/vatCodes.service';
-const CUSTOM = '__custom__';
+const LEGACY = '__legacy__';
const selectCls =
'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500';
@@ -32,11 +38,11 @@ export const VatRateSelect: React.FC = ({ rate, code, onChange, label, di
});
// Selected option: prefer the snapshotted code; else a code whose rate matches
- // (legacy rows / no code stored); else "custom".
+ // (legacy rows / no code stored); else the document's value is "off-list".
const matched: VatCodeOption | undefined =
(code ? codes.find((c) => c.code === code) : undefined)
|| (!code ? codes.find((c) => Number(c.rate) === Number(rate)) : undefined);
- const isCustom = !matched;
+ const showLegacy = !matched;
return (
);
};
diff --git a/frontend/src/constants/currencies.ts b/frontend/src/constants/currencies.ts
new file mode 100644
index 00000000..0b2478e8
--- /dev/null
+++ b/frontend/src/constants/currencies.ts
@@ -0,0 +1,30 @@
+/**
+ * Currency codes for the Business-profile "Default currency" dropdown.
+ * CH/LI-first ordering (picpeak's primary scope), then the common EUR/USD/GBP
+ * and a broad set of other ISO 4217 codes. Stored value is the bare 3-letter
+ * code (e.g. "CHF").
+ */
+export const CURRENCY_CODES: string[] = [
+ 'CHF', 'EUR', 'USD', 'GBP',
+ 'AUD', 'CAD', 'CNY', 'CZK', 'DKK', 'HKD', 'HUF', 'ILS', 'INR', 'JPY',
+ 'NOK', 'NZD', 'PLN', 'RON', 'SEK', 'SGD', 'THB', 'TRY', 'ZAR',
+];
+
+/**
+ * Normalise a stored/typed currency value to a known code. Upper-cases + trims
+ * (so an old free-text "chf" resolves to "CHF"). Returns the matched code, or
+ * the cleaned input if it isn't in the known list (caller preserves it as an
+ * extra option so nothing is lost), or '' for empty input.
+ */
+export function normalizeCurrency(value: string | null | undefined): string {
+ const cleaned = (value || '').trim().toUpperCase();
+ if (!cleaned) return '';
+ return CURRENCY_CODES.includes(cleaned) ? cleaned : cleaned;
+}
+
+/** Build the option list, prepending an unknown-but-set value so it's preserved. */
+export function currencyOptions(current: string | null | undefined): string[] {
+ const cur = normalizeCurrency(current);
+ if (cur && !CURRENCY_CODES.includes(cur)) return [cur, ...CURRENCY_CODES];
+ return CURRENCY_CODES;
+}
diff --git a/frontend/src/features/settings/tabs/AccountingTab.tsx b/frontend/src/features/settings/tabs/AccountingTab.tsx
index 90cb79d8..52a27047 100644
--- a/frontend/src/features/settings/tabs/AccountingTab.tsx
+++ b/frontend/src/features/settings/tabs/AccountingTab.tsx
@@ -14,6 +14,7 @@ import { accountingService } from '../../../services/accounting.service';
import { sortedCountryOptions } from '../../../constants/countries';
import { VatCodesManager } from '../../../components/admin/VatCodesManager';
import { ChartOfAccountsManager } from '../../../components/admin/ChartOfAccountsManager';
+import { AccountingProfileFields } from '../../../components/admin/AccountingProfileFields';
const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
const inputCls = 'w-full max-w-xs rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm';
@@ -119,6 +120,11 @@ export const AccountingTab: React.FC = () => {
+ {/* VAT label + default hourly rate — moved here from Business profile so
+ all financial/VAT config lives in one place (storage stays on
+ business_profile; this card has its own save). */}
+
+
{/* VAT codes + rate→code / treatment→code maps — relocated here from the
Chart-of-accounts page so all VAT config lives in one place. */}
diff --git a/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx b/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx
index 8bf35e60..b8d188e1 100644
--- a/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx
+++ b/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx
@@ -18,8 +18,8 @@ import {
type QrFormat,
} from '../../../services/businessProfile.service';
import { Button, Card, Loading, Input, CountrySelect, TimeField } from '../../../components/common';
-import { DecimalInput } from '../../../components/common/DecimalInput';
import { toast } from 'react-toastify';
+import { currencyOptions, normalizeCurrency } from '../../../constants/currencies';
// Full IANA timezone list for the picker. `Intl.supportedValuesOf` is ES2022
// (all current browsers); fall back to a small CH/LI-relevant set on the rare
@@ -45,7 +45,16 @@ export const SettingsBusinessProfilePage: React.FC = () => {
useEffect(() => { if (data?.profile) setProfile(data.profile); }, [data]);
const saveProfile = useMutation({
- mutationFn: () => profile ? businessProfileService.update(profile) : Promise.reject(),
+ // vatLabel + defaultHourlyRateMinor now live on Settings → Accounting, and
+ // vatRateDefault is retired (the rates are the Accounting VAT codes). Strip
+ // them from this save so an open Business-profile page can't clobber an edit
+ // made on the Accounting tab with its stale loaded value.
+ mutationFn: () => {
+ if (!profile) return Promise.reject();
+ const { vatLabel, defaultHourlyRateMinor, vatRateDefault, ...rest } = profile;
+ void vatLabel; void defaultHourlyRateMinor; void vatRateDefault;
+ return businessProfileService.update(rest);
+ },
onSuccess: () => {
toast.success(t('businessProfile.savedToast', 'Business profile saved.'));
qc.invalidateQueries({ queryKey: ['business-profile'] });
@@ -124,9 +133,29 @@ export const SettingsBusinessProfilePage: React.FC = () => {
+
+ {/* Dropdown; the stored value is normalised (e.g. an old free-text
+ "chf" → "CHF") so it pre-selects, and an unknown code is kept as
+ an extra option so nothing is lost. */}
+
+
- setProfile({ ...profile, vatLabel: e.target.value })} />
- setProfile({ ...profile, vatRateDefault: Number(e.target.value) })} />
- {/* Install-wide fallback hourly rate (migration 113). Stored in
- minor units; entered here in major units. Blank = no global
- default, so hours-logging then needs a per-customer or
- per-entry rate. Comma-tolerant via DecimalInput. */}
-
- {t('businessProfile.field.defaultHourlyRateHint',
- 'Fallback used when a customer has no own rate. In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.',
- { currency: profile.defaultCurrency || 'CHF' })}
-
-
+ {/* VAT rate %, VAT label and the default hourly rate moved to
+ Settings → Accounting (so all financial/VAT config lives in one
+ place). See the callout above. */}
+
+
+
+
{t('settings.accounting.vat.defaultOutputCodeHint', 'New invoices and quotes start with this VAT code selected. Existing documents are unaffected.')}
+
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index efcdd68f..ed02f635 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -1729,7 +1729,10 @@
"registered": "MwSt-pflichtig (Umsatzsteuer berechnen + Vorsteuer abziehen)",
"registeredHint": "Aus = Kleinunternehmen / unter der Schwelle: keine MwSt berechnet, Vorsteuer ist Aufwand (nicht abziehbar).",
"reclaimCountries": "Länder mit abziehbarer Vorsteuer",
- "reclaimCountriesHint": "Üblicherweise Ihr Inland (CH / LI). Kosten aus anderen Ländern gelten als nicht abziehbare ausländische MwSt. Cmd/Ctrl-Klick für Mehrfachauswahl."
+ "reclaimCountriesHint": "Üblicherweise Ihr Inland (CH / LI). Kosten aus anderen Ländern gelten als nicht abziehbare ausländische MwSt. Cmd/Ctrl-Klick für Mehrfachauswahl.",
+ "defaultOutputCode": "Standard-MwSt-Code für neue Rechnungen",
+ "defaultOutputCodeNone": "— keiner (bei 0% beginnen) —",
+ "defaultOutputCodeHint": "Neue Rechnungen und Angebote starten mit diesem MwSt-Code. Bestehende Dokumente bleiben unverändert."
},
"disclaimer": "Sätze und MwSt-/Steuerbehandlung dienen nur als Orientierung — mit Ihrem Treuhänder prüfen.",
"savedToast": "Buchhaltungseinstellungen gespeichert.",
@@ -3572,7 +3575,10 @@
"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."
+ "passthroughCustomerHint": "Optional — einen Kunden zuordnen, um diese durchlaufende Position weiterzuverrechnen; leer lassen, um sie nur auf das Event zu buchen.",
+ "supplierCountry": "Lieferantenland",
+ "supplierCountryNone": "— unbekannt —",
+ "supplierCountryHint": "Setzt die Steuerbehandlung automatisch: ausserhalb deiner Vorsteuer-Länder → ausländische MwSt (nicht abziehbar)."
}
},
"expenseStatus": {
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index ec11d9f8..aa75abc3 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -1287,7 +1287,10 @@
"registered": "VAT-registered (charge output VAT + reclaim input VAT)",
"registeredHint": "Off = small business / under threshold: no VAT charged, input VAT is a cost (not reclaimable).",
"reclaimCountries": "Countries where input VAT is reclaimable",
- "reclaimCountriesHint": "Typically your domestic country (CH / LI). Costs from other countries are treated as non-reclaimable foreign VAT. Cmd/Ctrl-click to multi-select."
+ "reclaimCountriesHint": "Typically your domestic country (CH / LI). Costs from other countries are treated as non-reclaimable foreign VAT. Cmd/Ctrl-click to multi-select.",
+ "defaultOutputCode": "Default VAT code for new invoices",
+ "defaultOutputCodeNone": "— none (start at 0%) —",
+ "defaultOutputCodeHint": "New invoices and quotes start with this VAT code selected. Existing documents are unaffected."
},
"disclaimer": "Rates and VAT/tax treatment are guidance only — verify with your Treuhänder.",
"savedToast": "Accounting settings saved.",
@@ -3572,7 +3575,10 @@
"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."
+ "passthroughCustomerHint": "Optional — attach a client to re-bill this passthrough; leave empty to only book it to the event.",
+ "supplierCountry": "Supplier country",
+ "supplierCountryNone": "— unknown —",
+ "supplierCountryHint": "Sets the tax treatment automatically: outside your VAT-reclaim countries → foreign VAT (not reclaimable)."
}
},
"expenseStatus": {
diff --git a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx
index ff95c061..58d34cdc 100644
--- a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx
+++ b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx
@@ -16,6 +16,7 @@ import { DecimalInput } from '../../../components/common/DecimalInput';
import { CustomerAccountPicker, type SelectedCustomer } from '../../../components/admin/CustomerAccountPicker';
import { EventBookingSelect } from '../../../components/admin/EventBookingSelect';
import { formatMoneyMinor } from '../../../utils/money';
+import { sortedCountryOptions } from '../../../constants/countries';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import {
accountingService, categoryLabel,
@@ -170,13 +171,14 @@ const ViewModal: React.FC<{ doc: InboundDocument; onClose: () => void }> = ({ do
};
const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[]; onClose: () => void; onDone: () => void }> = ({ doc, categories, onClose, onDone }) => {
- const { t } = useTranslation();
+ const { t, i18n } = useTranslation();
const [supplier, setSupplier] = useState(doc.supplierName || '');
const [amountMajor, setAmountMajor] = useState(doc.totalAmountMinor != null ? doc.totalAmountMinor / 100 : NaN);
const [currency, setCurrency] = useState(doc.currency || 'CHF');
const [invoiceDate, setInvoiceDate] = useState(doc.invoiceDate || '');
const [reference, setReference] = useState(doc.paymentReference || '');
const [note, setNote] = useState(doc.note || '');
+ const [supplierCountry, setSupplierCountry] = useState(doc.supplierCountry || '');
// 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(doc.disposition || 'eigener_aufwand');
@@ -205,7 +207,7 @@ 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, note: note || null });
+ await accountingService.updateInbound(doc.id, { supplierName: supplier || null, totalAmountMinor: totalMinor, currency: currency || null, invoiceDate: invoiceDate || null, paymentReference: reference || null, note: note || null, supplierCountry: supplierCountry || null });
await accountingService.categorizeInbound(doc.id, {
disposition,
eventId: BOOKING_DISPOSITIONS.includes(disposition) ? eventId : null,
@@ -243,6 +245,13 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
setSupplier(e.target.value)} />
setCurrency(e.target.value.toUpperCase())} />
+
+
+
{t('accounting.inbox.field.supplierCountryHint', 'Sets the tax treatment automatically: outside your VAT-reclaim countries → foreign VAT (not reclaimable).')}
+
setReference(e.target.value)} placeholder={t('accounting.inbox.field.referenceHint', 'QR / ESR reference or message') as string} />
diff --git a/frontend/src/pages/admin/bills/BillEditorPage.tsx b/frontend/src/pages/admin/bills/BillEditorPage.tsx
index 1980d1e6..c2f0245f 100644
--- a/frontend/src/pages/admin/bills/BillEditorPage.tsx
+++ b/frontend/src/pages/admin/bills/BillEditorPage.tsx
@@ -15,6 +15,8 @@ import { contractsService } from '../../../services/contracts.service';
import { businessProfileService } from '../../../services/businessProfile.service';
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
import { VatRateSelect } from '../../../components/admin/VatRateSelect';
+import { accountingService } from '../../../services/accounting.service';
+import { vatCodesService } from '../../../services/vatCodes.service';
import { LineItemsTable, type EditableLineItem } from '../../../components/admin/LineItemsTable';
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
import { customerAdminService } from '../../../services/customerAdmin.service';
@@ -197,6 +199,23 @@ export const BillEditorPage: React.FC = () => {
setCcPdfEmail((cur) => cur || currentAdmin.email);
}, [currentAdmin?.email, isEdit]);
+ // Seed the VAT from the configured default OUTPUT code (Settings →
+ // Accounting) on a brand-new, blank invoice — so new invoices don't silently
+ // start at 0%. Skips edits and conversions (quote/contract bring their own
+ // VAT), and never clobbers a value the admin already touched.
+ const { data: acctSettings } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
+ const { data: outputVatCodes } = useQuery({ queryKey: ['vat-codes', 'output'], queryFn: () => vatCodesService.listOutput() });
+ const didSeedVatRef = useRef(false);
+ useEffect(() => {
+ if (isEdit || didSeedVatRef.current) return;
+ if (searchParams.get('fromContractId') || searchParams.get('fromQuoteId')) return;
+ if (vatCode || vatRate) return;
+ const code = acctSettings?.accounting_default_output_vat_code;
+ if (!code || !outputVatCodes) return;
+ const match = outputVatCodes.find((c) => c.code === code);
+ if (match) { didSeedVatRef.current = true; setVatRate(Number(match.rate)); setVatCode(match.code); }
+ }, [isEdit, searchParams, acctSettings, outputVatCodes, vatCode, vatRate]);
+
// Pre-fill the customer when the editor is opened from a customer
// detail page via `?customerAccountId=42`. Runs once on mount, only
// when creating a new invoice, and skips if the user has already
diff --git a/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx b/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx
index a9ee3f2d..4e3ac177 100644
--- a/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx
+++ b/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx
@@ -26,6 +26,8 @@ import { LineItemsTable, type EditableLineItem } from '../../../components/admin
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
import { ProjectSelect } from '../../../components/admin/ProjectSelect';
import { VatRateSelect } from '../../../components/admin/VatRateSelect';
+import { accountingService } from '../../../services/accounting.service';
+import { vatCodesService } from '../../../services/vatCodes.service';
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
import { customerAdminService } from '../../../services/customerAdmin.service';
import { userManagementService } from '../../../services/userManagement.service';
@@ -189,6 +191,23 @@ export const QuoteEditorPage: React.FC = () => {
}
})();
}, [isEdit, searchParams]);
+
+ // Seed the VAT from the configured default OUTPUT code (Settings →
+ // Accounting) on a brand-new, blank quote — so quotes (and the invoices they
+ // convert to) don't silently start at 0%. Never clobbers a touched value.
+ const { data: acctSettings } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
+ const { data: outputVatCodes } = useQuery({ queryKey: ['vat-codes', 'output'], queryFn: () => vatCodesService.listOutput() });
+ const didSeedVatRef = useRef(false);
+ useEffect(() => {
+ if (isEdit || didSeedVatRef.current) return;
+ const code = acctSettings?.accounting_default_output_vat_code;
+ if (!code || !outputVatCodes) return;
+ const match = outputVatCodes.find((c) => c.code === code);
+ if (!match) return;
+ setForm((prev) => (prev.vatCode || prev.vatRate ? prev : { ...prev, vatRate: Number(match.rate), vatCode: match.code }));
+ didSeedVatRef.current = true;
+ }, [isEdit, acctSettings, outputVatCodes]);
+
// Customer search + inline-create state now lives inside
// (migration C.5 extraction).
diff --git a/frontend/src/services/accounting.service.ts b/frontend/src/services/accounting.service.ts
index 90d54e5c..c2d4d093 100644
--- a/frontend/src/services/accounting.service.ts
+++ b/frontend/src/services/accounting.service.ts
@@ -39,6 +39,8 @@ export interface InboundDocument {
customerAccountId: number | null;
customerName: string | null;
customerEmail: string | null;
+ /** ISO-2 supplier country — auto-defaults the tax treatment (reclaim list). */
+ supplierCountry: string | null;
/** Free-text categorisation note. */
note: string | null;
supplierPaid: boolean;
@@ -112,6 +114,8 @@ export interface AccountingSettings {
/** ISO-2 countries whose input VAT can be reclaimed (drives cost
* tax-treatment + the report's VAT-payable). */
accounting_vat_reclaim_countries: string[];
+ /** Output VAT code stamped onto NEW invoices/quotes ('' = none). */
+ accounting_default_output_vat_code: string;
}
export interface CategorizePayload {
@@ -210,6 +214,8 @@ export const accountingService = {
accounting_vat_registered: data.accounting_vat_registered === true,
accounting_vat_reclaim_countries: Array.isArray(data.accounting_vat_reclaim_countries)
? data.accounting_vat_reclaim_countries : [],
+ accounting_default_output_vat_code: typeof data.accounting_default_output_vat_code === 'string'
+ ? data.accounting_default_output_vat_code : '',
};
},
async updateSettings(payload: Partial): Promise<{ updated: string[] }> {
From 33d5408977666348eeb829d8b52bc479a8de408c Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Thu, 18 Jun 2026 15:56:52 +0200
Subject: [PATCH 08/11] refactor(accounting): consolidate the Accounting tab
into two cards + one Save
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Box 1 "Default rates": mileage, daily allowance, hourly rate, require-proof.
Hints now make the cost-vs-billing split explicit (daily allowance = expense,
hourly = billing fallback).
- Box 2 retitled "VAT": registration, reclaim, default invoice VAT code, and
the VAT label (moved out of its own card).
- Drop the third card (AccountingProfileFields deleted); the two Save buttons
become one — it persists both the app_settings and the two business_profile
fields (VAT label + hourly rate) together.
- Rename "Per-diem" → "Daily allowance" (EN) for clarity; German keeps the
established "Spesenpauschale".
---
.../admin/AccountingProfileFields.tsx | 82 -------------------
.../features/settings/tabs/AccountingTab.tsx | 67 ++++++++++-----
frontend/src/i18n/locales/de.json | 8 +-
frontend/src/i18n/locales/en.json | 12 +--
4 files changed, 58 insertions(+), 111 deletions(-)
delete mode 100644 frontend/src/components/admin/AccountingProfileFields.tsx
diff --git a/frontend/src/components/admin/AccountingProfileFields.tsx b/frontend/src/components/admin/AccountingProfileFields.tsx
deleted file mode 100644
index 943ffd0f..00000000
--- a/frontend/src/components/admin/AccountingProfileFields.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-/**
- * Business-profile financial fields surfaced on the Accounting tab: the
- * **VAT label** (printed on invoice/quote PDFs) and the **default hourly rate**
- * (install-wide fallback for hours logging). The values still live on
- * `business_profile`; this is a self-contained card with its own save (mirrors
- * VatCodesManager) so it can't clobber the rest of the business profile, and it
- * shares the `business-profile` query cache so both pages stay in sync.
- */
-import React, { useEffect, useState } from 'react';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { useTranslation } from 'react-i18next';
-import { toast } from 'react-toastify';
-import { Save } from 'lucide-react';
-import { Button, Card, CardContent, Input, Loading } from '../common';
-import { DecimalInput } from '../common/DecimalInput';
-import { businessProfileService } from '../../services/businessProfile.service';
-
-const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
-const inputCls = 'w-full max-w-xs rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm';
-
-export const AccountingProfileFields: React.FC = () => {
- const { t } = useTranslation();
- const qc = useQueryClient();
- const { data, isLoading } = useQuery({ queryKey: ['business-profile'], queryFn: () => businessProfileService.get() });
-
- const [vatLabel, setVatLabel] = useState('');
- const [hourlyMajor, setHourlyMajor] = useState(NaN);
- const currency = data?.profile?.defaultCurrency || 'CHF';
-
- useEffect(() => {
- if (data?.profile) {
- setVatLabel(data.profile.vatLabel || '');
- setHourlyMajor(data.profile.defaultHourlyRateMinor != null ? data.profile.defaultHourlyRateMinor / 100 : NaN);
- }
- }, [data]);
-
- const save = useMutation({
- mutationFn: () => businessProfileService.update({
- vatLabel: vatLabel || '',
- defaultHourlyRateMinor: Number.isFinite(hourlyMajor) ? Math.max(0, Math.round(hourlyMajor * 100)) : null,
- }),
- onSuccess: () => {
- toast.success(t('settings.accounting.profileFields.savedToast', 'Saved.'));
- qc.invalidateQueries({ queryKey: ['business-profile'] });
- },
- onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
- });
-
- if (isLoading) return ;
-
- return (
-
-
{t('settings.accounting.profileFields.vatLabelHint', 'Printed as the VAT-line label on invoice / quote PDFs. Leave blank to use the document language default.')}
-
-
-
-
-
-
- {t('settings.accounting.profileFields.hourlyRateHint', 'Fallback used when a customer has no own rate. In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.', { currency })}
-
{t('settings.accounting.kmRateHint', 'Default applied to mileage expenses; overridable per entry.')}
-
+
-
{t('settings.accounting.perDiemRateHint', 'Default applied to per-diem expenses; overridable per entry.')}
+
{t('settings.accounting.perDiemRateHint', 'A flat daily allowance booked as an expense (not a client billing rate); overridable per entry.')}
+
+
+
+
+
{t('settings.accounting.profileFields.hourlyRateHint', 'Billing fallback used when a customer has no own rate (hours logging). In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.', { currency })}
{t('settings.accounting.profileFields.vatLabelHint', 'Printed as the VAT-line label on invoice / quote PDFs. Leave blank to use the document language default.')}
+
- {/* VAT label + default hourly rate — moved here from Business profile so
- all financial/VAT config lives in one place (storage stays on
- business_profile; this card has its own save). */}
-
-
{/* VAT codes + rate→code / treatment→code maps — relocated here from the
Chart-of-accounts page so all VAT config lives in one place. */}
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index ed02f635..f71f6aaf 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -1718,14 +1718,14 @@
},
"accounting": {
"title": "Buchhaltung",
- "subtitle": "Standardsätze für interne Aufwände und die Belegpflicht.",
+ "subtitle": "Standardsätze, MwSt-Einstellungen und die Belegpflicht.",
"kmRate": "Kilometersatz (CHF / km)",
"kmRateHint": "Standard für Kilometer-Aufwände; pro Eintrag überschreibbar.",
"perDiemRate": "Spesenpauschale (CHF / Tag)",
- "perDiemRateHint": "Standard für Pauschal-Aufwände; pro Eintrag überschreibbar.",
+ "perDiemRateHint": "Eine Tagespauschale, die als Aufwand gebucht wird (kein Kunden-Verrechnungssatz); pro Eintrag überschreibbar.",
"requireProof": "Beleg für jeden Aufwand verlangen",
"vat": {
- "title": "MwSt-Registrierung & Vorsteuerabzug",
+ "title": "MwSt.",
"registered": "MwSt-pflichtig (Umsatzsteuer berechnen + Vorsteuer abziehen)",
"registeredHint": "Aus = Kleinunternehmen / unter der Schwelle: keine MwSt berechnet, Vorsteuer ist Aufwand (nicht abziehbar).",
"reclaimCountries": "Länder mit abziehbarer Vorsteuer",
@@ -1742,7 +1742,7 @@
"vatLabelHint": "Wird als Bezeichnung der MwSt-Zeile auf Rechnungs-/Angebots-PDFs gedruckt. Leer lassen, um die Standardbezeichnung der Dokumentsprache zu verwenden.",
"hourlyRate": "Standard-Stundensatz",
"hourlyRatePlaceholder": "z. B. 120.00",
- "hourlyRateHint": "Fallback, wenn ein Kunde keinen eigenen Satz hat. In {{currency}}, in Hauptwährungseinheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen.",
+ "hourlyRateHint": "Verrechnungs-Fallback, wenn ein Kunde keinen eigenen Satz hat (Stundenerfassung). In {{currency}}, in Hauptwährungseinheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen.",
"savedToast": "Gespeichert."
}
}
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index aa75abc3..e1d02ea1 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -1276,14 +1276,14 @@
},
"accounting": {
"title": "Accounting",
- "subtitle": "Default rates for internal expenses and the proof requirement.",
+ "subtitle": "Default rates, VAT settings and the proof requirement.",
"kmRate": "Mileage rate (CHF / km)",
"kmRateHint": "Default applied to mileage expenses; overridable per entry.",
- "perDiemRate": "Per-diem rate (CHF / day)",
- "perDiemRateHint": "Default applied to per-diem expenses; overridable per entry.",
+ "perDiemRate": "Daily allowance (CHF / day)",
+ "perDiemRateHint": "A flat daily allowance booked as an expense (not a client billing rate); overridable per entry.",
"requireProof": "Require a proof file on every expense",
"vat": {
- "title": "VAT registration & reclaim",
+ "title": "VAT",
"registered": "VAT-registered (charge output VAT + reclaim input VAT)",
"registeredHint": "Off = small business / under threshold: no VAT charged, input VAT is a cost (not reclaimable).",
"reclaimCountries": "Countries where input VAT is reclaimable",
@@ -1300,7 +1300,7 @@
"vatLabelHint": "Printed as the VAT-line label on invoice / quote PDFs. Leave blank to use the document language default.",
"hourlyRate": "Default hourly rate",
"hourlyRatePlaceholder": "e.g. 120.00",
- "hourlyRateHint": "Fallback used when a customer has no own rate. In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.",
+ "hourlyRateHint": "Billing fallback used when a customer has no own rate (hours logging). In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.",
"savedToast": "Saved."
}
}
@@ -3639,7 +3639,7 @@
"expenseKind": {
"amount": "Amount",
"mileage": "Mileage (km)",
- "per_diem": "Per-diem"
+ "per_diem": "Daily allowance"
},
"category": {
"infrastructure": "Infrastructure & rent",
From 707c5d027798bdafb9fe09d7efcbd9ea65330076 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Thu, 18 Jun 2026 18:40:11 +0200
Subject: [PATCH 09/11] fix(accounting): address the-luap PR #636 review
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- #1 resolveTaxTreatment: an unconfigured (empty) reclaim-countries list no
longer auto-classifies every supplier — incl. the admin's own domestic one —
as foreign; defer auto-classification until the setting is set (+ test).
- #2 pending re-bills on customer erase: eraseCustomer now returns the
customer's not-yet-billed inbound docs to the inbox (null customer + unsorted)
so they aren't billable to an anonymized account. (NB: picpeak has no hard
customer delete — erase anonymizes in place — so the orphan/404 premise can't
occur; this is hardening.)
- #4 VatRateSelect: when >1 configured code shares the same rate, fall through
to the legacy "(not configured)" option instead of silently picking the first.
- #5 unwindBilledLine: delete the (mutable, never-issued) invoice when the
unwound re-bill was its only line, instead of leaving a net-zero survivor.
- #6 isInvoiceMutable: clarify in a comment that invoices have no 'draft' status
(the editable state is 'scheduled' w/o send-at) — no behaviour change.
- nit: collapse normalizeCurrency's tautological ternary.
- Fix VAT picker i18n: t('vat.legacyRate') → 'ledger.vat.legacyRate' (the key's
real home), so the legacy label localizes instead of always showing English.
- Remove dead i18n keys left by the settings refactor (businessProfile.field VAT
/hourly + profileFields.title/savedToast).
---
.../services/expenseService.markup.test.js | 5 +++++
backend/src/services/customerAccountsService.js | 12 ++++++++++++
backend/src/services/expenseService.js | 17 +++++++++++++++++
frontend/src/components/admin/VatRateSelect.tsx | 11 ++++++++---
frontend/src/constants/currencies.ts | 4 +---
frontend/src/i18n/locales/de.json | 9 +--------
frontend/src/i18n/locales/en.json | 9 +--------
7 files changed, 45 insertions(+), 22 deletions(-)
diff --git a/backend/__tests__/services/expenseService.markup.test.js b/backend/__tests__/services/expenseService.markup.test.js
index a718a2a1..05b7b823 100644
--- a/backend/__tests__/services/expenseService.markup.test.js
+++ b/backend/__tests__/services/expenseService.markup.test.js
@@ -131,6 +131,11 @@ describe('resolveTaxTreatment (supplier-country auto-default)', () => {
expect(resolveTaxTreatment(undefined, '', reclaim)).toBe('domestic');
expect(resolveTaxTreatment(undefined, null, reclaim)).toBe('domestic');
});
+ it('an UNCONFIGURED (empty) reclaim list never auto-classifies as foreign (PR #636 #1)', () => {
+ expect(resolveTaxTreatment(undefined, 'CH', [])).toBe('domestic');
+ expect(resolveTaxTreatment(undefined, 'DE', [])).toBe('domestic');
+ expect(resolveTaxTreatment(undefined, 'US', undefined)).toBe('domestic');
+ });
it('invalid explicit treatment is ignored (falls through to country logic)', () => {
expect(resolveTaxTreatment('bogus', 'DE', reclaim)).toBe('foreign_vat_non_reclaimable');
});
diff --git a/backend/src/services/customerAccountsService.js b/backend/src/services/customerAccountsService.js
index db83b540..041790e0 100644
--- a/backend/src/services/customerAccountsService.js
+++ b/backend/src/services/customerAccountsService.js
@@ -785,6 +785,18 @@ async function eraseCustomer(id, erasedByAdminId) {
// Active reset tokens for this customer should be invalidated.
await trx('customer_password_resets').where('customer_account_id', id).del();
+
+ // Pending re-bills (incoming invoices, migration 132) attached to this
+ // customer would otherwise stay billable to the now-anonymized account —
+ // return the not-yet-billed ones to the inbox for re-triage so they're not
+ // silently lost or billed to a ghost (PR #636 review #2). Guarded for
+ // schema drift on installs that predate migration 132.
+ if (await trx.schema.hasColumn('inbound_documents', 'customer_account_id')) {
+ await trx('inbound_documents')
+ .where({ customer_account_id: id })
+ .whereNull('billed_invoice_id')
+ .update({ customer_account_id: null, disposition: null, status: 'unsorted', updated_at: new Date() });
+ }
});
await logActivity('customer_erased',
diff --git a/backend/src/services/expenseService.js b/backend/src/services/expenseService.js
index de5499e6..8943805a 100644
--- a/backend/src/services/expenseService.js
+++ b/backend/src/services/expenseService.js
@@ -76,6 +76,10 @@ function resolveTaxTreatment(payloadTreatment, supplierCountry, reclaimCountries
if (TAX_TREATMENTS.includes(payloadTreatment)) return payloadTreatment;
const cc = String(supplierCountry || '').toUpperCase();
if (!cc) return 'domestic';
+ // Don't auto-classify until the admin has actually configured their reclaim
+ // countries — an unset (empty) list must not make every supplier, including
+ // the admin's own domestic one, "foreign". (PR #636 review #1.)
+ if (!reclaimCountries || reclaimCountries.length === 0) return 'domestic';
return reclaimCountries.includes(cc) ? 'domestic' : 'foreign_vat_non_reclaimable';
}
@@ -287,6 +291,10 @@ const BOOKING_DISPOSITIONS = ['rebill', 'durchlaufend'];
function isInvoiceMutable(invoice) {
if (!invoice) return true; // referenced invoice gone — treat as not billed
if (invoice.is_monthly_draft === true || invoice.is_monthly_draft === 1) return true;
+ // NB: invoices have no 'draft' status (only quotes do). The editable,
+ // not-yet-sent invoice state IS 'scheduled' with no scheduled_send_at (or a
+ // future one), handled below — so there is no plain-'draft' case to slot in
+ // here (PR #636 review #6).
if (invoice.status !== 'scheduled') return false;
if (!invoice.scheduled_send_at) return true;
return new Date(invoice.scheduled_send_at).getTime() > Date.now();
@@ -312,6 +320,15 @@ async function unwindBilledLine(trx, doc) {
}
if (invoice) {
const allItems = await trx('invoice_line_items').where({ invoice_id: invoice.id });
+ if (allItems.length === 0) {
+ // The unwound re-bill was the only line — a net-zero invoice has no reason
+ // to survive, and these would otherwise pile up over re-categorisations.
+ // It's mutable (checked above) and never issued, so delete it outright
+ // (PR #636 review #5). For a monthly draft this just means the next append
+ // re-creates one.
+ await trx('invoices').where({ id: invoice.id }).del();
+ return;
+ }
let netMinor = 0;
for (const li of allItems) {
if (li.parent_line_item_id == null) netMinor += Number(li.line_total_minor || 0);
diff --git a/frontend/src/components/admin/VatRateSelect.tsx b/frontend/src/components/admin/VatRateSelect.tsx
index 588f3f40..6427a067 100644
--- a/frontend/src/components/admin/VatRateSelect.tsx
+++ b/frontend/src/components/admin/VatRateSelect.tsx
@@ -38,10 +38,15 @@ export const VatRateSelect: React.FC = ({ rate, code, onChange, label, di
});
// Selected option: prefer the snapshotted code; else a code whose rate matches
- // (legacy rows / no code stored); else the document's value is "off-list".
+ // (legacy rows / no code stored) — BUT only when that rate is unambiguous. If
+ // two configured codes share the rate (e.g. two 8.1% codes), rate-matching
+ // could silently swap one for the other on the next save, so fall through to
+ // the legacy "(not configured)" option and make the admin pick explicitly
+ // (PR #636 review #4).
const matched: VatCodeOption | undefined =
(code ? codes.find((c) => c.code === code) : undefined)
- || (!code ? codes.find((c) => Number(c.rate) === Number(rate)) : undefined);
+ || (!code && codes.filter((c) => Number(c.rate) === Number(rate)).length === 1
+ ? codes.find((c) => Number(c.rate) === Number(rate)) : undefined);
const showLegacy = !matched;
return (
@@ -61,7 +66,7 @@ export const VatRateSelect: React.FC = ({ rate, code, onChange, label, di
>
{showLegacy && (
)}
{codes.map((c) => (
diff --git a/frontend/src/constants/currencies.ts b/frontend/src/constants/currencies.ts
index 0b2478e8..cd094b60 100644
--- a/frontend/src/constants/currencies.ts
+++ b/frontend/src/constants/currencies.ts
@@ -17,9 +17,7 @@ export const CURRENCY_CODES: string[] = [
* extra option so nothing is lost), or '' for empty input.
*/
export function normalizeCurrency(value: string | null | undefined): string {
- const cleaned = (value || '').trim().toUpperCase();
- if (!cleaned) return '';
- return CURRENCY_CODES.includes(cleaned) ? cleaned : cleaned;
+ return (value || '').trim().toUpperCase();
}
/** Build the option list, prepending an unknown-but-set value so it's preserved. */
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index f71f6aaf..c8fbd7f6 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -1737,13 +1737,11 @@
"disclaimer": "Sätze und MwSt-/Steuerbehandlung dienen nur als Orientierung — mit Ihrem Treuhänder prüfen.",
"savedToast": "Buchhaltungseinstellungen gespeichert.",
"profileFields": {
- "title": "MwSt-Bezeichnung & Stundensatz",
"vatLabel": "MwSt-Bezeichnung (z. B. MwSt., VAT)",
"vatLabelHint": "Wird als Bezeichnung der MwSt-Zeile auf Rechnungs-/Angebots-PDFs gedruckt. Leer lassen, um die Standardbezeichnung der Dokumentsprache zu verwenden.",
"hourlyRate": "Standard-Stundensatz",
"hourlyRatePlaceholder": "z. B. 120.00",
- "hourlyRateHint": "Verrechnungs-Fallback, wenn ein Kunde keinen eigenen Satz hat (Stundenerfassung). In {{currency}}, in Hauptwährungseinheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen.",
- "savedToast": "Gespeichert."
+ "hourlyRateHint": "Verrechnungs-Fallback, wenn ein Kunde keinen eigenen Satz hat (Stundenerfassung). In {{currency}}, in Hauptwährungseinheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen."
}
}
},
@@ -4373,11 +4371,6 @@
"defaultCurrency": "Standardwährung",
"defaultLocale": "Standardsprache",
"timezone": "Zeitzone (IANA)",
- "vatLabel": "MwSt-Bezeichnung",
- "vatRateDefault": "Standard-MwSt-Satz %",
- "defaultHourlyRate": "Standard-Stundensatz",
- "defaultHourlyRatePlaceholder": "z. B. 120.00",
- "defaultHourlyRateHint": "Fallback, wenn ein Kunde keinen eigenen Satz hat. In {{currency}}, in ganzen Einheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen.",
"defaultQrFormat": "Standard-QR-Format",
"footerLine": "Fusszeile"
},
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index e1d02ea1..26973935 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -1295,13 +1295,11 @@
"disclaimer": "Rates and VAT/tax treatment are guidance only — verify with your Treuhänder.",
"savedToast": "Accounting settings saved.",
"profileFields": {
- "title": "VAT label & hourly rate",
"vatLabel": "VAT label (e.g. MwSt., VAT)",
"vatLabelHint": "Printed as the VAT-line label on invoice / quote PDFs. Leave blank to use the document language default.",
"hourlyRate": "Default hourly rate",
"hourlyRatePlaceholder": "e.g. 120.00",
- "hourlyRateHint": "Billing fallback used when a customer has no own rate (hours logging). In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.",
- "savedToast": "Saved."
+ "hourlyRateHint": "Billing fallback used when a customer has no own rate (hours logging). In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate."
}
}
},
@@ -4371,11 +4369,6 @@
"defaultCurrency": "Default currency",
"defaultLocale": "Default locale",
"timezone": "Timezone (IANA)",
- "vatLabel": "VAT label (e.g. MwSt., VAT)",
- "vatRateDefault": "Default VAT rate %",
- "defaultHourlyRate": "Default hourly rate",
- "defaultHourlyRatePlaceholder": "e.g. 120.00",
- "defaultHourlyRateHint": "Fallback used when a customer has no own rate. In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.",
"defaultQrFormat": "Default invoice QR",
"footerLine": "PDF footer line"
},
From db9e41d19846b31b29c5c1be2ee06a7958bb43b0 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Thu, 18 Jun 2026 18:40:22 +0200
Subject: [PATCH 10/11] fix(accounting): tax-report storno totals + hours-line
date on Postgres
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two pre-existing HIGH bugs surfaced by the codebase audit (accounting surface):
- taxReportService: income totals excluded only `status='cancelled'`, never
`kind='storno'`. A Storno (status='sent', amounts stored negative) netted into
the totals on top of the already-excluded cancelled original → double-subtract,
so a cancel-and-reissue read as 0 income instead of the reissued amount.
Now exclude storno rows from grandTotal*/byRate (kept visible in the row list).
Regression test reproduces the real cancel→storno→reissue 3-row flow.
- customerHoursService.buildLineItemFromEntry: `String(entry.entry_date).slice(0,10)`
on a `date` column → Postgres returns a JS Date, baking "Wed Apr 06" into the
invoice line + PDF (SQLite returns the bare string, so SQLite-only tests pass).
Normalise via the Date branch like every other date read.
---
.../services/taxReportService.test.js | 41 +++++++++++++++++++
backend/src/services/customerHoursService.js | 11 +++--
backend/src/services/taxReportService.js | 12 ++++--
3 files changed, 58 insertions(+), 6 deletions(-)
diff --git a/backend/__tests__/services/taxReportService.test.js b/backend/__tests__/services/taxReportService.test.js
index 5a87fd39..9fa9b8de 100644
--- a/backend/__tests__/services/taxReportService.test.js
+++ b/backend/__tests__/services/taxReportService.test.js
@@ -276,6 +276,47 @@ describe('getTaxReport', () => {
]);
});
+ it('excludes the negative Storno row from totals on a cancel + reissue (PR #636 audit)', async () => {
+ // The real cancel-and-reissue flow produces THREE rows in the period:
+ // the cancelled original, its negative Storno (kind='storno', status='sent'),
+ // and the reissue. Totals must read the reissued amount, not 0.
+ invoiceRowsForRun = [
+ {
+ id: 20, invoice_number: 'R-2026-0020', issue_date: '2026-02-01',
+ currency: 'CHF', status: 'cancelled', kind: 'invoice', vat_rate: 7.7,
+ net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
+ late_fee_amount_minor: 0, replaces_invoice_id: null,
+ customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
+ },
+ {
+ id: 21, invoice_number: 'R-2026-0020-S', issue_date: '2026-02-02',
+ currency: 'CHF', status: 'sent', kind: 'storno', vat_rate: 7.7,
+ net_amount_minor: -10000, vat_amount_minor: -770, total_amount_minor: -10770,
+ late_fee_amount_minor: 0, replaces_invoice_id: null,
+ customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
+ },
+ {
+ id: 22, invoice_number: 'R-2026-0021', issue_date: '2026-02-03',
+ currency: 'CHF', status: 'paid', kind: 'invoice', vat_rate: 7.7,
+ net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
+ late_fee_amount_minor: 0, replaces_invoice_id: 20,
+ customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
+ },
+ ];
+ replacementsRowsForRun = [{ replaces_invoice_id: 20, invoice_number: 'R-2026-0021' }];
+
+ const out = await taxReportService.getTaxReport({ from: '2026-01-01', to: '2026-03-31', currency: 'CHF' });
+ expect(out.rows).toHaveLength(3); // all three stay visible for the audit trail
+ // The negative storno must NOT net against the totals (the cancelled
+ // original is already excluded) — the reissued revenue stands.
+ expect(out.grandTotalNet).toBe(10000);
+ expect(out.grandTotalVat).toBe(770);
+ expect(out.grandTotal).toBe(10770);
+ expect(out.totalsByVatRate).toEqual([
+ { vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770 },
+ ]);
+ });
+
it('buckets totals by VAT rate (e.g. 7.7 + 8.1 in same period)', async () => {
invoiceRowsForRun = [
{
diff --git a/backend/src/services/customerHoursService.js b/backend/src/services/customerHoursService.js
index 8fcbf399..6f6230c8 100644
--- a/backend/src/services/customerHoursService.js
+++ b/backend/src/services/customerHoursService.js
@@ -127,9 +127,14 @@ function isEntryLocked(entry, invoice) {
*/
function buildLineItemFromEntry(entry, rateMinor) {
const hours = (entry.duration_minutes / 60).toFixed(2);
- // ISO date input is already YYYY-MM-DD; admin's locale formatting
- // happens at PDF render time, so keep the entry description portable.
- const datePart = String(entry.entry_date).slice(0, 10);
+ // Keep the entry description portable (admin's locale formatting happens at
+ // PDF render time). `entry_date` is a `date` column: Postgres hands it back as
+ // a JS Date, SQLite as a 'YYYY-MM-DD' string — so `String(dateObj).slice(0,10)`
+ // would bake "Wed Apr 06" into the invoice line on PG. Normalise via the Date
+ // branch (see feedback_pg_date_columns_serialize).
+ const datePart = entry.entry_date instanceof Date
+ ? entry.entry_date.toISOString().slice(0, 10)
+ : String(entry.entry_date).slice(0, 10);
const note = (entry.description || '').trim();
const description = `${datePart} ${entry.start_time}–${entry.end_time} (${hours}h)${note ? ': ' + note : ''}`;
const qty = Number(hours);
diff --git a/backend/src/services/taxReportService.js b/backend/src/services/taxReportService.js
index 60f2fadb..f4495992 100644
--- a/backend/src/services/taxReportService.js
+++ b/backend/src/services/taxReportService.js
@@ -433,9 +433,15 @@ async function getTaxReport({ from, to, currency, includeCosts = true } = {}) {
const rows = dbRows.map((r) => {
const reported = computeReportedAmounts(r);
const isCancelled = r.status === 'cancelled';
- if (isCancelled) {
- cancelledCount += 1;
- } else {
+ if (isCancelled) cancelledCount += 1;
+ // Exclude BOTH the cancelled original AND its negative Storno row from the
+ // totals. Both stay visible in the row list for the gap-free audit trail,
+ // but a Storno (kind='storno', status='sent', amounts stored negative)
+ // would otherwise double-subtract: the cancelled original is already
+ // netted out by exclusion, so adding the negative storno on top deducts
+ // the revenue a second time — making a cancel-and-reissue read as 0 income
+ // instead of the reissued amount. See feedback_storno_filter_everywhere.
+ if (!isCancelled && r.kind !== 'storno') {
grandTotalNet += reported.netMinor;
grandTotalVat += reported.vatMinor;
grandTotal += reported.totalMinor;
From e9b297c162a19da31d53de377b90bfd5cda1b0a7 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Thu, 18 Jun 2026 19:17:12 +0200
Subject: [PATCH 11/11] =?UTF-8?q?fix(crm):=20editor=20totals=20box=20compu?=
=?UTF-8?q?ted=20VAT=20100=C3=97=20too=20small?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
LineItemsTable's live preview did `Math.round(subtotal * vatRate) / 100` where
subtotal is in major units and vatRate is a fraction (0.081) — rounding to whole
units before the /100 divided the VAT by 100 (CHF 0.63 instead of 63.18). Add the
missing *100 inside the round so it rounds to cents. Backend computeTotals + the
PDF + the tax report were always correct; this preview-only bug just surfaced now
that new invoices seed a non-zero default VAT code instead of 0%.
---
frontend/src/components/admin/LineItemsTable.tsx | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/frontend/src/components/admin/LineItemsTable.tsx b/frontend/src/components/admin/LineItemsTable.tsx
index 3f11d931..152cc495 100644
--- a/frontend/src/components/admin/LineItemsTable.tsx
+++ b/frontend/src/components/admin/LineItemsTable.tsx
@@ -251,7 +251,12 @@ export const LineItemsTable: React.FC = ({
// never roll directly into net — they only feed their parent's
// auto-resolved line total.
const subtotal = items.filter((li) => !isSub(li)).reduce((s, li) => s + lineTotal(li), 0);
- const vatAmount = Math.round(subtotal * vatRate) / 100;
+ // subtotal is in MAJOR units, vatRate is a FRACTION (0.081). Round to cents:
+ // round(subtotal * vatRate * 100) / 100 — the *100 inside round was missing,
+ // which divided the VAT by 100 (CHF 0.63 instead of 63.18). Backend
+ // computeTotals + the PDF were always correct; only this live editor preview
+ // was wrong, and it only surfaced once invoices stopped defaulting to 0% VAT.
+ const vatAmount = Math.round(subtotal * vatRate * 100) / 100;
const total = subtotal + vatAmount + (Number(shippingAmount) || 0);
// Display numbering: top-level items get 1, 2, 3...; sub-items