feat(transactions): match overshoot guards + supplier voucher linking (#602)

* feat(transactions): match overshoot guards + supplier voucher linking

Three changes that together close the "I can't link a bank transaction
to an already-booked verifikat on the supplier side" gap and fix a
latent data-corruption bug on the per-tx match endpoints.

1. fix: clamp paid_amount on match endpoints when tx > remaining

   /api/transactions/[id]/match-{invoice,supplier-invoice} previously
   used transaction.amount wholesale as the paid amount, pushing
   invoice.paid_amount past invoice.total whenever the bank tx was
   larger than what was owed. Both endpoints now reject with
   MATCH_AMOUNT_EXCEEDS_REMAINING / MATCH_SI_AMOUNT_EXCEEDS_REMAINING
   and a structured { transaction_amount, remaining_amount, excess }
   payload that points the user at the future split-payment flow.
   FX branch already clamps to invoice.remaining_amount and is
   unchanged.

2. feat: supplier-side "link existing verifikat" (mirror of #591)

   lib/invoices/supplier-voucher-matching.ts mirrors the customer
   voucher-matching module: finds posted JEs that debit 2440
   (Leverantörsskulder), validates currency + remaining-amount, and
   atomically links them as supplier_invoice_payments rows. New
   /api/supplier-invoices/[id]/{voucher-candidates,link-to-voucher}
   routes wrap it. LinkVoucherPicker gains a mode='supplier_invoice'
   prop so the same component renders both flows. The supplier-invoice
   mark-paid dialog now uses Tabs ("Ny betalning" / "Befintlig
   verifikation") to match the customer-side UX.

3. infra: transaction_voucher_links junction + denorm guard

   Foundation migration for upcoming multi-tx ↔ multi-voucher flows.
   Adds the junction table (with RLS, updated_at, indexes), a
   block_contradictory_invoice_denorm trigger on transactions that
   refuses to set invoice_id/supplier_invoice_id to a value that
   contradicts an existing payment row, and is_transaction_booked(uuid)
   as a single source of truth for "is this tx anchored?" once
   multi-allocation leaves denorm columns NULL. No application code
   uses these yet — they unlock the batch allocation and bulk-book
   flows in follow-up PRs.

Tests: 98 unit tests pass across the touched paths (match-invoice,
match-supplier-invoice, supplier-voucher-matching, link-to-voucher).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(supplier-invoices): PR review — atomic link RPC, computeRemaining edge case, pg-real tests

Addresses the three real issues raised by Greptile on PR #602.

1. (P1) Atomic supplier voucher linking — new
   link_supplier_invoice_to_voucher PL/pgSQL RPC. The TS-side
   linkSupplierInvoiceToVoucher() previously did UPDATE-then-INSERT with
   a manual unconditional rollback. Under concurrent linking against the
   same invoice, request A's rollback could overwrite a sibling B's
   successful write while leaving B's payment row in place. Moving both
   writes into a single PG transaction (one RPC call) lets PG's own
   rollback handle the failure path correctly. TS wrapper now just
   translates the structured RPC return into the lib's Result type.

2. (P1) pg-real tests — tests/pg/transaction_voucher_links.pg.test.ts.
   CLAUDE.md mandates *.pg.test.ts for any PR adding a trigger, RPC, or
   RLS. The Phase 1A foundation migration added all three but had no
   pg-real coverage. Tests now cover:
     - trg_block_contradictory_invoice_denorm refusing contradictory
       UPDATEs on invoice_id and supplier_invoice_id
     - the same trigger PERMITTING a matching UPDATE (no false positives)
     - is_transaction_booked() returning true via journal_entry_id, via
       invoice_payments, and via transaction_voucher_links rows.

3. (P2) computeRemaining edge case — trust remaining_amount whenever
   the column is non-null (including the legitimate 0 for fully-paid
   invoices). The old "> 0" guard fell through to total - paid_amount,
   which under rounding drift could compute a tiny positive residue and
   slip a fully-paid invoice past LINK_SI_VOUCHER_INVOICE_FULLY_PAID.

The fourth Greptile comment (overdue invoices silently get no
candidates) was a misread: 'overdue' IS in the open-state list at
route.ts:35. No code change needed there.

Tests: 100 unit tests pass (16 in the directly-touched paths).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(supplier-invoices): PR review round 2 — broaden AP range, log event failures

Addresses the actionable findings from the compliance-swarm and
Swedish-accounting-compliance bot reviews on PR #602.

1. (swedish-accounting-compliance, high) AP account hardcoded to 2440
   rejected legitimate samlingsverifikationer that debit 2441
   (Leverantörsskulder i utländsk valuta), 2443 (Skuldfakturor), etc.
   BAS 2026 reserves the full 2440–2449 range for Leverantörsskulder.
   The TS-side AP_ACCOUNT constant becomes AP_ACCOUNT_PREFIX ('244')
   used with .like() and .startsWith(). The PL/pgSQL RPC's
   account_number filter becomes LIKE '244%'. The
   LINK_SI_VOUCHER_NO_AP_DEBIT error message updates to reference the
   244x range with examples.

2. (ISO 27001:2022 A.8.15 / OWASP V16) Empty catch on the
   supplier_invoice.paid event emission now logs with log.warn so a
   failure in the downstream reminder/audit subscriber leaves an
   auditable trail without blocking the response.

3. (GDPR Art.5(1)(c)) Documented design rationale for retaining
   select('*') on the post-link invoice re-fetch: the
   supplier_invoice.paid event payload is typed as
   `supplierInvoice: SupplierInvoice` in lib/events/types.ts, narrowing
   would break the subscriber contract. The event stays in-process
   and consumers legitimately need the full context.

Skipped findings:
  - V8.2.1 ownership concerns: route + RPC already filter by
    company_id from withRouteContext; the RPC's WHERE clause covers it.
  - DELETE policy scoping: matches the gnubok pattern across all
    company-scoped tables — any member with write access manages records.
  - transaction_id = NULL on the voucher-link path: by design — the
    flow has no bank tx (the voucher's 1930 line represents it).
  - Reverse-charge VAT (2614/2647) validation on linked vouchers:
    real concern but invasive change; tracked for follow-up.
  - Storno-chain integrity (linking the original of a storno pair):
    edge case; tracked for follow-up.

Tests: 26 unit tests pass in the directly-touched paths. RPC patch
applied to remote via Supabase MCP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-29 12:31:19 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent a586cc8a58
commit 7bcd46d503
19 changed files with 2510 additions and 44 deletions
+10
View File
@@ -508,6 +508,16 @@ export const LinkInvoiceToVoucherSchema = z.object({
notes: z.string().max(2000).optional(),
})
/**
* Supplier-invoice mirror: link an existing posted verifikat as payment for a
* supplier invoice. No new JE — only a supplier_invoice_payments row pointing
* at the supplied journal_entry_id, plus the invoice's paid/remaining advance.
*/
export const LinkSupplierInvoiceToVoucherSchema = z.object({
journal_entry_id: uuid,
notes: z.string().max(2000).optional(),
})
export const LinkTransactionJournalEntrySchema = z.object({
journal_entry_id: uuid,
// Optional invoice to settle alongside the link. When provided, the
+78
View File
@@ -394,6 +394,13 @@ const MATCH_INVOICE: Record<string, StructuredErrorEntry> = {
message_en:
'The candidate journal entry echoed in expected_journal_entry_id does not match the one detected at request time. Re-run the duplicate-payment pre-flight to obtain the current candidate, then retry.',
},
MATCH_AMOUNT_EXCEEDS_REMAINING: {
httpStatus: 400,
message_sv:
'Transaktionsbeloppet är större än fakturans återstående belopp. Dela betalningen och fördela överskottet på en eller flera andra fakturor.',
message_en:
'Transaction amount exceeds the invoice remaining amount. Use the split-payment flow to allocate the excess across one or more other invoices.',
},
}
const LINK_TX_JE: Record<string, StructuredErrorEntry> = {
@@ -479,6 +486,13 @@ const MATCH_SI: Record<string, StructuredErrorEntry> = {
message_en:
'Cash accounting does not support exchange-rate differences. Switch to accrual or book the FX difference manually.',
},
MATCH_SI_AMOUNT_EXCEEDS_REMAINING: {
httpStatus: 400,
message_sv:
'Transaktionsbeloppet är större än leverantörsfakturans återstående belopp. Dela betalningen och fördela överskottet på en eller flera andra leverantörsfakturor.',
message_en:
'Transaction amount exceeds the supplier invoice remaining amount. Use the split-payment flow to allocate the excess across one or more other supplier invoices.',
},
TX_UNCATEGORIZE_NOT_BOOKED: {
httpStatus: 400,
message_sv: 'Transaktionen är inte bokförd. Det finns inget att av-kategorisera.',
@@ -1725,6 +1739,69 @@ const LINK_INVOICE_VOUCHER: Record<string, StructuredErrorEntry> = {
},
}
// ─────────────────────────────────────────────────────────────────
// Link SUPPLIER invoice to an existing posted verifikat (no new JE)
// ─────────────────────────────────────────────────────────────────
const LINK_SI_VOUCHER: Record<string, StructuredErrorEntry> = {
LINK_SI_VOUCHER_INVOICE_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Leverantörsfakturan kunde inte hittas.',
message_en: 'Supplier invoice not found.',
},
LINK_SI_VOUCHER_VOUCHER_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Verifikationen kunde inte hittas.',
message_en: 'Journal entry not found.',
},
LINK_SI_VOUCHER_NOT_POSTED: {
httpStatus: 409,
message_sv:
'Verifikationen är inte bokförd. Endast bokförda verifikationer kan länkas som betalning.',
message_en: 'Journal entry is not posted. Only posted entries can be linked as a payment.',
},
LINK_SI_VOUCHER_NO_AP_DEBIT: {
httpStatus: 400,
message_sv:
'Verifikationen debiterar inget leverantörsskuldskonto (244x). Rätta bokföringen först med en stornoverifikation som debiterar t.ex. 2440 (SEK) eller 2441 (utländsk valuta), via gnubok_correct_entry.',
message_en:
'The journal entry does not debit any accounts-payable account in the 244x range (e.g. 2440 SEK, 2441 foreign currency). Correct the booking first via a storno+correction (gnubok_correct_entry).',
remediation: {
description:
'Use gnubok_correct_entry to storno the existing voucher and re-book the payment as Dr 244x / Cr 1930, then link the corrected voucher.',
tool: 'gnubok_correct_entry',
},
},
LINK_SI_VOUCHER_ALREADY_LINKED: {
httpStatus: 409,
message_sv: 'Verifikationen är redan länkad till den här leverantörsfakturan.',
message_en: 'This journal entry is already linked to this supplier invoice.',
},
LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING: {
httpStatus: 400,
message_sv:
'Verifikationens leverantörsskuldsdebitering är större än leverantörsfakturans återstående belopp. Verifikationen täcker fler fakturor — välj en annan verifikation eller rätta beloppet först.',
message_en:
'The voucher\'s AP debit exceeds the supplier invoice\'s remaining balance. Split the voucher across multiple supplier invoices via gnubok_correct_entry first, or pick a different voucher.',
},
LINK_SI_VOUCHER_CURRENCY_MISMATCH: {
httpStatus: 400,
message_sv:
'Verifikationens valuta matchar inte leverantörsfakturans. Endast verifikationer i fakturans valuta kan länkas.',
message_en: 'The voucher\'s currency does not match the supplier invoice currency.',
},
LINK_SI_VOUCHER_INVOICE_FULLY_PAID: {
httpStatus: 409,
message_sv: 'Leverantörsfakturan har redan slutbetalats. Inget mer behöver länkas.',
message_en: 'Supplier invoice is already fully paid.',
},
LINK_SI_VOUCHER_DB_ERROR: {
httpStatus: 500,
message_sv: 'Databasfel under länkning. Försök igen.',
message_en: 'Database error while linking the voucher. Please retry.',
},
}
// ─────────────────────────────────────────────────────────────────
// Combined registry
// ─────────────────────────────────────────────────────────────────
@@ -1736,6 +1813,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
...MATCH_INVOICE,
...LINK_TX_JE,
...LINK_INVOICE_VOUCHER,
...LINK_SI_VOUCHER,
...MATCH_SI,
...INVOICE,
...SUPPLIER_INVOICE,
@@ -0,0 +1,444 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
validateVoucherForSupplierInvoiceLink,
linkSupplierInvoiceToVoucher,
} from '../supplier-voucher-matching'
import {
makeSupplierInvoice,
createQueuedMockSupabase,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events/bus'
// ============================================================
// validateVoucherForSupplierInvoiceLink — happy path + rejects
// ============================================================
describe('validateVoucherForSupplierInvoiceLink', () => {
beforeEach(() => {
vi.clearAllMocks()
})
function setup(
invoice = makeSupplierInvoice({ remaining_amount: 1000, total: 1000, currency: 'SEK' }),
) {
return invoice
}
it('rejects when the invoice has nothing remaining', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup(
makeSupplierInvoice({
remaining_amount: 0,
paid_amount: 1000,
total: 1000,
currency: 'SEK',
}),
)
enqueue({ data: null }) // unused — short-circuits before any query
const result = await validateVoucherForSupplierInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_SI_VOUCHER_INVOICE_FULLY_PAID')
})
it('rejects when the voucher is missing', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({ data: null, error: null }) // journal_entries.maybeSingle → null
const result = await validateVoucherForSupplierInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-missing',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_SI_VOUCHER_VOUCHER_NOT_FOUND')
})
it('rejects when the voucher is not posted', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
enqueue({
data: {
id: 'je-1',
voucher_series: 'B',
voucher_number: 12,
entry_date: '2024-06-15',
description: '',
status: 'draft',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
const result = await validateVoucherForSupplierInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_SI_VOUCHER_NOT_POSTED')
})
it('rejects when the voucher has no AP debit on 2440', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup()
// journal_entries lookup
enqueue({
data: {
id: 'je-1',
voucher_series: 'B',
voucher_number: 12,
entry_date: '2024-06-15',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
// journal_entry_lines — no 2440 line
enqueue({
data: [
{ account_number: '1930', debit_amount: 0, credit_amount: 1000, currency: 'SEK' },
{ account_number: '4010', debit_amount: 1000, credit_amount: 0, currency: 'SEK' },
],
})
const result = await validateVoucherForSupplierInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_SI_VOUCHER_NO_AP_DEBIT')
})
it('rejects when the AP debit exceeds invoice remaining', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup(
makeSupplierInvoice({
remaining_amount: 1000,
paid_amount: 0,
total: 1000,
currency: 'SEK',
}),
)
enqueue({
data: {
id: 'je-1',
voucher_series: 'B',
voucher_number: 12,
entry_date: '2024-06-15',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
// 5 000 debit on 2440 — overshoots a 1 000 invoice
enqueue({
data: [
{ account_number: '2440', debit_amount: 5000, credit_amount: 0, currency: 'SEK' },
{ account_number: '1930', debit_amount: 0, credit_amount: 5000, currency: 'SEK' },
],
})
const result = await validateVoucherForSupplierInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe('LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING')
expect(result.details?.ap_debit).toBe(5000)
expect(result.details?.remaining).toBe(1000)
}
})
it('accepts an exact-amount match and reports paymentAmount + isFullyPaid', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup(
makeSupplierInvoice({
remaining_amount: 1000,
paid_amount: 0,
total: 1000,
currency: 'SEK',
}),
)
enqueue({
data: {
id: 'je-1',
voucher_series: 'B',
voucher_number: 12,
entry_date: '2024-06-15',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
enqueue({
data: [
{ account_number: '2440', debit_amount: 1000, credit_amount: 0, currency: 'SEK' },
{ account_number: '1930', debit_amount: 0, credit_amount: 1000, currency: 'SEK' },
],
})
// existingLinks lookup — none
enqueue({ data: [], error: null })
const result = await validateVoucherForSupplierInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.apDebitAmount).toBe(1000)
expect(result.paymentAmount).toBe(1000)
expect(result.isFullyPaid).toBe(true)
expect(result.remainingAfter).toBe(0)
}
})
it('accepts a partial-payment voucher (debit < remaining) and reports partially_paid math', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup(
makeSupplierInvoice({
remaining_amount: 1000,
paid_amount: 0,
total: 1000,
currency: 'SEK',
}),
)
enqueue({
data: {
id: 'je-1',
voucher_series: 'B',
voucher_number: 12,
entry_date: '2024-06-15',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
enqueue({
data: [
{ account_number: '2440', debit_amount: 400, credit_amount: 0, currency: 'SEK' },
{ account_number: '1930', debit_amount: 0, credit_amount: 400, currency: 'SEK' },
],
})
enqueue({ data: [], error: null })
const result = await validateVoucherForSupplierInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.paymentAmount).toBe(400)
expect(result.isFullyPaid).toBe(false)
expect(result.remainingAfter).toBe(600)
}
})
it('rejects currency mismatch', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = setup(
makeSupplierInvoice({
remaining_amount: 200,
paid_amount: 0,
total: 200,
currency: 'EUR',
}),
)
enqueue({
data: {
id: 'je-1',
voucher_series: 'B',
voucher_number: 12,
entry_date: '2024-06-15',
description: '',
status: 'posted',
source_type: 'manual',
fiscal_period_id: 'fp-1',
company_id: 'company-1',
},
})
enqueue({
data: [
{ account_number: '2440', debit_amount: 200, credit_amount: 0, currency: 'SEK' },
{ account_number: '1930', debit_amount: 0, credit_amount: 200, currency: 'SEK' },
],
})
const result = await validateVoucherForSupplierInvoiceLink(
supabase as never,
'company-1',
invoice as never,
'je-1',
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_SI_VOUCHER_CURRENCY_MISMATCH')
})
})
// ============================================================
// linkSupplierInvoiceToVoucher — end-to-end advancement
// ============================================================
describe('linkSupplierInvoiceToVoucher', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// The implementation now delegates the lock + validate + UPDATE + INSERT
// sequence to the link_supplier_invoice_to_voucher PL/pgSQL RPC (PR #602
// review fix). The TS wrapper only translates the RPC's structured jsonb
// return into the lib's typed Result type and emits the paid event. These
// tests mock the RPC response directly.
it('rejects with INVOICE_NOT_FOUND when the RPC reports the invoice is missing', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: { ok: false, code: 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND' },
error: null,
})
const result = await linkSupplierInvoiceToVoucher(supabase as never, 'user-1', 'company-1', {
supplierInvoiceId: 'si-missing',
journalEntryId: 'je-1',
})
expect(result.ok).toBe(false)
if (!result.ok) expect(result.code).toBe('LINK_SI_VOUCHER_INVOICE_NOT_FOUND')
})
it('rejects with INVOICE_FULLY_PAID when the RPC reports the invoice is already paid', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
ok: false,
code: 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID',
details: { status: 'paid' },
},
error: null,
})
const result = await linkSupplierInvoiceToVoucher(supabase as never, 'user-1', 'company-1', {
supplierInvoiceId: 'si-1',
journalEntryId: 'je-1',
})
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe('LINK_SI_VOUCHER_INVOICE_FULLY_PAID')
expect(result.details?.status).toBe('paid')
}
})
it('returns LINK_SI_VOUCHER_DB_ERROR when the RPC raises an error', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'connection lost' } })
const result = await linkSupplierInvoiceToVoucher(supabase as never, 'user-1', 'company-1', {
supplierInvoiceId: 'si-1',
journalEntryId: 'je-1',
})
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.code).toBe('LINK_SI_VOUCHER_DB_ERROR')
expect(result.details?.reason).toBe('connection lost')
}
})
it('returns success + emits supplier_invoice.paid on the happy path (full payment)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const invoice = makeSupplierInvoice({
status: 'paid',
paid_amount: 1000,
remaining_amount: 0,
total: 1000,
currency: 'SEK',
})
// 1. RPC returns the happy path
enqueue({
data: {
ok: true,
payment_id: 'sip-1',
invoice_status: 'paid',
paid_amount: 1000,
remaining_amount: 0,
payment_amount: 1000,
journal_entry_id: 'je-1',
currency: 'SEK',
},
error: null,
})
// 2. Lightweight invoice re-fetch for the event payload
enqueue({ data: invoice, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit').mockResolvedValue(undefined)
const result = await linkSupplierInvoiceToVoucher(supabase as never, 'user-1', 'company-1', {
supplierInvoiceId: invoice.id,
journalEntryId: 'je-1',
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.result.invoiceStatus).toBe('paid')
expect(result.result.paidAmount).toBe(1000)
expect(result.result.remainingAmount).toBe(0)
expect(result.result.paymentAmount).toBe(1000)
expect(result.result.journalEntryId).toBe('je-1')
expect(result.result.paymentId).toBe('sip-1')
}
expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'supplier_invoice.paid',
payload: expect.objectContaining({ paymentAmount: 1000, userId: 'user-1' }),
}),
)
})
it('still returns success even if the post-link invoice re-fetch is empty (event is best-effort)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
ok: true,
payment_id: 'sip-2',
invoice_status: 'partially_paid',
paid_amount: 400,
remaining_amount: 600,
payment_amount: 400,
journal_entry_id: 'je-1',
currency: 'SEK',
},
error: null,
})
enqueue({ data: null, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit').mockResolvedValue(undefined)
const result = await linkSupplierInvoiceToVoucher(supabase as never, 'user-1', 'company-1', {
supplierInvoiceId: 'si-2',
journalEntryId: 'je-1',
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.result.invoiceStatus).toBe('partially_paid')
expect(result.result.remainingAmount).toBe(600)
}
// Event NOT emitted when re-fetch found nothing
expect(emitSpy).not.toHaveBeenCalled()
})
})
+649
View File
@@ -0,0 +1,649 @@
/**
* Link an existing posted verifikat to a supplier invoice as its payment row.
*
* Mirror of voucher-matching.ts but targets 2440 (Leverantörsskulder) debits
* instead of 151x credits. Used when the GL already contains a verifikat that
* pays down AP — e.g. an SIE-imported payment voucher, a manually entered
* bank-transfer voucher, or any flow where the bookkeeping landed without
* supplier-invoice linkage. No new journal entry is created. Only a
* supplier_invoice_payments row is inserted pointing at the existing
* journal_entry_id, plus the invoice's paid_amount / remaining_amount /
* status are advanced.
*
* Vouchers that book the supplier expense directly without going through 2440
* (e.g. Dr 4010 / Cr 1930 for a non-invoiced purchase) are rejected with
* LINK_SI_VOUCHER_NO_AP_DEBIT. The proper fix for those is a storno+correction
* via gnubok_correct_entry — out of scope for V1.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events/bus'
import { createLogger } from '@/lib/logger'
import {
CONFIDENCE,
amountsMatchExact,
amountsMatchFuzzy,
customerNameMatches,
} from './invoice-matching'
import type { SupplierInvoice, Supplier } from '@/types'
const log = createLogger('supplier-voucher-matching')
/** AP account class. BAS 2026 reserves 2440–2449 for Leverantörsskulder
* (2440 SEK, 2441 utländsk valuta, 2443 Skuldfakturor, 2448 övriga). The
* supplier sub-ledger lives in the supplier_invoices table, not in per-
* supplier accounts. A samlingsverifikat that pays mixed SEK + EUR
* suppliers will legitimately debit both 2440 and 2441 — summing across
* the 244x range catches that. PR #602 Swedish-compliance fix. */
const AP_ACCOUNT_PREFIX = '244'
/** ±90 days from the invoice's due_date as the default search window. */
const DEFAULT_DATE_WINDOW_DAYS = 90
/** Tolerance for floating-point comparisons on monetary amounts (0.5 öre). */
const AMOUNT_TOLERANCE = 0.005
/** Date-proximity bump applied when entry_date is within ±7 days of due_date. */
const DATE_PROXIMITY_BUMP = 0.05
export interface SupplierVoucherCandidate {
journal_entry_id: string
voucher_series: string | null
voucher_number: number | null
entry_date: string
description: string
/** Total debit on the AP account (2440) on this voucher, always positive. */
ap_debit_amount: number
currency: string
/** Currency of the AP-debit line; nullable when the line stores SEK only. */
ap_line_currency: string | null
/** True when the voucher's fiscal period is closed or locked. */
period_locked: boolean
/** Confidence score 0..1 (or 0.99 for OCR match). */
confidence: number
/** Localized reason in Swedish. */
match_reason: string
}
interface JournalEntryLine {
id: string
journal_entry_id: string
account_number: string
debit_amount: number | null
credit_amount: number | null
currency: string | null
}
interface VoucherRow {
id: string
voucher_series: string | null
voucher_number: number | null
entry_date: string
description: string
status: string
source_type: string | null
fiscal_period_id: string
}
interface FiscalPeriodRow {
id: string
status: string
}
interface CandidateContext {
invoice: SupplierInvoice & { supplier?: Supplier }
remainingAmount: number
}
const EXCLUDED_SOURCE_TYPES = ['opening_balance', 'storno']
/**
* Find posted journal entries whose lines debit 2440 and could plausibly be
* the payment for this supplier invoice. Ranking mirrors the customer side:
* exact amount + supplier match wins, then exact, then fuzzy (±1% capped at
* 500 SEK), with a small bump for date proximity to due_date.
*/
export async function findMatchingVouchersForSupplierInvoice(
supabase: SupabaseClient,
companyId: string,
invoice: SupplierInvoice & { supplier?: Supplier },
options: { limit?: number; dateWindowDays?: number } = {},
): Promise<SupplierVoucherCandidate[]> {
const limit = options.limit ?? 10
const windowDays = options.dateWindowDays ?? DEFAULT_DATE_WINDOW_DAYS
const remainingAmount = computeRemaining(invoice)
if (remainingAmount <= AMOUNT_TOLERANCE) return []
const dueDate = new Date(invoice.due_date)
const dateFrom = new Date(dueDate)
dateFrom.setDate(dateFrom.getDate() - windowDays)
const dateTo = new Date(dueDate)
dateTo.setDate(dateTo.getDate() + windowDays)
const { data: lines, error } = await supabase
.from('journal_entry_lines')
.select(
`
id,
journal_entry_id,
account_number,
debit_amount,
credit_amount,
currency,
journal_entries!inner (
id,
voucher_series,
voucher_number,
entry_date,
description,
status,
source_type,
fiscal_period_id,
company_id
)
`,
)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.status', 'posted')
.like('account_number', `${AP_ACCOUNT_PREFIX}%`)
.gt('debit_amount', 0)
.gte('journal_entries.entry_date', dateFrom.toISOString().slice(0, 10))
.lte('journal_entries.entry_date', dateTo.toISOString().slice(0, 10))
.limit(limit * 10)
if (error || !lines) return []
// Sum the AP debit per voucher across multiple 2440 lines (a samlings-
// verifikation paying several supplier invoices in one shot will have one
// 2440 row per supplier).
const byEntry = new Map<
string,
{ entry: VoucherRow; apDebitTotal: number; lineCurrency: string | null }
>()
for (const raw of lines) {
const line = raw as unknown as JournalEntryLine & {
journal_entries: VoucherRow
}
const entry = line.journal_entries
if (!entry) continue
if (EXCLUDED_SOURCE_TYPES.includes(entry.source_type ?? '')) continue
const debit = Number(line.debit_amount ?? 0)
if (debit <= 0) continue
const existing = byEntry.get(entry.id)
if (existing) {
existing.apDebitTotal += debit
} else {
byEntry.set(entry.id, {
entry,
apDebitTotal: debit,
lineCurrency: line.currency,
})
}
}
if (byEntry.size === 0) return []
// Drop entries already fully linked to *this* supplier invoice.
const candidateEntryIds = Array.from(byEntry.keys())
const { data: existingLinks } = await supabase
.from('supplier_invoice_payments')
.select('journal_entry_id')
.eq('company_id', companyId)
.eq('supplier_invoice_id', invoice.id)
.in('journal_entry_id', candidateEntryIds)
const alreadyLinked = new Set(
(existingLinks ?? [])
.map((row) => (row as { journal_entry_id: string | null }).journal_entry_id)
.filter((id): id is string => !!id),
)
for (const id of alreadyLinked) byEntry.delete(id)
if (byEntry.size === 0) return []
// Period-lock flags (informational — linking is allowed in locked periods
// because no JE is mutated).
const periodIds = Array.from(
new Set(Array.from(byEntry.values()).map((v) => v.entry.fiscal_period_id)),
)
const { data: periods } = await supabase
.from('fiscal_periods')
.select('id, status')
.in('id', periodIds)
const lockedPeriods = new Set(
(periods ?? [])
.filter(
(p) =>
(p as FiscalPeriodRow).status === 'closed' ||
(p as FiscalPeriodRow).status === 'locked',
)
.map((p) => (p as FiscalPeriodRow).id),
)
const ctx: CandidateContext = { invoice, remainingAmount }
const candidates: SupplierVoucherCandidate[] = []
for (const { entry, apDebitTotal, lineCurrency } of byEntry.values()) {
const scored = scoreCandidate(entry, apDebitTotal, lineCurrency, ctx)
if (!scored) continue
candidates.push({
journal_entry_id: entry.id,
voucher_series: entry.voucher_series,
voucher_number: entry.voucher_number,
entry_date: entry.entry_date,
description: entry.description,
ap_debit_amount: round2(apDebitTotal),
currency: invoice.currency,
ap_line_currency: lineCurrency,
period_locked: lockedPeriods.has(entry.fiscal_period_id),
confidence: scored.confidence,
match_reason: scored.match_reason,
})
}
candidates.sort(
(a, b) => b.confidence - a.confidence || a.entry_date.localeCompare(b.entry_date),
)
return candidates.slice(0, limit)
}
function scoreCandidate(
entry: VoucherRow,
apDebitTotal: number,
lineCurrency: string | null,
ctx: CandidateContext,
): { confidence: number; match_reason: string } | null {
// OCR-style: invoice number or arrival number appears in the entry description.
const invoiceNumberHit =
ctx.invoice.supplier_invoice_number &&
descriptionMentionsToken(entry.description, ctx.invoice.supplier_invoice_number)
const arrivalHit =
ctx.invoice.arrival_number != null &&
descriptionMentionsToken(entry.description, String(ctx.invoice.arrival_number))
if (invoiceNumberHit || arrivalHit) {
return {
confidence: CONFIDENCE.OCR_REFERENCE_MATCH,
match_reason: invoiceNumberHit
? `Fakturanummer ${ctx.invoice.supplier_invoice_number} omnämnt i verifikatets beskrivning`
: `Ankomstnummer ${ctx.invoice.arrival_number} omnämnt i verifikatets beskrivning`,
}
}
// Currency check — 2440 line currency must match invoice currency (or be
// unset, which we treat as the invoice currency).
const lineCurrencyEffective = lineCurrency ?? ctx.invoice.currency
if (lineCurrencyEffective !== ctx.invoice.currency) {
return null
}
const exactRemaining = amountsMatchExact(apDebitTotal, ctx.remainingAmount)
const exactTotal =
!exactRemaining && amountsMatchExact(apDebitTotal, ctx.invoice.total)
const fuzzyRemaining =
!exactRemaining &&
!exactTotal &&
amountsMatchFuzzy(apDebitTotal, ctx.remainingAmount)
// Supplier name in description — reuse customer-side helper since the logic
// (significant tokens of the counterparty name appearing in free text) is
// identical regardless of AR vs AP.
const supplierMatch = customerNameMatches(
ctx.invoice.supplier?.name,
entry.description,
null,
)
let confidence = 0
let reason = ''
if (exactRemaining && supplierMatch) {
confidence = CONFIDENCE.EXACT_AMOUNT_CUSTOMER
reason = `Exakt belopp (${formatNumber(apDebitTotal)} ${ctx.invoice.currency}) och leverantörsnamn matchar`
} else if (exactRemaining) {
confidence = CONFIDENCE.EXACT_AMOUNT_ONLY
reason = `Exakt belopp (${formatNumber(apDebitTotal)} ${ctx.invoice.currency})`
} else if (exactTotal && supplierMatch) {
confidence = CONFIDENCE.FUZZY_AMOUNT_CUSTOMER
reason = `Fakturans totalbelopp och leverantörsnamn matchar`
} else if (exactTotal) {
confidence = CONFIDENCE.FUZZY_AMOUNT_ONLY + 0.05
reason = `Fakturans totalbelopp matchar`
} else if (fuzzyRemaining && supplierMatch) {
confidence = CONFIDENCE.FUZZY_AMOUNT_CUSTOMER
reason = `Belopp nära (±1%) och leverantörsnamn matchar`
} else if (fuzzyRemaining) {
confidence = CONFIDENCE.FUZZY_AMOUNT_ONLY
reason = `Belopp nära (±1%)`
} else {
return null
}
if (isDateWithinDays(entry.entry_date, ctx.invoice.due_date, 7)) {
confidence = Math.min(CONFIDENCE.OCR_REFERENCE_MATCH - 0.001, confidence + DATE_PROXIMITY_BUMP)
}
return { confidence, match_reason: reason }
}
export type SupplierVoucherLinkErrorCode =
| 'LINK_SI_VOUCHER_INVOICE_NOT_FOUND'
| 'LINK_SI_VOUCHER_VOUCHER_NOT_FOUND'
| 'LINK_SI_VOUCHER_NOT_POSTED'
| 'LINK_SI_VOUCHER_NO_AP_DEBIT'
| 'LINK_SI_VOUCHER_ALREADY_LINKED'
| 'LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING'
| 'LINK_SI_VOUCHER_CURRENCY_MISMATCH'
| 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID'
| 'LINK_SI_VOUCHER_DB_ERROR'
export type ValidateSupplierVoucherResult =
| {
ok: true
apDebitAmount: number
apLineCurrency: string | null
voucher: VoucherRow
remainingAfter: number
isFullyPaid: boolean
paymentAmount: number
}
| {
ok: false
code: SupplierVoucherLinkErrorCode
details?: Record<string, unknown>
}
/**
* Validate that a journal entry can be linked as payment for a supplier
* invoice. Used by both the staging path (MCP tool, future) and the commit
* path (web route + MCP commit handler, future) so the guards stay identical.
*/
export async function validateVoucherForSupplierInvoiceLink(
supabase: SupabaseClient,
companyId: string,
invoice: SupplierInvoice & { supplier?: Supplier },
journalEntryId: string,
): Promise<ValidateSupplierVoucherResult> {
const remainingAmount = computeRemaining(invoice)
if (remainingAmount <= AMOUNT_TOLERANCE) {
return { ok: false, code: 'LINK_SI_VOUCHER_INVOICE_FULLY_PAID' }
}
const { data: voucher, error: voucherError } = await supabase
.from('journal_entries')
.select(
'id, voucher_series, voucher_number, entry_date, description, status, source_type, fiscal_period_id, company_id',
)
.eq('id', journalEntryId)
.eq('company_id', companyId)
.maybeSingle()
if (voucherError || !voucher) {
return { ok: false, code: 'LINK_SI_VOUCHER_VOUCHER_NOT_FOUND' }
}
const v = voucher as VoucherRow & { company_id: string }
if (v.status !== 'posted') {
return { ok: false, code: 'LINK_SI_VOUCHER_NOT_POSTED', details: { status: v.status } }
}
if (EXCLUDED_SOURCE_TYPES.includes(v.source_type ?? '')) {
return {
ok: false,
code: 'LINK_SI_VOUCHER_NO_AP_DEBIT',
details: { source_type: v.source_type },
}
}
const { data: lines, error: linesError } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, currency')
.eq('journal_entry_id', journalEntryId)
if (linesError || !lines || lines.length === 0) {
return { ok: false, code: 'LINK_SI_VOUCHER_NO_AP_DEBIT' }
}
let apDebitTotal = 0
let lineCurrency: string | null = null
for (const raw of lines) {
const line = raw as {
account_number: string
debit_amount: number | null
credit_amount: number | null
currency: string | null
}
if (!line.account_number?.startsWith(AP_ACCOUNT_PREFIX)) continue
const debit = Number(line.debit_amount ?? 0)
if (debit <= 0) continue
apDebitTotal += debit
if (!lineCurrency) lineCurrency = line.currency
}
apDebitTotal = round2(apDebitTotal)
if (apDebitTotal <= 0) {
return { ok: false, code: 'LINK_SI_VOUCHER_NO_AP_DEBIT' }
}
const lineCurrencyEffective = lineCurrency ?? invoice.currency
if (lineCurrencyEffective !== invoice.currency) {
return {
ok: false,
code: 'LINK_SI_VOUCHER_CURRENCY_MISMATCH',
details: {
invoice_currency: invoice.currency,
line_currency: lineCurrencyEffective,
},
}
}
if (apDebitTotal > remainingAmount + AMOUNT_TOLERANCE) {
return {
ok: false,
code: 'LINK_SI_VOUCHER_AMOUNT_EXCEEDS_REMAINING',
details: { ap_debit: apDebitTotal, remaining: round2(remainingAmount) },
}
}
const { data: existingLinks } = await supabase
.from('supplier_invoice_payments')
.select('id')
.eq('company_id', companyId)
.eq('supplier_invoice_id', invoice.id)
.eq('journal_entry_id', journalEntryId)
.limit(1)
if (existingLinks && existingLinks.length > 0) {
return { ok: false, code: 'LINK_SI_VOUCHER_ALREADY_LINKED' }
}
const paymentAmount = Math.min(apDebitTotal, round2(remainingAmount))
const remainingAfter = Math.max(0, round2(remainingAmount - paymentAmount))
const isFullyPaid = remainingAfter <= AMOUNT_TOLERANCE
return {
ok: true,
apDebitAmount: apDebitTotal,
apLineCurrency: lineCurrency,
voucher: v,
remainingAfter,
isFullyPaid,
paymentAmount,
}
}
export interface LinkSupplierInvoiceToVoucherParams {
supplierInvoiceId: string
journalEntryId: string
notes?: string
}
export interface LinkSupplierInvoiceToVoucherResult {
paymentId: string
invoiceStatus: 'paid' | 'partially_paid'
paidAmount: number
remainingAmount: number
paymentAmount: number
journalEntryId: string
}
/**
* Atomically link an existing posted verifikat as payment for a supplier
* invoice. Inserts a supplier_invoice_payments row pointing at the JE, advances
* the invoice's paid_amount / remaining_amount, and emits supplier_invoice.paid
* (reusing the existing event so reminder/automation subscribers fire without
* a new channel).
*
* Re-validates inside the same call to defend against stage→commit drift.
*/
interface RpcLinkOk {
ok: true
payment_id: string
invoice_status: 'paid' | 'partially_paid'
paid_amount: number
remaining_amount: number
payment_amount: number
journal_entry_id: string
currency: string
}
interface RpcLinkErr {
ok: false
code: SupplierVoucherLinkErrorCode
details?: Record<string, unknown>
}
export async function linkSupplierInvoiceToVoucher(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: LinkSupplierInvoiceToVoucherParams,
): Promise<
| { ok: true; result: LinkSupplierInvoiceToVoucherResult }
| { ok: false; code: SupplierVoucherLinkErrorCode; details?: Record<string, unknown> }
> {
// All validation + writes happen inside link_supplier_invoice_to_voucher
// (PL/pgSQL). The function locks the invoice row, validates the voucher,
// and applies UPDATE + INSERT in a single PG transaction so a failure on
// either rolls back automatically. The previous TS implementation did
// UPDATE-then-INSERT with a manual rollback that could overwrite a
// concurrent sibling's successful write — PR #602 review fix.
const { data, error } = await supabase.rpc('link_supplier_invoice_to_voucher', {
p_supplier_invoice_id: params.supplierInvoiceId,
p_journal_entry_id: params.journalEntryId,
p_user_id: userId,
p_company_id: companyId,
p_notes: params.notes ?? null,
})
if (error) {
log.error('link_supplier_invoice_to_voucher RPC error', {
companyId,
userId,
supplierInvoiceId: params.supplierInvoiceId,
journalEntryId: params.journalEntryId,
message: error.message,
})
return {
ok: false,
code: 'LINK_SI_VOUCHER_DB_ERROR',
details: { reason: error.message },
}
}
const result = data as RpcLinkOk | RpcLinkErr | null
if (!result) {
return { ok: false, code: 'LINK_SI_VOUCHER_DB_ERROR', details: { reason: 'empty RPC response' } }
}
if (!result.ok) {
return { ok: false, code: result.code, details: result.details }
}
// Fetch the now-updated invoice for event emission. Lightweight; the RPC
// committed before this read so the row reflects post-link state.
// select('*') is intentional — the supplier_invoice.paid event payload is
// typed as `supplierInvoice: SupplierInvoice` in lib/events/types.ts, so
// narrowing here would either break the subscriber contract or require a
// separate event payload type. The event stays in-process (eventBus is a
// module-level singleton) and any consumer subscribing to this event
// legitimately needs the full invoice context for downstream reminders
// and audit-log routing. PR #602 compliance review note documented.
const { data: invoice } = await supabase
.from('supplier_invoices')
.select('*')
.eq('id', params.supplierInvoiceId)
.eq('company_id', companyId)
.maybeSingle()
if (invoice) {
try {
await eventBus.emit({
type: 'supplier_invoice.paid',
payload: {
supplierInvoice: invoice as SupplierInvoice,
paymentAmount: result.payment_amount,
userId,
companyId,
},
})
} catch (err) {
// Event emission failure must not block the response, but should leave
// an audit trail (ISO 27001:2022 A.8.15 / OWASP V16). Logged at warn
// because the link itself succeeded — the downstream reminder/audit
// subscriber will need separate intervention.
log.warn('supplier_invoice.paid event emission failed', {
err,
supplierInvoiceId: params.supplierInvoiceId,
journalEntryId: params.journalEntryId,
})
}
}
return {
ok: true,
result: {
paymentId: result.payment_id,
invoiceStatus: result.invoice_status,
paidAmount: result.paid_amount,
remainingAmount: result.remaining_amount,
paymentAmount: result.payment_amount,
journalEntryId: result.journal_entry_id,
},
}
}
// ── Helpers ─────────────────────────────────────────────────
function computeRemaining(invoice: SupplierInvoice): number {
// Trust the stored value whenever present, including the legitimate 0 for
// a fully-paid invoice. Falling through to `total - paid_amount` for the
// 0 case can leak rounding drift across multiple payments and return a
// tiny positive number, slipping a fully-paid invoice past
// LINK_SI_VOUCHER_INVOICE_FULLY_PAID. PR #602 review fix.
if (typeof invoice.remaining_amount === 'number') {
return Math.max(0, invoice.remaining_amount)
}
const paid = invoice.paid_amount ?? 0
return Math.max(0, round2(invoice.total - paid))
}
function round2(n: number): number {
return Math.round(n * 100) / 100
}
function isDateWithinDays(a: string, b: string, days: number): boolean {
const ad = new Date(a).getTime()
const bd = new Date(b).getTime()
if (Number.isNaN(ad) || Number.isNaN(bd)) return false
return Math.abs(ad - bd) <= days * 24 * 3600 * 1000
}
function descriptionMentionsToken(description: string | null, token: string): boolean {
if (!description || !token) return false
const normalizedDesc = description.replace(/\s+/g, '').toLowerCase()
const normalizedTok = token.replace(/\s+/g, '').toLowerCase()
if (normalizedTok.length < 2) return false
return normalizedDesc.includes(normalizedTok)
}
function formatNumber(n: number): string {
return new Intl.NumberFormat('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(n)
}