Files
accounted/lib/payments/__tests__/supplier-payee.test.ts
T
f776e375c5 feat(payments): supplier payment batch schema + pain.001 domain lib (betalfil 1/3) (#1500)
* 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>
2026-08-10 19:53:39 +02:00

148 lines
5.0 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { luhnCheckDigit } from '@/lib/bankgiro/luhn'
import {
formatPayeeLabel,
resolvePaymentReference,
resolveSupplierPayee,
} from '@/lib/payments/supplier-payee'
// Luhn-valid fixtures built from the same check-digit routine the validators use.
const VALID_BG = '5050-1055' // Skatteverket's bankgiro; known Luhn-valid
const VALID_PG = `123456${luhnCheckDigit('123456')}`
const VALID_OCR = `1234567${luhnCheckDigit('1234567')}`
const emptySource = {
bankgiro: null,
plusgiro: null,
bank_account: null,
clearing_number: null,
account_number: null,
}
describe('resolveSupplierPayee', () => {
it('resolves a valid bankgiro first', () => {
const result = resolveSupplierPayee({
...emptySource,
bankgiro: VALID_BG,
plusgiro: VALID_PG,
clearing_number: '3300',
account_number: '1234567',
})
expect(result).toEqual({ ok: true, payee: { type: 'bankgiro', bankgiro: '50501055' } })
})
it('fails on an invalid bankgiro instead of falling through to plusgiro', () => {
const result = resolveSupplierPayee({
...emptySource,
bankgiro: '1234-5678',
plusgiro: VALID_PG,
})
expect(result).toEqual({ ok: false, reason: 'payee_invalid' })
})
it('resolves plusgiro when no bankgiro is set', () => {
const result = resolveSupplierPayee({ ...emptySource, plusgiro: VALID_PG })
expect(result).toEqual({ ok: true, payee: { type: 'plusgiro', plusgiro: VALID_PG } })
})
it('resolves structured clearing + account columns', () => {
const result = resolveSupplierPayee({
...emptySource,
clearing_number: '3300',
account_number: '000123456',
})
expect(result).toEqual({
ok: true,
payee: { type: 'bank_account', clearing: '3300', account: '000123456' },
})
})
it('rejects an invalid clearing in the structured columns', () => {
const result = resolveSupplierPayee({
...emptySource,
clearing_number: '12',
account_number: '1234567',
})
expect(result).toEqual({ ok: false, reason: 'payee_invalid' })
})
it('parses free-text bank_account with an explicit separator', () => {
const result = resolveSupplierPayee({ ...emptySource, bank_account: '3300-123 456 789' })
expect(result).toEqual({
ok: true,
payee: { type: 'bank_account', clearing: '3300', account: '123456789' },
})
})
it('accepts a 5-digit Swedbank clearing in free text', () => {
const result = resolveSupplierPayee({ ...emptySource, bank_account: '83279 123456789' })
expect(result).toEqual({
ok: true,
payee: { type: 'bank_account', clearing: '83279', account: '123456789' },
})
})
it('refuses an ambiguous free-text digit blob', () => {
const result = resolveSupplierPayee({ ...emptySource, bank_account: '83279123456789' })
expect(result).toEqual({ ok: false, reason: 'payee_invalid' })
})
it('reports payee_missing when nothing is set', () => {
expect(resolveSupplierPayee(emptySource)).toEqual({ ok: false, reason: 'payee_missing' })
})
it('treats whitespace-only fields as missing', () => {
expect(resolveSupplierPayee({ ...emptySource, bankgiro: ' ' })).toEqual({
ok: false,
reason: 'payee_missing',
})
})
})
describe('formatPayeeLabel', () => {
it('formats each payee type', () => {
expect(formatPayeeLabel({ type: 'bankgiro', bankgiro: '50501055' })).toBe('BG 5050-1055')
expect(formatPayeeLabel({ type: 'plusgiro', plusgiro: '1234567' })).toBe('PG 123456-7')
expect(formatPayeeLabel({ type: 'bank_account', clearing: '3300', account: '123456789' })).toBe(
'3300 123456789',
)
})
})
describe('resolvePaymentReference', () => {
it('uses a Luhn-valid OCR as a structured reference', () => {
const { reference, ocrInvalid } = resolvePaymentReference({
payment_reference: VALID_OCR,
supplier_invoice_number: 'F-1001',
})
expect(reference).toEqual({ type: 'ocr', value: VALID_OCR })
expect(ocrInvalid).toBe(false)
})
it('strips formatting from the OCR before validating', () => {
const { reference } = resolvePaymentReference({
payment_reference: ` ${VALID_OCR.slice(0, 4)} ${VALID_OCR.slice(4)} `,
supplier_invoice_number: 'F-1001',
})
expect(reference).toEqual({ type: 'ocr', value: VALID_OCR })
})
it('falls back to the invoice number with a warning on an invalid OCR', () => {
const { reference, ocrInvalid } = resolvePaymentReference({
payment_reference: '1234568',
supplier_invoice_number: 'F-1001',
})
expect(reference).toEqual({ type: 'invoice_number', value: 'F-1001' })
expect(ocrInvalid).toBe(true)
})
it('falls back to the invoice number without a warning when no OCR is set', () => {
const { reference, ocrInvalid } = resolvePaymentReference({
payment_reference: null,
supplier_invoice_number: 'F-1001',
})
expect(reference).toEqual({ type: 'invoice_number', value: 'F-1001' })
expect(ocrInvalid).toBe(false)
})
})