fix(accounting): tax-report storno totals + hours-line date on Postgres
Two pre-existing HIGH bugs surfaced by the codebase audit (accounting surface): - taxReportService: income totals excluded only `status='cancelled'`, never `kind='storno'`. A Storno (status='sent', amounts stored negative) netted into the totals on top of the already-excluded cancelled original → double-subtract, so a cancel-and-reissue read as 0 income instead of the reissued amount. Now exclude storno rows from grandTotal*/byRate (kept visible in the row list). Regression test reproduces the real cancel→storno→reissue 3-row flow. - customerHoursService.buildLineItemFromEntry: `String(entry.entry_date).slice(0,10)` on a `date` column → Postgres returns a JS Date, baking "Wed Apr 06" into the invoice line + PDF (SQLite returns the bare string, so SQLite-only tests pass). Normalise via the Date branch like every other date read.
This commit is contained in:
@@ -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 () => {
|
it('buckets totals by VAT rate (e.g. 7.7 + 8.1 in same period)', async () => {
|
||||||
invoiceRowsForRun = [
|
invoiceRowsForRun = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -127,9 +127,14 @@ function isEntryLocked(entry, invoice) {
|
|||||||
*/
|
*/
|
||||||
function buildLineItemFromEntry(entry, rateMinor) {
|
function buildLineItemFromEntry(entry, rateMinor) {
|
||||||
const hours = (entry.duration_minutes / 60).toFixed(2);
|
const hours = (entry.duration_minutes / 60).toFixed(2);
|
||||||
// ISO date input is already YYYY-MM-DD; admin's locale formatting
|
// Keep the entry description portable (admin's locale formatting happens at
|
||||||
// happens at PDF render time, so keep the entry description portable.
|
// PDF render time). `entry_date` is a `date` column: Postgres hands it back as
|
||||||
const datePart = String(entry.entry_date).slice(0, 10);
|
// 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 note = (entry.description || '').trim();
|
||||||
const description = `${datePart} ${entry.start_time}–${entry.end_time} (${hours}h)${note ? ': ' + note : ''}`;
|
const description = `${datePart} ${entry.start_time}–${entry.end_time} (${hours}h)${note ? ': ' + note : ''}`;
|
||||||
const qty = Number(hours);
|
const qty = Number(hours);
|
||||||
|
|||||||
@@ -433,9 +433,15 @@ async function getTaxReport({ from, to, currency, includeCosts = true } = {}) {
|
|||||||
const rows = dbRows.map((r) => {
|
const rows = dbRows.map((r) => {
|
||||||
const reported = computeReportedAmounts(r);
|
const reported = computeReportedAmounts(r);
|
||||||
const isCancelled = r.status === 'cancelled';
|
const isCancelled = r.status === 'cancelled';
|
||||||
if (isCancelled) {
|
if (isCancelled) cancelledCount += 1;
|
||||||
cancelledCount += 1;
|
// Exclude BOTH the cancelled original AND its negative Storno row from the
|
||||||
} else {
|
// 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;
|
grandTotalNet += reported.netMinor;
|
||||||
grandTotalVat += reported.vatMinor;
|
grandTotalVat += reported.vatMinor;
|
||||||
grandTotal += reported.totalMinor;
|
grandTotal += reported.totalMinor;
|
||||||
|
|||||||
Reference in New Issue
Block a user