* feat(payments): supplier payment batch schema + pain.001 domain lib Betalfil for leverantorsfakturor, part 1 of 3. New tables supplier_payment_batches + supplier_payment_batch_items (RLS, immutable item snapshots, FK RESTRICT on invoices), payee/reference resolution, eligibility rules shared by preview and create, and a supplier-dialect pain.001.001.03 generator (SESBA 9900 BGNR / 9960 BBAN / clearing BBAN, SCOR for Luhn-valid OCR, Ustrd fallback, no SvcLvl/CtgyPurp). Deterministic regeneration: msg_id derives from the batch id, CreDtTm from created_at, so re-downloads are byte-identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(payments): use lib/money helpers instead of raw ore rounding The naive-ore-round ratchet flags new Math.round(x*100)/100 sites; roundOre/sumOre/ORE_TOLERANCE are the sanctioned forms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(payments): classify batch tables in full-archive contract + fixture The no-phantom-columns contract requires every company-scoped table to be triaged in full-archive-export; the batch rows are underlag for the payments they initiated, so they dump with the archive. makeSupplier gains the clearing/account columns the Supplier type now carries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(payments): harden batch integrity per review Composite (id, company_id) FKs so items can never cross-link a batch and an invoice from different companies; BEFORE UPDATE trigger keeps batches immutable outside lifecycle + download metadata and one-way on created -> cancelled; active-batch lookup now fails closed (an error no longer reads as no active batches, which would have silently disabled the duplicate-batch guard); today derives from Europe/Stockholm, not UTC; pain.001 control sums add the amounts as rendered so CtrlSum always equals sum(InstdAmt); event-bus reset in test hooks; Danske LB date claim in DECISIONS verified against the primary page (the bot's 12 May date is the alias-initiation date, not LB retirement). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(payments): bind cancellation metadata to the cancel transition cancelled_at/cancelled_by may only be written by created -> cancelled; cancelled_by may still become NULL so the FK's ON DELETE SET NULL keeps working when the cancelling user's account is deleted (proven in pg). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
144 lines
5.4 KiB
TypeScript
144 lines
5.4 KiB
TypeScript
/**
|
|
* Payee and payment-reference resolution for supplier payment files.
|
|
*
|
|
* A payee is the routing target a payment instruction addresses. Suppliers
|
|
* store their payment details across five columns of mixed quality
|
|
* (bankgiro, plusgiro, structured clearing_number + account_number, and the
|
|
* legacy free-text bank_account); this module is the single place that turns
|
|
* them into a validated, discriminated payee or an explicit failure. Nothing
|
|
* downstream may guess: an ambiguous digit blob must become 'payee_invalid',
|
|
* never a payment to the wrong account.
|
|
*
|
|
* Priority mirrors the suppliers list page (bankgiro before plusgiro before
|
|
* bank account) minus IBAN, which v1 does not pay to (SEK domestic only).
|
|
*/
|
|
|
|
import { validateBankgiroNumber, validatePlusgiroNumber, validateOcrReference } from '@/lib/bankgiro/luhn'
|
|
import { isValidAccount, isValidClearing } from '@/lib/salary/payment/bank-account'
|
|
|
|
export type SupplierPayee =
|
|
| { type: 'bankgiro'; bankgiro: string }
|
|
| { type: 'plusgiro'; plusgiro: string }
|
|
| { type: 'bank_account'; clearing: string; account: string }
|
|
|
|
export type PayeeResolution =
|
|
| { ok: true; payee: SupplierPayee }
|
|
| { ok: false; reason: 'payee_missing' | 'payee_invalid' }
|
|
|
|
export interface SupplierPayeeSource {
|
|
bankgiro: string | null
|
|
plusgiro: string | null
|
|
bank_account: string | null
|
|
clearing_number?: string | null
|
|
account_number?: string | null
|
|
}
|
|
|
|
/**
|
|
* Free-text bank_account values are accepted only when they carry an explicit
|
|
* clearing/account separator: "8327-9 123456789", "3300-1234567". A bare digit
|
|
* blob cannot be split safely (is 83279123456789 clearing 8327 or 8327-9?),
|
|
* so it resolves to payee_invalid.
|
|
*/
|
|
const FREE_TEXT_BANK_ACCOUNT = /^(\d{4}|8\d{4})[-\s]+([\d\s-]{5,15})$/
|
|
|
|
function digits(value: string | null | undefined): string {
|
|
return (value ?? '').replace(/\D/g, '')
|
|
}
|
|
|
|
/**
|
|
* Resolve the payee a supplier payment should be routed to, or an explicit
|
|
* failure. A present-but-invalid value in a higher-priority field fails the
|
|
* resolution rather than falling through: silently paying a supplier's
|
|
* plusgiro because its bankgiro has a typo hides the typo forever.
|
|
*/
|
|
export function resolveSupplierPayee(supplier: SupplierPayeeSource): PayeeResolution {
|
|
if (supplier.bankgiro?.trim()) {
|
|
if (!validateBankgiroNumber(supplier.bankgiro)) return { ok: false, reason: 'payee_invalid' }
|
|
return { ok: true, payee: { type: 'bankgiro', bankgiro: digits(supplier.bankgiro) } }
|
|
}
|
|
|
|
if (supplier.plusgiro?.trim()) {
|
|
if (!validatePlusgiroNumber(supplier.plusgiro)) return { ok: false, reason: 'payee_invalid' }
|
|
return { ok: true, payee: { type: 'plusgiro', plusgiro: digits(supplier.plusgiro) } }
|
|
}
|
|
|
|
const clearing = digits(supplier.clearing_number)
|
|
const account = digits(supplier.account_number)
|
|
if (clearing || account) {
|
|
if (!isValidClearing(clearing) || !isValidAccount(account)) {
|
|
return { ok: false, reason: 'payee_invalid' }
|
|
}
|
|
return { ok: true, payee: { type: 'bank_account', clearing, account } }
|
|
}
|
|
|
|
const freeText = supplier.bank_account?.trim()
|
|
if (freeText) {
|
|
const match = FREE_TEXT_BANK_ACCOUNT.exec(freeText)
|
|
if (!match) return { ok: false, reason: 'payee_invalid' }
|
|
const ftClearing = match[1]
|
|
const ftAccount = digits(match[2])
|
|
if (!isValidClearing(ftClearing) || !isValidAccount(ftAccount)) {
|
|
return { ok: false, reason: 'payee_invalid' }
|
|
}
|
|
return { ok: true, payee: { type: 'bank_account', clearing: ftClearing, account: ftAccount } }
|
|
}
|
|
|
|
return { ok: false, reason: 'payee_missing' }
|
|
}
|
|
|
|
/** Human-readable payee label for previews and batch views: "BG 5050-1055". */
|
|
export function formatPayeeLabel(payee: SupplierPayee): string {
|
|
switch (payee.type) {
|
|
case 'bankgiro': {
|
|
const bg = payee.bankgiro
|
|
return `BG ${bg.slice(0, bg.length - 4)}-${bg.slice(-4)}`
|
|
}
|
|
case 'plusgiro': {
|
|
const pg = payee.plusgiro
|
|
return `PG ${pg.slice(0, pg.length - 1)}-${pg.slice(-1)}`
|
|
}
|
|
case 'bank_account':
|
|
return `${payee.clearing} ${payee.account}`
|
|
}
|
|
}
|
|
|
|
export type PaymentReference =
|
|
| { type: 'ocr'; value: string }
|
|
| { type: 'invoice_number'; value: string }
|
|
|
|
export interface PaymentReferenceSource {
|
|
payment_reference: string | null
|
|
supplier_invoice_number: string
|
|
}
|
|
|
|
/**
|
|
* The reference the receiver uses to match the payment to the invoice.
|
|
*
|
|
* A Luhn-valid payment_reference is a real OCR number and rides the structured
|
|
* rail (pain.001 CdtrRefInf SCOR). Anything else falls back to the supplier's
|
|
* invoice number as a plain message: a mistyped OCR still reaches the supplier
|
|
* as text a human can match, whereas a structured SCOR reference that fails
|
|
* the receiver's OCR check can bounce the whole payment. Callers surface the
|
|
* fallback-on-invalid case as a warning (ocrInvalid) so the typo gets fixed.
|
|
*/
|
|
export function resolvePaymentReference(invoice: PaymentReferenceSource): {
|
|
reference: PaymentReference
|
|
ocrInvalid: boolean
|
|
} {
|
|
const raw = invoice.payment_reference?.trim()
|
|
if (raw) {
|
|
const ocr = digits(raw)
|
|
if (validateOcrReference(ocr)) {
|
|
return { reference: { type: 'ocr', value: ocr }, ocrInvalid: false }
|
|
}
|
|
return {
|
|
reference: { type: 'invoice_number', value: invoice.supplier_invoice_number },
|
|
ocrInvalid: true,
|
|
}
|
|
}
|
|
return {
|
|
reference: { type: 'invoice_number', value: invoice.supplier_invoice_number },
|
|
ocrInvalid: false,
|
|
}
|
|
}
|