fix(underlag): a verifikat a customer invoice points at is backed by it; PS follows the invoice link (#2298) (#2347)

* fix(underlag): a verifikat a customer invoice points at is backed by it; PS follows the invoice link (#2298)

The invoice-to-verifikat link is written on the invoice side only
(invoices.journal_entry_id, invoice_payments.journal_entry_id), while the
missing-underlag predicate and the periodisk sammanstallning resolved the
invoice from the entry's own source columns. A SIE-imported sale matched to
its invoice afterwards therefore kept warning "Underlag saknas" and was left
out of the EU sales list, although the account-based momsdeklaration showed
it and the verifikat page already listed the invoice as its underlag.

- verifikat_without_documents / transactions_without_documents: customer-
  invoice hanvisning arm (BFL 5 kap 7 §), tenant-scoped on the link row;
  new migration 20260906135702, pinned by a pg-real test.
- getInvoiceReferencesForJournalEntries(): one TS mirror of that arm, used
  by the journal-list filter and bulk exempt, /api/documents/counts (new
  invoice_references map) and the transactions list; the push cron mirrors
  it with its global reads.
- Journal list: no "Underlag saknas" chip for a covered entry, matching
  the engine's own invoice rows and the verifikat detail page.
- Periodisk sammanstallning: entries fetched by their EU-revenue lines and
  attributed through every link (engine source_id, invoices.journal_entry_id,
  invoice_payments.journal_entry_id); kontantmetod invoice_cash_payment
  entries are filed too, which the old source_type filter dropped.

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

* fix(underlag): issued invoices only, blocking mixed-customer settlements in PS, chunk-level degrade (#2298 review)

- The customer-invoice hanvisning arms (RPCs, both TS resolvers, push cron)
  now require an ISSUED invoice: status not in ('draft', 'cancelled'), the
  schema's own definition (migration 20260427150000). NON_ISSUED_INVOICE_
  STATUSES in lib/invoices/matchable-statuses.ts is the shared constant; the
  pg test pins a draft-linked and a cancelled-payment entry as still missing.
- Periodisk sammanstallning: one verifikat linked to invoices of different
  customers is no longer attributed to the first invoice; it is left out of
  the accumulators and reported once as a blocking MIXED_CUSTOMER_SETTLEMENT
  naming the voucher, the customer count and the amount. Same-customer
  settlements are filed in full.
- Transactions list: a failed invoice-reference lookup leaves that chunk's
  verdict unknown (no badges) and continues with the remaining chunks instead
  of abandoning them.

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 19:04:30 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent cce0de5704
commit 7448490fb7
16 changed files with 1429 additions and 125 deletions
@@ -1,7 +1,10 @@
import { describe, it, expect } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { getJournalEntryUnderlagReferences } from '../journal-entry-references'
import {
getInvoiceReferencesForJournalEntries,
getJournalEntryUnderlagReferences,
} from '../journal-entry-references'
/**
* The resolver issues its queries in a fixed `.from()` order, and the queued
@@ -156,4 +159,112 @@ describe('getJournalEntryUnderlagReferences', () => {
{ type: 'supplier_invoice', id: 'si-2', number: 'LF-2' },
])
})
it('asks only for ISSUED customer invoices: a draft or cancelled one is no underlag (#2298)', async () => {
const mock = createQueuedMockSupabase()
mock.enqueueMany([
{ data: [] }, // 1. invoices direct
{ data: [{ invoice_id: 'inv-x' }] }, // 2. invoice_payments
{ data: [] }, // 3. invoices by id: the cancelled invoice is filtered out server-side
{ data: [] }, // 4. supplier registration
{ data: [] }, // 5. supplier payment
{ data: [] }, // 6. supplier_invoice_payments
])
const refs = await getJournalEntryUnderlagReferences(
mock.supabase as unknown as SupabaseClient,
'company-1',
'je-1',
)
expect(refs).toEqual([])
const notCalls = mock.findCalls('invoices', 'not')
expect(notCalls).toHaveLength(2)
for (const call of notCalls) expect(call).toEqual(['status', 'in', '("draft","cancelled")'])
})
})
/**
* Batch resolver behind every TS mirror of the RPC's customer-invoice arm
* (#2298). Fixed `.from()` order: invoices (by journal_entry_id), then
* invoice_payments (by journal_entry_id).
*/
describe('getInvoiceReferencesForJournalEntries', () => {
const setup = (results: { data: unknown }[]) => {
const mock = createQueuedMockSupabase()
mock.enqueueMany(results)
return mock
}
it('returns nothing, without a round trip, for an empty id list', async () => {
const mock = setup([])
const refs = await getInvoiceReferencesForJournalEntries(
mock.supabase as unknown as SupabaseClient,
'company-1',
[],
)
expect(refs.size).toBe(0)
expect(mock.supabase.from).not.toHaveBeenCalled()
})
it('maps the registration link and payment rows onto their entries, deduplicated', async () => {
const mock = setup([
{ data: [{ id: 'inv-reg', journal_entry_id: 'je-1' }] },
{
data: [
// The reported case: a SIE-imported voucher matched to an invoice.
{ id: 'pay-a', invoice_id: 'inv-imp', journal_entry_id: 'je-2' },
// Same invoice already reached through the direct link: once.
{ id: 'pay-b', invoice_id: 'inv-reg', journal_entry_id: 'je-1' },
// One deposit settling two invoices: both are references.
{ id: 'pay-c', invoice_id: 'inv-other', journal_entry_id: 'je-2' },
// Defensive: a row without an invoice id is not a reference.
{ id: 'pay-d', invoice_id: null, journal_entry_id: 'je-3' },
],
},
])
const refs = await getInvoiceReferencesForJournalEntries(
mock.supabase as unknown as SupabaseClient,
'company-1',
['je-1', 'je-2', 'je-3'],
)
expect(Array.from(refs.entries())).toEqual([
['je-1', ['inv-reg']],
['je-2', ['inv-imp', 'inv-other']],
])
})
it('scopes both lookups to the company and the given ids', async () => {
const mock = setup([{ data: [] }, { data: [] }])
await getInvoiceReferencesForJournalEntries(
mock.supabase as unknown as SupabaseClient,
'company-1',
['je-1', 'je-2'],
)
expect(mock.findCalls('invoices', 'eq')).toContainEqual(['company_id', 'company-1'])
expect(mock.findCalls('invoices', 'in')).toContainEqual(['journal_entry_id', ['je-1', 'je-2']])
expect(mock.findCalls('invoice_payments', 'eq')).toContainEqual(['company_id', 'company-1'])
expect(mock.findCalls('invoice_payments', 'in')).toContainEqual([
'journal_entry_id',
['je-1', 'je-2'],
])
})
it('asks only for ISSUED invoices on both links, mirroring the RPC status guard', async () => {
const mock = setup([{ data: [] }, { data: [] }])
await getInvoiceReferencesForJournalEntries(
mock.supabase as unknown as SupabaseClient,
'company-1',
['je-1'],
)
expect(mock.findCalls('invoices', 'not')).toContainEqual(['status', 'in', '("draft","cancelled")'])
// The payment query carries the invoice status as an inner embed and
// filters on it, so a non-issued invoice's payment row never comes back.
expect(mock.findCall('invoice_payments', 'select')).toEqual([
'id, invoice_id, journal_entry_id, invoices!inner(status)',
])
expect(mock.findCalls('invoice_payments', 'not')).toContainEqual([
'invoices.status',
'in',
'("draft","cancelled")',
])
})
})