Files
accounted/lib/invoices/supplier-voucher-matching.ts
T
Jakob WennbergandClaude Opus 4.8 c74b19df1b Accounted rebrand + swarm-skill cleanup + bank-reconciliation fixes (#643)
* feat(reconciliation): close the bank-feed loop on voucher links and re-tag mis-typed opening balances

Two related fixes to bank reconciliation correctness:

1. Auto-reconcile on voucher link. Linking an invoice or supplier invoice to
   an existing voucher previously advanced only the invoice — the bank
   transaction that paid it kept sitting in the Transactions inbox with a null
   journal_entry_id. linkInvoiceToVoucher / linkSupplierInvoiceToVoucher now
   call autoReconcileTransactionForLinkedVoucher (lib/reconciliation), which
   links the bank transaction to the same verifikat when exactly one unbooked
   line matches it. Best-effort and post-commit: a failure here never fails the
   link. The result surfaces reconciledTransactionId; the inbox row leaves the
   list and the UI shows link_success_tx_reconciled.

2. Re-tag mis-typed opening balances. getReconciliationStatus and the GL-line
   matching RPCs identify a cash account's ingående balans solely by
   journal_entries.source_type='opening_balance'. Companies migrated from other
   systems often booked the bank IB as an ordinary voucher (source_type
   'import' or 'manual'), so it was never excluded and surfaced as a phantom
   reconciliation difference equal to the opening balance. Adds:
   - migration mark_entry_as_opening_balance: a GUC-gated carve-out in the
     immutability trigger plus a SECURITY DEFINER RPC that validates the entry
     (balance-sheet lines only, dated on a fiscal-period boundary), flips the
     source_type, and writes an audit row — no blanket data sweep.
   - POST /api/reconciliation/bank/mark-opening-balance + MarkOpeningBalanceSchema.
   - BankReconciliationView action to trigger it from the IB diff.

The gnubok_create_voucher executor now accepts a typed is_opening_balance flag
and derives source_type='opening_balance' only after validating class 1/2 lines
on the period start, so new IBs land correctly typed.

Covered by lib/reconciliation auto-reconcile tests, voucher-executors tests,
and a mark-entry-as-opening-balance pg-real test.

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

* chore: rebrand gnubok → Accounted and prune swarm agent skills

Product rebrand and skills housekeeping. No runtime behaviour change.

Rebrand: replace user-visible "gnubok" with "Accounted" across docs, READMEs,
in-code comments, doc-site content, MCP skill/resource prose, and the
gnubok-mcp package description. The MCP resource URI scheme is moved gnubok://
→ Accounted:// consistently across resource registrations, the event-type
comment, and the resource/skill tests. Deliberately preserved as stable
identifiers (NOT rebranded): the gnubok-company-id cookie, gnubok_sk_ / gnubok_inv_
token prefixes, the gnubok-mcp npm bridge name, and the AGI <gem:Programnamn>
value (kept 'gnubok' per its source comment — it is the software identifier sent
to Skatteverket and must not churn across visual rebrands).

Skills: remove the 27 swarm-* agent SKILL.md atoms (no longer used; already
absent from the agent_atom_registry in prod), refresh the remaining skill docs,
add the .claude/rules/ path-scoped rule set, and regenerate the
seed_agent_atom_bodies migration + .skill-body-manifest.json via
`npm run skills:generate` so the DB-backed skill bodies match the trimmed set.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:52:01 +02:00

678 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { autoReconcileTransactionForLinkedVoucher } from '@/lib/reconciliation/bank-reconciliation'
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
/** Bank transaction auto-reconciled to the linked voucher, if exactly one
* unbooked line matched it; null when nothing was safely linkable. */
reconciledTransactionId: string | null
}
/**
* 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,
})
}
}
// Close the loop on the bank feed: the link above only advanced the supplier
// invoice, leaving the bank transaction that paid it in the Transactions
// inbox. Reconcile it to the same verifikat when unambiguous. Best-effort —
// the link RPC has already committed.
let reconciledTransactionId: string | null = null
try {
const recon = await autoReconcileTransactionForLinkedVoucher(
supabase,
companyId,
userId,
params.journalEntryId,
{ supplierInvoiceId: params.supplierInvoiceId },
)
reconciledTransactionId = recon?.linkedTransactionId ?? null
} catch (err) {
log.warn('auto-reconcile of bank transaction after supplier voucher link failed (non-blocking)', {
companyId,
supplierInvoiceId: params.supplierInvoiceId,
journalEntryId: params.journalEntryId,
reason: err instanceof Error ? err.message : String(err),
})
}
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,
reconciledTransactionId,
},
}
}
// ── 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)
}