707c5d0277
- #1 resolveTaxTreatment: an unconfigured (empty) reclaim-countries list no longer auto-classifies every supplier — incl. the admin's own domestic one — as foreign; defer auto-classification until the setting is set (+ test). - #2 pending re-bills on customer erase: eraseCustomer now returns the customer's not-yet-billed inbound docs to the inbox (null customer + unsorted) so they aren't billable to an anonymized account. (NB: picpeak has no hard customer delete — erase anonymizes in place — so the orphan/404 premise can't occur; this is hardening.) - #4 VatRateSelect: when >1 configured code shares the same rate, fall through to the legacy "(not configured)" option instead of silently picking the first. - #5 unwindBilledLine: delete the (mutable, never-issued) invoice when the unwound re-bill was its only line, instead of leaving a net-zero survivor. - #6 isInvoiceMutable: clarify in a comment that invoices have no 'draft' status (the editable state is 'scheduled' w/o send-at) — no behaviour change. - nit: collapse normalizeCurrency's tautological ternary. - Fix VAT picker i18n: t('vat.legacyRate') → 'ledger.vat.legacyRate' (the key's real home), so the legacy label localizes instead of always showing English. - Remove dead i18n keys left by the settings refactor (businessProfile.field VAT /hourly + profileFields.title/savedToast).
161 lines
7.9 KiB
JavaScript
161 lines
7.9 KiB
JavaScript
/**
|
|
* Unit tests for the accounting money logic — re-bill markup (incoming
|
|
* invoices) and internal-expense amount/build. Pure functions via _internal.
|
|
*/
|
|
const expenseService = require('../../src/services/expenseService');
|
|
|
|
const { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, buildInboundLineItem, isInvoiceMutable, resolveTaxTreatment } = expenseService._internal;
|
|
|
|
describe('computeMarkupMinor', () => {
|
|
it('percent of base, rounded', () => {
|
|
expect(computeMarkupMinor(10000, { type: 'percent', percent: 10 })).toBe(1000);
|
|
expect(computeMarkupMinor(333, { type: 'percent', percent: 10 })).toBe(33);
|
|
expect(computeMarkupMinor(335, { type: 'percent', percent: 10 })).toBe(34);
|
|
});
|
|
it('flat / none', () => {
|
|
expect(computeMarkupMinor(10000, { type: 'flat', flatMinor: 500 })).toBe(500);
|
|
expect(computeMarkupMinor(10000, { type: 'none' })).toBe(0);
|
|
expect(computeMarkupMinor(10000, { type: 'percent', percent: null })).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('resolveMarkup precedence (no contract / no DB)', () => {
|
|
it('override > source clause', async () => {
|
|
await expect(resolveMarkup({ markupType: 'flat', markupFlatMinor: 999 }, { markupType: 'percent', markupPercent: 5 }, null, null))
|
|
.resolves.toEqual({ type: 'percent', percent: 5, flatMinor: null });
|
|
});
|
|
it("source clause when no override", async () => {
|
|
await expect(resolveMarkup({ markupType: 'flat', markupFlatMinor: 200 }, {}, null, null))
|
|
.resolves.toEqual({ type: 'flat', percent: null, flatMinor: 200 });
|
|
});
|
|
it('none when nothing set', async () => {
|
|
await expect(resolveMarkup({ markupType: 'none' }, {}, null, null))
|
|
.resolves.toEqual({ type: 'none', percent: null, flatMinor: null });
|
|
});
|
|
});
|
|
|
|
describe('computeExpenseAmount', () => {
|
|
it('mileage / per-diem = quantity x rate, rounded', () => {
|
|
expect(computeExpenseAmount('mileage', 42, 70, null)).toBe(2940); // 42 km x CHF 0.70
|
|
expect(computeExpenseAmount('per_diem', 3, 8000, null)).toBe(24000); // 3 days x CHF 80
|
|
expect(computeExpenseAmount('mileage', 10.5, 71, null)).toBe(746); // 745.5 -> 746
|
|
});
|
|
it('amount = the entered minor amount', () => {
|
|
expect(computeExpenseAmount('amount', null, null, 5000)).toBe(5000);
|
|
});
|
|
it('null when quantity or rate missing', () => {
|
|
expect(computeExpenseAmount('mileage', null, 70, null)).toBeNull();
|
|
expect(computeExpenseAmount('mileage', 42, null, null)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('buildExpenseInsert (internal expense)', () => {
|
|
it('defaults: kind=amount, disposition=eigener_aufwand, tax=domestic, status=open', () => {
|
|
const row = buildExpenseInsert({ chfAmountMinor: 5000 }, 7);
|
|
expect(row.kind).toBe('amount');
|
|
expect(row.disposition).toBe('eigener_aufwand');
|
|
expect(row.tax_treatment).toBe('domestic');
|
|
expect(row.status).toBe('open');
|
|
expect(row.chf_amount_minor).toBe(5000);
|
|
expect(row.created_by_admin_id).toBe(7);
|
|
expect(row.inbound_document_id).toBeNull();
|
|
});
|
|
|
|
it('mileage uses the override rate, else the settings km rate', () => {
|
|
const withDefault = buildExpenseInsert({ kind: 'mileage', quantity: 42 }, 1, { kmRateMinor: 70 });
|
|
expect(withDefault.rate_minor).toBe(70);
|
|
expect(withDefault.chf_amount_minor).toBe(2940);
|
|
|
|
const withOverride = buildExpenseInsert({ kind: 'mileage', quantity: 42, rateMinor: 100 }, 1, { kmRateMinor: 70 });
|
|
expect(withOverride.rate_minor).toBe(100);
|
|
expect(withOverride.chf_amount_minor).toBe(4200);
|
|
});
|
|
|
|
it('per_diem uses days x per-diem rate', () => {
|
|
const row = buildExpenseInsert({ kind: 'per_diem', quantity: 2 }, 1, { perDiemRateMinor: 8000 });
|
|
expect(row.rate_minor).toBe(8000);
|
|
expect(row.chf_amount_minor).toBe(16000);
|
|
});
|
|
|
|
it('event_id null = booked to company; proof path carried', () => {
|
|
const company = buildExpenseInsert({ kind: 'amount', chfAmountMinor: 100 }, 1, { receiptPath: '/p/x.pdf' });
|
|
expect(company.event_id).toBeNull();
|
|
expect(company.receipt_path).toBe('/p/x.pdf');
|
|
const evt = buildExpenseInsert({ kind: 'amount', chfAmountMinor: 100, eventId: 9 }, 1);
|
|
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);
|
|
});
|
|
});
|