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:
co-authored by
Claude Opus 4.7
parent
a586cc8a58
commit
7bcd46d503
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user