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:
@@ -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 = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user