fix(reports): periodisk sammanställning nets a stornoed or corrected EU invoice voucher (#2354)

* fix(reports): periodisk sammanställning nets a stornoed or corrected EU invoice voucher (#2351)

The PS reads entries with status posted or reversed, so a stornoed EU
invoice voucher kept its 3308/3108 credit while the storno that nets it
was dropped: its source_type is 'storno' and no register row points at
it. A makulerad EU sale was over-reported while the account-based ruta 39
was zero.

Which invoice explains an entry is now resolved by one composed helper,
getInvoicesExplainingJournalEntries (lib/core/bookkeeping/
journal-entry-references.ts): the engine's own source_id, the invoice-side
rows, and the rättelse chain through correction_of_id / reverses_id, walked
upwards under the MAX_CHAIN_WALK cap correction-chain.ts already uses.
Parents outside the batch are fetched by id, company-scoped. The link
columns are followed rather than the storno's copied source_id because
correctEntry() copies none onto its storno or correction, and a storno's
copied source_id is polymorphic (a bank row on a bank booking).

getInvoiceReferencesForJournalEntries (the RPC mirror behind the underlag
surfaces) is unchanged: storno and correction are not doc-requiring source
types, so those surfaces have no such hole.

- INVOICE_SOURCED_ENTRY_TYPES / LINK_LOOKUP_CHUNK move to the helper module
  (the set now names every engine type whose source_id is an invoice).
- PS: one resolver call; reverses_id, correction_of_id in the select; the
  ZERO_NET_EXCLUDED text names makulering beside kreditfaktura.
- Tests: resolver cases (chain inheritance, out-of-batch fetch, own link
  wins, mirror, cycle, cap) and PS cases (the issue's storno nets to
  ZERO_NET_EXCLUDED, rättelse chain, later-period storno, mirror, linked
  import, gone invoice).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1

* fix(bookkeeping): PR #2354 review: bound in-batch chain propagation at MAX_CHAIN_WALK

getInvoicesExplainingJournalEntries attributed an in-batch chain of
stornos/corrections all the way down through a recursive assign over
waitingOn, while parents fetched from outside the batch stopped at
MAX_CHAIN_WALK. The two paths now agree: every attribution carries its
depth from the root (0 for an entry resolved by its own source_id or
invoice-side link), propagation is an explicit queue instead of recursion,
and a descendant more than MAX_CHAIN_WALK links below the root resolves to
no invoice, both when it inherits from an already attributed parent and
when it is reached by propagation. Test: an in-batch chain of
MAX_CHAIN_WALK + 1 links, in both batch orders, attributes the links within
the cap and not the one beyond it, with no parent fetch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-06 21:10:11 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 6a5fd6cd00
commit 4bc007cdeb
4 changed files with 560 additions and 30 deletions
@@ -705,3 +705,188 @@ describe('one verifikat settling several invoices (#2298 review)', () => {
expect(report.rows[0]).toMatchObject({ services: 5000 })
})
})
// ============================================================
// Storno and rättelse chains (#2351)
// ============================================================
describe('storno and rättelse chains (#2351)', () => {
/**
* What reverseEntry() / correctEntry() write for the storno: source_type
* 'storno' and reverses_id. reverseEntry() also copies the original's
* source_id; correctEntry() copies nothing. The resolver follows the link
* column either way, so the fixtures leave source_id null unless a test
* says otherwise.
*/
function entryStorno(id: string, reversesId: string, lines: LineFx[], extra: Record<string, unknown> = {}) {
return {
id,
entry_date: '2025-05-25',
status: 'posted',
source_type: 'storno',
source_id: null as string | null,
reverses_id: reversesId,
correction_of_id: null,
journal_entry_lines: lines,
...extra,
}
}
/** The correction correctEntry() writes: source_type 'correction', correction_of_id. */
function entryCorrection(id: string, correctionOfId: string, lines: LineFx[]) {
return {
id,
entry_date: '2025-05-25',
status: 'posted',
source_type: 'correction',
source_id: null as string | null,
reverses_id: null,
correction_of_id: correctionOfId,
journal_entry_lines: lines,
}
}
/** Table of every `.from()` call, in order: pins which lookups ran. */
const fromTables = () => (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((c: unknown[]) => c[0])
it('a reversed EU invoice voucher and its storno in the same period net to ZERO_NET_EXCLUDED', async () => {
// The reported case: the registration voucher was stornoed with
// reverseEntry(). status 'reversed' keeps the original in the fetch; the
// storno (source_id copied, reverses_id set) must be filed under the same
// invoice or the makulerad sale is over-reported while ruta 39 nets it.
results = [
{
data: [
{ ...entryEU('inv-de', [lineEU('3308', 10000)]), status: 'reversed' },
entryStorno('je-storno', je('inv-de'), [lineCredit('3308', 10000)], { source_id: 'inv-de' }),
],
error: null,
},
{ data: [], error: null }, // invoices by journal_entry_id: the storno
{ data: [], error: null }, // invoice_payments: the storno
{ data: [invDE()], error: null },
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows).toEqual([])
expect(report.totals.grand).toBe(0)
expect(report.warnings.map((w) => w.code)).toEqual(['ZERO_NET_EXCLUDED'])
expect(report.warnings[0].message).toContain('makulering')
// The original is in the batch: no parent fetch.
expect(fromTables()).toEqual(['journal_entries', 'invoices', 'invoice_payments', 'invoices'])
})
it('a rättelse chain (original, storno, correction) is filed once, at the corrected amount', async () => {
// correctEntry() copies no source_id onto either new entry: only the
// link columns tie them to the invoice.
results = [
{
data: [
{ ...entryEU('inv-de', [lineEU('3308', 10000)]), status: 'reversed' },
entryStorno('je-storno', je('inv-de'), [lineCredit('3308', 10000)]),
entryCorrection('je-correction', je('inv-de'), [lineEU('3308', 12000)]),
],
error: null,
},
{ data: [], error: null },
{ data: [], error: null },
{ data: [invDE()], error: null },
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.warnings).toEqual([])
expect(report.rows).toHaveLength(1)
expect(report.rows[0]).toMatchObject({ country: 'DE', vatNumber: '123456789', services: 12000 })
})
it('a storno booked in a later period is filed under the original\'s customer, like a later credit note', async () => {
// June holds only the storno; the May original is loaded by id so the
// PS for June nets the same way ruta 39 does for June.
results = [
{
data: [
entryStorno('je-storno', 'je-may', [lineCredit('3308', 10000)], { entry_date: '2025-06-03' }),
],
error: null,
},
{ data: [], error: null }, // invoices by journal_entry_id
{ data: [], error: null }, // invoice_payments
{
data: [{ id: 'je-may', source_type: 'invoice_created', source_id: 'inv-de', reverses_id: null, correction_of_id: null }],
error: null,
}, // journal_entries by id: the May original
{ data: [invDE()], error: null },
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 6)
expect(report.warnings).toEqual([])
expect(report.rows).toHaveLength(1)
expect(report.rows[0]).toMatchObject({ country: 'DE', vatNumber: '123456789', services: -10000 })
expect(fromTables()).toEqual(['journal_entries', 'invoices', 'invoice_payments', 'journal_entries', 'invoices'])
})
it('mirror: a storno of a manual 3308 posting no invoice points at stays out of the filing, silently', async () => {
results = [
{
data: [
entryOther('je-man', 'manual', [lineEU('3308', 5000)]),
entryStorno('je-storno', 'je-man', [lineCredit('3308', 5000)]),
],
error: null,
},
{ data: [], error: null }, // invoices by journal_entry_id: both
{ data: [], error: null }, // invoice_payments: both
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows).toEqual([])
expect(report.warnings).toEqual([])
// The original is in the batch and unexplained: no parent fetch, no
// invoice load.
expect(fromTables()).toEqual(['journal_entries', 'invoices', 'invoice_payments'])
})
it('a storno of a linked import nets it: the link lives on the original', async () => {
results = [
{
data: [
entryOther('je-imp', 'import', [lineEU('3308', 12000)]),
entryStorno('je-storno', 'je-imp', [lineCredit('3308', 12000)]),
],
error: null,
},
{ data: [], error: null },
{ data: [{ id: 'pay-1', invoice_id: 'inv-de', journal_entry_id: 'je-imp' }], error: null },
{ data: [invDE()], error: null },
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
expect(report.rows).toEqual([])
expect(report.warnings.map((w) => w.code)).toEqual(['ZERO_NET_EXCLUDED'])
})
it('a storno of an engine entry whose invoice is gone is reported, never silenced', async () => {
results = [
{
data: [
{ ...entryEU('inv-gone', [lineEU('3308', 5000)]), status: 'reversed' },
entryStorno('je-storno', je('inv-gone'), [lineCredit('3308', 5000)]),
],
error: null,
},
{ data: [], error: null },
{ data: [], error: null },
{ data: [], error: null }, // invoices by id: nothing
]
const report = await generatePeriodiskSammanstallning(supabase, 'c1', 'monthly', 2025, 5)
// One blocking error per posting, the same verdict the original gets.
expect(report.warnings.filter((w) => w.code === 'CUSTOMER_NOT_FOUND' && w.level === 'error')).toHaveLength(2)
})
})