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
@@ -0,0 +1,222 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import { getPool } from './setup'
|
||||
import { seedCompany, insertPostedJournalEntry, insertTransaction } from './fixtures'
|
||||
|
||||
/**
|
||||
* Customer-invoice hänvisning in the missing-underlag predicate (#2298,
|
||||
* migration 20260906135702).
|
||||
*
|
||||
* BFL 5 kap 7 §: a verifikation may satisfy the underlag requirement by
|
||||
* hänvisning till underlag. An entry that a register invoice points at is
|
||||
* backed by that invoice: the invoice Accounted issued IS the verifikation
|
||||
* for the sale, and the payment row identifies the inbetalning. Both links
|
||||
* are written on the invoice side only (invoices.journal_entry_id,
|
||||
* invoice_payments.journal_entry_id), so a SIE-imported or manual verifikat
|
||||
* that an invoice was matched to afterwards keeps its own source_type and must
|
||||
* be resolved from the link, never by rewriting the posted entry.
|
||||
*
|
||||
* Pins, on real Postgres:
|
||||
* - an 'import' entry linked through invoice_payments is NOT missing underlag;
|
||||
* - the same shape without a link IS (the needs-doc list still applies);
|
||||
* - a 'manual' entry referenced by invoices.journal_entry_id is NOT missing;
|
||||
* - a bank-driven entry linked through invoice_payments leaves BOTH surfaces,
|
||||
* so transactions_without_documents stays a strict subset;
|
||||
* - the link row is tenant-scoped: another company's invoice pointing at the
|
||||
* entry does not silence it.
|
||||
*/
|
||||
|
||||
type VerifikatResult = {
|
||||
ok: boolean
|
||||
total_count?: number
|
||||
verifikat?: Array<{ journal_entry_id: string; source_type: string }>
|
||||
}
|
||||
type TransactionsResult = {
|
||||
ok: boolean
|
||||
total_count?: number
|
||||
transactions?: Array<{ id: string; journal_entry_id: string }>
|
||||
}
|
||||
|
||||
async function verifikatSurface(companyId: string): Promise<string[]> {
|
||||
const { rows } = await getPool().query<{ r: VerifikatResult }>(
|
||||
`SELECT public.verifikat_without_documents($1, NULL, 0, 100, 0) AS r`,
|
||||
[companyId],
|
||||
)
|
||||
expect(rows[0].r.ok).toBe(true)
|
||||
return (rows[0].r.verifikat ?? []).map((v) => v.journal_entry_id)
|
||||
}
|
||||
|
||||
async function transactionsSurface(companyId: string): Promise<string[]> {
|
||||
const { rows } = await getPool().query<{ r: TransactionsResult }>(
|
||||
`SELECT public.transactions_without_documents($1, NULL, 100, 0) AS r`,
|
||||
[companyId],
|
||||
)
|
||||
expect(rows[0].r.ok).toBe(true)
|
||||
return (rows[0].r.transactions ?? []).map((t) => t.journal_entry_id)
|
||||
}
|
||||
|
||||
async function insertCustomerInvoice(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
journalEntryId?: string | null
|
||||
/** Defaults to 'sent' (issued). 'draft' / 'cancelled' are no document. */
|
||||
status?: string
|
||||
}): Promise<string> {
|
||||
const customerId = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.customers (id, user_id, company_id, name, customer_type)
|
||||
VALUES ($1, $2, $3, 'EU Kund GmbH', 'eu_business')`,
|
||||
[customerId, params.userId, params.companyId],
|
||||
)
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.invoices
|
||||
(id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date,
|
||||
currency, subtotal, vat_amount, total, vat_treatment, vat_rate, status,
|
||||
paid_amount, remaining_amount, journal_entry_id)
|
||||
VALUES ($1, $2, $3, $4, $5, '2026-06-01', '2026-06-30', 'SEK',
|
||||
10000, 0, 10000, 'reverse_charge', 0, $7, 0, 10000, $6)`,
|
||||
[
|
||||
id,
|
||||
params.userId,
|
||||
params.companyId,
|
||||
customerId,
|
||||
`F-${id.slice(0, 8)}`,
|
||||
params.journalEntryId ?? null,
|
||||
params.status ?? 'sent',
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
/** The row link_invoice_to_voucher writes: the voucher becomes the invoice's payment. */
|
||||
async function linkAsPayment(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
invoiceId: string
|
||||
journalEntryId: string
|
||||
}): Promise<void> {
|
||||
await getPool().query(
|
||||
`INSERT INTO public.invoice_payments
|
||||
(user_id, company_id, invoice_id, payment_date, amount, currency, journal_entry_id)
|
||||
VALUES ($1, $2, $3, '2026-06-10', 10000, 'SEK', $4)`,
|
||||
[params.userId, params.companyId, params.invoiceId, params.journalEntryId],
|
||||
)
|
||||
}
|
||||
|
||||
describe('customer-invoice hänvisning silences "Underlag saknas" (#2298)', () => {
|
||||
let companyId: string
|
||||
let jeImportLinked: string // SIE-imported sale, invoice matched to it → covered
|
||||
let jeImportLoose: string // SIE-imported sale, nothing points at it → missing
|
||||
let jeManualRegistered: string // manual booking the invoice register links directly → covered
|
||||
let jeBankLinked: string // bank-driven entry, invoice matched to it → covered on BOTH surfaces
|
||||
let jeImportForeignLink: string // linked only from ANOTHER company's invoice → still missing
|
||||
let jeManualDraftLink: string // a DRAFT invoice points at it: no document yet → still missing
|
||||
let jeImportCancelledPayment: string // payment row of a CANCELLED invoice → still missing
|
||||
|
||||
beforeAll(async () => {
|
||||
const s = await seedCompany()
|
||||
companyId = s.companyId
|
||||
const { userId, fiscalPeriodId } = s
|
||||
|
||||
// The importer's shape for an EU service sale under kontantmetoden:
|
||||
// debit bank, credit 3308. Source type 'import' is in the needs-doc list.
|
||||
const mkJe = (n: number, sourceType: string) =>
|
||||
insertPostedJournalEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
voucherNumber: n,
|
||||
entryDate: `2026-06-${String(n).padStart(2, '0')}`,
|
||||
description: `${sourceType} ${n}`,
|
||||
sourceType,
|
||||
lines: [
|
||||
{ accountNumber: '1930', debitAmount: 10000, creditAmount: 0 },
|
||||
{ accountNumber: '3308', debitAmount: 0, creditAmount: 10000 },
|
||||
],
|
||||
})
|
||||
|
||||
jeImportLinked = await mkJe(1, 'import')
|
||||
jeImportLoose = await mkJe(2, 'import')
|
||||
jeManualRegistered = await mkJe(3, 'manual')
|
||||
jeBankLinked = await mkJe(4, 'bank_transaction')
|
||||
jeImportForeignLink = await mkJe(5, 'import')
|
||||
jeManualDraftLink = await mkJe(6, 'manual')
|
||||
jeImportCancelledPayment = await mkJe(7, 'import')
|
||||
|
||||
const linkedInvoice = await insertCustomerInvoice({ userId, companyId })
|
||||
await linkAsPayment({ userId, companyId, invoiceId: linkedInvoice, journalEntryId: jeImportLinked })
|
||||
|
||||
await insertCustomerInvoice({ userId, companyId, journalEntryId: jeManualRegistered })
|
||||
|
||||
// Non-issued invoices: the link row exists but no document does, the
|
||||
// counterpart of an unanchored supplier document.
|
||||
await insertCustomerInvoice({ userId, companyId, journalEntryId: jeManualDraftLink, status: 'draft' })
|
||||
const cancelledInvoice = await insertCustomerInvoice({ userId, companyId, status: 'cancelled' })
|
||||
await linkAsPayment({
|
||||
userId,
|
||||
companyId,
|
||||
invoiceId: cancelledInvoice,
|
||||
journalEntryId: jeImportCancelledPayment,
|
||||
})
|
||||
|
||||
await insertTransaction({ userId, companyId, journalEntryId: jeBankLinked, date: '2026-06-04' })
|
||||
const bankInvoice = await insertCustomerInvoice({ userId, companyId })
|
||||
await linkAsPayment({ userId, companyId, invoiceId: bankInvoice, journalEntryId: jeBankLinked })
|
||||
|
||||
// Another tenant's invoice pointing at this company's entry: the FK
|
||||
// allows it, the predicate must not honour it.
|
||||
const other = await seedCompany()
|
||||
const foreignInvoice = await insertCustomerInvoice({ userId: other.userId, companyId: other.companyId })
|
||||
await linkAsPayment({
|
||||
userId: other.userId,
|
||||
companyId: other.companyId,
|
||||
invoiceId: foreignInvoice,
|
||||
journalEntryId: jeImportForeignLink,
|
||||
})
|
||||
})
|
||||
|
||||
it('an imported verifikat matched to a register invoice through invoice_payments is not missing underlag', async () => {
|
||||
const ids = await verifikatSurface(companyId)
|
||||
expect(ids).not.toContain(jeImportLinked)
|
||||
})
|
||||
|
||||
it('the same imported shape without a link still is (needs-doc source type, no hänvisning)', async () => {
|
||||
const ids = await verifikatSurface(companyId)
|
||||
expect(ids).toContain(jeImportLoose)
|
||||
})
|
||||
|
||||
it('a manual verifikat the register points at through invoices.journal_entry_id is not missing underlag', async () => {
|
||||
const ids = await verifikatSurface(companyId)
|
||||
expect(ids).not.toContain(jeManualRegistered)
|
||||
})
|
||||
|
||||
it('a link from another company does not silence the entry', async () => {
|
||||
const ids = await verifikatSurface(companyId)
|
||||
expect(ids).toContain(jeImportForeignLink)
|
||||
})
|
||||
|
||||
it('a bank-driven entry matched to an invoice leaves both surfaces, so the subset invariant holds', async () => {
|
||||
const [ver, tx] = await Promise.all([verifikatSurface(companyId), transactionsSurface(companyId)])
|
||||
expect(ver).not.toContain(jeBankLinked)
|
||||
expect(tx).not.toContain(jeBankLinked)
|
||||
for (const id of tx) expect(ver).toContain(id)
|
||||
})
|
||||
|
||||
it('a DRAFT invoice pointing at the entry is no underlag: still missing', async () => {
|
||||
const ids = await verifikatSurface(companyId)
|
||||
expect(ids).toContain(jeManualDraftLink)
|
||||
})
|
||||
|
||||
it('a payment row of a CANCELLED invoice is no underlag: still missing', async () => {
|
||||
const ids = await verifikatSurface(companyId)
|
||||
expect(ids).toContain(jeImportCancelledPayment)
|
||||
})
|
||||
|
||||
it('the full verdict: exactly the unlinked, foreign-linked and non-issued-linked entries remain', async () => {
|
||||
const ids = await verifikatSurface(companyId)
|
||||
expect(ids.sort()).toEqual(
|
||||
[jeImportLoose, jeImportForeignLink, jeManualDraftLink, jeImportCancelledPayment].sort(),
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user