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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
6a5fd6cd00
commit
4bc007cdeb
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user