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

feat(accounting): incoming-invoice workflow v2 + VAT/financial settings consolidation
This commit is contained in:
Paul Nothaft
2026-06-18 21:23:51 +02:00
committed by GitHub
30 changed files with 1434 additions and 288 deletions
@@ -4,7 +4,7 @@
*/
const expenseService = require('../../src/services/expenseService');
const { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert } = expenseService._internal;
const { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, buildInboundLineItem, isInvoiceMutable, resolveTaxTreatment } = expenseService._internal;
describe('computeMarkupMinor', () => {
it('percent of base, rounded', () => {
@@ -85,3 +85,76 @@ describe('buildExpenseInsert (internal expense)', () => {
expect(evt.event_id).toBe(9);
});
});
describe('buildInboundLineItem (re-bill line)', () => {
it('rebill: base + percent markup, Weiterverrechnung suffix', () => {
const li = buildInboundLineItem({ totalAmountMinor: 10000, supplierName: 'ACME' }, 'rebill', { type: 'percent', percent: 10 });
expect(li.unit_price_minor).toBe(11000);
expect(li.line_total_minor).toBe(11000);
expect(li.quantity).toBe(1);
expect(li.description).toBe('ACME (Weiterverrechnung)');
});
it('passthrough: distinct suffix, no markup passes through at cost', () => {
const li = buildInboundLineItem({ totalAmountMinor: 5000, supplierName: 'SBB' }, 'durchlaufend', { type: 'none' });
expect(li.unit_price_minor).toBe(5000);
expect(li.description).toBe('SBB (Durchlaufende Position)');
});
it('falls back to net amount + generic label when total/supplier missing', () => {
const li = buildInboundLineItem({ totalAmountMinor: null, netAmountMinor: 7000 }, 'rebill', { type: 'flat', flatMinor: 300 });
expect(li.unit_price_minor).toBe(7300);
expect(li.description).toBe('Weiterverrechnete Auslage (Weiterverrechnung)');
});
it('throws when there is no amount to re-bill', () => {
expect(() => buildInboundLineItem({ totalAmountMinor: null, netAmountMinor: null }, 'rebill', { type: 'none' }))
.toThrow(/no amount/i);
});
});
describe('resolveTaxTreatment (supplier-country auto-default)', () => {
const reclaim = ['CH', 'LI'];
it('explicit valid treatment always wins', () => {
expect(resolveTaxTreatment('reverse_charge_service', 'DE', reclaim)).toBe('reverse_charge_service');
expect(resolveTaxTreatment('import_goods', 'CH', reclaim)).toBe('import_goods');
});
it('country in the reclaim list → domestic', () => {
expect(resolveTaxTreatment(undefined, 'CH', reclaim)).toBe('domestic');
expect(resolveTaxTreatment(null, 'li', reclaim)).toBe('domestic'); // case-insensitive
});
it('country outside the reclaim list → foreign non-reclaimable', () => {
expect(resolveTaxTreatment(undefined, 'DE', reclaim)).toBe('foreign_vat_non_reclaimable');
expect(resolveTaxTreatment(undefined, 'US', reclaim)).toBe('foreign_vat_non_reclaimable');
});
it('unknown / empty country falls back to domestic', () => {
expect(resolveTaxTreatment(undefined, '', reclaim)).toBe('domestic');
expect(resolveTaxTreatment(undefined, null, reclaim)).toBe('domestic');
});
it('an UNCONFIGURED (empty) reclaim list never auto-classifies as foreign (PR #636 #1)', () => {
expect(resolveTaxTreatment(undefined, 'CH', [])).toBe('domestic');
expect(resolveTaxTreatment(undefined, 'DE', [])).toBe('domestic');
expect(resolveTaxTreatment(undefined, 'US', undefined)).toBe('domestic');
});
it('invalid explicit treatment is ignored (falls through to country logic)', () => {
expect(resolveTaxTreatment('bogus', 'DE', reclaim)).toBe('foreign_vat_non_reclaimable');
});
});
describe('isInvoiceMutable (re-categorise unwind guard)', () => {
const future = new Date(Date.now() + 86400000).toISOString();
const past = new Date(Date.now() - 86400000).toISOString();
it('monthly draft and not-yet-armed scheduled are mutable', () => {
expect(isInvoiceMutable(null)).toBe(true); // referenced invoice gone
expect(isInvoiceMutable({ is_monthly_draft: true })).toBe(true);
expect(isInvoiceMutable({ is_monthly_draft: 1 })).toBe(true);
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: null })).toBe(true);
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: future })).toBe(true);
});
it('armed / issued invoices are locked', () => {
expect(isInvoiceMutable({ status: 'scheduled', scheduled_send_at: past })).toBe(false);
expect(isInvoiceMutable({ status: 'sent' })).toBe(false);
expect(isInvoiceMutable({ status: 'paid' })).toBe(false);
expect(isInvoiceMutable({ status: 'cancelled' })).toBe(false);
});
});
@@ -276,6 +276,47 @@ describe('getTaxReport', () => {
]);
});
it('excludes the negative Storno row from totals on a cancel + reissue (PR #636 audit)', async () => {
// The real cancel-and-reissue flow produces THREE rows in the period:
// the cancelled original, its negative Storno (kind='storno', status='sent'),
// and the reissue. Totals must read the reissued amount, not 0.
invoiceRowsForRun = [
{
id: 20, invoice_number: 'R-2026-0020', issue_date: '2026-02-01',
currency: 'CHF', status: 'cancelled', kind: 'invoice', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
},
{
id: 21, invoice_number: 'R-2026-0020-S', issue_date: '2026-02-02',
currency: 'CHF', status: 'sent', kind: 'storno', vat_rate: 7.7,
net_amount_minor: -10000, vat_amount_minor: -770, total_amount_minor: -10770,
late_fee_amount_minor: 0, replaces_invoice_id: null,
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
},
{
id: 22, invoice_number: 'R-2026-0021', issue_date: '2026-02-03',
currency: 'CHF', status: 'paid', kind: 'invoice', vat_rate: 7.7,
net_amount_minor: 10000, vat_amount_minor: 770, total_amount_minor: 10770,
late_fee_amount_minor: 0, replaces_invoice_id: 20,
customer_company_name: 'ACME GmbH', event_name: 'Wedding A',
},
];
replacementsRowsForRun = [{ replaces_invoice_id: 20, invoice_number: 'R-2026-0021' }];
const out = await taxReportService.getTaxReport({ from: '2026-01-01', to: '2026-03-31', currency: 'CHF' });
expect(out.rows).toHaveLength(3); // all three stay visible for the audit trail
// The negative storno must NOT net against the totals (the cancelled
// original is already excluded) — the reissued revenue stands.
expect(out.grandTotalNet).toBe(10000);
expect(out.grandTotalVat).toBe(770);
expect(out.grandTotal).toBe(10770);
expect(out.totalsByVatRate).toEqual([
{ vatRate: 7.7, netMinor: 10000, vatMinor: 770, totalMinor: 10770 },
]);
});
it('buckets totals by VAT rate (e.g. 7.7 + 8.1 in same period)', async () => {
invoiceRowsForRun = [
{