Files
accounted/tests/pg/underlag-customer-invoice-reference.pg.test.ts
T
7448490fb7 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>
2026-09-06 19:04:30 +02:00

223 lines
9.1 KiB
TypeScript

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(),
)
})
})