Files
accounted/lib/payments/__tests__/batch-eligibility.test.ts
T
Jakob Wennberg 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

149 lines
5.1 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { luhnCheckDigit } from '@/lib/bankgiro/luhn'
import {
evaluateInvoiceForBatch,
PAYABLE_SUPPLIER_INVOICE_STATUSES,
type BatchInvoiceFacts,
} from '@/lib/payments/batch-eligibility'
const VALID_OCR = `1234567${luhnCheckDigit('1234567')}`
const TODAY = '2026-08-10'
const supplier = {
bankgiro: '5050-1055',
plusgiro: null,
bank_account: null,
clearing_number: null,
account_number: null,
}
function invoice(overrides: Partial<BatchInvoiceFacts> = {}): BatchInvoiceFacts {
return {
id: 'inv-1',
status: 'approved',
approved_at: '2026-08-01T10:00:00Z',
due_date: '2026-08-20',
remaining_amount: 737.5,
currency: 'SEK',
is_credit_note: false,
payment_reference: VALID_OCR,
supplier_invoice_number: 'CD3014794407',
...overrides,
}
}
describe('evaluateInvoiceForBatch', () => {
it('accepts every payable status and rejects the rest', () => {
for (const status of PAYABLE_SUPPLIER_INVOICE_STATUSES) {
const result = evaluateInvoiceForBatch(invoice({ status }), supplier, { today: TODAY })
expect(result.eligible, status).toBe(true)
}
for (const status of ['paid', 'disputed', 'credited', 'reversed']) {
const result = evaluateInvoiceForBatch(invoice({ status }), supplier, { today: TODAY })
expect(result).toEqual({ eligible: false, reason: 'not_payable' })
}
})
it('rejects credit notes before anything else', () => {
const result = evaluateInvoiceForBatch(
invoice({ is_credit_note: true, status: 'paid' }),
supplier,
{ today: TODAY },
)
expect(result).toEqual({ eligible: false, reason: 'credit_note' })
})
it('rejects a settled remainder at the epsilon boundary', () => {
const atEpsilon = evaluateInvoiceForBatch(invoice({ remaining_amount: 0.005 }), supplier, {
today: TODAY,
})
expect(atEpsilon).toEqual({ eligible: false, reason: 'nothing_remaining' })
const justAbove = evaluateInvoiceForBatch(invoice({ remaining_amount: 0.01 }), supplier, {
today: TODAY,
})
expect(justAbove.eligible).toBe(true)
})
it('rejects foreign currency', () => {
const result = evaluateInvoiceForBatch(invoice({ currency: 'EUR' }), supplier, { today: TODAY })
expect(result).toEqual({ eligible: false, reason: 'foreign_currency' })
})
it('rejects a supplier without payment details', () => {
const result = evaluateInvoiceForBatch(
invoice(),
{ bankgiro: null, plusgiro: null, bank_account: null },
{ today: TODAY },
)
expect(result).toEqual({ eligible: false, reason: 'payee_missing' })
})
it('rejects a supplier with an invalid bankgiro', () => {
const result = evaluateInvoiceForBatch(
invoice(),
{ ...supplier, bankgiro: '1234-5678' },
{ today: TODAY },
)
expect(result).toEqual({ eligible: false, reason: 'payee_invalid' })
})
it('defaults the payment date to the due date when it is in the future', () => {
const result = evaluateInvoiceForBatch(invoice({ due_date: '2026-08-20' }), supplier, {
today: TODAY,
})
expect(result.eligible && result.defaults.payment_date).toBe('2026-08-20')
})
it('defaults the payment date to today when the due date has passed', () => {
const result = evaluateInvoiceForBatch(
invoice({ due_date: '2026-07-07', status: 'overdue' }),
supplier,
{ today: TODAY },
)
expect(result.eligible && result.defaults.payment_date).toBe(TODAY)
})
it('defaults the amount to the rounded remaining amount', () => {
const result = evaluateInvoiceForBatch(invoice({ remaining_amount: 199.291 }), supplier, {
today: TODAY,
})
expect(result.eligible && result.defaults.amount).toBe(199.29)
})
it('warns on an un-attested invoice instead of blocking it', () => {
const result = evaluateInvoiceForBatch(
invoice({ status: 'registered', approved_at: null }),
supplier,
{ today: TODAY },
)
expect(result.eligible && result.warnings).toContain('unattested')
})
it('warns when the invoice already sits in an active batch', () => {
const result = evaluateInvoiceForBatch(invoice(), supplier, {
today: TODAY,
activeBatchIdByInvoice: new Map([['inv-1', 'batch-9']]),
})
expect(result.eligible && result.warnings).toContain('already_batched')
expect(result.eligible && result.activeBatchId).toBe('batch-9')
})
it('warns on an invalid OCR and falls back to the invoice number', () => {
const result = evaluateInvoiceForBatch(invoice({ payment_reference: '1234568' }), supplier, {
today: TODAY,
})
expect(result.eligible && result.warnings).toContain('ocr_invalid')
expect(result.eligible && result.reference).toEqual({
type: 'invoice_number',
value: 'CD3014794407',
})
})
it('carries a clean OCR through as the structured reference', () => {
const result = evaluateInvoiceForBatch(invoice(), supplier, { today: TODAY })
expect(result.eligible && result.reference).toEqual({ type: 'ocr', value: VALID_OCR })
expect(result.eligible && result.warnings).toEqual([])
})
})