Merge pull request #636 from Luca-Timo/feat/accounting-inbound-invoices

feat(accounting): incoming-invoice workflow v2 + VAT/financial settings consolidation
This commit is contained in:
Paul Nothaft
2026-06-18 21:23:51 +02:00
committed by GitHub
30 changed files with 1434 additions and 288 deletions
@@ -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();
});
});
@@ -4,7 +4,7 @@
*/
const expenseService = require('../../src/services/expenseService');
const { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert } = expenseService._internal;
const { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, buildInboundLineItem, isInvoiceMutable, resolveTaxTreatment } = expenseService._internal;
describe('computeMarkupMinor', () => {
it('percent of base, rounded', () => {
@@ -85,3 +85,76 @@ describe('buildExpenseInsert (internal expense)', () => {
expect(evt.event_id).toBe(9);
});
});
describe('buildInboundLineItem (re-bill line)', () => {
it('rebill: base + percent markup, Weiterverrechnung suffix', () => {
const li = buildInboundLineItem({ totalAmountMinor: 10000, supplierName: 'ACME' }, 'rebill', { type: 'percent', percent: 10 });
expect(li.unit_price_minor).toBe(11000);
expect(li.line_total_minor).toBe(11000);
expect(li.quantity).toBe(1);
expect(li.description).toBe('ACME (Weiterverrechnung)');
});
it('passthrough: distinct suffix, no markup passes through at cost', () => {
const li = buildInboundLineItem({ totalAmountMinor: 5000, supplierName: 'SBB' }, 'durchlaufend', { type: 'none' });
expect(li.unit_price_minor).toBe(5000);
expect(li.description).toBe('SBB (Durchlaufende Position)');
});
it('falls back to net amount + generic label when total/supplier missing', () => {
const li = buildInboundLineItem({ totalAmountMinor: null, netAmountMinor: 7000 }, 'rebill', { type: 'flat', flatMinor: 300 });
expect(li.unit_price_minor).toBe(7300);
expect(li.description).toBe('Weiterverrechnete Auslage (Weiterverrechnung)');
});
it('throws when there is no amount to re-bill', () => {
expect(() => buildInboundLineItem({ totalAmountMinor: null, netAmountMinor: null }, 'rebill', { type: 'none' }))
.toThrow(/no amount/i);
});
});
describe('resolveTaxTreatment (supplier-country auto-default)', () => {
const reclaim = ['CH', 'LI'];
it('explicit valid treatment always wins', () => {
expect(resolveTaxTreatment('reverse_charge_service', 'DE', reclaim)).toBe('reverse_charge_service');
expect(resolveTaxTreatment('import_goods', 'CH', reclaim)).toBe('import_goods');
});
it('country in the reclaim list → domestic', () => {
expect(resolveTaxTreatment(undefined, 'CH', reclaim)).toBe('domestic');
expect(resolveTaxTreatment(null, 'li', reclaim)).toBe('domestic'); // case-insensitive
});
it('country outside the reclaim list → foreign non-reclaimable', () => {
expect(resolveTaxTreatment(undefined, 'DE', reclaim)).toBe('foreign_vat_non_reclaimable');
expect(resolveTaxTreatment(undefined, 'US', reclaim)).toBe('foreign_vat_non_reclaimable');
});
it('unknown / empty country falls back to domestic', () => {
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');
});
});
describe('isInvoiceMutable (re-categorise unwind guard)', () => {
const future = new Date(Date.now() + 86400000).toISOString();
const past = new Date(Date.now() - 86400000).toISOString();
it('monthly draft and not-yet-armed scheduled are mutable', () => {
expect(isInvoiceMutable(null)).toBe(true); // referenced invoice gone
expect(isInvoiceMutable({ is_monthly_draft: true })).toBe(true);
expect(isInvoiceMutable({ is_monthly_draft: 1 })).toBe(true);
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: null })).toBe(true);
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: future })).toBe(true);
});
it('armed / issued invoices are locked', () => {
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: past })).toBe(false);
expect(isInvoiceMutable({ status: 'sent' })).toBe(false);
expect(isInvoiceMutable({ status: 'paid' })).toBe(false);
expect(isInvoiceMutable({ status: 'cancelled' })).toBe(false);
});
});
@@ -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 = [
{
@@ -0,0 +1,48 @@
/**
* Migration 132: incoming-invoice categorisation note + customer linkage.
*
* - note : free-text note captured during triage (issue: no
* note field on categorisation).
* - customer_account_id: the client a rebill/passthrough is attached to.
* Previously the customer was passed transiently to
* the re-bill call and only lived on the resulting
* invoice. Persisting it lets a categorised-but-not-
* yet-billed item sit as a PENDING re-bill in the
* customer's pool (per-event customers), exactly like
* unbilled hour entries. Loose link (no hard FK —
* mirrors the inbound event_id / expenses approach),
* indexed for the pending-summary lookup.
*
* Migration 126 (which added the disposition/re-bill columns) is already
* deployed to beta, so these go in a NEW migration rather than an in-place
* edit. Additive + hasColumn-guarded so re-runs are safe.
*/
async function addColumn(knex, table, column, builder) {
// eslint-disable-next-line no-await-in-loop
if (!(await knex.schema.hasColumn(table, column))) {
await knex.schema.alterTable(table, builder);
}
}
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('inbound_documents'))) return;
await addColumn(knex, 'inbound_documents', 'note', (t) => t.text('note'));
await addColumn(knex, 'inbound_documents', 'customer_account_id', (t) => t.integer('customer_account_id').unsigned());
if (await knex.schema.hasColumn('inbound_documents', 'customer_account_id')) {
// Index the pending-rebill lookup (customer_account_id + billed_invoice_id).
try {
await knex.schema.alterTable('inbound_documents', (t) => t.index(['customer_account_id'], 'inbound_documents_customer_account_id_index'));
} catch (_e) { /* index may already exist */ }
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('inbound_documents'))) return;
for (const col of ['note', 'customer_account_id']) {
// eslint-disable-next-line no-await-in-loop
if (await knex.schema.hasColumn('inbound_documents', col)) {
// eslint-disable-next-line no-await-in-loop
await knex.schema.alterTable('inbound_documents', (t) => t.dropColumn(col));
}
}
};
@@ -0,0 +1,34 @@
/**
* Migration 133: invoices (Bills) force-enable the Accounting master.
*
* Invoice VAT config (codes + label) and the default hourly rate now live under
* Settings → Accounting, so an install with Bills enabled must have Accounting
* available. `applyDependencyRules` enforces this on every flag READ/WRITE, but
* the `requireFeatureFlag('accounting')` middleware reads the STORED row
* directly — so existing installs that already have `bills=true, accounting=false`
* would show the Accounting tab yet 403 its endpoints. This one-time correction
* brings the stored value in line (forward fix, not a compensation: it encodes a
* new dependency rule, it doesn't patch a buggy earlier migration).
*
* Idempotent: only flips accounting ON where Bills is on; never turns it off.
*/
function isOn(row) {
return !!(row && (row.value === true || row.value === 1 || row.value === '1'));
}
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('feature_flags'))) return;
const bills = await knex('feature_flags').where({ key: 'bills' }).first();
if (!isOn(bills)) return;
const accounting = await knex('feature_flags').where({ key: 'accounting' }).first();
if (!accounting) {
await knex('feature_flags').insert({ key: 'accounting', value: true });
} else if (!isOn(accounting)) {
await knex('feature_flags').where({ key: 'accounting' }).update({ value: true });
}
};
// No down — we can't know whether Accounting was independently wanted, and
// turning it back off could hide a section the admin now relies on.
exports.down = async function () {};
@@ -0,0 +1,25 @@
/**
* Migration 134: supplier country on incoming invoices.
*
* `supplier_country` (ISO-3166 alpha-2) lets categorisation auto-default the
* `tax_treatment`: a supplier whose country is in the install's VAT reclaim
* list (Settings → Accounting → `accounting_vat_reclaim_countries`, typically
* CH / LI) → `domestic` (input VAT reclaimable); otherwise →
* `foreign_vat_non_reclaimable`. Closes the dangling VAT-consolidation slice
* where the reclaim-countries setting was stored but never consumed.
*
* Additive + hasColumn-guarded.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('inbound_documents'))) return;
if (!(await knex.schema.hasColumn('inbound_documents', 'supplier_country'))) {
await knex.schema.alterTable('inbound_documents', (t) => t.string('supplier_country', 2));
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('inbound_documents'))) return;
if (await knex.schema.hasColumn('inbound_documents', 'supplier_country')) {
await knex.schema.alterTable('inbound_documents', (t) => t.dropColumn('supplier_country'));
}
};
+16 -1
View File
@@ -101,6 +101,17 @@ router.get('/inbound', requireIncoming, requirePermission('accounting.view'),
[query('status').optional().isString(), query('page').optional().isInt({ min: 1 }), query('pageSize').optional().isInt({ min: 1, max: 100 })],
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, await expenseService.listInbound(req.query)); }));
// Pending re-bills grouped by customer (per-event customers with categorised
// but not-yet-billed rebill/passthrough docs). Registered BEFORE /inbound/:id
// so the literal path isn't swallowed by the :id param matcher.
router.get('/inbound/pending-summary', requireIncoming, requirePermission('accounting.view'),
handleAsync(async (_req, res) => successResponse(res, { items: await expenseService.listPendingRebillSummary() })));
// Bundle a customer's pending re-bills into one invoice (per-event only).
router.post('/inbound/bill-pending', requireIncoming, requirePermission('accounting.manage'),
[body('customerAccountId').isInt({ min: 1 })],
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, await expenseService.billPendingRebills(toInt(req.body.customerAccountId), req.admin.id), 201, 'Re-billed'); }));
router.get('/inbound/:id/file', requireIncoming, requirePermission('accounting.view'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
@@ -144,7 +155,11 @@ router.patch('/inbound/:id', requireIncoming, requirePermission('accounting.mana
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, { document: await expenseService.updateInbound(toInt(req.params.id), req.body, req.admin.id) }); }));
router.post('/inbound/:id/categorize', requireIncoming, requirePermission('accounting.manage'),
[param('id').isInt({ min: 1 }), body('disposition').isIn(expenseService.DISPOSITIONS)],
[param('id').isInt({ min: 1 }), body('disposition').isIn(expenseService.DISPOSITIONS),
body('customerAccountId').optional({ nullable: true }).isInt({ min: 1 }),
body('eventId').optional({ nullable: true }).isInt({ min: 1 }),
body('categoryId').optional({ nullable: true }).isInt({ min: 1 }),
body('markupType').optional().isIn(expenseService.MARKUP_TYPES)],
handleAsync(async (req, res) => { validateRequest(req); return successResponse(res, { document: await expenseService.categorizeInbound(toInt(req.params.id), req.body, req.admin.id) }, 200, 'Categorized'); }));
router.post('/inbound/:id/rebill', requireIncoming, requirePermission('accounting.manage'),
+5
View File
@@ -127,6 +127,11 @@ function applyDependencyRules(flags) {
// Sub-features can't outlive their parents.
if (out.quotes === false) out.bills = false;
if (out.calendar === false) out.calendarBooking = false;
// Invoices (Bills) force-enable the Accounting master: invoice VAT config
// (codes + label) and the hourly rate live under Settings → Accounting, so
// an install with invoices must have Accounting available. Runs BEFORE the
// accounting→children rule so the sub-features keep their own stored state.
if (out.bills === true) out.accounting = true;
// Accounting is a top-level MASTER; its sub-features can't outlive it.
// Tax export is now independent of Bills — it relocated permanently
// into the Accounting section (its own master gate).
+4
View File
@@ -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,
+10
View File
@@ -258,6 +258,16 @@ router.put('/accounting', adminAuth, requirePermission('settings.edit'), async (
setting_type: 'accounting',
});
}
// Default OUTPUT VAT code stamped onto NEW invoices/quotes (the editor
// seeds its VAT picker from it). Stored as the code string; '' clears it.
if (Object.prototype.hasOwnProperty.call(req.body, 'accounting_default_output_vat_code')) {
const code = String(req.body.accounting_default_output_vat_code || '').trim().slice(0, 16);
updates.push({
setting_key: 'accounting_default_output_vat_code',
setting_value: JSON.stringify(code),
setting_type: 'accounting',
});
}
if (Object.prototype.hasOwnProperty.call(req.body, 'accounting_vat_reclaim_countries')) {
const arr = Array.isArray(req.body.accounting_vat_reclaim_countries)
? req.body.accounting_vat_reclaim_countries
+4
View File
@@ -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,
@@ -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',
+35 -33
View File
@@ -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);
@@ -219,7 +224,14 @@ async function createEntry(customerId, payload, adminId) {
// if neither override, customer default, nor install default is set.
resolveEffectiveRate({ hourly_rate_minor_override: override }, customer, installDefaultMinor);
return await db.transaction(async (trx) => {
// logActivity writes via the GLOBAL db; calling it inside the transaction
// below deadlocks against the held write lock on a SQLite-backed install (a
// second write connection blocks). Stage it here, fire it AFTER commit.
// (The monthly/billing paths additionally route through createInvoice, whose
// OWN internal logActivity still runs in-trx — that shared root limitation is
// tracked in feedback_sqlite_global_write_in_transaction.)
let logInfo = null;
const result = await db.transaction(async (trx) => {
const row = {
customer_account_id: customer.id,
entry_date: entryDate,
@@ -268,21 +280,15 @@ async function createEntry(customerId, payload, adminId) {
billed_at: new Date(),
updated_at: new Date(),
});
try {
await logActivity('hour_entry_logged_to_monthly_draft',
{ entryId, customerId: customer.id, invoiceId },
null, `admin:${adminId}`);
} catch (_) {}
logInfo = { type: 'hour_entry_logged_to_monthly_draft', meta: { entryId, customerId: customer.id, invoiceId } };
return { id: entryId, status: 'billed', invoiceId };
}
try {
await logActivity('hour_entry_logged',
{ entryId, customerId: customer.id },
null, `admin:${adminId}`);
} catch (_) {}
logInfo = { type: 'hour_entry_logged', meta: { entryId, customerId: customer.id } };
return { id: entryId, status: 'unbilled' };
});
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} }
return result;
}
/**
@@ -293,7 +299,8 @@ async function createEntry(customerId, payload, adminId) {
* stay accurate.
*/
async function updateEntry(entryId, payload, adminId) {
return await db.transaction(async (trx) => {
let logInfo = null; // logged after commit — see createEntry note.
const result = await db.transaction(async (trx) => {
const entry = await trx('customer_hour_entries').where({ id: entryId }).first();
if (!entry) throw new AppError('Entry not found', 404);
const invoice = entry.invoice_id
@@ -375,13 +382,11 @@ async function updateEntry(entryId, payload, adminId) {
updated_at: next.updated_at,
});
try {
await logActivity('hour_entry_updated',
{ entryId, customerId: entry.customer_account_id },
null, `admin:${adminId}`);
} catch (_) {}
logInfo = { type: 'hour_entry_updated', meta: { entryId, customerId: entry.customer_account_id } };
return { id: entryId };
});
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} }
return result;
}
/**
@@ -390,7 +395,8 @@ async function updateEntry(entryId, payload, adminId) {
* recomputes invoice totals before deleting the entry row itself.
*/
async function deleteEntry(entryId, adminId) {
return await db.transaction(async (trx) => {
let logInfo = null; // logged after commit — see createEntry note.
const result = await db.transaction(async (trx) => {
const entry = await trx('customer_hour_entries').where({ id: entryId }).first();
if (!entry) throw new AppError('Entry not found', 404);
const invoice = entry.invoice_id
@@ -427,13 +433,11 @@ async function deleteEntry(entryId, adminId) {
await trx('customer_hour_entries').where({ id: entryId }).del();
try {
await logActivity('hour_entry_deleted',
{ entryId, customerId: entry.customer_account_id, hadInvoice: !!entry.invoice_id },
null, `admin:${adminId}`);
} catch (_) {}
logInfo = { type: 'hour_entry_deleted', meta: { entryId, customerId: entry.customer_account_id, hadInvoice: !!entry.invoice_id } };
return { deleted: true };
});
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} }
return result;
}
/**
@@ -454,7 +458,8 @@ async function billUnbilledEntries(customerId, adminId) {
);
}
return await db.transaction(async (trx) => {
let logInfo = null; // logged after commit — see createEntry note.
const result = await db.transaction(async (trx) => {
const unbilled = await trx('customer_hour_entries')
.where({ customer_account_id: customer.id, status: 'unbilled' })
.orderBy('entry_date', 'asc').orderBy('start_time', 'asc');
@@ -500,14 +505,11 @@ async function billUnbilledEntries(customerId, adminId) {
});
}
try {
await logActivity('hour_entries_billed',
{ customerId: customer.id, invoiceId, entryCount: unbilled.length },
null, `admin:${adminId}`);
} catch (_) {}
logInfo = { type: 'hour_entries_billed', meta: { customerId: customer.id, invoiceId, entryCount: unbilled.length } };
return { invoiceId, entriesBilled: unbilled.length };
});
if (logInfo) { try { await logActivity(logInfo.type, logInfo.meta, null, `admin:${adminId}`); } catch (_) {} }
return result;
}
/**
+376 -70
View File
@@ -44,7 +44,8 @@ function toIsoDate(v) {
// ── Accounting settings (app_settings, type 'accounting') ───────────────────
async function getAccountingSettings() {
const keys = ['accounting_km_rate_minor', 'accounting_per_diem_rate_minor', 'accounting_require_proof'];
const keys = ['accounting_km_rate_minor', 'accounting_per_diem_rate_minor', 'accounting_require_proof',
'accounting_vat_reclaim_countries'];
let rows = [];
try {
rows = await db('app_settings').whereIn('setting_key', keys).select('setting_key', 'setting_value');
@@ -59,9 +60,29 @@ async function getAccountingSettings() {
kmRateMinor: Number.isFinite(Number(map.accounting_km_rate_minor)) ? Number(map.accounting_km_rate_minor) : 0,
perDiemRateMinor: Number.isFinite(Number(map.accounting_per_diem_rate_minor)) ? Number(map.accounting_per_diem_rate_minor) : 0,
requireProof: map.accounting_require_proof === true || map.accounting_require_proof === 1 || map.accounting_require_proof === '1',
vatReclaimCountries: Array.isArray(map.accounting_vat_reclaim_countries)
? map.accounting_vat_reclaim_countries.map((c) => String(c || '').toUpperCase()) : [],
};
}
/**
* Default tax treatment from the supplier country: explicit payload wins; else
* a country in the reclaim list (typically CH / LI) is `domestic` (input VAT
* reclaimable), an out-of-list country is `foreign_vat_non_reclaimable`, and an
* unknown country falls back to `domestic`. reverse_charge / import_goods stay
* admin-set (can't be auto-detected).
*/
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';
}
// ── Incoming invoices (inbound_documents) ───────────────────────────────────
function transformInbound(row) {
if (!row) return null;
@@ -96,6 +117,14 @@ function transformInbound(row) {
markupFlatMinor: row.markup_flat_minor,
billedInvoiceId: row.billed_invoice_id,
billedInvoiceLineItemId: row.billed_invoice_line_item_id,
// re-bill customer linkage (migration 132) — the client a rebill/passthrough
// is attached to. customerName/Email are denormalised from a LEFT JOIN in
// list/get (null when the row came from a query without the join).
customerAccountId: row.customer_account_id || null,
customerName: row.customer_display_name || row.customer_company_name || null,
customerEmail: row.customer_email || null,
supplierCountry: row.supplier_country || null,
note: row.note || null,
// supplier payment (paid on the incoming invoice itself)
supplierPaid: !!row.supplier_paid,
supplierPaidAt: row.supplier_paid_at,
@@ -168,19 +197,34 @@ async function recordInboundDocument({ source, filePath, originalFilename, mimeT
return getInbound(id);
}
// Denormalise the attached customer's name/email for the inbox UI (re-bill
// chip + pending-pool grouping). LEFT JOIN so docs without a customer still
// return. Selected explicitly to avoid colliding with inbound_documents.*.
const INBOUND_CUSTOMER_SELECT = [
'inbound_documents.*',
'c.display_name as customer_display_name',
'c.company_name as customer_company_name',
'c.email as customer_email',
];
function inboundWithCustomer() {
return db('inbound_documents')
.leftJoin('customer_accounts as c', 'inbound_documents.customer_account_id', 'c.id');
}
async function getInbound(id) {
const row = await db('inbound_documents').where({ id }).first();
const row = await inboundWithCustomer().where('inbound_documents.id', id).first(INBOUND_CUSTOMER_SELECT);
if (!row) throw new AppError('Incoming invoice not found', 404, 'INBOUND_NOT_FOUND');
return transformInbound(row);
}
async function listInbound({ status, page, pageSize } = {}) {
const { p, ps } = clampPage(page, pageSize);
const base = db('inbound_documents');
if (status) base.where({ status });
const countRow = await base.clone().count({ count: '*' }).first();
const base = inboundWithCustomer();
if (status) base.where('inbound_documents.status', status);
const countRow = await base.clone().clearSelect().count({ count: 'inbound_documents.id' }).first();
const total = parseInt(countRow?.count || 0, 10);
const rows = await base.clone().orderBy('created_at', 'desc').limit(ps).offset((p - 1) * ps);
const rows = await base.clone().orderBy('inbound_documents.created_at', 'desc').limit(ps).offset((p - 1) * ps)
.select(INBOUND_CUSTOMER_SELECT);
return { items: rows.map(transformInbound), pagination: { page: p, pageSize: ps, total, totalPages: Math.ceil(total / ps) } };
}
@@ -188,7 +232,7 @@ const INBOUND_EDITABLE = {
supplierName: 'supplier_name', invoiceNumber: 'invoice_number', invoiceDate: 'invoice_date',
dueDate: 'due_date', currency: 'currency', netAmountMinor: 'net_amount_minor',
vatAmountMinor: 'vat_amount_minor', totalAmountMinor: 'total_amount_minor', iban: 'iban',
paymentReference: 'payment_reference',
paymentReference: 'payment_reference', note: 'note', supplierCountry: 'supplier_country',
};
async function updateInbound(id, payload, adminId) {
@@ -232,79 +276,339 @@ function computeMarkupMinor(baseMinor, markup) {
return 0;
}
/** Re-bill an incoming invoice to a client (mints an editable scheduled invoice). */
async function rebillInbound(id, payload, adminId, trx0) {
const run = async (trx) => {
const row = await trx('inbound_documents').where({ id }).first();
if (!row) throw new AppError('Incoming invoice not found', 404, 'INBOUND_NOT_FOUND');
const doc = transformInbound(row);
if (doc.billedInvoiceId) throw new AppError('Already re-billed', 409, 'ALREADY_BILLED');
if (!payload.customerAccountId) throw new AppError('customerAccountId is required to re-bill', 400, 'CUSTOMER_REQUIRED');
const base = doc.totalAmountMinor != null ? doc.totalAmountMinor : doc.netAmountMinor;
if (base == null) throw new AppError('Incoming invoice has no amount to re-bill', 400, 'AMOUNT_REQUIRED');
// Dispositions that can be billed to a client. 'rebill' always carries a
// customer; 'durchlaufend' (passthrough) may now ALSO attach to a customer
// (with optional markup) so it can be re-billed like a rebill.
const CUSTOMER_DISPOSITIONS = ['rebill', 'durchlaufend'];
const BOOKING_DISPOSITIONS = ['rebill', 'durchlaufend'];
const markup = await resolveMarkup(
{ markupType: doc.markupType, markupPercent: doc.markupPercent, markupFlatMinor: doc.markupFlatMinor },
payload, payload.contractId, trx,
);
const lineTotal = base + computeMarkupMinor(base, markup);
const label = doc.supplierName || 'Weiterverrechnete Auslage';
const { invoiceIds } = await invoiceService.createInvoice({
customerAccountId: payload.customerAccountId,
eventId: payload.eventId || doc.eventId || null,
lineItems: [{ description: `${label} (Weiterverrechnung)`, quantity: 1, unit_price_minor: lineTotal, discount_percent: 0, line_total_minor: lineTotal }],
}, adminId, trx);
const invoiceId = Array.isArray(invoiceIds) ? invoiceIds[0] : null;
if (!invoiceId) throw new AppError('Failed to create the re-bill invoice', 500, 'REBILL_FAILED');
const line = await trx('invoice_line_items').where({ invoice_id: invoiceId }).orderBy('id', 'desc').first('id');
await trx('inbound_documents').where({ id }).update({
disposition: 'rebill',
status: 'categorized',
event_id: payload.eventId || doc.eventId || null,
markup_type: markup.type,
markup_percent: markup.type === 'percent' ? markup.percent : null,
markup_flat_minor: markup.type === 'flat' ? markup.flatMinor : null,
billed_invoice_id: invoiceId,
billed_invoice_line_item_id: line ? line.id : null,
updated_at: new Date(),
});
await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId }, adminId);
return invoiceId;
};
const invoiceId = trx0 ? await run(trx0) : await db.transaction(run);
return { document: await getInbound(id), invoiceId };
/**
* Can this invoice still be edited (line removed / appended)? Mirrors the
* hour-entry lock rules (customerHoursService.isEntryLocked, inverted):
* monthly drafts and not-yet-armed scheduled invoices are mutable; anything
* sent/paid/overdue/cancelled or past its scheduled_send_at is locked.
*/
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();
}
/** Give an incoming invoice a disposition (updates the document, no expense row). */
/**
* Re-categorisation unwind: remove this document's billed line item from its
* invoice and recompute the invoice totals, so the disposition can change.
* Refuses when the invoice is already issued (Storno required instead).
*/
async function unwindBilledLine(trx, doc) {
const invoice = doc.billedInvoiceId
? await trx('invoices').where({ id: doc.billedInvoiceId }).first()
: null;
if (invoice && !isInvoiceMutable(invoice)) {
throw new AppError(
'This re-bill is on an invoice that has already been issued — Storno it before re-categorising.',
409, 'INVOICE_LOCKED',
);
}
if (doc.billedInvoiceLineItemId) {
await trx('invoice_line_items').where({ id: doc.billedInvoiceLineItemId }).del();
}
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);
}
const vatRate = Number(invoice.vat_rate || 0);
const vatMinor = Math.round(netMinor * vatRate / 100);
const shippingMinor = Number(invoice.shipping_amount_minor || 0);
await trx('invoices').where({ id: invoice.id }).update({
net_amount_minor: netMinor,
vat_amount_minor: vatMinor,
total_amount_minor: netMinor + vatMinor + shippingMinor,
updated_at: new Date(),
});
}
}
/** The single invoice line that re-bills one incoming invoice (base + markup). */
function buildInboundLineItem(doc, disposition, markup) {
const base = doc.totalAmountMinor != null ? doc.totalAmountMinor : doc.netAmountMinor;
if (base == null) throw new AppError('Incoming invoice has no amount to re-bill', 400, 'AMOUNT_REQUIRED');
const lineTotal = base + computeMarkupMinor(base, markup);
const label = doc.supplierName || 'Weiterverrechnete Auslage';
const suffix = disposition === 'durchlaufend' ? ' (Durchlaufende Position)' : ' (Weiterverrechnung)';
return { description: `${label}${suffix}`, quantity: 1, unit_price_minor: lineTotal, discount_percent: 0, line_total_minor: lineTotal };
}
/**
* Immediately bill ONE incoming invoice to its customer. createInvoice routes
* monthly/manual customers onto the running draft (consolidated, like hours)
* and mints a standalone invoice for per-event customers. Stamps the document
* with the resulting invoice + line.
*/
async function billInboundNow(trx, id, customerAccountId, eventId, disposition, markup, adminId) {
const row = await trx('inbound_documents').where({ id }).first();
const doc = transformInbound(row);
const lineItem = buildInboundLineItem(doc, disposition, markup);
const { invoiceIds } = await invoiceService.createInvoice({
customerAccountId,
eventId: eventId || doc.eventId || null,
lineItems: [lineItem],
}, adminId, trx);
const invoiceId = Array.isArray(invoiceIds) ? invoiceIds[0] : null;
if (!invoiceId) throw new AppError('Failed to create the re-bill invoice', 500, 'REBILL_FAILED');
const line = await trx('invoice_line_items').where({ invoice_id: invoiceId }).orderBy('id', 'desc').first('id');
await trx('inbound_documents').where({ id }).update({
billed_invoice_id: invoiceId,
billed_invoice_line_item_id: line ? line.id : null,
updated_at: new Date(),
});
// 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;
}
/**
* Give an incoming invoice a disposition (updates the document, no expense
* row). Re-runnable: re-categorising an already-billed document first unwinds
* its prior re-bill line. For rebill/passthrough with a customer, monthly &
* manual customers are billed immediately onto the running draft (like hours);
* per-event customers stay PENDING in the customer's pool until "Bill these".
*/
async function categorizeInbound(id, payload, adminId) {
const doc = await getInbound(id);
const disposition = payload.disposition;
if (!DISPOSITIONS.includes(disposition)) {
throw new AppError(`disposition must be one of ${DISPOSITIONS.join(', ')}`, 400, 'BAD_DISPOSITION');
}
if (disposition === 'rebill') {
const { document } = await rebillInbound(id, payload, adminId);
// also stamp tax_treatment/category/event from payload
await db('inbound_documents').where({ id }).update({
tax_treatment: TAX_TREATMENTS.includes(payload.taxTreatment) ? payload.taxTreatment : (document.taxTreatment || 'domestic'),
category_id: payload.categoryId || null,
const billsToCustomer = CUSTOMER_DISPOSITIONS.includes(disposition);
const customerAccountId = billsToCustomer && payload.customerAccountId ? payload.customerAccountId : null;
// rebill REQUIRES a customer; passthrough may omit one (then it's only booked
// to an event/company and never re-billed).
if (disposition === 'rebill' && !customerAccountId) {
throw new AppError('customerAccountId is required to re-bill', 400, 'CUSTOMER_REQUIRED');
}
// Reclaim-country list for the tax-treatment auto-default (loaded before the
// transaction — a global-db read).
const { vatReclaimCountries } = await getAccountingSettings();
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');
const doc = transformInbound(row);
// #1: unwind any prior re-bill so the disposition can change.
if (doc.billedInvoiceId) await unwindBilledLine(trx, doc);
// 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,
)
: { type: 'none', percent: null, flatMinor: null };
const patch = {
disposition,
// Explicit treatment wins; else auto-default from the supplier country.
tax_treatment: resolveTaxTreatment(payload.taxTreatment, doc.supplierCountry, vatReclaimCountries),
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: 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,
status: DISPOSITION_DOC_STATUS[disposition] || 'categorized',
updated_at: new Date(),
};
if (disposition === 'duplikat' && payload.duplicateOfId) patch.duplicate_of_id = payload.duplicateOfId;
await trx('inbound_documents').where({ id }).update(patch);
if (customerAccountId) {
const customer = await trx('customer_accounts').where({ id: customerAccountId }).first();
if (!customer) throw new AppError('Customer not found', 404, 'CUSTOMER_NOT_FOUND');
// 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') {
billedInvoiceId = await billInboundNow(trx, id, customerAccountId, payload.eventId || null, disposition, markup, 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);
}
/**
* Explicit "re-bill this one now" endpoint (legacy /inbound/:id/rebill). Forces
* an immediate single-document bill regardless of cadence. Re-runnable: unwinds
* a prior re-bill first.
*/
async function rebillInbound(id, payload, adminId, trx0) {
if (!payload.customerAccountId) throw new AppError('customerAccountId is required to re-bill', 400, 'CUSTOMER_REQUIRED');
const run = async (trx) => {
const row = await trx('inbound_documents').where({ id }).first();
if (!row) throw new AppError('Incoming invoice not found', 404, 'INBOUND_NOT_FOUND');
const doc = transformInbound(row);
if (doc.billedInvoiceId) await unwindBilledLine(trx, doc);
const markup = await resolveMarkup(
{ markupType: doc.markupType, markupPercent: doc.markupPercent, markupFlatMinor: doc.markupFlatMinor },
payload, payload.contractId, trx,
);
await trx('inbound_documents').where({ id }).update({
disposition: 'rebill',
status: 'categorized',
customer_account_id: payload.customerAccountId,
event_id: payload.eventId || doc.eventId || null,
markup_type: markup.type,
markup_percent: markup.type === 'percent' ? markup.percent : null,
markup_flat_minor: markup.type === 'flat' ? markup.flatMinor : null,
updated_at: new Date(),
});
return getInbound(id);
}
const patch = {
disposition,
tax_treatment: TAX_TREATMENTS.includes(payload.taxTreatment) ? payload.taxTreatment : 'domestic',
event_id: payload.eventId || null, // null = company
category_id: disposition === 'eigener_aufwand' ? (payload.categoryId || null) : null,
status: DISPOSITION_DOC_STATUS[disposition] || 'categorized',
updated_at: new Date(),
return billInboundNow(trx, id, payload.customerAccountId, payload.eventId || doc.eventId || null, 'rebill', markup, adminId);
};
if (disposition === 'duplikat' && payload.duplicateOfId) patch.duplicate_of_id = payload.duplicateOfId;
await db('inbound_documents').where({ id }).update(patch);
await logActivity('incoming_invoice_categorized', { inboundDocumentId: id, disposition }, adminId);
return getInbound(id);
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 };
}
/**
* Landing aggregate for the inbox "pending re-bills" card: one row per customer
* that carries categorised-but-unbilled rebill/passthrough documents, with the
* count + open amount (base + markup). In practice only per-event customers
* surface here monthly/manual cadences bill immediately on categorise.
*/
async function listPendingRebillSummary() {
const rows = await db('inbound_documents as d')
.join('customer_accounts as c', 'd.customer_account_id', 'c.id')
.whereNotNull('d.customer_account_id')
.whereNull('d.billed_invoice_id')
.whereIn('d.disposition', CUSTOMER_DISPOSITIONS)
.where('d.status', 'categorized')
.select(
'd.customer_account_id', 'd.total_amount_minor', 'd.net_amount_minor',
'd.markup_type', 'd.markup_percent', 'd.markup_flat_minor',
'c.company_name', 'c.display_name', 'c.first_name', 'c.last_name',
'c.email', 'c.password_hash', 'c.billing_cadence',
);
const byCustomer = new Map();
for (const r of rows) {
let agg = byCustomer.get(r.customer_account_id);
if (!agg) {
agg = {
customerAccountId: r.customer_account_id,
companyName: r.company_name || null,
displayName: r.display_name || null,
firstName: r.first_name || null,
lastName: r.last_name || null,
email: r.email || null,
isPassive: r.password_hash == null,
billingCadence: r.billing_cadence || null,
itemCount: 0,
openAmountMinor: 0,
};
byCustomer.set(r.customer_account_id, agg);
}
agg.itemCount += 1;
const base = r.total_amount_minor != null ? Number(r.total_amount_minor)
: (r.net_amount_minor != null ? Number(r.net_amount_minor) : 0);
const markup = {
type: MARKUP_TYPES.includes(r.markup_type) ? r.markup_type : 'none',
percent: r.markup_percent != null ? Number(r.markup_percent) : null,
flatMinor: Number.isInteger(r.markup_flat_minor) ? r.markup_flat_minor : null,
};
agg.openAmountMinor += base + computeMarkupMinor(base, markup);
}
return Array.from(byCustomer.values()).sort((a, b) => b.openAmountMinor - a.openAmountMinor);
}
/**
* Per-event flow: bundle all pending rebill/passthrough documents for a
* customer into ONE invoice, one line per document. Refuses for monthly/manual
* customers (those bill immediately on categorise). Mirrors
* customerHoursService.billUnbilledEntries.
*/
async function billPendingRebills(customerId, adminId) {
const customer = await db('customer_accounts').where({ id: customerId }).first();
if (!customer) throw new AppError('Customer not found', 404);
if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') {
throw new AppError(
'Monthly/manual customers consolidate automatically on categorise; bundling is for per-event customers.',
409, 'CADENCE_MISMATCH',
);
}
const result = await db.transaction(async (trx) => {
const pending = await trx('inbound_documents')
.where({ customer_account_id: customer.id })
.whereNull('billed_invoice_id')
.whereIn('disposition', CUSTOMER_DISPOSITIONS)
.where('status', 'categorized')
.orderBy('invoice_date', 'asc').orderBy('id', 'asc');
if (pending.length === 0) throw new AppError('No pending re-bills to bill', 409, 'NO_PENDING');
const lineItems = [];
for (let i = 0; i < pending.length; i += 1) {
const doc = transformInbound(pending[i]);
// eslint-disable-next-line no-await-in-loop
const markup = await resolveMarkup(
{ markupType: doc.markupType, markupPercent: doc.markupPercent, markupFlatMinor: doc.markupFlatMinor },
null, null, trx,
);
lineItems.push({ ...buildInboundLineItem(doc, doc.disposition, markup), position: i + 1 });
}
const { invoiceIds } = await invoiceService.createInvoice({
customerAccountId: customer.id,
lineItems,
}, adminId, trx);
const invoiceId = invoiceIds[0];
const insertedLines = await trx('invoice_line_items').where({ invoice_id: invoiceId }).orderBy('position', 'asc');
const lineByPos = new Map(insertedLines.map((li) => [li.position, li.id]));
const now = new Date();
for (let i = 0; i < pending.length; i += 1) {
// eslint-disable-next-line no-await-in-loop
await trx('inbound_documents').where({ id: pending[i].id }).update({
billed_invoice_id: invoiceId,
billed_invoice_line_item_id: lineByPos.get(i + 1) || null,
updated_at: now,
});
}
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). */
@@ -518,6 +822,8 @@ module.exports = {
updateInbound,
categorizeInbound,
rebillInbound,
listPendingRebillSummary,
billPendingRebills,
markInboundSupplierPayment,
// expenses
createExpense,
@@ -531,5 +837,5 @@ module.exports = {
PAYMENT_METHODS,
EXPENSE_KINDS,
// unit-test surface
_internal: { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, transformExpense, transformInbound },
_internal: { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, transformExpense, transformInbound, buildInboundLineItem, isInvoiceMutable, resolveTaxTreatment },
};
+3 -1
View File
@@ -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;
+9 -3
View File
@@ -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;
+82 -50
View File
@@ -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 122125)
Numbered from **122** to avoid colliding with the in-flight `feat/crm-improvements` migrations **117121** (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 122132)
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
`<input type="file" accept="image/*" capture="environment">` 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/<id>/page-<n>.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.
@@ -251,7 +251,12 @@ export const LineItemsTable: React.FC<Props> = ({
// 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
+28 -25
View File
@@ -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,16 @@ export const VatRateSelect: React.FC<Props> = ({ 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) — 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);
const isCustom = !matched;
|| (!code && codes.filter((c) => Number(c.rate) === Number(rate)).length === 1
? codes.find((c) => Number(c.rate) === Number(rate)) : undefined);
const showLegacy = !matched;
return (
<div>
@@ -46,32 +57,24 @@ export const VatRateSelect: React.FC<Props> = ({ rate, code, onChange, label, di
<select
className={selectCls}
disabled={disabled}
value={isCustom ? CUSTOM : String(matched!.id)}
value={matched ? String(matched.id) : LEGACY}
onChange={(e) => {
if (e.target.value === CUSTOM) { onChange(rate, null); return; }
if (e.target.value === LEGACY) { onChange(rate, code); return; } // keep the legacy value
const c = codes.find((x) => String(x.id) === e.target.value);
if (c) onChange(Number(c.rate), c.code);
}}
>
{showLegacy && (
<option value={LEGACY}>
{t('ledger.vat.legacyRate', '{{rate}}% (not configured)', { rate: Number(rate || 0).toFixed(1) })}
</option>
)}
{codes.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name} ({Number(c.rate).toFixed(1)}%)
</option>
))}
<option value={CUSTOM}>{t('vat.customRate', 'Other (custom rate)')}</option>
</select>
{isCustom && (
<input
type="number"
step="0.1"
min="0"
className={`${selectCls} mt-2`}
disabled={disabled}
value={rate}
placeholder={t('vat.ratePercent', 'VAT rate %') as string}
onChange={(e) => onChange(Number(e.target.value) || 0, null)}
/>
)}
</div>
);
};
+28
View File
@@ -0,0 +1,28 @@
/**
* 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 {
return (value || '').trim().toUpperCase();
}
/** 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;
}
@@ -91,6 +91,10 @@ function applyDependencyRules(flags: FeatureFlags): FeatureFlags {
out.galleries = true; // foundation — always on
if (out.quotes === false) out.bills = false; // bills depend on quotes
if (out.calendar === false) out.calendarBooking = false; // booking depends on calendar
// Invoices (Bills) force-enable the Accounting master — invoice VAT config +
// hourly rate live under Settings → Accounting. Before the accounting→children
// rule so sub-features keep their own state.
if (out.bills === true) out.accounting = true;
// Accounting sub-features require the Accounting master. Tax export is
// independent of Bills now — it relocated permanently into Accounting.
if (out.accounting === false) {
@@ -8,9 +8,11 @@ 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, Loading } from '../../../components/common';
import { Button, Card, CardContent, Input, Loading } from '../../../components/common';
import { DecimalInput } from '../../../components/common/DecimalInput';
import { accountingService } from '../../../services/accounting.service';
import { businessProfileService } from '../../../services/businessProfile.service';
import { vatCodesService } from '../../../services/vatCodes.service';
import { sortedCountryOptions } from '../../../constants/countries';
import { VatCodesManager } from '../../../components/admin/VatCodesManager';
import { ChartOfAccountsManager } from '../../../components/admin/ChartOfAccountsManager';
@@ -22,12 +24,19 @@ export const AccountingTab: React.FC = () => {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
const { data: outputVatCodes = [] } = useQuery({ queryKey: ['vat-codes', 'output'], queryFn: () => vatCodesService.listOutput() });
// VAT label + default hourly rate live on business_profile, surfaced here so
// all financial/VAT config sits in one tab (and one Save).
const { data: profileSnap } = useQuery({ queryKey: ['business-profile'], queryFn: () => businessProfileService.get() });
const [kmMajor, setKmMajor] = useState<number>(NaN);
const [perDiemMajor, setPerDiemMajor] = useState<number>(NaN);
const [hourlyMajor, setHourlyMajor] = useState<number>(NaN);
const [requireProof, setRequireProof] = useState(false);
const [vatRegistered, setVatRegistered] = useState(false);
const [reclaimCountries, setReclaimCountries] = useState<string[]>([]);
const [defaultOutputVatCode, setDefaultOutputVatCode] = useState('');
const [vatLabel, setVatLabel] = useState('');
useEffect(() => {
if (data) {
@@ -36,20 +45,41 @@ export const AccountingTab: React.FC = () => {
setRequireProof(data.accounting_require_proof);
setVatRegistered(data.accounting_vat_registered);
setReclaimCountries(data.accounting_vat_reclaim_countries || []);
setDefaultOutputVatCode(data.accounting_default_output_vat_code || '');
}
}, [data]);
useEffect(() => {
if (profileSnap?.profile) {
setVatLabel(profileSnap.profile.vatLabel || '');
setHourlyMajor(profileSnap.profile.defaultHourlyRateMinor != null ? profileSnap.profile.defaultHourlyRateMinor / 100 : NaN);
}
}, [profileSnap]);
const countries = sortedCountryOptions(i18n.language);
const currency = profileSnap?.profile?.defaultCurrency || 'CHF';
// One Save persists BOTH the app_settings (rates/VAT/proof) and the two
// business_profile fields (VAT label + hourly rate).
const save = useMutation({
mutationFn: () => accountingService.updateSettings({
accounting_km_rate_minor: Number.isFinite(kmMajor) ? Math.round(kmMajor * 100) : 0,
accounting_per_diem_rate_minor: Number.isFinite(perDiemMajor) ? Math.round(perDiemMajor * 100) : 0,
accounting_require_proof: requireProof,
accounting_vat_registered: vatRegistered,
accounting_vat_reclaim_countries: reclaimCountries,
}),
onSuccess: () => { toast.success(t('settings.accounting.savedToast', 'Accounting settings saved.')); qc.invalidateQueries({ queryKey: ['accounting-settings'] }); },
mutationFn: async () => {
await accountingService.updateSettings({
accounting_km_rate_minor: Number.isFinite(kmMajor) ? Math.round(kmMajor * 100) : 0,
accounting_per_diem_rate_minor: Number.isFinite(perDiemMajor) ? Math.round(perDiemMajor * 100) : 0,
accounting_require_proof: requireProof,
accounting_vat_registered: vatRegistered,
accounting_vat_reclaim_countries: reclaimCountries,
accounting_default_output_vat_code: defaultOutputVatCode,
});
await businessProfileService.update({
vatLabel: vatLabel || '',
defaultHourlyRateMinor: Number.isFinite(hourlyMajor) ? Math.max(0, Math.round(hourlyMajor * 100)) : null,
});
},
onSuccess: () => {
toast.success(t('settings.accounting.savedToast', 'Accounting settings saved.'));
qc.invalidateQueries({ queryKey: ['accounting-settings'] });
qc.invalidateQueries({ queryKey: ['business-profile'] });
},
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
});
@@ -69,9 +99,14 @@ export const AccountingTab: React.FC = () => {
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.kmRateHint', 'Default applied to mileage expenses; overridable per entry.')}</p>
</div>
<div>
<label className={labelCls}>{t('settings.accounting.perDiemRate', 'Per-diem rate (CHF / day)')}</label>
<label className={labelCls}>{t('settings.accounting.perDiemRate', 'Daily allowance (CHF / day)')}</label>
<DecimalInput value={perDiemMajor} onChange={setPerDiemMajor} fractionDigits={2} className={inputCls} />
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.perDiemRateHint', 'Default applied to per-diem expenses; overridable per entry.')}</p>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.perDiemRateHint', 'A flat daily allowance booked as an expense (not a client billing rate); overridable per entry.')}</p>
</div>
<div>
<label className={labelCls}>{t('settings.accounting.profileFields.hourlyRate', 'Default hourly rate')}</label>
<DecimalInput value={hourlyMajor} onChange={setHourlyMajor} fractionDigits={2} className={inputCls} placeholder={t('settings.accounting.profileFields.hourlyRatePlaceholder', 'e.g. 120.00') as string} />
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{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 })}</p>
</div>
<label className="flex items-center gap-2 text-sm text-neutral-800 dark:text-neutral-200">
<input type="checkbox" checked={requireProof} onChange={(e) => setRequireProof(e.target.checked)} className="rounded border-neutral-300" />
@@ -85,7 +120,7 @@ export const AccountingTab: React.FC = () => {
the tax report's VAT-payable). */}
<Card><CardContent className="p-5 space-y-4">
<h3 className="text-sm font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
{t('settings.accounting.vat.title', 'VAT registration & reclaim')}
{t('settings.accounting.vat.title', 'VAT')}
</h3>
<label className="flex items-start gap-2 text-sm text-neutral-800 dark:text-neutral-200">
<input type="checkbox" checked={vatRegistered} onChange={(e) => setVatRegistered(e.target.checked)} className="mt-0.5 rounded border-neutral-300" />
@@ -113,6 +148,19 @@ export const AccountingTab: React.FC = () => {
{t('settings.accounting.vat.reclaimCountriesHint', 'Typically your domestic country (CH / LI). Costs from other countries are treated as non-reclaimable foreign VAT. Cmd/Ctrl-click to multi-select.')}
</p>
</div>
<div>
<label className={labelCls}>{t('settings.accounting.vat.defaultOutputCode', 'Default VAT code for new invoices')}</label>
<select value={defaultOutputVatCode} onChange={(e) => setDefaultOutputVatCode(e.target.value)} className={inputCls}>
<option value="">{t('settings.accounting.vat.defaultOutputCodeNone', '— none (start at 0%) —')}</option>
{outputVatCodes.map((c) => <option key={c.id} value={c.code}>{c.name} ({Number(c.rate).toFixed(1)}%)</option>)}
</select>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.vat.defaultOutputCodeHint', 'New invoices and quotes start with this VAT code selected. Existing documents are unaffected.')}</p>
</div>
<div>
<label className={labelCls}>{t('settings.accounting.profileFields.vatLabel', 'VAT label (e.g. MwSt., VAT)')}</label>
<Input value={vatLabel} onChange={(e) => setVatLabel(e.target.value)} className={inputCls} />
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.profileFields.vatLabelHint', 'Printed as the VAT-line label on invoice / quote PDFs. Leave blank to use the document language default.')}</p>
</div>
</CardContent></Card>
<div>
@@ -324,6 +324,13 @@ export const FeaturesTab: React.FC = () => {
sidebarLabel={t('settings.features.accounting.sidebar', 'Accounting')}
enabled={staged.accounting}
onToggle={(next) => setFlag('accounting', next)}
// Invoices force-enable Accounting (invoice VAT settings live here),
// so the master can't be turned off while Bills is on.
disabled={staged.bills}
lockedReason={staged.bills ? t(
'settings.features.accounting.requiredByBills',
'On automatically because Invoices is enabled — invoice VAT settings live in the Accounting section.',
) : undefined}
/>
<FeatureCard
+47 -16
View File
@@ -1671,7 +1671,8 @@
"accounting": {
"title": "Buchhaltung",
"description": "Ein eigener Buchhaltungsbereich, getrennt vom CRM. Hier aktivieren und dann die Unterfunktionen unten einschalten (Steuerexport, Eingangsrechnungen). MwSt-/Steuerbehandlung dient nur als Orientierung — vor dem Verlassen darauf mit Ihrem Treuhänder prüfen.",
"sidebar": "Buchhaltung"
"sidebar": "Buchhaltung",
"requiredByBills": "Automatisch an, weil Rechnungen aktiviert ist — die MwSt-Einstellungen der Rechnungen liegen im Buchhaltungsbereich."
},
"incomingInvoices": {
"title": "Eingangsrechnungen",
@@ -1742,21 +1743,31 @@
},
"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",
"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."
"savedToast": "Buchhaltungseinstellungen gespeichert.",
"profileFields": {
"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."
}
}
},
"branding": {
@@ -3538,7 +3549,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",
@@ -3556,6 +3574,7 @@
"saveCategorize": "Speichern",
"saveCategorizePay": "Speichern & als bezahlt markieren",
"categorize": "Kategorisieren",
"recategorize": "Neu kategorisieren",
"view": "Ansehen",
"empty": "Noch keine Dokumente — oben eines erfassen.",
"untitled": "Unbenanntes Dokument",
@@ -3587,7 +3606,13 @@
"eventId": "Event-ID (optional)",
"markup": "Zuschlag",
"reference": "Zahlungsreferenz",
"referenceHint": "QR-/ESR-Referenz oder Mitteilung"
"referenceHint": "QR-/ESR-Referenz oder Mitteilung",
"note": "Notiz",
"noteHint": "Interne Notiz zu dieser Rechnung (optional)",
"passthroughCustomerHint": "Optional — einen Kunden zuordnen, um diese durchlaufende Position weiterzuverrechnen; leer lassen, um sie nur auf das Event zu buchen.",
"supplierCountry": "Lieferantenland",
"supplierCountryNone": "— unbekannt —",
"supplierCountryHint": "Setzt die Steuerbehandlung automatisch: ausserhalb deiner Vorsteuer-Länder → ausländische MwSt (nicht abziehbar)."
}
},
"expenseStatus": {
@@ -3608,7 +3633,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",
@@ -3620,7 +3646,15 @@
"paid": "Bezahlt",
"paidToast": "Als bezahlt markiert.",
"categorizedToast": "Kategorisiert.",
"categorizedPaidToast": "Kategorisiert und als bezahlt markiert."
"categorizedPaidToast": "Kategorisiert und als bezahlt markiert.",
"pendingRebill": "Weiterverrechnung offen",
"pendingTitle": "Offene Weiterverrechnungen",
"pendingBody": "Kategorisierte Rechnungen, die auf die Weiterverrechnung warten. Posten eines Kunden zu einer Rechnung bündeln.",
"pendingCount": "{{count}} Posten",
"pendingCount_other": "{{count}} Posten",
"billPending": "Verrechnen",
"bundledToast": "{{count}} Weiterverrechnung zu einer Rechnung gebündelt.",
"bundledToast_other": "{{count}} Weiterverrechnungen zu einer Rechnung gebündelt."
},
"expense": {
"kind": "Art",
@@ -3953,7 +3987,8 @@
"vat": {
"addTitle": "MWST-Code hinzufügen", "editTitle": "MWST-Code bearbeiten",
"code": "Code", "name": "Name", "rate": "Satz %", "direction": "Richtung",
"account": "MWST-Konto", "noAccount": "— keines —", "confirmDelete": "Diesen MWST-Code löschen?"
"account": "MWST-Konto", "noAccount": "— keines —", "confirmDelete": "Diesen MWST-Code löschen?",
"legacyRate": "{{rate}}% (nicht konfiguriert)"
},
"export": {
"title": "Treuhänder-Export",
@@ -4325,6 +4360,7 @@
},
"businessProfile": {
"savedToast": "Geschäftsprofil gespeichert.",
"movedToAccounting": "MwSt-Satz, MwSt-Bezeichnung und Standard-Stundensatz befinden sich jetzt unter Einstellungen → Buchhaltung.",
"title": "Geschäftsprofil",
"subtitle": "Briefkopf, Kontaktdaten und Standardwerte für Angebote und Rechnungen.",
"businessHours": {
@@ -4371,11 +4407,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"
},
+49 -18
View File
@@ -1229,7 +1229,8 @@
"accounting": {
"title": "Accounting",
"description": "A dedicated Accounting area, separate from CRM. Turn this on, then enable the sub-features below (Tax export, Incoming invoices). VAT / tax treatment is guidance only — verify with your Treuhänder before relying on it.",
"sidebar": "Accounting"
"sidebar": "Accounting",
"requiredByBills": "On automatically because Invoices is enabled — invoice VAT settings live in the Accounting section."
},
"incomingInvoices": {
"title": "Incoming invoices",
@@ -1300,21 +1301,31 @@
},
"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",
"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."
"savedToast": "Accounting settings saved.",
"profileFields": {
"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."
}
}
},
"analytics": {
@@ -3538,7 +3549,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",
@@ -3556,6 +3574,7 @@
"saveCategorize": "Save",
"saveCategorizePay": "Save & mark paid",
"categorize": "Categorize",
"recategorize": "Re-categorize",
"view": "View",
"empty": "No documents yet — capture one above.",
"untitled": "Untitled document",
@@ -3587,7 +3606,13 @@
"eventId": "Event ID (optional)",
"markup": "Markup",
"reference": "Payment reference",
"referenceHint": "QR / ESR reference or message"
"referenceHint": "QR / ESR reference or message",
"note": "Note",
"noteHint": "Internal note for this invoice (optional)",
"passthroughCustomerHint": "Optional — attach a client to re-bill this passthrough; leave empty to only book it to the event.",
"supplierCountry": "Supplier country",
"supplierCountryNone": "— unknown —",
"supplierCountryHint": "Sets the tax treatment automatically: outside your VAT-reclaim countries → foreign VAT (not reclaimable)."
}
},
"expenseStatus": {
@@ -3608,7 +3633,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",
@@ -3620,7 +3646,15 @@
"paid": "Paid",
"paidToast": "Marked as paid.",
"categorizedToast": "Categorized.",
"categorizedPaidToast": "Categorized and marked paid."
"categorizedPaidToast": "Categorized and marked paid.",
"pendingRebill": "Pending re-bill",
"pendingTitle": "Pending re-bills",
"pendingBody": "Categorized invoices waiting to be re-billed. Bundle a clients items into one invoice.",
"pendingCount": "{{count}} item",
"pendingCount_other": "{{count}} items",
"billPending": "Bill these",
"bundledToast": "Bundled {{count}} re-bill into one invoice.",
"bundledToast_other": "Bundled {{count}} re-bills into one invoice."
},
"expense": {
"kind": "Type",
@@ -3639,7 +3673,7 @@
"expenseKind": {
"amount": "Amount",
"mileage": "Mileage (km)",
"per_diem": "Per-diem"
"per_diem": "Daily allowance"
},
"category": {
"infrastructure": "Infrastructure & rent",
@@ -3953,7 +3987,8 @@
"vat": {
"addTitle": "Add VAT code", "editTitle": "Edit VAT code",
"code": "Code", "name": "Name", "rate": "Rate %", "direction": "Direction",
"account": "VAT account", "noAccount": "— none —", "confirmDelete": "Delete this VAT code?"
"account": "VAT account", "noAccount": "— none —", "confirmDelete": "Delete this VAT code?",
"legacyRate": "{{rate}}% (not configured)"
},
"export": {
"title": "Treuhänder export",
@@ -4323,6 +4358,7 @@
},
"businessProfile": {
"savedToast": "Business profile saved.",
"movedToAccounting": "The VAT rate, VAT label and default hourly rate now live under Settings → Accounting.",
"title": "Business profile",
"subtitle": "Issuer block shown on every quote and invoice PDF.",
"businessHours": {
@@ -4369,11 +4405,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"
},
@@ -6,15 +6,17 @@
* client. PDFs are previewed as server-rasterised page images (never raw).
*/
import React, { useRef, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Camera, Upload, Inbox, X, Circle, Eye, RotateCcw } from 'lucide-react';
import { Camera, Upload, Inbox, X, Circle, Eye, RotateCcw, Send, Pencil } from 'lucide-react';
import { Button, Card, CardContent, Input, LocalizedDateInput, Loading } from '../../../components/common';
import { DecimalInput } from '../../../components/common/DecimalInput';
import { CustomerAccountPicker, type SelectedCustomer } from '../../../components/admin/CustomerAccountPicker';
import { EventBookingSelect } from '../../../components/admin/EventBookingSelect';
import { formatMoneyMinor } from '../../../utils/money';
import { sortedCountryOptions } from '../../../constants/countries';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import {
accountingService, categoryLabel,
@@ -150,10 +152,14 @@ const ViewModal: React.FC<{ doc: InboundDocument; onClose: () => void }> = ({ do
{field(t('accounting.inbox.field.total', 'Total'), doc.totalAmountMinor != null ? formatMoneyMinor(doc.totalAmountMinor, doc.currency || 'CHF') : null)}
{field(t('accounting.inbox.field.invoiceDate', 'Invoice date'), doc.invoiceDate ? format(doc.invoiceDate) : null)}
{field(t('accounting.inbox.field.disposition', 'Disposition'), doc.disposition ? t(`accounting.disposition.${doc.disposition}`, doc.disposition) : null)}
{field(t('accounting.inbox.status.label', 'Status'), t(`accounting.inbox.status.${doc.status}`, doc.status))}
{doc.customerName && field(t('accounting.inbox.field.customer', 'Client'), doc.customerName)}
{field(t('accounting.inbox.status.label', 'Status'), doc.customerAccountId && !doc.billedInvoiceId
? t('accounting.incoming.pendingRebill', 'Pending re-bill')
: t(`accounting.inbox.status.${doc.status}`, doc.status))}
{field(t('accounting.incoming.paid', 'Paid'), doc.supplierPaid
? (doc.supplierPaidAt ? format(doc.supplierPaidAt) : t('common.yes', 'Yes'))
: t('common.no', 'No'))}
{doc.note && field(t('accounting.inbox.field.note', 'Note'), doc.note)}
</div>
</div>
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
@@ -165,18 +171,28 @@ 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<number>(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 [disposition, setDisposition] = useState<Disposition>('eigener_aufwand');
const [categoryId, setCategoryId] = useState<number | undefined>(undefined);
const [eventId, setEventId] = useState<number | null>(null);
const [customer, setCustomer] = useState<SelectedCustomer[]>([]);
const [markupType, setMarkupType] = useState<MarkupType>('none');
const [markupValue, setMarkupValue] = useState<number>(NaN);
const [note, setNote] = useState(doc.note || '');
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<Disposition>(doc.disposition || 'eigener_aufwand');
const [categoryId, setCategoryId] = useState<number | undefined>(doc.categoryId ?? undefined);
const [eventId, setEventId] = useState<number | null>(doc.eventId ?? null);
const [customer, setCustomer] = useState<SelectedCustomer[]>(
doc.customerAccountId ? [{ id: doc.customerAccountId, email: doc.customerEmail || '', displayName: doc.customerName }] : [],
);
const [markupType, setMarkupType] = useState<MarkupType>(doc.markupType || 'none');
const [markupValue, setMarkupValue] = useState<number>(
doc.markupType === 'percent' && doc.markupPercent != null ? doc.markupPercent
: doc.markupType === 'flat' && doc.markupFlatMinor != null ? doc.markupFlatMinor / 100
: NaN,
);
const totalMinor = Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null;
@@ -191,13 +207,15 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
// entered) — no second dialog — so "Save & mark paid" actually pays.
const save = useMutation({
mutationFn: async (pay: boolean) => {
await accountingService.updateInbound(doc.id, { supplierName: supplier || null, totalAmountMinor: totalMinor, currency: currency || null, invoiceDate: invoiceDate || null, paymentReference: reference || null });
await accountingService.updateInbound(doc.id, { supplierName: supplier || null, totalAmountMinor: totalMinor, currency: currency || null, invoiceDate: invoiceDate || null, paymentReference: reference || null, note: note || null, supplierCountry: supplierCountry || null });
await accountingService.categorizeInbound(doc.id, {
disposition,
eventId: BOOKING_DISPOSITIONS.includes(disposition) ? eventId : null,
categoryId: disposition === 'eigener_aufwand' ? (categoryId ?? null) : null,
customerAccountId: disposition === 'rebill' && customer[0] ? customer[0].id : null,
...markupPayload(),
// Both rebill and passthrough can attach to a customer (#3).
customerAccountId: BOOKING_DISPOSITIONS.includes(disposition) && customer[0] ? customer[0].id : null,
// 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 });
@@ -227,20 +245,35 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.supplier', 'Supplier')}</label><Input value={supplier} onChange={(e) => setSupplier(e.target.value)} /></div>
<div><label className={labelCls}>{t('accounting.inbox.field.total', 'Total')}</label><DecimalInput value={amountMajor} onChange={setAmountMajor} fractionDigits={2} className={selectCls} /></div>
<div><label className={labelCls}>{t('accounting.inbox.field.currency', 'Currency')}</label><Input value={currency} maxLength={3} onChange={(e) => setCurrency(e.target.value.toUpperCase())} /></div>
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.supplierCountry', 'Supplier country')}</label>
<select value={supplierCountry} onChange={(e) => setSupplierCountry(e.target.value)} className={selectCls}>
<option value="">{t('accounting.inbox.field.supplierCountryNone', '— unknown —')}</option>
{sortedCountryOptions(i18n.language).map((c) => <option key={c.code} value={c.code}>{c.label}</option>)}
</select>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('accounting.inbox.field.supplierCountryHint', 'Sets the tax treatment automatically: outside your VAT-reclaim countries → foreign VAT (not reclaimable).')}</p>
</div>
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.invoiceDate', 'Invoice date')}</label><LocalizedDateInput value={invoiceDate} onChange={setInvoiceDate} /></div>
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.reference', 'Payment reference')}</label><Input value={reference} onChange={(e) => setReference(e.target.value)} placeholder={t('accounting.inbox.field.referenceHint', 'QR / ESR reference or message') as string} /></div>
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.note', 'Note')}</label>
<textarea value={note} onChange={(e) => setNote(e.target.value)} rows={2} className={selectCls} placeholder={t('accounting.inbox.field.noteHint', 'Internal note for this invoice (optional)') as string} /></div>
</div>
<div><label className={labelCls}>{t('accounting.inbox.field.disposition', 'Disposition')}</label>
<select value={disposition} onChange={(e) => setDisposition(e.target.value as Disposition)} className={selectCls}>
{DISPOSITIONS.map((d) => <option key={d} value={d}>{t(`accounting.disposition.${d}`, d)}</option>)}
</select>
{/* Explain the selected disposition re-bill vs pass-through vs
company expense aren't obvious from the labels alone. */}
<p className="mt-1 rounded-md bg-neutral-50 dark:bg-neutral-800/60 px-2.5 py-1.5 text-xs text-neutral-600 dark:text-neutral-400">
{t(`accounting.disposition.help.${disposition}`, '')}
</p>
</div>
{BOOKING_DISPOSITIONS.includes(disposition) && (
<div>
<label className={labelCls}>{t('accounting.booking.label', 'Book to')}</label>
<EventBookingSelect value={eventId} onChange={setEventId} className={selectCls} />
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{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.')}</p>
</div>
)}
@@ -253,18 +286,24 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
</div>
)}
{disposition === 'rebill' && (
{BOOKING_DISPOSITIONS.includes(disposition) && (
<div className="space-y-3 rounded-lg border border-neutral-200 dark:border-neutral-700 p-3">
<div><label className={labelCls}>{t('accounting.inbox.field.customer', 'Client')} *</label>
<CustomerAccountPicker value={customer.slice(0, 1)} onChange={(next) => setCustomer(next.slice(-1))} /></div>
<div><label className={labelCls}>{t('accounting.inbox.field.markup', 'Markup')}</label>
<select value={markupType} onChange={(e) => setMarkupType(e.target.value as MarkupType)} className={selectCls}>
<option value="none">{t('accounting.markup.none', 'None / from contract')}</option>
<option value="percent">{t('accounting.markup.percent', 'Percent')}</option>
<option value="flat">{t('accounting.markup.flat', 'Flat')}</option>
</select>
<div><label className={labelCls}>{t('accounting.inbox.field.customer', 'Client')} {disposition === 'rebill' ? '*' : ''}</label>
<CustomerAccountPicker value={customer.slice(0, 1)} onChange={(next) => setCustomer(next.slice(-1))} />
{disposition === 'durchlaufend' && <p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('accounting.inbox.field.passthroughCustomerHint', 'Optional — attach a client to re-bill this passthrough; leave empty to only book it to the event.')}</p>}
</div>
{markupType !== 'none' && <DecimalInput value={markupValue} onChange={setMarkupValue} fractionDigits={2} className={selectCls} placeholder={markupType === 'percent' ? '%' : currency} />}
{/* Markup is a re-bill concept only. A pass-through is invoiced
at cost (VAT-neutral), so no markup control here. */}
{disposition === 'rebill' && (<>
<div><label className={labelCls}>{t('accounting.inbox.field.markup', 'Markup')}</label>
<select value={markupType} onChange={(e) => setMarkupType(e.target.value as MarkupType)} className={selectCls}>
<option value="none">{t('accounting.markup.none', 'None / from contract')}</option>
<option value="percent">{t('accounting.markup.percent', 'Percent')}</option>
<option value="flat">{t('accounting.markup.flat', 'Flat')}</option>
</select>
</div>
{markupType !== 'none' && <DecimalInput value={markupValue} onChange={setMarkupValue} fractionDigits={2} className={selectCls} placeholder={markupType === 'percent' ? '%' : currency} />}
</>)}
</div>
)}
</div>
@@ -283,6 +322,7 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
export const AccountingInboxPage: React.FC = () => {
const { t } = useTranslation();
const qc = useQueryClient();
const navigate = useNavigate();
const { format } = useLocalizedDate();
const cameraRef = useRef<HTMLInputElement>(null);
const uploadRef = useRef<HTMLInputElement>(null);
@@ -294,6 +334,8 @@ export const AccountingInboxPage: React.FC = () => {
// without a manual reload (the poller runs server-side every 60s).
const { data, isLoading } = useQuery({ queryKey: ['accounting-inbound'], queryFn: () => accountingService.listInbound({ pageSize: 100 }), refetchInterval: 30000, refetchOnWindowFocus: true });
const { data: categories } = useQuery({ queryKey: ['expense-categories'], queryFn: () => accountingService.listCategories() });
// Per-event customers carrying pending (categorised, unbilled) re-bills (#3).
const { data: pending } = useQuery({ queryKey: ['accounting-pending-rebills'], queryFn: () => accountingService.listPendingRebills(), refetchInterval: 30000 });
const upload = useMutation({
mutationFn: ({ file, source }: { file: File; source: 'upload' | 'camera' }) => accountingService.uploadInbound(file, source),
@@ -305,11 +347,24 @@ export const AccountingInboxPage: React.FC = () => {
onSuccess: () => { qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); },
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
});
const billPending = useMutation({
mutationFn: (customerAccountId: number) => accountingService.billPendingRebills(customerAccountId),
onSuccess: ({ invoiceId, count }) => {
toast.success(t('accounting.incoming.bundledToast', 'Bundled {{count}} re-bill(s) into one invoice.', { count }));
qc.invalidateQueries({ queryKey: ['accounting-inbound'] });
qc.invalidateQueries({ queryKey: ['accounting-pending-rebills'] });
navigate(`/admin/clients/bills/${invoiceId}/edit`);
},
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
});
const onFile = (source: 'upload' | 'camera') => (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; if (file) upload.mutate({ file, source }); e.target.value = '';
};
const refresh = () => qc.invalidateQueries({ queryKey: ['accounting-inbound'] });
const refresh = () => { qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); qc.invalidateQueries({ queryKey: ['accounting-pending-rebills'] }); };
const pendingItems = pending ?? [];
const customerLabel = (p: typeof pendingItems[number]) => p.displayName || p.companyName || [p.firstName, p.lastName].filter(Boolean).join(' ') || p.email || `#${p.customerAccountId}`;
const handleTriageDone = () => { setTriageDoc(null); refresh(); };
@@ -329,6 +384,31 @@ export const AccountingInboxPage: React.FC = () => {
<Button variant="outline" onClick={() => uploadRef.current?.click()} disabled={upload.isPending}><Upload className="w-4 h-4 mr-2" /> {t('accounting.inbox.uploadFile', 'Upload file')}</Button>
</CardContent></Card>
{/* Pending re-bills (#3): per-event customers accumulate categorised
rebill/passthrough invoices here; bundle them into one invoice like
"Bill these hours". Monthly/manual customers never surface (they
consolidate onto their running draft at categorise time). */}
{pendingItems.length > 0 && (
<Card className="mb-6"><CardContent className="p-5">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100 mb-1">{t('accounting.incoming.pendingTitle', 'Pending re-bills')}</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-3">{t('accounting.incoming.pendingBody', 'Categorized invoices waiting to be re-billed. Bundle a clients items into one invoice.')}</p>
<div className="space-y-2">
{pendingItems.map((p) => (
<div key={p.customerAccountId} className="flex flex-wrap items-center gap-3 rounded-lg border border-neutral-200 dark:border-neutral-700 px-4 py-2">
<div className="flex-1 min-w-[12rem]">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{customerLabel(p)}</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('accounting.incoming.pendingCount', '{{count}} item(s)', { count: p.itemCount })}
{' · '}{formatMoneyMinor(p.openAmountMinor, 'CHF')}
</div>
</div>
<Button size="sm" onClick={() => billPending.mutate(p.customerAccountId)} disabled={billPending.isPending}><Send className="w-3.5 h-3.5 mr-1" /> {t('accounting.incoming.billPending', 'Bill these')}</Button>
</div>
))}
</div>
</CardContent></Card>
)}
{isLoading ? <Loading /> : items.length === 0 ? (
<div className="rounded-xl border border-dashed border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900 p-8 text-center">
<Inbox className="w-10 h-10 mx-auto mb-3 text-neutral-400" />
@@ -362,6 +442,10 @@ export const AccountingInboxPage: React.FC = () => {
{doc.totalAmountMinor != null ? formatMoneyMinor(doc.totalAmountMinor, doc.currency || 'CHF') : t('accounting.inbox.noAmount', 'amount not entered')}
{' · '}{format(doc.createdAt)}
{doc.disposition && <>{' · '}{t(`accounting.disposition.${doc.disposition}`, doc.disposition)}</>}
{/* Pending re-bill = attached to a client but not yet on an invoice. */}
{doc.customerAccountId && !doc.billedInvoiceId && (
<span className="text-indigo-600 dark:text-indigo-400">{' · '}{t('accounting.incoming.pendingRebill', 'Pending re-bill')}{doc.customerName ? `${doc.customerName}` : ''}</span>
)}
</div>
</button>
<Button size="sm" variant="ghost" onClick={() => setViewDoc(doc)}><Eye className="w-3.5 h-3.5 mr-1" /> {t('accounting.inbox.view', 'View')}</Button>
@@ -372,7 +456,11 @@ export const AccountingInboxPage: React.FC = () => {
? <Button size="sm" variant="ghost" onClick={() => unpay.mutate(doc.id)} disabled={unpay.isPending}><RotateCcw className="w-3.5 h-3.5 mr-1" /> {t('accounting.incoming.markUnpaid', 'Mark unpaid')}</Button>
: <Button size="sm" variant="outline" onClick={() => setPayDoc(doc)}><Circle className="w-3.5 h-3.5 mr-1" /> {t('accounting.incoming.markPaid', 'Mark paid')}</Button>
)}
{doc.status === 'unsorted' && <Button size="sm" onClick={() => setTriageDoc(doc)}>{t('accounting.inbox.categorize', 'Categorize')}</Button>}
{/* #1: re-categorize is available after the first triage too, so a
disposition can be changed (e.g. passthrough company expense). */}
{doc.status === 'unsorted'
? <Button size="sm" onClick={() => setTriageDoc(doc)}>{t('accounting.inbox.categorize', 'Categorize')}</Button>
: <Button size="sm" variant="outline" onClick={() => setTriageDoc(doc)}><Pencil className="w-3.5 h-3.5 mr-1" /> {t('accounting.inbox.recategorize', 'Re-categorize')}</Button>}
</div>
))}
</div>
@@ -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
@@ -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
// <CustomerPicker> (migration C.5 extraction).
@@ -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 = () => {
<Card>
<h3 className="font-semibold mb-3">{t('businessProfile.section.defaults', 'Defaults')}</h3>
{/* Pointer so admins who look for the old VAT/hourly-rate fields here
know where they went. */}
<p className="mb-3 rounded-md border border-blue-200 dark:border-blue-900/50 bg-blue-50 dark:bg-blue-900/20 px-3 py-2 text-xs text-blue-800 dark:text-blue-300">
{t('businessProfile.movedToAccounting', 'The VAT rate, VAT label and default hourly rate now live under Settings → Accounting.')}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input label={t('businessProfile.field.defaultCurrency', 'Default currency') as string} value={profile.defaultCurrency}
maxLength={3} onChange={(e) => setProfile({ ...profile, defaultCurrency: e.target.value.toUpperCase() })} />
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('businessProfile.field.defaultCurrency', 'Default currency')}
</label>
{/* 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. */}
<select
value={normalizeCurrency(profile.defaultCurrency)}
onChange={(e) => setProfile({ ...profile, defaultCurrency: e.target.value })}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
{currencyOptions(profile.defaultCurrency).map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<Input label={t('businessProfile.field.defaultLocale', 'Default locale') as string} value={profile.defaultLocale}
maxLength={8} onChange={(e) => setProfile({ ...profile, defaultLocale: e.target.value })} />
{/* Migration 137 IANA timezone for the admin calendar + the
@@ -149,35 +178,9 @@ export const SettingsBusinessProfilePage: React.FC = () => {
))}
</select>
</div>
<Input label={t('businessProfile.field.vatLabel', 'VAT label (e.g. MwSt., VAT)') as string} value={profile.vatLabel}
onChange={(e) => setProfile({ ...profile, vatLabel: e.target.value })} />
<Input type="number" step="0.01" label={t('businessProfile.field.vatRateDefault', 'Default VAT rate %') as string}
value={profile.vatRateDefault ?? 0}
onChange={(e) => 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. */}
<div>
<label className="block text-sm font-medium mb-1">
{t('businessProfile.field.defaultHourlyRate', 'Default hourly rate')}
</label>
<DecimalInput
value={profile.defaultHourlyRateMinor != null ? profile.defaultHourlyRateMinor / 100 : NaN}
fractionDigits={2}
onChange={(n) => setProfile({
...profile,
defaultHourlyRateMinor: Number.isFinite(n) ? Math.max(0, Math.round(n * 100)) : null,
})}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
placeholder={t('businessProfile.field.defaultHourlyRatePlaceholder', 'e.g. 120.00') as string}
/>
<p className="text-xs text-muted-theme mt-1">
{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' })}
</p>
</div>
{/* 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. */}
<div>
<label className="block text-sm font-medium mb-1">{t('businessProfile.field.defaultQrFormat', 'Default invoice QR')}</label>
<select value={profile.defaultQrFormat} onChange={(e) => setProfile({ ...profile, defaultQrFormat: e.target.value as QrFormat })}
@@ -35,6 +35,14 @@ 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;
/** ISO-2 supplier country — auto-defaults the tax treatment (reclaim list). */
supplierCountry: string | null;
/** Free-text categorisation note. */
note: string | null;
supplierPaid: boolean;
supplierPaidAt: string | null;
supplierPaymentMethod: PaymentMethod | null;
@@ -79,6 +87,20 @@ export interface InvoiceExpensePayload {
markupFlatMinor?: number | null;
}
/** One customer with categorised-but-unbilled rebill/passthrough docs. */
export interface PendingRebillSummary {
customerAccountId: number;
companyName: string | null;
displayName: string | null;
firstName: string | null;
lastName: string | null;
email: string | null;
isPassive: boolean;
billingCadence: string | null;
itemCount: number;
openAmountMinor: number;
}
export interface ExpenseCategory { id: number; name: string; color: string | null; is_seed: boolean; display_order: number; }
export interface Paginated<T> { items: T[]; pagination: { page: number; pageSize: number; total: number; totalPages: number }; }
@@ -92,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 {
@@ -148,6 +172,10 @@ export const accountingService = {
async updateInbound(id: number, fields: Partial<InboundDocument>): Promise<InboundDocument> { const { data } = await api.patch(`/admin/expenses/inbound/${id}`, fields); return data.document; },
async categorizeInbound(id: number, payload: CategorizePayload): Promise<InboundDocument> { const { data } = await api.post(`/admin/expenses/inbound/${id}/categorize`, payload); return data.document; },
async rebillInbound(id: number, payload: CategorizePayload): Promise<{ document: InboundDocument; invoiceId: number }> { const { data } = await api.post(`/admin/expenses/inbound/${id}/rebill`, payload); return data; },
/** Per-event customers with pending (categorised, unbilled) re-bills. */
async listPendingRebills(): Promise<PendingRebillSummary[]> { const { data } = await api.get('/admin/expenses/inbound/pending-summary'); return data.items; },
/** Bundle one customer's pending re-bills into a single invoice. */
async billPendingRebills(customerAccountId: number): Promise<{ invoiceId: number; count: number }> { const { data } = await api.post('/admin/expenses/inbound/bill-pending', { customerAccountId }); return data; },
async markInboundPaid(id: number, payload: { paid: boolean; paidAt?: string; paymentMethod?: PaymentMethod; paymentReference?: string }): Promise<InboundDocument> { const { data } = await api.post(`/admin/expenses/inbound/${id}/supplier-payment`, payload); return data.document; },
async getInboundFileBlob(id: number): Promise<Blob> { const { data } = await api.get(`/admin/expenses/inbound/${id}/file`, { responseType: 'blob' }); return data; },
async getInboundPageBlob(id: number, page: number): Promise<Blob> { const { data } = await api.get(`/admin/expenses/inbound/${id}/page/${page}`, { responseType: 'blob' }); return data; },
@@ -186,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<AccountingSettings>): Promise<{ updated: string[] }> {