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
parent 6a5fd6cd00
commit 4bc007cdeb
4 changed files with 560 additions and 30 deletions
@@ -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<typeof setup>, 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<typeof setup>) => 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)
})
})
@@ -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<string> = 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<Map<string, string[]>> {
const result = new Map<string, string[]>()
if (entries.length === 0) return result
// Chain entries waiting for their parent's attribution, by parent id.
const waitingOn = new Map<string, string[]>()
// 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<string, number>()
// 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<string>(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<ExplainableJournalEntry>(({ 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
}
@@ -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)
})
})
+25 -30
View File
@@ -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<string, 'services' | 'goods' | 'triangulation'>
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<RawEntry>(({ 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<string, string[]>()
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<string>()
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,
})