From 4bc007cdebb41e26df33f6af85549c23aa429662 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Sun, 6 Sep 2026 21:10:11 +0200 Subject: [PATCH] =?UTF-8?q?fix(reports):=20periodisk=20sammanst=C3=A4llnin?= =?UTF-8?q?g=20nets=20a=20stornoed=20or=20corrected=20EU=20invoice=20vouch?= =?UTF-8?q?er=20(#2354)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- .../journal-entry-references.test.ts | 199 ++++++++++++++++++ .../bookkeeping/journal-entry-references.ts | 151 +++++++++++++ .../periodisk-sammanstallning.test.ts | 185 ++++++++++++++++ lib/reports/periodisk-sammanstallning.ts | 55 +++-- 4 files changed, 560 insertions(+), 30 deletions(-) diff --git a/lib/core/bookkeeping/__tests__/journal-entry-references.test.ts b/lib/core/bookkeeping/__tests__/journal-entry-references.test.ts index 0fd46469..87070b32 100644 --- a/lib/core/bookkeeping/__tests__/journal-entry-references.test.ts +++ b/lib/core/bookkeeping/__tests__/journal-entry-references.test.ts @@ -2,9 +2,13 @@ import { describe, it, expect } from 'vitest' import type { SupabaseClient } from '@supabase/supabase-js' import { createQueuedMockSupabase } from '@/tests/helpers' import { + INVOICE_SOURCED_ENTRY_TYPES, getInvoiceReferencesForJournalEntries, + getInvoicesExplainingJournalEntries, getJournalEntryUnderlagReferences, + type ExplainableJournalEntry, } from '../journal-entry-references' +import { MAX_CHAIN_WALK } from '../correction-chain' /** * The resolver issues its queries in a fixed `.from()` order, and the queued @@ -268,3 +272,198 @@ describe('getInvoiceReferencesForJournalEntries', () => { ]) }) }) + +/** + * Every link through which a register invoice explains an entry (#2351). + * Fixed `.from()` order per hop: invoices (by journal_entry_id) and + * invoice_payments for the entries the engine's own source_id did not settle, + * then journal_entries (by id) for the parents of unresolved stornos and + * corrections that are not in the batch. + */ +describe('getInvoicesExplainingJournalEntries', () => { + const setup = (results: { data: unknown }[]) => { + const mock = createQueuedMockSupabase() + mock.enqueueMany(results) + return mock + } + const run = (mock: ReturnType, entries: ExplainableJournalEntry[]) => + getInvoicesExplainingJournalEntries( + mock.supabase as unknown as SupabaseClient, + 'company-1', + entries, + ) + const engine = (id: string, sourceId: string): ExplainableJournalEntry => + ({ id, source_type: 'invoice_created', source_id: sourceId }) + const storno = (id: string, reversesId: string, sourceId: string | null = null): ExplainableJournalEntry => + ({ id, source_type: 'storno', source_id: sourceId, reverses_id: reversesId }) + const correction = (id: string, correctionOfId: string): ExplainableJournalEntry => + ({ id, source_type: 'correction', source_id: null, correction_of_id: correctionOfId }) + const other = (id: string, sourceType = 'import', sourceId: string | null = null): ExplainableJournalEntry => + ({ id, source_type: sourceType, source_id: sourceId }) + const parentFetches = (mock: ReturnType) => mock.findCalls('journal_entries', 'in') + + it('pins the engine source types whose source_id is a register invoice', () => { + // rot_rut_payout carries the ROT/RUT request id, never an invoice. + expect([...INVOICE_SOURCED_ENTRY_TYPES].sort()).toEqual([ + 'credit_note', + 'invoice_cash_payment', + 'invoice_created', + 'invoice_paid', + 'reminder_fee', + ]) + }) + + it('returns nothing, without a round trip, for an empty list', async () => { + const mock = setup([]) + expect((await run(mock, [])).size).toBe(0) + expect(mock.supabase.from).not.toHaveBeenCalled() + }) + + it('attributes an engine entry by its source_id with no round trip, even when that invoice is gone', async () => { + // A missing invoice is a data defect for the caller to report + // (CUSTOMER_NOT_FOUND in the PS), never a reason to fall silent. + const mock = setup([]) + const refs = await run(mock, [engine('je-1', 'inv-gone')]) + expect(Array.from(refs.entries())).toEqual([['je-1', ['inv-gone']]]) + expect(mock.supabase.from).not.toHaveBeenCalled() + }) + + it('resolves a non-engine entry through the invoice-side links', async () => { + const mock = setup([ + { data: [] }, + { data: [{ id: 'pay-1', invoice_id: 'inv-x', journal_entry_id: 'je-imp' }] }, + ]) + const refs = await run(mock, [other('je-imp')]) + expect(Array.from(refs.entries())).toEqual([['je-imp', ['inv-x']]]) + expect(mock.supabase.from).toHaveBeenCalledTimes(2) + }) + + it('a storno in the same batch as its engine original inherits the invoice without a fetch (#2351)', async () => { + const mock = setup([{ data: [] }, { data: [] }]) + const refs = await run(mock, [engine('je-o', 'inv-1'), storno('je-s', 'je-o')]) + expect(refs.get('je-o')).toEqual(['inv-1']) + expect(refs.get('je-s')).toEqual(['inv-1']) + expect(parentFetches(mock)).toEqual([]) + expect(mock.supabase.from).toHaveBeenCalledTimes(2) + }) + + it('follows reverses_id, never the copied source_id: a storno of a bank booking resolves to nothing', async () => { + // reverseEntry() copies the original's source_id verbatim; on a bank + // booking that is a transaction id, which no reader may take for an + // invoice. The link column says what the storno cancels. + const mock = setup([{ data: [] }, { data: [] }]) + const refs = await run(mock, [ + other('je-b', 'bank_transaction', 'tx-1'), + storno('je-s', 'je-b', 'tx-1'), + ]) + expect(refs.size).toBe(0) + expect(parentFetches(mock)).toEqual([]) + }) + + it('fetches an original outside the batch by id, company scoped, and both its storno and its correction inherit', async () => { + // The storno of a May invoice booked in June: the PS for June only holds + // the storno and the correction, so the original is loaded by id, once. + const mock = setup([ + { data: [] }, + { data: [] }, + { data: [engine('je-o', 'inv-1')] }, + ]) + const refs = await run(mock, [storno('je-s', 'je-o'), correction('je-c', 'je-o')]) + expect(refs.get('je-s')).toEqual(['inv-1']) + expect(refs.get('je-c')).toEqual(['inv-1']) + expect(parentFetches(mock)).toEqual([['id', ['je-o']]]) + expect(mock.findCalls('journal_entries', 'eq')).toContainEqual(['company_id', 'company-1']) + expect(mock.findCall('journal_entries', 'select')).toEqual([ + 'id, source_type, source_id, reverses_id, correction_of_id', + ]) + }) + + it('a storno of a linked import inherits every invoice the payment rows name', async () => { + // The link lives on the original (invoice_payments.journal_entry_id); + // the storno gets the whole list, so a mixed-customer settlement stays + // mixed when it is reversed. + const mock = setup([ + { data: [] }, + { data: [ + { id: 'pay-1', invoice_id: 'inv-a', journal_entry_id: 'je-imp' }, + { id: 'pay-2', invoice_id: 'inv-b', journal_entry_id: 'je-imp' }, + ] }, + ]) + const refs = await run(mock, [other('je-imp'), storno('je-s', 'je-imp')]) + expect(refs.get('je-s')).toEqual(['inv-a', 'inv-b']) + expect(parentFetches(mock)).toEqual([]) + }) + + it('an explicit link on the correction itself wins over the inherited one', async () => { + const mock = setup([ + { data: [{ id: 'inv-new', journal_entry_id: 'je-c' }] }, + { data: [] }, + ]) + const refs = await run(mock, [correction('je-c', 'je-o')]) + expect(Array.from(refs.entries())).toEqual([['je-c', ['inv-new']]]) + expect(parentFetches(mock)).toEqual([]) + }) + + it('walks a chain of corrections up to its root, one fetch per generation', async () => { + const mock = setup([ + { data: [] }, + { data: [] }, + { data: [correction('je-c1', 'je-o')] }, + { data: [] }, + { data: [] }, + { data: [engine('je-o', 'inv-1')] }, + ]) + const refs = await run(mock, [correction('je-c2', 'je-c1')]) + expect(refs.get('je-c2')).toEqual(['inv-1']) + expect(parentFetches(mock)).toEqual([ + ['id', ['je-c1']], + ['id', ['je-o']], + ]) + }) + + it('mirror: a storno whose original no invoice explains resolves to nothing, without a fetch', async () => { + const mock = setup([{ data: [] }, { data: [] }]) + const refs = await run(mock, [other('je-m', 'manual'), storno('je-s', 'je-m')]) + expect(refs.size).toBe(0) + expect(parentFetches(mock)).toEqual([]) + expect(mock.supabase.from).toHaveBeenCalledTimes(2) + }) + + it('a cycle terminates without a fetch', async () => { + const mock = setup([{ data: [] }, { data: [] }]) + const refs = await run(mock, [storno('a', 'b'), storno('b', 'a')]) + expect(refs.size).toBe(0) + expect(parentFetches(mock)).toEqual([]) + }) + + it('bounds an in-batch chain at MAX_CHAIN_WALK links, in either batch order (PR #2354 review)', async () => { + // je-0 is the engine root and je-k the storno of je-(k-1): eleven links + // in one batch. The ten within the cap inherit, the eleventh does not, + // the same answer the fetched walk gives a chain of that length. Both + // orders: ascending resolves each child off an already attributed + // parent, descending queues them all and propagates from the root. + const chain = [engine('je-0', 'inv-1')] + for (let k = 1; k <= MAX_CHAIN_WALK + 1; k++) chain.push(storno(`je-${k}`, `je-${k - 1}`)) + for (const batch of [chain, [...chain].reverse()]) { + const mock = setup([{ data: [] }, { data: [] }]) + const refs = await run(mock, batch) + for (let k = 0; k <= MAX_CHAIN_WALK; k++) expect(refs.get(`je-${k}`), `je-${k}`).toEqual(['inv-1']) + expect(refs.has(`je-${MAX_CHAIN_WALK + 1}`)).toBe(false) + expect(parentFetches(mock)).toEqual([]) + } + }) + + it('stops a chain that never reaches a root at MAX_CHAIN_WALK generations', async () => { + const mock = createQueuedMockSupabase() + for (let k = 0; k <= MAX_CHAIN_WALK; k++) { + mock.enqueueMany([ + { data: [] }, + { data: [] }, + { data: [storno(`p${k + 1}`, `p${k + 2}`)] }, + ]) + } + const refs = await run(mock, [storno('je-s', 'p1')]) + expect(refs.size).toBe(0) + expect(parentFetches(mock)).toHaveLength(MAX_CHAIN_WALK) + }) +}) diff --git a/lib/core/bookkeeping/journal-entry-references.ts b/lib/core/bookkeeping/journal-entry-references.ts index f02f4e1c..d59b709d 100644 --- a/lib/core/bookkeeping/journal-entry-references.ts +++ b/lib/core/bookkeeping/journal-entry-references.ts @@ -1,6 +1,8 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { chunk } from '@/lib/utils' import { NON_ISSUED_INVOICE_STATUSES_FILTER } from '@/lib/invoices/matchable-statuses' +import { MAX_CHAIN_WALK } from './correction-chain' /** * A followable reference from a verifikation back to its underlag: the customer @@ -270,3 +272,152 @@ export async function getInvoiceReferencesForJournalEntries( return result } + +/** + * Source types the invoice engine writes with `source_id` = the id of the + * register invoice (an `invoices` row; credit notes are rows there too) that + * the entry books: issuance and payment under faktureringsmetoden, the + * kontantmetod inbetalning, a credit note, and a reminder fee. For these the + * entry's own source columns are the link; no invoice-side row is needed. + * `rot_rut_payout` is deliberately absent: its source_id is the ROT/RUT + * request, not an invoice. + */ +export const INVOICE_SOURCED_ENTRY_TYPES: ReadonlySet = new Set([ + 'invoice_created', + 'invoice_paid', + 'invoice_cash_payment', + 'credit_note', + 'reminder_fee', +]) + +/** Ids per PostgREST `.in()` filter (URL-length convention, lib/worklist/categories.ts). */ +export const LINK_LOOKUP_CHUNK = 100 + +/** The columns {@link getInvoicesExplainingJournalEntries} reads off an entry. */ +export interface ExplainableJournalEntry { + id: string + source_type: string | null + source_id: string | null + /** Storno: the entry this one cancels (reverseEntry / correctEntry). */ + reverses_id?: string | null + /** Rättelse: the entry this one replaces (correctEntry). */ + correction_of_id?: string | null +} + +/** Literal select for the parent rows the chain walk fetches. */ +const CHAIN_COLUMNS = 'id, source_type, source_id, reverses_id, correction_of_id' + +/** + * Which register invoices explain each of the given journal entries, through + * every link the register keeps: + * + * 1. the engine's own entries: source_id IS the invoice id + * (INVOICE_SOURCED_ENTRY_TYPES); + * 2. the invoice side: invoices.journal_entry_id and + * invoice_payments.journal_entry_id (getInvoiceReferencesForJournalEntries); + * 3. the rättelse chain: a storno or correction carries reverses_id / + * correction_of_id and is explained by whatever explains the entry it + * cancels or replaces. Neither writer leaves a usable link of its own on + * the new entry (reverseEntry copies the original's polymorphic + * source_id, correctEntry copies nothing, and the register never points + * at a storno), so the chain is walked upwards until an attribution is + * found: the same links correctionChainDepth trusts, under the same + * MAX_CHAIN_WALK cap. + * + * A reader that followed only 1 and 2 kept a reversed original in its totals + * and dropped the storno that nets it (#2351): a makulerad EU sale stayed in + * the periodisk sammanställning while the account-based ruta 39 was zero. + * + * Values are invoice ids per entry id; an entry is present only when at least + * one invoice explains it. An engine entry is attributed by its source_id + * whether or not that invoice still exists (a missing one is a data defect + * for the caller to report, not a reason for silence), and a chain entry + * inherits its root's attribution the same way. An explicit link on the entry + * itself wins over an inherited one. Parents are fetched by id, company + * scoped and chunked, so the storno of a May invoice booked in June is + * resolved from June's entries alone. + */ +export async function getInvoicesExplainingJournalEntries( + supabase: SupabaseClient, + companyId: string, + entries: readonly ExplainableJournalEntry[], +): Promise> { + const result = new Map() + if (entries.length === 0) return result + + // Chain entries waiting for their parent's attribution, by parent id. + const waitingOn = new Map() + // Links between an attributed entry and the root that explains it: 0 for + // an entry attributed by its own source_id or invoice-side link. + const depthOf = new Map() + // Every id considered so far (the batch plus fetched parents): a cycle, or + // a parent shared by several children, is never fetched twice. + const seen = new Set(entries.map((e) => e.id)) + + // Attribute an entry and every descendant waiting on it. Iterative, so a + // pathological in-batch chain cannot exhaust the stack, and with the depth + // carried along: a descendant more than MAX_CHAIN_WALK links below the root + // resolves to "no invoice", the cap the fetched walk below applies, so an + // in-batch chain and an out-of-batch chain of the same length agree. + const assign = (entryId: string, invoiceIds: string[], depth: number): void => { + const queue: [string, number][] = [[entryId, depth]] + for (let i = 0; i < queue.length; i++) { + const [id, d] = queue[i] + if (result.has(id)) continue + result.set(id, i === 0 ? invoiceIds : [...invoiceIds]) + depthOf.set(id, d) + if (d >= MAX_CHAIN_WALK) continue + for (const child of waitingOn.get(id) ?? []) queue.push([child, d + 1]) + } + } + + let frontier: ExplainableJournalEntry[] = [...entries] + for (let hop = 0; frontier.length > 0; hop++) { + const unresolved: ExplainableJournalEntry[] = [] + for (const entry of frontier) { + if (entry.source_id && INVOICE_SOURCED_ENTRY_TYPES.has(entry.source_type ?? '')) { + assign(entry.id, [entry.source_id], 0) + } else { + unresolved.push(entry) + } + } + + for (const ids of chunk(unresolved.map((e) => e.id), LINK_LOOKUP_CHUNK)) { + const refs = await getInvoiceReferencesForJournalEntries(supabase, companyId, ids) + for (const [entryId, invoiceIds] of refs) assign(entryId, invoiceIds, 0) + } + + const parentIds: string[] = [] + for (const entry of unresolved) { + if (result.has(entry.id)) continue + const parentId = entry.correction_of_id ?? entry.reverses_id ?? null + if (!parentId) continue + const inherited = result.get(parentId) + if (inherited) { + const depth = (depthOf.get(parentId) ?? 0) + 1 + if (depth <= MAX_CHAIN_WALK) assign(entry.id, [...inherited], depth) + continue + } + const waiting = waitingOn.get(parentId) + if (waiting) waiting.push(entry.id) + else waitingOn.set(parentId, [entry.id]) + if (!seen.has(parentId)) { + seen.add(parentId) + parentIds.push(parentId) + } + } + if (parentIds.length === 0 || hop >= MAX_CHAIN_WALK) break + + frontier = [] + for (const ids of chunk(parentIds, LINK_LOOKUP_CHUNK)) { + const parents = await fetchAllRows(({ from, to }) => + supabase.from('journal_entries').select(CHAIN_COLUMNS) + .eq('company_id', companyId).in('id', ids) + .order('id', { ascending: true }).range(from, to), + ) + frontier.push(...parents) + } + } + + return result +} diff --git a/lib/reports/__tests__/periodisk-sammanstallning.test.ts b/lib/reports/__tests__/periodisk-sammanstallning.test.ts index 197a0b1d..e5247f3f 100644 --- a/lib/reports/__tests__/periodisk-sammanstallning.test.ts +++ b/lib/reports/__tests__/periodisk-sammanstallning.test.ts @@ -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 = {}) { + 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).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) + }) +}) diff --git a/lib/reports/periodisk-sammanstallning.ts b/lib/reports/periodisk-sammanstallning.ts index bd527a41..4966fecb 100644 --- a/lib/reports/periodisk-sammanstallning.ts +++ b/lib/reports/periodisk-sammanstallning.ts @@ -1,7 +1,11 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { chunk } from '@/lib/utils' -import { getInvoiceReferencesForJournalEntries } from '@/lib/core/bookkeeping/journal-entry-references' +import { + INVOICE_SOURCED_ENTRY_TYPES, + LINK_LOOKUP_CHUNK, + getInvoicesExplainingJournalEntries, +} from '@/lib/core/bookkeeping/journal-entry-references' import { calculatePeriodDates, formatPeriodLabel } from './period-dates' import { calculateVatDeclaration } from './vat-declaration' import { normalizeCountryCode } from '@/lib/vat/country-codes' @@ -22,9 +26,12 @@ import { normalizeCountryCode } from '@/lib/vat/country-codes' * register keeps (the engine's source_id, invoices.journal_entry_id, * invoice_payments.journal_entry_id), so a SIE-imported sale matched to * its invoice afterwards and a kontantmetod inbetalning are filed too - * (#2298). A 3308/3108 posting no invoice points at is not filed (there - * is no customer to name); the momsdeklaration reconciliation (ruta - * 35/38/39) is where such a gap shows. + * (#2298). A storno or rättelse of such a posting is filed under the + * same invoice by following reverses_id / correction_of_id (#2351), so a + * makulerad sale nets to zero here exactly as it does in ruta 39. A + * 3308/3108 posting no invoice points at is not filed (there is no + * customer to name); the momsdeklaration reconciliation (ruta 35/38/39) + * is where such a gap shows. * - Account 3305/3105 (non-EU export) are NOT in this report: they go to * Ruta 36/40 only. * - Trepartshandel (3107) is included so the report works if someone posts @@ -117,17 +124,6 @@ const ACCOUNT_TO_BUCKET: Record const PS_ACCOUNTS = Object.keys(ACCOUNT_TO_BUCKET) -/** - * Source types the invoice engine writes with `source_id` = the register - * invoice id AND that can carry EU revenue lines: issuance - * (faktureringsmetod), credit notes, and the kontantmetod inbetalning, which - * is where a cash-method company books its revenue at all. - */ -const INVOICE_SOURCED_ENTRY_TYPES = new Set(['invoice_created', 'credit_note', 'invoice_cash_payment']) - -/** Ids per PostgREST `.in()` filter (URL-length convention, lib/worklist/categories.ts). */ -const LINK_LOOKUP_CHUNK = 100 - interface RawEntryLine { account_number: string debit_amount: number | string @@ -142,6 +138,10 @@ interface RawEntry { status: string source_type: string | null source_id: string | null + /** Storno: the entry this one cancels; its invoice explains this one too. */ + reverses_id: string | null + /** Rättelse: the entry this one replaces; likewise. */ + correction_of_id: string | null /** Only the PS-account lines: the embed is filtered on account_number. */ journal_entry_lines: RawEntryLine[] | null } @@ -251,7 +251,7 @@ export async function generatePeriodiskSammanstallning( const entries = await fetchAllRows(({ from, to }) => supabase .from('journal_entries') - .select('id, voucher_series, voucher_number, entry_date, status, source_type, source_id, journal_entry_lines!inner(account_number, debit_amount, credit_amount)') + .select('id, voucher_series, voucher_number, entry_date, status, source_type, source_id, reverses_id, correction_of_id, journal_entry_lines!inner(account_number, debit_amount, credit_amount)') .eq('company_id', companyId) .in('status', ['posted', 'reversed']) .gte('entry_date', start) @@ -262,13 +262,18 @@ export async function generatePeriodiskSammanstallning( .range(from, to) as unknown as PromiseLike<{ data: RawEntry[] | null; error: { message: string } | null }>, ) - // Which register invoice does each posting belong to? Three links; only + // Which register invoice does each posting belong to? Four links; only // the first lives on the entry itself: // 1. the engine's own entries: source_id IS the invoice id; // 2. invoices.journal_entry_id (registration booking, backfilled); // 3. invoice_payments.journal_entry_id: kontantmetod inbetalning, // delbetalning, and "matcha mot befintligt verifikat", which is how a - // SIE-imported sale gets its invoice after migration (#2298). + // SIE-imported sale gets its invoice after migration (#2298); + // 4. reverses_id / correction_of_id: a storno or rättelse is explained by + // whatever explains the entry it cancels or replaces (#2351). The + // reversed original stays in the fetch (status 'reversed'), so its + // storno must be filed under the same invoice or the makulerad sale + // is over-reported while ruta 39 nets it to zero. // Following 1 alone (the old source_type filter) dropped every linked // import and every kontantmetod sale from the filing while the // account-based momsdeklaration kept showing them in ruta 39. @@ -276,17 +281,7 @@ export async function generatePeriodiskSammanstallning( // (source_id); a linked entry may name several when one inbetalning settled // several invoices. All of them are loaded so the loop below can tell "two // invoices, one customer" from "two customers on one posting". - const invoiceIdsByEntry = new Map() - for (const entry of entries) { - if (entry.source_id && INVOICE_SOURCED_ENTRY_TYPES.has(entry.source_type ?? '')) { - invoiceIdsByEntry.set(entry.id, [entry.source_id]) - } - } - const unresolved = entries.filter((e) => !invoiceIdsByEntry.has(e.id)).map((e) => e.id) - for (const ids of chunk(unresolved, LINK_LOOKUP_CHUNK)) { - const refs = await getInvoiceReferencesForJournalEntries(supabase, companyId, ids) - for (const [entryId, invoiceIds] of refs) invoiceIdsByEntry.set(entryId, invoiceIds) - } + const invoiceIdsByEntry = await getInvoicesExplainingJournalEntries(supabase, companyId, entries) const allInvoiceIds = new Set() for (const ids of invoiceIdsByEntry.values()) for (const id of ids) allInvoiceIds.add(id) @@ -508,7 +503,7 @@ export async function generatePeriodiskSammanstallning( code: 'ZERO_NET_EXCLUDED', message: `Kund "${acc.customerName ?? acc.vatNumber}" nettar till 0 kr för perioden ` + - '(kreditfaktura tar ut original). Exkluderad från filen.', + '(kreditfaktura eller makulering tar ut originalet). Exkluderad från filen.', customerId: acc.customerId ?? undefined, customerName: acc.customerName ?? undefined, })