f776e375c5
* 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>
149 lines
5.1 KiB
TypeScript
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([])
|
|
})
|
|
})
|