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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
cce0de5704
commit
7448490fb7
@@ -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")',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { NON_ISSUED_INVOICE_STATUSES_FILTER } from '@/lib/invoices/matchable-statuses'
|
||||
|
||||
/**
|
||||
* A followable reference from a verifikation back to its underlag: the customer
|
||||
@@ -87,9 +88,13 @@ export async function getJournalEntryUnderlagReferences(
|
||||
const invoices = new Map<string, string>()
|
||||
|
||||
// Direct link (faktureringsmetod registration, or invoices.journal_entry_id).
|
||||
// Issued invoices only: a draft or cancelled invoice is no underlag, and the
|
||||
// verifikat page counts these references as underlag (same verdict as the
|
||||
// missing-underlag surfaces: NON_ISSUED_INVOICE_STATUSES).
|
||||
const directInvoices = await fetchAllRows<InvoiceRow>(({ from, to }) =>
|
||||
supabase.from('invoices').select('id, invoice_number')
|
||||
.eq('company_id', companyId).eq('journal_entry_id', journalEntryId)
|
||||
.not('status', 'in', NON_ISSUED_INVOICE_STATUSES_FILTER)
|
||||
.order('id', { ascending: true }).range(from, to),
|
||||
)
|
||||
|
||||
@@ -112,6 +117,7 @@ export async function getJournalEntryUnderlagReferences(
|
||||
const paidInvoices = await fetchAllRows<InvoiceRow>(({ from, to }) =>
|
||||
supabase.from('invoices').select('id, invoice_number')
|
||||
.eq('company_id', companyId).in('id', Array.from(paymentInvoiceIds))
|
||||
.not('status', 'in', NON_ISSUED_INVOICE_STATUSES_FILTER)
|
||||
.order('id', { ascending: true }).range(from, to),
|
||||
)
|
||||
|
||||
@@ -195,3 +201,72 @@ export async function getJournalEntryUnderlagReferences(
|
||||
}
|
||||
return references
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch form of the customer-invoice arm above, for the surfaces that decide
|
||||
* "saknar underlag" for many verifikat at once: which register invoices point
|
||||
* at each of the given journal entries, through the two links the register
|
||||
* keeps (invoices.journal_entry_id for the registration booking,
|
||||
* invoice_payments.journal_entry_id for a kontantmetod inbetalning, a
|
||||
* delbetalning, or "matcha mot befintligt verifikat").
|
||||
*
|
||||
* An entry that appears in the result is backed by that invoice under BFL
|
||||
* 5 kap 7 § (hänvisning till underlag): the invoice Accounted issued is the
|
||||
* verifikation for the sale, and the payment row identifies the inbetalning.
|
||||
* This is the TS mirror of the customer arm in the verifikat_without_documents
|
||||
* / transactions_without_documents RPCs (migration 20260906135702, #2298):
|
||||
* every TS surface (journal-list filter, documents/counts, transactions list)
|
||||
* must reach the same verdict as the dashboard badge and the MCP tools.
|
||||
*
|
||||
* Values are invoice ids per journal entry id, direct link first and then
|
||||
* payment rows in id order, deduplicated. Only entries with at least one link
|
||||
* to an ISSUED invoice are present: a draft or cancelled invoice is no
|
||||
* document, so it cannot back a verifikat (NON_ISSUED_INVOICE_STATUSES, the
|
||||
* counterpart of the anchored-document requirement on the supplier arm).
|
||||
* Every query is company-scoped (defense in depth alongside RLS).
|
||||
*
|
||||
* Callers pass at most one PostgREST `.in()` chunk (the ~150-id URL-length
|
||||
* convention in lib/worklist/categories.ts). The two queries run in a fixed
|
||||
* order (invoices, then invoice_payments) so queued test mocks stay simple.
|
||||
*/
|
||||
export async function getInvoiceReferencesForJournalEntries(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
journalEntryIds: readonly string[],
|
||||
): Promise<Map<string, string[]>> {
|
||||
const result = new Map<string, string[]>()
|
||||
if (journalEntryIds.length === 0) return result
|
||||
const ids = [...journalEntryIds]
|
||||
|
||||
const add = (journalEntryId: string | null | undefined, invoiceId: string | null | undefined) => {
|
||||
if (!journalEntryId || !invoiceId) return
|
||||
const list = result.get(journalEntryId)
|
||||
if (!list) result.set(journalEntryId, [invoiceId])
|
||||
else if (!list.includes(invoiceId)) list.push(invoiceId)
|
||||
}
|
||||
|
||||
const direct = await fetchAllRows<{ id: string; journal_entry_id: string | null }>(
|
||||
({ from, to }) =>
|
||||
supabase.from('invoices').select('id, journal_entry_id')
|
||||
.eq('company_id', companyId).in('journal_entry_id', ids)
|
||||
.not('status', 'in', NON_ISSUED_INVOICE_STATUSES_FILTER)
|
||||
.order('id', { ascending: true }).range(from, to),
|
||||
)
|
||||
for (const row of direct) add(row.journal_entry_id, row.id)
|
||||
|
||||
// The invoice's status rides along as an inner embed so the filter drops
|
||||
// payment rows of non-issued invoices server-side (one query, no id list).
|
||||
const payments = await fetchAllRows<{
|
||||
id: string
|
||||
invoice_id: string | null
|
||||
journal_entry_id: string | null
|
||||
}>(({ from, to }) =>
|
||||
supabase.from('invoice_payments').select('id, invoice_id, journal_entry_id, invoices!inner(status)')
|
||||
.eq('company_id', companyId).in('journal_entry_id', ids)
|
||||
.not('invoices.status', 'in', NON_ISSUED_INVOICE_STATUSES_FILTER)
|
||||
.order('id', { ascending: true }).range(from, to),
|
||||
)
|
||||
for (const row of payments) add(row.journal_entry_id, row.invoice_id)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user