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
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user