diff --git a/.release-please-manifest-beta.json b/.release-please-manifest-beta.json index d311e8be..ced1e09d 100644 --- a/.release-please-manifest-beta.json +++ b/.release-please-manifest-beta.json @@ -1,3 +1,3 @@ { - ".": "3.64.0-beta.0" + ".": "3.65.0-beta.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index b392a407..61520fa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to PicPeak will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.65.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.64.0-beta.0...v3.65.0-beta.0) (2026-06-18) + + +### Features + +* **accounting:** consolidate VAT/financial config into Settings → Accounting ([dc7b87b](https://github.com/the-luap/picpeak/commit/dc7b87bb874e22ac6902fd2d48531ecdb6108c88)) +* **accounting:** explain dispositions inline, drop markup from pass-through ([9a023c0](https://github.com/the-luap/picpeak/commit/9a023c019750ebcd8d21e005aaf9a77a32cb34a3)) +* **accounting:** incoming-invoice workflow v2 + VAT/financial settings consolidation ([b527915](https://github.com/the-luap/picpeak/commit/b5279155ea4c545e13bab8bde46a39cfccf107fe)) +* **accounting:** invoices force-enable the Accounting master ([51837c3](https://github.com/the-luap/picpeak/commit/51837c3a88f711b164fafe2c7677e1a91c7542f9)) +* **accounting:** re-categorize incoming invoices, note field, pending re-bill pool ([36a8e42](https://github.com/the-luap/picpeak/commit/36a8e42f90f15a1ba96d9c4f004fa542d33e4937)) +* **accounting:** supplier-country tax default + configurable default output VAT code ([267b121](https://github.com/the-luap/picpeak/commit/267b121d66994bc57b10cd0694ab0e4320b163d9)) + + +### Bug Fixes + +* **accounting:** address the-luap PR [#636](https://github.com/the-luap/picpeak/issues/636) review ([707c5d0](https://github.com/the-luap/picpeak/commit/707c5d027798bdafb9fe09d7efcbd9ea65330076)) +* **accounting:** tax-report storno totals + hours-line date on Postgres ([db9e41d](https://github.com/the-luap/picpeak/commit/db9e41d19846b31b29c5c1be2ee06a7958bb43b0)) +* **crm:** editor totals box computed VAT 100× too small ([e9b297c](https://github.com/the-luap/picpeak/commit/e9b297c162a19da31d53de377b90bfd5cda1b0a7)) +* **hours:** move logActivity out of the entry transactions (SQLite deadlock) ([348955b](https://github.com/the-luap/picpeak/commit/348955b261713fc9f0b48391a1d4117f6f8c873f)) + ## [3.64.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.63.0-beta.0...v3.64.0-beta.0) (2026-06-18) diff --git a/backend/__tests__/integration/incomingInvoiceRebill.test.js b/backend/__tests__/integration/incomingInvoiceRebill.test.js new file mode 100644 index 00000000..9d9312c7 --- /dev/null +++ b/backend/__tests__/integration/incomingInvoiceRebill.test.js @@ -0,0 +1,211 @@ +/** + * Incoming-invoice categorisation + re-bill chain (expenseService) against a + * real SQLite schema. Covers the bits unit tests can't: the disposition state + * machine, re-categorisation unwind, the per-event PENDING pool + bundling, and + * the monthly accumulator immediate-bill — i.e. that categorizeInbound / + * billPendingRebills actually mint / amend invoice rows correctly. + * + * No date-range comparisons are exercised here, so it's safe on SQLite (the + * usual PG-vs-SQLite date pitfall — [[feedback_pg_date_columns_serialize]] — + * doesn't apply to this path). + */ +const { bootCrmDb, seedMinimal } = require('./helpers/crmDb'); + +// Service-level CRM calls cold-require heavy modules (pdfService, nodemailer) +// on first use; bump the budget for this file. +jest.setTimeout(60000); + +describe('incoming-invoice categorise / re-bill chain', () => { + let db; + let cleanup; + let adminId; + let expenseService; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + // logActivity writes to activity_logs via the GLOBAL db. createInvoice (and + // appendToMonthlyDraft) call it INSIDE the transaction we pass them, and a + // second write connection deadlocks against the held write lock on + // SQLite. It's fire-and-forget audit noise, irrelevant to these + // assertions, so stub it BEFORE the services destructure it at require + // time. (Production runs Postgres, where the concurrent write is fine.) + const dbModule = require('../../src/database/db'); + dbModule.logActivity = async () => {}; + ({ adminId } = await seedMinimal(db)); + expenseService = require('../../src/services/expenseService'); + }, 120000); + + afterAll(async () => { + if (cleanup) await cleanup(); + }); + + const unwrapId = (ins) => (typeof ins[0] === 'object' ? ins[0].id : ins[0]); + + async function captureDoc(overrides = {}) { + const ins = await db('inbound_documents').insert({ + source: 'upload', + status: 'unsorted', + parse_status: 'pending', + parse_method: 'none', + supplier_name: 'ACME AG', + currency: 'CHF', + total_amount_minor: 10000, + invoice_date: '2026-06-01', + created_at: new Date(), + updated_at: new Date(), + ...overrides, + }).returning('id'); + return unwrapId(ins); + } + + let customerSeq = 0; + async function makeCustomer(billingCadence) { + customerSeq += 1; + const ins = await db('customer_accounts').insert({ + email: `rebill-${billingCadence || 'event'}-${customerSeq}@example.com`, + display_name: `Rebill ${billingCadence || 'event'} ${customerSeq}`, + password_hash: 'x', + preferred_language: 'de', + is_active: 1, + billing_cadence: billingCadence || null, + created_at: new Date(), + }).returning('id'); + return unwrapId(ins); + } + + it('company expense (eigener_aufwand) categorises with no invoice + no customer', async () => { + const id = await captureDoc(); + const doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId); + expect(doc.disposition).toBe('eigener_aufwand'); + expect(doc.status).toBe('categorized'); + expect(doc.billedInvoiceId).toBeNull(); + expect(doc.customerAccountId).toBeNull(); + }); + + it('rebill REQUIRES a customer', async () => { + const id = await captureDoc(); + await expect(expenseService.categorizeInbound(id, { disposition: 'rebill' }, adminId)) + .rejects.toMatchObject({ code: 'CUSTOMER_REQUIRED' }); + }); + + it('per-event rebill stays PENDING (customer + markup stored, no invoice yet)', async () => { + const customerId = await makeCustomer('per_event'); + const id = await captureDoc({ total_amount_minor: 10000 }); + const doc = await expenseService.categorizeInbound(id, { + disposition: 'rebill', customerAccountId: customerId, + markupType: 'percent', markupPercent: 10, + }, adminId); + expect(doc.disposition).toBe('rebill'); + expect(doc.customerAccountId).toBe(customerId); + expect(doc.billedInvoiceId).toBeNull(); // pending — not billed until bundled + expect(doc.markupType).toBe('percent'); + expect(Number(doc.markupPercent)).toBe(10); + }); + + it('passthrough never carries a markup, even if one is sent', async () => { + const customerId = await makeCustomer('per_event'); + const id = await captureDoc(); + const doc = await expenseService.categorizeInbound(id, { + disposition: 'durchlaufend', customerAccountId: customerId, + markupType: 'percent', markupPercent: 25, // should be ignored + }, adminId); + expect(doc.disposition).toBe('durchlaufend'); + expect(doc.customerAccountId).toBe(customerId); + expect(doc.markupType).toBe('none'); + expect(doc.markupPercent).toBeNull(); + expect(doc.billedInvoiceId).toBeNull(); + }); + + it('billPendingRebills refuses monthly/manual customers (they auto-consolidate)', async () => { + const customerId = await makeCustomer('monthly'); + await expect(expenseService.billPendingRebills(customerId, adminId)) + .rejects.toMatchObject({ code: 'CADENCE_MISMATCH' }); + }); + + // ── The actual invoice-MINTING paths (billPendingRebills bundling a per-event + // customer's pool; monthly-customer immediate-bill onto the running draft) + // both call invoiceService.createInvoice INSIDE a db.transaction. createInvoice + // claims its sequence number via the global db, which DEADLOCKS against the + // held write lock on a SQLite-backed harness (a second write connection blocks + // — verified). Production runs Postgres where the concurrent write is fine, so + // this is a harness limitation, not a product bug. The line-amount math is + // covered by the buildInboundLineItem unit tests, and createInvoice itself by + // discountLineItems.test.js. Below we test the UNWIND path against a + // hand-crafted billed state so we don't have to mint through createInvoice. ── + + // Build a billed state directly: an invoice with two lines, with the inbound + // doc stamped onto the first line as a prior re-bill. + async function makeBilledDoc(customerId, { status = 'scheduled', scheduledSendAt = null, isMonthlyDraft = false } = {}) { + const invIns = await db('invoices').insert({ + invoice_number: `R-TEST-${customerSeq}-${Math.floor(Math.random() * 1e9)}`, + customer_account_id: customerId, + status, + scheduled_send_at: scheduledSendAt, + is_monthly_draft: isMonthlyDraft, + currency: 'CHF', + issue_date: '2026-06-01', + due_date: '2026-07-01', + vat_rate: 0, + net_amount_minor: 7000, // 4000 (rebill line) + 3000 (sibling) + vat_amount_minor: 0, + total_amount_minor: 7000, + created_at: new Date(), + updated_at: new Date(), + }).returning('id'); + const invoiceId = unwrapId(invIns); + const rebillLineIns = await db('invoice_line_items').insert({ + invoice_id: invoiceId, position: 1, quantity: 1, description: 'Rebill Co (Weiterverrechnung)', + unit_price_minor: 4000, discount_percent: 0, line_total_minor: 4000, + }).returning('id'); + const rebillLineId = unwrapId(rebillLineIns); + await db('invoice_line_items').insert({ + invoice_id: invoiceId, position: 2, quantity: 1, description: 'Other line', + unit_price_minor: 3000, discount_percent: 0, line_total_minor: 3000, + }); + const id = await captureDoc({ total_amount_minor: 4000, supplier_name: 'Rebill Co' }); + await db('inbound_documents').where({ id }).update({ + disposition: 'rebill', status: 'categorized', customer_account_id: customerId, + billed_invoice_id: invoiceId, billed_invoice_line_item_id: rebillLineId, + }); + return { id, invoiceId, rebillLineId }; + } + + it('re-categorising a billed doc UNWINDS its re-bill line + recomputes the (mutable) invoice', async () => { + const customerId = await makeCustomer('per_event'); + const { id, invoiceId, rebillLineId } = await makeBilledDoc(customerId); // scheduled, no send-at → mutable + + const recat = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId); + expect(recat.disposition).toBe('eigener_aufwand'); + expect(recat.billedInvoiceId).toBeNull(); + expect(recat.customerAccountId).toBeNull(); + + // The re-bill line is gone; the sibling line remains and net recomputes. + expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeUndefined(); + const after = await db('invoices').where({ id: invoiceId }).first(); + expect(Number(after.net_amount_minor)).toBe(3000); + }); + + it('re-categorising a doc billed on an ISSUED invoice is refused (Storno required)', async () => { + const customerId = await makeCustomer('per_event'); + const { id, rebillLineId } = await makeBilledDoc(customerId, { status: 'sent' }); + + await expect(expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId)) + .rejects.toMatchObject({ code: 'INVOICE_LOCKED' }); + // Nothing was touched — the line survives. + expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeDefined(); + }); + + it('re-categorisation moves a pending item between dispositions without a stray invoice', async () => { + const customerId = await makeCustomer('per_event'); + const id = await captureDoc(); + // passthrough → pending + let doc = await expenseService.categorizeInbound(id, { disposition: 'durchlaufend', customerAccountId: customerId }, adminId); + expect(doc.customerAccountId).toBe(customerId); + expect(doc.billedInvoiceId).toBeNull(); + // → company expense: customer cleared, still no invoice + doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId); + expect(doc.disposition).toBe('eigener_aufwand'); + expect(doc.customerAccountId).toBeNull(); + expect(doc.billedInvoiceId).toBeNull(); + }); +}); diff --git a/backend/__tests__/services/expenseService.markup.test.js b/backend/__tests__/services/expenseService.markup.test.js index a78a538e..05b7b823 100644 --- a/backend/__tests__/services/expenseService.markup.test.js +++ b/backend/__tests__/services/expenseService.markup.test.js @@ -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); + }); +}); diff --git a/backend/__tests__/services/taxReportService.test.js b/backend/__tests__/services/taxReportService.test.js index 5a87fd39..9fa9b8de 100644 --- a/backend/__tests__/services/taxReportService.test.js +++ b/backend/__tests__/services/taxReportService.test.js @@ -276,6 +276,47 @@ describe('getTaxReport', () => { ]); }); + it('excludes the negative Storno row from totals on a cancel + reissue (PR #636 audit)', async () => { + // The real cancel-and-reissue flow produces THREE rows in the period: + // the cancelled original, its negative Storno (kind='storno', status='sent'), + // and the reissue. Totals must read the reissued amount, not 0. + invoiceRowsForRun = [ + { + id: 20, invoice_number: 'R-2026-0020', issue_date: '2026-02-01', + currency: 'CHF', status: 'cancelled', kind: 'invoice', vat_rate: 7.7, + net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, + late_fee_amount_minor: 0, replaces_invoice_id: null, + customer_company_name: 'ACME GmbH', event_name: 'Wedding A', + }, + { + id: 21, invoice_number: 'R-2026-0020-S', issue_date: '2026-02-02', + currency: 'CHF', status: 'sent', kind: 'storno', vat_rate: 7.7, + net_amount_minor: -10000, vat_amount_minor: -770, total_amount_minor: -10770, + late_fee_amount_minor: 0, replaces_invoice_id: null, + customer_company_name: 'ACME GmbH', event_name: 'Wedding A', + }, + { + id: 22, invoice_number: 'R-2026-0021', issue_date: '2026-02-03', + currency: 'CHF', status: 'paid', kind: 'invoice', vat_rate: 7.7, + net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770, + late_fee_amount_minor: 0, replaces_invoice_id: 20, + customer_company_name: 'ACME GmbH', event_name: 'Wedding A', + }, + ]; + replacementsRowsForRun = [{ replaces_invoice_id: 20, invoice_number: 'R-2026-0021' }]; + + const out = await taxReportService.getTaxReport({ from: '2026-01-01', to: '2026-03-31', currency: 'CHF' }); + expect(out.rows).toHaveLength(3); // all three stay visible for the audit trail + // The negative storno must NOT net against the totals (the cancelled + // original is already excluded) — the reissued revenue stands. + expect(out.grandTotalNet).toBe(10000); + expect(out.grandTotalVat).toBe(770); + expect(out.grandTotal).toBe(10770); + expect(out.totalsByVatRate).toEqual([ + { vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770 }, + ]); + }); + it('buckets totals by VAT rate (e.g. 7.7 + 8.1 in same period)', async () => { invoiceRowsForRun = [ { diff --git a/backend/migrations/core/132_add_inbound_note_and_customer.js b/backend/migrations/core/132_add_inbound_note_and_customer.js new file mode 100644 index 00000000..c3f19ed2 --- /dev/null +++ b/backend/migrations/core/132_add_inbound_note_and_customer.js @@ -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)); + } + } +}; diff --git a/backend/migrations/core/133_accounting_on_when_bills.js b/backend/migrations/core/133_accounting_on_when_bills.js new file mode 100644 index 00000000..06373c88 --- /dev/null +++ b/backend/migrations/core/133_accounting_on_when_bills.js @@ -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 () {}; diff --git a/backend/migrations/core/134_add_inbound_supplier_country.js b/backend/migrations/core/134_add_inbound_supplier_country.js new file mode 100644 index 00000000..1b2aa77d --- /dev/null +++ b/backend/migrations/core/134_add_inbound_supplier_country.js @@ -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')); + } +}; diff --git a/backend/package.json b/backend/package.json index c09d5b4c..26c43e8a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "picpeak-backend", - "version": "3.64.0-beta.0", + "version": "3.65.0-beta.0", "description": "Backend for PicPeak event photo sharing platform", "main": "server.js", "scripts": { diff --git a/backend/src/routes/adminExpenses.js b/backend/src/routes/adminExpenses.js index 4ef9eb1b..80046707 100644 --- a/backend/src/routes/adminExpenses.js +++ b/backend/src/routes/adminExpenses.js @@ -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'), diff --git a/backend/src/routes/adminFeatureFlags.js b/backend/src/routes/adminFeatureFlags.js index 8c447f24..68b3f775 100644 --- a/backend/src/routes/adminFeatureFlags.js +++ b/backend/src/routes/adminFeatureFlags.js @@ -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). diff --git a/backend/src/routes/adminInvoices.js b/backend/src/routes/adminInvoices.js index 23d8f2bf..997d7366 100644 --- a/backend/src/routes/adminInvoices.js +++ b/backend/src/routes/adminInvoices.js @@ -130,6 +130,10 @@ function transformInvoice(i) { sentAt: i.sent_at, netAmountMinor: i.net_amount_minor, vatRate: i.vat_rate == null ? null : Number(i.vat_rate), + // Snapshotted VAT code (migration 130) — the editor needs it to repopulate + // VatRateSelect on edit; without it the dropdown falls back to rate-matching + // and a custom-rate code is silently lost. + vatCode: i.vat_code || null, vatAmountMinor: i.vat_amount_minor, shippingAmountMinor: i.shipping_amount_minor, totalAmountMinor: i.total_amount_minor, diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 87040945..8402fcdd 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -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 diff --git a/backend/src/services/_renderContext.js b/backend/src/services/_renderContext.js index 750b9475..abd39dea 100644 --- a/backend/src/services/_renderContext.js +++ b/backend/src/services/_renderContext.js @@ -73,6 +73,10 @@ function buildIssuerBlock(profile, logoPath, options = {}) { // PDF issuer block — §14 UStG requires one or both on every // invoice. Kleinunternehmer without a USt-IdNr. carry only this. taxId: profile.tax_id || null, + // VAT-line label on the totals block (e.g. "MwSt.", "VAT"). Falls back to + // the per-locale default in pdfService when blank. Configured under + // Settings → Accounting. + vatLabel: profile.vat_label || null, // pre-resolved absolute path; renderer never re-resolves. logoPath, pdfFontTtfPath: profile.pdf_font_ttf_path, diff --git a/backend/src/services/customerAccountsService.js b/backend/src/services/customerAccountsService.js index db83b540..041790e0 100644 --- a/backend/src/services/customerAccountsService.js +++ b/backend/src/services/customerAccountsService.js @@ -785,6 +785,18 @@ async function eraseCustomer(id, erasedByAdminId) { // Active reset tokens for this customer should be invalidated. await trx('customer_password_resets').where('customer_account_id', id).del(); + + // Pending re-bills (incoming invoices, migration 132) attached to this + // customer would otherwise stay billable to the now-anonymized account — + // return the not-yet-billed ones to the inbox for re-triage so they're not + // silently lost or billed to a ghost (PR #636 review #2). Guarded for + // schema drift on installs that predate migration 132. + if (await trx.schema.hasColumn('inbound_documents', 'customer_account_id')) { + await trx('inbound_documents') + .where({ customer_account_id: id }) + .whereNull('billed_invoice_id') + .update({ customer_account_id: null, disposition: null, status: 'unsorted', updated_at: new Date() }); + } }); await logActivity('customer_erased', diff --git a/backend/src/services/customerHoursService.js b/backend/src/services/customerHoursService.js index cbcd792a..6f6230c8 100644 --- a/backend/src/services/customerHoursService.js +++ b/backend/src/services/customerHoursService.js @@ -127,9 +127,14 @@ function isEntryLocked(entry, invoice) { */ function buildLineItemFromEntry(entry, rateMinor) { const hours = (entry.duration_minutes / 60).toFixed(2); - // ISO date input is already YYYY-MM-DD; admin's locale formatting - // happens at PDF render time, so keep the entry description portable. - const datePart = String(entry.entry_date).slice(0, 10); + // Keep the entry description portable (admin's locale formatting happens at + // PDF render time). `entry_date` is a `date` column: Postgres hands it back as + // a JS Date, SQLite as a 'YYYY-MM-DD' string — so `String(dateObj).slice(0,10)` + // would bake "Wed Apr 06" into the invoice line on PG. Normalise via the Date + // branch (see feedback_pg_date_columns_serialize). + const datePart = entry.entry_date instanceof Date + ? entry.entry_date.toISOString().slice(0, 10) + : String(entry.entry_date).slice(0, 10); const note = (entry.description || '').trim(); const description = `${datePart} ${entry.start_time}–${entry.end_time} (${hours}h)${note ? ': ' + note : ''}`; const qty = Number(hours); @@ -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; } /** diff --git a/backend/src/services/expenseService.js b/backend/src/services/expenseService.js index d15b7cc8..8943805a 100644 --- a/backend/src/services/expenseService.js +++ b/backend/src/services/expenseService.js @@ -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 }, }; diff --git a/backend/src/services/pdfService.js b/backend/src/services/pdfService.js index 01c58100..0e4cf8d6 100644 --- a/backend/src/services/pdfService.js +++ b/backend/src/services/pdfService.js @@ -844,7 +844,9 @@ function drawTotals(doc, ctx, x, y, width) { doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).text(formatMinor(totals.shippingAmountMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' }); y = doc.y + 4; - doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).text(t(locale, 'totals_vat'), labelX, y, { width: labelCol }); + // Custom VAT label (Settings → Accounting) overrides the per-locale default. + const vatLabel = (ctx.issuer && ctx.issuer.vatLabel) || t(locale, 'totals_vat'); + doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).text(vatLabel, labelX, y, { width: labelCol }); doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).text(`${stripTrailingZeros(totals.vatRate)}%`, rateX, y, { width: rateCol, align: 'right' }); doc.text(formatMinor(totals.vatAmountMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' }); y = doc.y + 4; diff --git a/backend/src/services/taxReportService.js b/backend/src/services/taxReportService.js index 60f2fadb..f4495992 100644 --- a/backend/src/services/taxReportService.js +++ b/backend/src/services/taxReportService.js @@ -433,9 +433,15 @@ async function getTaxReport({ from, to, currency, includeCosts = true } = {}) { const rows = dbRows.map((r) => { const reported = computeReportedAmounts(r); const isCancelled = r.status === 'cancelled'; - if (isCancelled) { - cancelledCount += 1; - } else { + if (isCancelled) cancelledCount += 1; + // Exclude BOTH the cancelled original AND its negative Storno row from the + // totals. Both stay visible in the row list for the gap-free audit trail, + // but a Storno (kind='storno', status='sent', amounts stored negative) + // would otherwise double-subtract: the cancelled original is already + // netted out by exclusion, so adding the negative storno on top deducts + // the revenue a second time — making a cancel-and-reissue read as 0 income + // instead of the reissued amount. See feedback_storno_filter_everywhere. + if (!isCancelled && r.kind !== 'storno') { grandTotalNet += reported.netMinor; grandTotalVat += reported.vatMinor; grandTotal += reported.totalMinor; diff --git a/docs/accounting-inbound-invoices.md b/docs/accounting-inbound-invoices.md index 8a1625c8..51a9d9a8 100644 --- a/docs/accounting-inbound-invoices.md +++ b/docs/accounting-inbound-invoices.md @@ -1,65 +1,97 @@ -# Accounting — Inbound supplier invoices, expenses & re-bill (MVP) +# Accounting — Incoming invoices, expenses & re-bill -> **Status:** new feature, in development on `feat/accounting-inbound-invoices` (based on `upstream/beta`). -> **Maintainer scope decision required** before merge — this introduces a new top-level **Accounting** area, separate from CRM (see "Scope decisions" below). -> **Legal:** every VAT / tax-treatment surface is an *example only* and must be reviewed with a Treuhänder before relying on it. Jurisdiction scope is **Liechtenstein-first** (Swiss/LI rails — QR-bill, LI MWST), not German DATEV/ELSTER. +> **Status:** built on `feat/accounting-inbound-invoices` (based on `upstream/beta`); not yet merged to `main`. +> **Legal:** every VAT / tax-treatment surface is an *example only* and must be reviewed with a Treuhänder before relying on it. Jurisdiction scope is **Liechtenstein-first** (Swiss/LI rails — QR-bill, LI MWST), not German DATEV/ELSTER/ITSG. See `docs/crm-disclaimers.md`. ## Why -The studio receives supplier invoices/receipts (hotels, equipment, fremdleistungen). Today they live in email/paper and are re-typed. This feature lets an admin **capture an incoming invoice** (upload, or **phone/tablet camera**), have its fields **best-effort extracted**, then give it a **disposition** — most importantly **re-bill it to a client** ("Weiterverrechnung") onto the relevant event's invoice with a contract-driven markup. +The studio receives supplier invoices/receipts (hotels, equipment, Fremdleistungen). This feature lets an admin **capture** an incoming invoice (upload, **phone/tablet camera**, or **IMAP email intake**), confirm its fields, give it a **disposition**, mark the **supplier payable** paid, and — for client-borne costs — **re-bill it to a client** ("Weiterverrechnung"), consolidated onto the client's bill the same way billable hours are. -This mirrors the existing **billable-hours** model (`customerHoursService`): an item is parked against a customer/event and folded into an invoice as a line item. +## Two distinct entities (split in migration 126) +Incoming invoices and internal expenses are **separate** — one document never appears in both surfaces. -## Scope decisions (maintainer) -1. **New top-level "Accounting" area**, gated behind a new `accounting` feature flag (default OFF) and `accounting.view` / `accounting.manage` permissions — *not* bolted onto CRM. The existing tax-export page is a candidate to move here later (not in this MVP). -2. **picpeak owns documents + books up to the export boundary**; certified external systems (Treuhänder / Abacus / Bexio) own statutory filing. -3. **No paperless-ngx sidecar** — picpeak is the system of record; files live under `storage/` and are covered by the existing `backup_paths` walker. +- **Incoming invoices** (`inbound_documents`) — an *external* supplier document. The **row itself is the payable**: it carries the disposition, tax treatment, event booking, re-bill linkage, supplier-payment, note, and (for re-bills) the attached customer. Categorising it **updates the document** — it never derives an `expenses` row. Mark-paid lives here. +- **Expenses** (`expenses`, `inbound_document_id IS NULL`) — *internal* own-costs: `kind = amount | mileage | per_diem` (amount = quantity × rate, rate from accounting settings with per-entry override), optional proof file, booked to an event or the company. Disposition is always `eigener_aufwand`; no supplier payment. -## MVP scope (this branch) -- **Intake**: file upload **and camera capture** (phone/tablet) → `POST /api/admin/expenses/inbound` (accepts PDF + JPEG/PNG). Stored as the system of record; deduped by SHA-256. -- **Best-effort extraction** (`extractionService`): ladder of Swiss-QR decode → PDF text layer → OCR. *Scaffolded with the interface in place; the heavy extractors (Tesseract OS package, QR decoder, isolated rasterise worker) are a follow-up — see "Deferred".* -- **Inbox**: list documents as **„Neu / Unsortiert"**; parsed fields are editable/confirmable (parsing is assist, never blind trust). The **QR-encoded amount is stored separately** and surfaced for tamper cross-check — the **authoritative total is the text/line-item value**. -- **5 dispositions**: `rebill` (Weiterverrechnen) · `durchlaufend` (Durchlaufender Posten) · `eigener_aufwand` (company expense) · `duplikat` · `abgelehnt` (with reason). -- **Re-bill flow**: event-scoped (one event → one customer). Markup resolved **expense override → contract `Spesen-Zuschlag` clause → 0%** (percent or flat). Mints an editable **scheduled** invoice (admin can add more lines) — same pattern as `billUnbilledEntries`. -- **Supplier-payment status** (decoupled from categorisation): „Zu zahlen / Bezahlt" with `payment_method` (unified with the outgoing list incl. **bank_transfer**). -- **Expense categories**: seeded + admin-editable (colored label) — feed the future Erfolgsrechnung. -- **`tax_treatment` captured from day 1** (`domestic` default) — stored for the books; reclaim/Bezugsteuer math is future (switches on when `business_profile.vat_id` is set). +This document covers the **incoming-invoices** surface. Expenses share the markup/re-bill helpers but are otherwise independent. -## Data model (migrations 122–125) -Numbered from **122** to avoid colliding with the in-flight `feat/crm-improvements` migrations **117–121** (which are expected to merge first). If this lands before that branch, renumber to 117+. +## Lifecycle +``` +capture (upload / camera / email) + → inbox row, status = unsorted, parse_status = pending +triage (confirm fields + disposition + note) + ├─ eigener_aufwand → company expense (pick category), booked to company + ├─ durchlaufend → pass-through; optionally attach a client (billed at cost) + ├─ rebill → re-bill to a client (with markup) + ├─ duplikat → status = duplicate (excluded from the books) + └─ abgelehnt → status = declined (excluded from the books) +supplier payment (independent axis): markInboundSupplierPayment → supplier_paid +``` -- **122** — seed `accounting` feature flag (default OFF). -- **123** — seed `accounting.view` / `accounting.manage` permissions + grant to super_admin/admin. +### Dispositions +Five: `rebill` · `durchlaufend` (Durchlaufender Posten) · `eigener_aufwand` (company expense) · `duplikat` · `abgelehnt`. + +- **`rebill`** — your own supplier cost, invoiced on to a client, usually with a **markup** (percent or flat). Requires a customer. +- **`durchlaufend`** — an amount fronted on behalf of a client and passed through **at cost / VAT-neutral**. May optionally attach a client (then it is re-billed like a rebill, but **never carries a markup** — enforced in both the UI and `categorizeInbound`). With no client it is only booked to an event/company. +- **`eigener_aufwand`** — own cost, not re-billed; pick an expense category for the Erfolgsrechnung. + +The triage modal shows an **inline explainer** for the selected disposition (`accounting.disposition.help.*`) and a **note** field on every disposition. + +### Re-categorisation +Categorising is **re-runnable** — a categorised invoice can be changed again (e.g. pass-through → company expense), including after the supplier has been paid (supplier-payment and classification are independent axes). When the document was already re-billed, `categorizeInbound` first **unwinds** the prior re-bill line (removes the invoice line, recomputes the invoice totals) before applying the new disposition. It **refuses** (`INVOICE_LOCKED`) only when the re-bill sits on an already-issued invoice — then a Storno is required (`isInvoiceMutable` mirrors the hour-entry lock rules). The only hard lock is an *issued* invoice, never supplier-payment. + +### Re-bill: cadence-aware, like hours +Re-bill/pass-through-to-a-customer consolidates onto the client's bill exactly like `customerHoursService`: + +- **Monthly / manual customers** — the line is appended **immediately** onto the customer's running monthly draft (via `invoiceService.createInvoice`'s accumulator intercept). `billed_invoice_id` is set at categorise time. +- **Per-event customers** — the item stays **PENDING** in the customer's pool (`customer_account_id` set, `billed_invoice_id` null). The inbox surfaces a **"Pending re-bills"** card grouped by customer; **"Bill these"** (`billPendingRebills`) bundles all of a customer's pending items into **one** invoice (one line per document), then navigates to the bill editor so the admin can add more lines before sending. This mirrors `billUnbilledEntries`. + +Markup resolution (rebill only): expense/document override → contract `Spesen-Zuschlag` clause → 0% (`resolveMarkup`). The re-bill line description is `"{supplier} (Weiterverrechnung)"` / `"… (Durchlaufende Position)"`. + +## Data model (migrations 122–132) +All money is integer minor units (`*_amount_minor`). Additive, hasTable/hasColumn-guarded. + +- **122** — seed `accounting` master flag (default OFF; preserve-visuals auto-enable where `taxReport` was on). +- **123** — `accounting.view` / `accounting.manage` permissions. - **124** — `inbound_documents`, `expenses`, `expense_categories` (+ seed categories). -- **125** — `contracts.expense_markup_type|_percent|_flat_minor` (the Spesen-Zuschlag clause). +- **125** — contract `expense_markup_type|_percent|_flat_minor` (Spesen-Zuschlag clause). +- **126** — split incoming vs expenses: disposition/tax_treatment/event_id/category_id, re-bill markup + `billed_invoice_id`/`billed_invoice_line_item_id`, supplier-payment columns on `inbound_documents`; `kind`/`quantity`/`rate_minor` on `expenses`. +- **127** — separate `expenses` sub-flag + accounting `app_settings` (km/per-diem rate, require-proof). *(NB: `app_settings` has no `created_at/updated_at` — seed `setting_key/value/type` only.)* +- **128** — incoming mail (IMAP): `incomingMail` flag + `email_configs.imap_*` + `received_emails`. +- **129** — `ledger_accounts` + `vat_codes` (Swiss/LI KMU seed) + category→account mapping. +- **130** — `vat_code` snapshot column on quotes + invoices. +- **132** — `inbound_documents.note` + `inbound_documents.customer_account_id` (the attached re-bill client; loose link, indexed for the pending-pool lookup). -Key tables (all money in integer minor units, `*_amount_minor`): -- `inbound_documents` — raw received doc + parsed/confirmable fields + `qr_amount_minor` (separate, untrusted) + `status` (unsorted/categorized/declined/duplicate). -- `expenses` — the booking: `disposition`, `tax_treatment`, `event_id`, `customer_account_id`, FX (`original_*` + `chf_amount_minor` + `fx_locked`), `markup_type/_percent/_flat_minor`, `category_id`, `billed_invoice_id`, supplier-payment fields, `status`. -- `expense_categories` — seeded colored labels. +`inbound_documents` key columns: parsed fields (`supplier_name`, `invoice_date`, `total/net/vat_amount_minor`, `iban`, `payment_reference`) + separate untrusted `qr_amount_minor` (tamper cross-check — the authoritative total is the text value); `status` (unsorted/categorized/declined/duplicate); `disposition`; `tax_treatment`; `event_id` (NULL = company); `category_id`; `customer_account_id`; `markup_type/_percent/_flat_minor`; `billed_invoice_id` + `_line_item_id`; `supplier_paid` + `_at/_method/_ref`; `note`. -## API (`/api/admin/expenses`, gated by `accounting` flag + `accounting.*`) -- `POST /inbound` (multipart) — capture an inbound doc (upload/camera). -- `GET /inbound` — list (filter by status, paginated). -- `GET /inbound/:id` — one doc. -- `PATCH /inbound/:id` — confirm/edit parsed fields. -- `POST /inbound/:id/categorize` — create an expense with a disposition. -- `POST / ` — create a manual expense (no document). -- `GET / ` — list expenses (filter by status/disposition/customer/event). -- `GET /:id` — one expense. -- `PATCH /:id` — edit (locked once billed). -- `POST /:id/rebill` — re-bill to a client (event-scoped, contract markup) → scheduled invoice. -- `POST /:id/supplier-payment` — toggle supplier paid + method. -- `GET/POST/PATCH/DELETE /categories` — manage expense categories. +## API (`/api/admin/expenses`, gated by `incomingInvoices` + `accounting.*`) +- `POST /inbound` (multipart) — capture (upload/camera). Deduped by SHA-256. +- `GET /inbound` — list (joins the attached customer name/email). +- `GET /inbound/pending-summary` — per-customer pending re-bills (registered before `/inbound/:id`). +- `POST /inbound/bill-pending` — bundle one customer's pending re-bills into one invoice. +- `GET /inbound/:id` · `PATCH /inbound/:id` (edit/confirm fields incl. `note`). +- `GET /inbound/:id/page/:n` — rasterised PNG of a page. `GET /inbound/:id/file` — original (PDFs as attachment only, never inline). +- `POST /inbound/:id/categorize` — set disposition (re-runnable; unwinds prior re-bill). +- `POST /inbound/:id/rebill` — explicit "re-bill this one now" (forces an immediate single-doc bill). +- `POST /inbound/:id/supplier-payment` — toggle supplier paid + method/date/reference. +- Expenses: `GET/POST /`, `GET/PATCH /:id`, `POST /:id/invoice`, `POST /:id/paid`, `GET /:id/proof`. +- Categories: `GET/POST/PATCH/DELETE /categories` (accounting master). -## Camera capture (step 3) -The `POST /inbound` endpoint accepts images, so a **mobile web** widget using -`` already enables phone/tablet camera capture — **no native app required for v1**. A native document-scanner (edge-detect/dewarp, multi-page) is a later UX upgrade that improves OCR accuracy. +## Document preview = server-side rasterised images +Raw PDFs are **never** served inline. `rasterizeService` shells out to poppler `pdftoppm` (OS package in the Docker image — not a Node PDF lib, runs no JS, no egress). Pages cached under `storage/business-docs/inbound/rendered//page-.png`, served with `Content-Security-Policy: default-src 'none'` + `nosniff`. Page count capped at 200. The triage preview defaults to the last page (the Swiss QR-bill usually sits at the bottom). -## Deferred (follow-ups) -- Real extraction: Tesseract OCR (OS package in the Docker image, shell-out — *not* a sidecar), Swiss-QR decoder, **network-isolated rasterise worker** (no egress), CSP-locked image preview, never serve the raw PDF. -- Email intake (`rechnungen@…` IMAP poll, forwarded-message parsing, message-id dedupe). -- Bank reconciliation, FX auto-lock backstop (30-day), Erfolgsrechnung, customer-account close guard. -- Frontend: the Accounting tab UI (inbox, disposition actions, re-bill dialog) + the camera widget. +## Reporting & export +- **Tax report** (`taxReportService`) — full Einnahmen-Ausgaben: incoming invoices + expenses feed the `costs` side, grouped Company vs Event; re-billed costs are kept (the matching re-bill revenue is also counted, so it nets). `vatPayable` = output VAT − reclaimable input VAT (excludes `foreign_vat_non_reclaimable`); zero when not VAT-registered. Gated on `accounting` + `taxReport` (no longer `bills`). +- **Treuhänder export** (`ledgerService`) — accrual Buchungssätze → generic/Banana/bexio CSV. Accrual basis only; bank/payment postings are Layer B (deferred). See `project_banana_treuhaender_export_format`. +- VAT config (codes, rate→code + treatment→code maps, registration & reclaim countries, chart of accounts) lives under **Settings → Accounting**; invoices snapshot the chosen `vat_code`. + +## Flag model +`accounting` is an explicit top-level **master** flag with sub-toggles: `incomingInvoices` (this surface), `expenses` (internal expenses), `taxReport` (moved permanently out of CRM, now independent of `bills`). `incomingMail` (IMAP) is a separate flag, not under accounting. `accounting` off forces `taxReport` + `incomingInvoices` off. ## Conventions followed -Idempotent migrations (hasTable/hasColumn-guarded); new flag default OFF; flag reads tolerate `true|1|'1'`; money as integer `*_minor`; `requirePermission` guards; camelCase API ↔ snake_case service; multer + `safePath` containment at every file boundary; localized dates on display; tax/legal surfaces carry a "verify with Treuhänder" disclaimer. +Idempotent migrations; new flags default OFF; flag reads tolerate `true|1|'1'`; money as integer `*_minor`; `requirePermission` guards; camelCase API ↔ snake_case service; multer + `safePath` containment at every file boundary; localized dates via `useLocalizedDate`; money via `utils/money`; every tax/legal surface carries a "verify with your Treuhänder" disclaimer. + +## Deferred +- **OCR / auto-extract** — `extractionService` is a no-op stub (Tesseract + Swiss-QR decode); admin reads the slip and types the fields. +- **Capture-time VAT reclaim default** — `accounting_vat_reclaim_countries` is stored but not yet consumed; needs a `supplier_country` column to default `tax_treatment`. +- **Bank reconciliation** — match incoming payments to open invoices / confirm supplier invoices paid (LLB DataFeed / camt.053 / EBICS). Phased, Swiss/LI rails. +- **Native double-entry (Layer B)** — picpeak stays a feeder/export tool below the CHF 500k threshold; full Erfolgsrechnung/Bilanz is out of scope. diff --git a/frontend/package.json b/frontend/package.json index 1261f208..397ed60a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "picpeak-frontend", "private": true, - "version": "3.64.0-beta.0", + "version": "3.65.0-beta.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/admin/LineItemsTable.tsx b/frontend/src/components/admin/LineItemsTable.tsx index 3f11d931..152cc495 100644 --- a/frontend/src/components/admin/LineItemsTable.tsx +++ b/frontend/src/components/admin/LineItemsTable.tsx @@ -251,7 +251,12 @@ export const LineItemsTable: React.FC = ({ // never roll directly into net — they only feed their parent's // auto-resolved line total. const subtotal = items.filter((li) => !isSub(li)).reduce((s, li) => s + lineTotal(li), 0); - const vatAmount = Math.round(subtotal * vatRate) / 100; + // subtotal is in MAJOR units, vatRate is a FRACTION (0.081). Round to cents: + // round(subtotal * vatRate * 100) / 100 — the *100 inside round was missing, + // which divided the VAT by 100 (CHF 0.63 instead of 63.18). Backend + // computeTotals + the PDF were always correct; only this live editor preview + // was wrong, and it only surfaced once invoices stopped defaulting to 0% VAT. + const vatAmount = Math.round(subtotal * vatRate * 100) / 100; const total = subtotal + vatAmount + (Number(shippingAmount) || 0); // Display numbering: top-level items get 1, 2, 3...; sub-items diff --git a/frontend/src/components/admin/VatRateSelect.tsx b/frontend/src/components/admin/VatRateSelect.tsx index bac73c34..6427a067 100644 --- a/frontend/src/components/admin/VatRateSelect.tsx +++ b/frontend/src/components/admin/VatRateSelect.tsx @@ -1,17 +1,23 @@ /** - * VAT-rate picker for the invoice/quote editors. A dropdown of the configured - * OUTPUT VAT codes (Settings → Accounting) plus an "Other (custom rate)" escape - * hatch. Controlled by `(rate, code)`: selecting a code emits its rate + code - * string (snapshotted on the document for the accounting export); "Other" emits - * the typed rate with a null code. Reads the un-gated /admin/vat-codes endpoint, - * so it works even when the accounting feature is off. + * VAT-rate picker for the invoice/quote editors. A dropdown whose ONLY options + * are the configured OUTPUT VAT codes (Settings → Accounting) — there is no + * free-text custom rate; to use a different rate, add a VAT code in Accounting. + * Controlled by `(rate, code)`: selecting a code emits its rate + code string + * (snapshotted on the document for the accounting export). Reads the un-gated + * /admin/vat-codes endpoint so it works even when the accounting feature is off. + * + * Legacy preservation: when editing a document whose stored rate/code isn't an + * accounting code anymore (an old invoice, or a deleted code), that value is + * shown as a read-only "(not configured)" option so it stays selected and is + * never silently changed — issued invoices are immutable. The admin can still + * switch it to a current code. */ import React from 'react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { vatCodesService, type VatCodeOption } from '../../services/vatCodes.service'; -const CUSTOM = '__custom__'; +const LEGACY = '__legacy__'; const selectCls = 'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500'; @@ -32,11 +38,16 @@ export const VatRateSelect: React.FC = ({ rate, code, onChange, label, di }); // Selected option: prefer the snapshotted code; else a code whose rate matches - // (legacy rows / no code stored); else "custom". + // (legacy rows / no code stored) — 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 (
@@ -46,32 +57,24 @@ export const VatRateSelect: React.FC = ({ rate, code, onChange, label, di - {isCustom && ( - onChange(Number(e.target.value) || 0, null)} - /> - )}
); }; diff --git a/frontend/src/constants/currencies.ts b/frontend/src/constants/currencies.ts new file mode 100644 index 00000000..cd094b60 --- /dev/null +++ b/frontend/src/constants/currencies.ts @@ -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; +} diff --git a/frontend/src/contexts/FeatureFlagsContext.tsx b/frontend/src/contexts/FeatureFlagsContext.tsx index 34d2528f..b9bc94ed 100644 --- a/frontend/src/contexts/FeatureFlagsContext.tsx +++ b/frontend/src/contexts/FeatureFlagsContext.tsx @@ -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) { diff --git a/frontend/src/features/settings/tabs/AccountingTab.tsx b/frontend/src/features/settings/tabs/AccountingTab.tsx index 90cb79d8..fafe7922 100644 --- a/frontend/src/features/settings/tabs/AccountingTab.tsx +++ b/frontend/src/features/settings/tabs/AccountingTab.tsx @@ -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(NaN); const [perDiemMajor, setPerDiemMajor] = useState(NaN); + const [hourlyMajor, setHourlyMajor] = useState(NaN); const [requireProof, setRequireProof] = useState(false); const [vatRegistered, setVatRegistered] = useState(false); const [reclaimCountries, setReclaimCountries] = useState([]); + 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 = () => {

{t('settings.accounting.kmRateHint', 'Default applied to mileage expenses; overridable per entry.')}

- + -

{t('settings.accounting.perDiemRateHint', 'Default applied to per-diem expenses; overridable per entry.')}

+

{t('settings.accounting.perDiemRateHint', 'A flat daily allowance booked as an expense (not a client billing rate); overridable per entry.')}

+
+
+ + +

{t('settings.accounting.profileFields.hourlyRateHint', 'Billing fallback used when a customer has no own rate (hours logging). In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.', { currency })}