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>
This commit is contained in:
Jakob Wennberg
2026-08-10 19:53:39 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent f0ce322a7c
commit f776e375c5
14 changed files with 2420 additions and 0 deletions
@@ -0,0 +1,148 @@
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([])
})
})
@@ -0,0 +1,413 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
createSupplierPaymentBatch,
previewSupplierPaymentBatch,
renderSupplierPaymentBatchFile,
} from '@/lib/payments/batch-service'
import type { SupplierPaymentBatch, SupplierPaymentBatchItem } from '@/types'
const COMPANY_ID = 'c0000000-0000-0000-0000-000000000001'
const USER_ID = 'u0000000-0000-0000-0000-000000000001'
const companyRow = { name: 'Testbolaget AB', org_number: '556677-8899' }
const settingsRow = {
company_name: 'Testbolaget AB',
iban: 'SE3550000000054910000003',
bic: 'ESSESESS',
clearing_number: null,
bank_name: null,
}
function invoiceRow(overrides: Record<string, unknown> = {}) {
return {
id: 'inv-1',
status: 'approved',
approved_at: '2026-08-01T10:00:00Z',
due_date: '2099-08-20',
remaining_amount: 737.5,
currency: 'SEK',
is_credit_note: false,
payment_reference: null,
supplier_invoice_number: 'CD3014794407',
supplier: {
id: 'sup-1',
name: 'Derome Bygg & Industri AB',
bankgiro: '5050-1055',
plusgiro: null,
bank_account: null,
clearing_number: null,
account_number: null,
},
...overrides,
}
}
function batchRow(overrides: Record<string, unknown> = {}): SupplierPaymentBatch {
return {
id: 'b0000000-0000-0000-0000-000000000001',
company_id: COMPANY_ID,
user_id: USER_ID,
format: 'pain001',
status: 'created',
currency: 'SEK',
total_amount: 737.5,
item_count: 1,
msg_id: 'ACCOUNTED-5566778899-BB0000000',
debtor_snapshot: {
name: 'Testbolaget AB',
org_number: '556677-8899',
iban: 'SE3550000000054910000003',
bic: 'ESSESESS',
},
file_generated_at: null,
download_count: 0,
cancelled_at: null,
cancelled_by: null,
created_at: '2026-08-10T12:00:00Z',
updated_at: '2026-08-10T12:00:00Z',
...overrides,
} as SupplierPaymentBatch
}
describe('previewSupplierPaymentBatch', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
it('returns eligible lines with payee, reference and totals', async () => {
const mock = createQueuedMockSupabase()
// from() order: supplier_invoices, batch items (active map), companies, settings
mock.enqueueMany([
{ data: [invoiceRow()] },
{ data: [] },
{ data: companyRow },
{ data: settingsRow },
])
const preview = await previewSupplierPaymentBatch(
mock.supabase as unknown as SupabaseClient,
COMPANY_ID,
{ ids: ['inv-1'] },
)
expect(preview.excluded).toEqual([])
expect(preview.eligible).toHaveLength(1)
expect(preview.eligible[0]).toMatchObject({
id: 'inv-1',
supplier_name: 'Derome Bygg & Industri AB',
amount: 737.5,
payment_date: '2099-08-20',
payee: { type: 'bankgiro', label: 'BG 5050-1055' },
reference: { type: 'invoice_number', value: 'CD3014794407' },
warnings: [],
})
expect(preview.total).toBe(737.5)
expect(preview.debtor_ok).toBe(true)
})
it('excludes ineligible invoices with a reason and unknown ids as not_found', async () => {
const mock = createQueuedMockSupabase()
mock.enqueueMany([
{ data: [invoiceRow({ status: 'paid' })] },
{ data: [] },
{ data: companyRow },
{ data: settingsRow },
])
const preview = await previewSupplierPaymentBatch(
mock.supabase as unknown as SupabaseClient,
COMPANY_ID,
{ ids: ['inv-1', 'inv-ghost'] },
)
expect(preview.eligible).toEqual([])
expect(preview.excluded).toEqual([
{ id: 'inv-1', reason: 'not_payable' },
{ id: 'inv-ghost', reason: 'not_found' },
])
})
it('fails closed when the active-batch lookup errors', async () => {
const mock = createQueuedMockSupabase()
mock.enqueueMany([
{ data: [invoiceRow()] },
{ error: { message: 'relation missing' } },
{ data: companyRow },
{ data: settingsRow },
])
await expect(
previewSupplierPaymentBatch(mock.supabase as unknown as SupabaseClient, COMPANY_ID, {
ids: ['inv-1'],
}),
).rejects.toBeTruthy()
})
it('reports a missing debtor IBAN without blocking the preview', async () => {
const mock = createQueuedMockSupabase()
mock.enqueueMany([
{ data: [invoiceRow()] },
{ data: [] },
{ data: companyRow },
{ data: { ...settingsRow, iban: null } },
])
const preview = await previewSupplierPaymentBatch(
mock.supabase as unknown as SupabaseClient,
COMPANY_ID,
{ ids: ['inv-1'] },
)
expect(preview.debtor_ok).toBe(false)
expect(preview.debtor_missing).toBe('iban')
expect(preview.eligible).toHaveLength(1)
})
})
describe('createSupplierPaymentBatch', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
// from() order for create: companies, settings (debtor first), then
// supplier_invoices, batch items, then the two inserts.
it('creates a batch with snapshotted payee rows and a derived msg_id', async () => {
const mock = createQueuedMockSupabase()
mock.enqueueMany([
{ data: companyRow },
{ data: settingsRow },
{ data: [invoiceRow()] },
{ data: [] },
{ data: batchRow() },
{ data: null },
])
const result = await createSupplierPaymentBatch(
mock.supabase as unknown as SupabaseClient,
COMPANY_ID,
USER_ID,
{ format: 'pain001', items: [{ supplier_invoice_id: 'inv-1' }] },
)
expect(result.ok).toBe(true)
const batchInsert = mock.findCall('supplier_payment_batches', 'insert')?.[0] as Record<
string,
unknown
>
expect(batchInsert).toMatchObject({
company_id: COMPANY_ID,
user_id: USER_ID,
format: 'pain001',
status: 'created',
currency: 'SEK',
total_amount: 737.5,
item_count: 1,
debtor_snapshot: {
name: 'Testbolaget AB',
org_number: '556677-8899',
iban: 'SE3550000000054910000003',
bic: 'ESSESESS',
},
})
const msgId = batchInsert.msg_id as string
expect(msgId.startsWith('ACCOUNTED-5566778899-B')).toBe(true)
expect(msgId.length).toBeLessThanOrEqual(35)
const itemsInsert = mock.findCall('supplier_payment_batch_items', 'insert')?.[0] as Array<
Record<string, unknown>
>
expect(itemsInsert).toHaveLength(1)
expect(itemsInsert[0]).toMatchObject({
batch_id: batchInsert.id,
company_id: COMPANY_ID,
supplier_invoice_id: 'inv-1',
amount: 737.5,
payment_date: '2099-08-20',
payee_type: 'bankgiro',
payee_bankgiro: '50501055',
payee_name: 'Derome Bygg & Industri AB',
reference_type: 'invoice_number',
reference: 'CD3014794407',
})
})
it('rejects the whole batch when any invoice is ineligible', async () => {
const mock = createQueuedMockSupabase()
mock.enqueueMany([
{ data: companyRow },
{ data: settingsRow },
{ data: [invoiceRow(), invoiceRow({ id: 'inv-2', currency: 'EUR' })] },
{ data: [] },
])
const result = await createSupplierPaymentBatch(
mock.supabase as unknown as SupabaseClient,
COMPANY_ID,
USER_ID,
{
format: 'pain001',
items: [{ supplier_invoice_id: 'inv-1' }, { supplier_invoice_id: 'inv-2' }],
},
)
expect(result).toEqual({
ok: false,
code: 'ineligible',
details: [{ id: 'inv-2', reason: 'foreign_currency' }],
})
expect(mock.findCall('supplier_payment_batches', 'insert')).toBeUndefined()
})
it('rejects an amount override above the remaining amount', async () => {
const mock = createQueuedMockSupabase()
mock.enqueueMany([
{ data: companyRow },
{ data: settingsRow },
{ data: [invoiceRow()] },
{ data: [] },
])
const result = await createSupplierPaymentBatch(
mock.supabase as unknown as SupabaseClient,
COMPANY_ID,
USER_ID,
{ format: 'pain001', items: [{ supplier_invoice_id: 'inv-1', amount: 800 }] },
)
expect(result).toEqual({
ok: false,
code: 'amount_exceeds_remaining',
details: [{ id: 'inv-1' }],
})
})
it('refuses an invoice already in an active batch unless confirmed', async () => {
const mock = createQueuedMockSupabase()
const activeItems = [
{ supplier_invoice_id: 'inv-1', batch: { id: 'batch-9', status: 'created' } },
]
mock.enqueueMany([
{ data: companyRow },
{ data: settingsRow },
{ data: [invoiceRow()] },
{ data: activeItems },
])
const result = await createSupplierPaymentBatch(
mock.supabase as unknown as SupabaseClient,
COMPANY_ID,
USER_ID,
{ format: 'pain001', items: [{ supplier_invoice_id: 'inv-1' }] },
)
expect(result).toEqual({
ok: false,
code: 'already_batched',
details: [{ id: 'inv-1', batch_id: 'batch-9' }],
})
})
it('proceeds past an active batch when explicitly confirmed', async () => {
const mock = createQueuedMockSupabase()
const activeItems = [
{ supplier_invoice_id: 'inv-1', batch: { id: 'batch-9', status: 'created' } },
]
mock.enqueueMany([
{ data: companyRow },
{ data: settingsRow },
{ data: [invoiceRow()] },
{ data: activeItems },
{ data: batchRow() },
{ data: null },
])
const result = await createSupplierPaymentBatch(
mock.supabase as unknown as SupabaseClient,
COMPANY_ID,
USER_ID,
{
format: 'pain001',
items: [{ supplier_invoice_id: 'inv-1' }],
confirm_already_batched: true,
},
)
expect(result.ok).toBe(true)
})
it('fails closed when the active-batch lookup errors instead of skipping the guard', async () => {
const mock = createQueuedMockSupabase()
mock.enqueueMany([
{ data: companyRow },
{ data: settingsRow },
{ data: [invoiceRow()] },
{ error: { message: 'relation missing' } },
])
await expect(
createSupplierPaymentBatch(mock.supabase as unknown as SupabaseClient, COMPANY_ID, USER_ID, {
format: 'pain001',
items: [{ supplier_invoice_id: 'inv-1' }],
}),
).rejects.toBeTruthy()
expect(mock.findCall('supplier_payment_batches', 'insert')).toBeUndefined()
})
it('fails up front when the debtor is incomplete', async () => {
const mock = createQueuedMockSupabase()
mock.enqueueMany([{ data: companyRow }, { data: { ...settingsRow, iban: null } }])
const result = await createSupplierPaymentBatch(
mock.supabase as unknown as SupabaseClient,
COMPANY_ID,
USER_ID,
{ format: 'pain001', items: [{ supplier_invoice_id: 'inv-1' }] },
)
expect(result).toEqual({ ok: false, code: 'debtor_incomplete', missing: 'iban' })
})
})
describe('renderSupplierPaymentBatchFile', () => {
it('renders a deterministic pain.001 file from stored rows alone', () => {
const batch = batchRow()
const items: SupplierPaymentBatchItem[] = [
{
id: 'item-1',
batch_id: batch.id,
company_id: COMPANY_ID,
supplier_invoice_id: 'inv-1',
amount: 737.5,
payment_date: '2026-08-15',
payee_type: 'bankgiro',
payee_bankgiro: '50501055',
payee_plusgiro: null,
payee_clearing: null,
payee_account: null,
payee_name: 'Derome Bygg & Industri AB',
reference_type: 'invoice_number',
reference: 'CD3014794407',
created_at: '2026-08-10T12:00:00Z',
},
]
const first = renderSupplierPaymentBatchFile(batch, items)
const second = renderSupplierPaymentBatchFile(batch, items)
expect(first.content).toBe(second.content)
expect(first.content).toContain(`<MsgId>${batch.msg_id}</MsgId>`)
expect(first.content).toContain('<CreDtTm>2026-08-10T12:00:00Z</CreDtTm>')
expect(first.contentType).toBe('application/xml; charset=utf-8')
expect(first.filename).toBe('betalfil_20260810_b0000000.xml')
})
it('refuses a format the renderer does not support', () => {
expect(() => renderSupplierPaymentBatchFile(batchRow({ format: 'bg_lb' }), [])).toThrow()
})
})
@@ -0,0 +1,172 @@
import { describe, expect, it } from 'vitest'
import {
generateSupplierPain001,
type SupplierPain001Payment,
} from '@/lib/payments/pain001-supplier'
const debtor = {
name: 'Testbolaget AB',
orgNumber: '556677-8899',
iban: 'SE3550000000054910000003',
bic: 'ESSESESS',
}
const options = {
messageId: 'ACCOUNTED-5566778899-B1A2B3C4D',
createdAt: '2026-08-10T12:34:56.789Z',
}
function bgPayment(overrides: Partial<SupplierPain001Payment> = {}): SupplierPain001Payment {
return {
payee: { type: 'bankgiro', bankgiro: '50501055' },
payeeName: 'Derome Bygg & Industri AB',
amount: 737.5,
paymentDate: '2026-08-15',
reference: { type: 'ocr', value: '12345678' },
...overrides,
}
}
describe('generateSupplierPain001', () => {
it('is byte-identical for identical inputs (deterministic regeneration)', () => {
const a = generateSupplierPain001(debtor, [bgPayment()], options)
const b = generateSupplierPain001(debtor, [bgPayment()], options)
expect(a).toBe(b)
})
it('derives CreDtTm from createdAt, never the clock', () => {
const xml = generateSupplierPain001(debtor, [bgPayment()], options)
expect(xml).toContain('<CreDtTm>2026-08-10T12:34:56Z</CreDtTm>')
})
it('carries no SvcLvl and no CtgyPurp (domestic NURG supplier transfer)', () => {
const xml = generateSupplierPain001(debtor, [bgPayment()], options)
expect(xml).not.toContain('<SvcLvl>')
expect(xml).not.toContain('<CtgyPurp>')
})
it('addresses a bankgiro payee through Bankgirot (SESBA 9900, BGNR proprietary scheme)', () => {
const xml = generateSupplierPain001(debtor, [bgPayment()], options)
expect(xml).toContain('<ClrSysId><Cd>SESBA</Cd></ClrSysId>')
expect(xml).toContain('<MmbId>9900</MmbId>')
expect(xml).toContain('<Id>50501055</Id>')
expect(xml).toContain('<SchmeNm><Prtry>BGNR</Prtry></SchmeNm>')
})
it('addresses a plusgiro payee via SESBA 9960 with BBAN scheme', () => {
const xml = generateSupplierPain001(
debtor,
[bgPayment({ payee: { type: 'plusgiro', plusgiro: '1234567' } })],
options,
)
expect(xml).toContain('<MmbId>9960</MmbId>')
expect(xml).toContain('<Id>1234567</Id>')
expect(xml).toContain('<SchmeNm><Cd>BBAN</Cd></SchmeNm>')
})
it('addresses a bank-account payee via its clearing with the salary routing rules', () => {
const xml = generateSupplierPain001(
debtor,
[
bgPayment({
// Swedbank 5-digit clearing: 5th digit shifts into the account.
payee: { type: 'bank_account', clearing: '83279', account: '123456789' },
}),
],
options,
)
expect(xml).toContain('<MmbId>8327</MmbId>')
expect(xml).toContain('<Id>9123456789</Id>')
expect(xml).toContain('<SchmeNm><Cd>BBAN</Cd></SchmeNm>')
})
it('renders exactly one structured SCOR reference for an OCR payment', () => {
const xml = generateSupplierPain001(debtor, [bgPayment()], options)
expect(xml.match(/<CdtrRefInf>/g)).toHaveLength(1)
expect(xml).toContain('<CdOrPrtry><Cd>SCOR</Cd></CdOrPrtry>')
expect(xml).toContain('<Ref>12345678</Ref>')
expect(xml).not.toContain('<Ustrd>')
})
it('renders an invoice-number reference as unstructured text, truncated to 25 chars', () => {
const xml = generateSupplierPain001(
debtor,
[
bgPayment({
reference: { type: 'invoice_number', value: 'F-12345678901234567890123456789' },
}),
],
options,
)
expect(xml).toContain('<Ustrd>F-12345678901234567890123</Ustrd>')
expect(xml).not.toContain('<Strd>')
})
it('groups payments into one PmtInf per distinct execution date, dates ascending', () => {
const xml = generateSupplierPain001(
debtor,
[
bgPayment({ paymentDate: '2026-08-20', amount: 100 }),
bgPayment({ paymentDate: '2026-08-15', amount: 200 }),
bgPayment({ paymentDate: '2026-08-20', amount: 300 }),
],
options,
)
expect(xml.match(/<PmtInf>/g)).toHaveLength(2)
const first15 = xml.indexOf('<ReqdExctnDt>2026-08-15</ReqdExctnDt>')
const first20 = xml.indexOf('<ReqdExctnDt>2026-08-20</ReqdExctnDt>')
expect(first15).toBeGreaterThan(-1)
expect(first20).toBeGreaterThan(first15)
// Group control sums: 200.00 for the 15th, 400.00 for the 20th, 600.00 total.
expect(xml).toContain('<CtrlSum>600.00</CtrlSum>')
expect(xml).toContain('<CtrlSum>200.00</CtrlSum>')
expect(xml).toContain('<CtrlSum>400.00</CtrlSum>')
// The tx counter runs across groups so ids stay unique file-wide.
expect(xml).toContain('-TX0001')
expect(xml).toContain('-TX0002')
expect(xml).toContain('-TX0003')
expect(xml.match(/<PmtInfId>[^<]*-P1<\/PmtInfId>/)).not.toBeNull()
expect(xml.match(/<PmtInfId>[^<]*-P2<\/PmtInfId>/)).not.toBeNull()
})
it('escapes XML special characters in names', () => {
const xml = generateSupplierPain001(debtor, [bgPayment()], options)
expect(xml).toContain('<Nm>Derome Bygg &amp; Industri AB</Nm>')
})
it('keeps all ids within Max35Text with the suffix intact', () => {
const xml = generateSupplierPain001(
debtor,
[bgPayment()],
{ ...options, messageId: 'X'.repeat(60) },
)
const ids = [...xml.matchAll(/<(MsgId|PmtInfId|InstrId|EndToEndId)>([^<]+)<\/\1>/g)].map(
(m) => m[2],
)
expect(ids.length).toBeGreaterThan(0)
for (const id of ids) expect(id.length).toBeLessThanOrEqual(35)
expect(xml).toContain('-TX0001</InstrId>')
})
it('formats amounts with two decimals and a dot separator', () => {
const xml = generateSupplierPain001(debtor, [bgPayment({ amount: 199.291 })], options)
expect(xml).toContain('<InstdAmt Ccy="SEK">199.29</InstdAmt>')
})
it('control sums equal the sum of the rendered amounts, not the raw floats', () => {
// Raw floats: 0.014 + 0.014 = 0.028 -> rounded once = 0.03, but each
// InstdAmt renders as 0.01. Banks reject CtrlSum != sum(InstdAmt).
const xml = generateSupplierPain001(
debtor,
[bgPayment({ amount: 0.014 }), bgPayment({ amount: 0.014 })],
options,
)
expect(xml.match(/<InstdAmt Ccy="SEK">0\.01<\/InstdAmt>/g)).toHaveLength(2)
expect(xml).toContain('<CtrlSum>0.02</CtrlSum>')
expect(xml).not.toContain('<CtrlSum>0.03</CtrlSum>')
})
it('refuses an empty batch', () => {
expect(() => generateSupplierPain001(debtor, [], options)).toThrow()
})
})
@@ -0,0 +1,147 @@
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)
})
})
+115
View File
@@ -0,0 +1,115 @@
/**
* Eligibility rules for including a supplier invoice in a payment batch.
*
* Single source of truth used by BOTH the preview route and the create route:
* create re-evaluates every invoice against the same rules, so a row that
* changed between preview and create (paid meanwhile, due date moved, supplier
* details edited) is rejected instead of silently paid on stale terms.
*
* Warnings never block; exclusions always do. An un-attested invoice is a
* warning (mark-paid pays registered invoices today, and sjalvbokforare have
* no attest step), while a missing payee is an exclusion (there is nothing to
* route the payment to).
*/
import { ORE_TOLERANCE, roundOre } from '@/lib/money'
import {
resolvePaymentReference,
resolveSupplierPayee,
type PaymentReference,
type SupplierPayee,
type SupplierPayeeSource,
} from './supplier-payee'
/**
* Statuses a payment instruction may be created for: exactly the set the
* mark-paid route accepts, so the batch can never contain an invoice the
* settlement path would refuse.
*/
export const PAYABLE_SUPPLIER_INVOICE_STATUSES = [
'registered',
'approved',
'partially_paid',
'overdue',
] as const
export type BatchExclusionReason =
| 'not_payable'
| 'nothing_remaining'
| 'credit_note'
| 'foreign_currency'
| 'payee_missing'
| 'payee_invalid'
export type BatchItemWarning = 'unattested' | 'already_batched' | 'ocr_invalid'
export interface BatchInvoiceFacts {
id: string
status: string
approved_at: string | null
due_date: string
remaining_amount: number
currency: string
is_credit_note: boolean
payment_reference: string | null
supplier_invoice_number: string
}
export interface BatchEvaluationOptions {
/** ISO yyyy-MM-dd. Passed in so preview and create agree within a request. */
today: string
/** invoice id -> active (created, not cancelled) batch id it already sits in. */
activeBatchIdByInvoice?: ReadonlyMap<string, string>
}
export type BatchInvoiceEvaluation =
| {
eligible: true
defaults: { amount: number; payment_date: string }
payee: SupplierPayee
reference: PaymentReference
warnings: BatchItemWarning[]
activeBatchId: string | null
}
| { eligible: false; reason: BatchExclusionReason }
export function evaluateInvoiceForBatch(
invoice: BatchInvoiceFacts,
supplier: SupplierPayeeSource,
options: BatchEvaluationOptions,
): BatchInvoiceEvaluation {
if (invoice.is_credit_note) return { eligible: false, reason: 'credit_note' }
if (!(PAYABLE_SUPPLIER_INVOICE_STATUSES as readonly string[]).includes(invoice.status)) {
return { eligible: false, reason: 'not_payable' }
}
if (invoice.remaining_amount <= ORE_TOLERANCE) {
return { eligible: false, reason: 'nothing_remaining' }
}
if (invoice.currency !== 'SEK') return { eligible: false, reason: 'foreign_currency' }
const resolution = resolveSupplierPayee(supplier)
if (!resolution.ok) return { eligible: false, reason: resolution.reason }
const { reference, ocrInvalid } = resolvePaymentReference(invoice)
const warnings: BatchItemWarning[] = []
if (!invoice.approved_at) warnings.push('unattested')
const activeBatchId = options.activeBatchIdByInvoice?.get(invoice.id) ?? null
if (activeBatchId) warnings.push('already_batched')
if (ocrInvalid) warnings.push('ocr_invalid')
return {
eligible: true,
defaults: {
amount: roundOre(invoice.remaining_amount),
// A due date in the future is honored; a passed one pays as soon as the
// bank can execute.
payment_date: invoice.due_date > options.today ? invoice.due_date : options.today,
},
payee: resolution.payee,
reference,
warnings,
activeBatchId,
}
}
+409
View File
@@ -0,0 +1,409 @@
/**
* Supplier payment batch orchestration: preview, create, render.
*
* A batch is an immutable snapshot of payment instructions. Preview and create
* share evaluateInvoiceForBatch so nothing can be created that the preview
* would not have shown; create re-reads and re-evaluates every invoice so a
* row that changed since the preview (settled meanwhile, supplier edited) is
* rejected rather than paid on stale terms.
*
* The file is rendered deterministically from the stored batch + item rows
* alone: msg_id and created_at are fixed at creation, so every download of a
* batch is byte-identical and bank-side duplicate detection (keyed on MsgId)
* works. Generating or downloading a file books nothing and settles nothing.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { getBranding } from '@/lib/branding/service'
import { getSwedishLocalDate } from '@/lib/bookkeeping/engine'
import { ORE_TOLERANCE, roundOre, sumOre } from '@/lib/money'
import {
lookupBicByClearing,
lookupBicByBankName,
normalizeBankNumber,
} from '@/lib/salary/payment/bank-account'
import {
evaluateInvoiceForBatch,
type BatchExclusionReason,
type BatchInvoiceFacts,
type BatchItemWarning,
} from './batch-eligibility'
import { formatPayeeLabel, type SupplierPayeeSource } from './supplier-payee'
import { generateSupplierPain001, type SupplierPain001Payment } from './pain001-supplier'
import type { SupplierPaymentBatch, SupplierPaymentBatchItem } from '@/types'
type InvoiceRow = BatchInvoiceFacts & {
supplier: (SupplierPayeeSource & { id: string; name: string }) | null
}
const INVOICE_SELECT =
'id, status, approved_at, due_date, remaining_amount, currency, is_credit_note, ' +
'payment_reference, supplier_invoice_number, ' +
'supplier:suppliers(id, name, bankgiro, plusgiro, bank_account, clearing_number, account_number)'
export interface BatchDebtor {
name: string
org_number: string
iban: string
bic: string
}
export type DebtorResolution =
| { ok: true; debtor: BatchDebtor }
| { ok: false; missing: 'iban' | 'bic' }
/**
* Resolve the paying company (pain.001 debtor) from settings, mirroring the
* salary pain001 route: saved BIC first, then derivation from the clearing
* number or bank name the company already entered, so most users only ever
* fill in the IBAN.
*/
export async function resolveBatchDebtor(
supabase: SupabaseClient,
companyId: string,
): Promise<DebtorResolution> {
const [{ data: company }, { data: settings }] = await Promise.all([
supabase.from('companies').select('name, org_number').eq('id', companyId).single(),
supabase
.from('company_settings')
.select('company_name, iban, bic, clearing_number, bank_name')
.eq('company_id', companyId)
.single(),
])
const iban = (settings?.iban ?? '').replace(/\s/g, '').toUpperCase()
if (!iban) return { ok: false, missing: 'iban' }
const bic =
settings?.bic?.trim() ||
lookupBicByClearing(normalizeBankNumber(settings?.clearing_number)) ||
lookupBicByBankName(settings?.bank_name)
if (!bic) return { ok: false, missing: 'bic' }
return {
ok: true,
debtor: {
name: settings?.company_name || company?.name || '',
org_number: company?.org_number || '',
iban,
bic,
},
}
}
/**
* invoice id -> id of the active (created) batch it already sits in.
*
* Fails CLOSED: a lookup error must abort the caller, because treating it as
* "no active batches" would silently disable the duplicate-batch guard and
* let a second payable file be created without confirm_already_batched.
*/
export async function loadActiveBatchMap(
supabase: SupabaseClient,
companyId: string,
): Promise<Map<string, string>> {
const { data, error } = await supabase
.from('supplier_payment_batch_items')
.select('supplier_invoice_id, batch:supplier_payment_batches!inner(id, status)')
.eq('company_id', companyId)
.eq('batch.status', 'created')
if (error) throw error
const map = new Map<string, string>()
for (const row of data ?? []) {
const batch = row.batch as unknown as { id: string }
if (!map.has(row.supplier_invoice_id)) map.set(row.supplier_invoice_id, batch.id)
}
return map
}
export interface BatchPreviewLine {
id: string
supplier_name: string
invoice_number: string
amount: number
payment_date: string
payee: { type: string; label: string }
reference: { type: 'ocr' | 'invoice_number'; value: string }
warnings: BatchItemWarning[]
active_batch_id: string | null
}
export interface BatchPreview {
eligible: BatchPreviewLine[]
excluded: Array<{ id: string; reason: BatchExclusionReason | 'not_found' }>
total: number
debtor_ok: boolean
debtor_missing?: 'iban' | 'bic'
}
export async function previewSupplierPaymentBatch(
supabase: SupabaseClient,
companyId: string,
input: { ids: string[] },
): Promise<BatchPreview> {
// Swedish calendar date, not UTC: between 00:00 and 01:59 Swedish summer
// time a UTC slice is still yesterday, and "pay today" would produce an
// execution date the bank rejects as passed.
const today = getSwedishLocalDate()
const [{ data: invoices }, activeBatchIdByInvoice, debtorResolution] = await Promise.all([
supabase
.from('supplier_invoices')
.select(INVOICE_SELECT)
.eq('company_id', companyId)
.in('id', input.ids),
loadActiveBatchMap(supabase, companyId),
resolveBatchDebtor(supabase, companyId),
])
const rows = (invoices ?? []) as unknown as InvoiceRow[]
const byId = new Map(rows.map((row) => [row.id, row]))
const eligible: BatchPreviewLine[] = []
const excluded: BatchPreview['excluded'] = []
for (const id of input.ids) {
const invoice = byId.get(id)
if (!invoice || !invoice.supplier) {
excluded.push({ id, reason: invoice ? 'payee_missing' : 'not_found' })
continue
}
const evaluation = evaluateInvoiceForBatch(invoice, invoice.supplier, {
today,
activeBatchIdByInvoice,
})
if (!evaluation.eligible) {
excluded.push({ id, reason: evaluation.reason })
continue
}
eligible.push({
id,
supplier_name: invoice.supplier.name,
invoice_number: invoice.supplier_invoice_number,
amount: evaluation.defaults.amount,
payment_date: evaluation.defaults.payment_date,
payee: { type: evaluation.payee.type, label: formatPayeeLabel(evaluation.payee) },
reference: evaluation.reference,
warnings: evaluation.warnings,
active_batch_id: evaluation.activeBatchId,
})
}
const total = sumOre(eligible.map((line) => line.amount))
return {
eligible,
excluded,
total,
debtor_ok: debtorResolution.ok,
...(debtorResolution.ok ? {} : { debtor_missing: debtorResolution.missing }),
}
}
export interface CreateBatchItemInput {
supplier_invoice_id: string
amount?: number
payment_date?: string
}
export interface CreateBatchInput {
format: 'pain001'
items: CreateBatchItemInput[]
confirm_already_batched?: boolean
}
export type CreateBatchResult =
| { ok: true; batch: SupplierPaymentBatch }
| { ok: false; code: 'debtor_incomplete'; missing: 'iban' | 'bic' }
| { ok: false; code: 'ineligible'; details: Array<{ id: string; reason: string }> }
| { ok: false; code: 'amount_exceeds_remaining'; details: Array<{ id: string }> }
| { ok: false; code: 'invalid_amount'; details: Array<{ id: string }> }
| { ok: false; code: 'already_batched'; details: Array<{ id: string; batch_id: string }> }
| { ok: false; code: 'create_failed' }
export async function createSupplierPaymentBatch(
supabase: SupabaseClient,
companyId: string,
userId: string,
input: CreateBatchInput,
): Promise<CreateBatchResult> {
const today = getSwedishLocalDate()
const ids = input.items.map((item) => item.supplier_invoice_id)
const debtorResolution = await resolveBatchDebtor(supabase, companyId)
if (!debtorResolution.ok) {
return { ok: false, code: 'debtor_incomplete', missing: debtorResolution.missing }
}
const { debtor } = debtorResolution
const [{ data: invoices }, activeBatchIdByInvoice] = await Promise.all([
supabase
.from('supplier_invoices')
.select(INVOICE_SELECT)
.eq('company_id', companyId)
.in('id', ids),
loadActiveBatchMap(supabase, companyId),
])
const rows = (invoices ?? []) as unknown as InvoiceRow[]
const byId = new Map(rows.map((row) => [row.id, row]))
const ineligible: Array<{ id: string; reason: string }> = []
const excessive: Array<{ id: string }> = []
const invalidAmount: Array<{ id: string }> = []
const alreadyBatched: Array<{ id: string; batch_id: string }> = []
const itemRows: Array<Omit<SupplierPaymentBatchItem, 'id' | 'batch_id' | 'created_at'>> = []
for (const item of input.items) {
const invoice = byId.get(item.supplier_invoice_id)
if (!invoice || !invoice.supplier) {
ineligible.push({ id: item.supplier_invoice_id, reason: invoice ? 'payee_missing' : 'not_found' })
continue
}
const evaluation = evaluateInvoiceForBatch(invoice, invoice.supplier, {
today,
activeBatchIdByInvoice,
})
if (!evaluation.eligible) {
ineligible.push({ id: invoice.id, reason: evaluation.reason })
continue
}
if (evaluation.activeBatchId && !input.confirm_already_batched) {
alreadyBatched.push({ id: invoice.id, batch_id: evaluation.activeBatchId })
continue
}
const amount = item.amount !== undefined ? roundOre(item.amount) : evaluation.defaults.amount
if (amount <= 0) {
invalidAmount.push({ id: invoice.id })
continue
}
if (amount > invoice.remaining_amount + ORE_TOLERANCE) {
excessive.push({ id: invoice.id })
continue
}
// A payment date in the past is normalized to today: banks reject passed
// execution dates, and "pay now" is what an overdue due date means.
const requestedDate = item.payment_date ?? evaluation.defaults.payment_date
const paymentDate = requestedDate > today ? requestedDate : today
const { payee } = evaluation
itemRows.push({
company_id: companyId,
supplier_invoice_id: invoice.id,
amount,
payment_date: paymentDate,
payee_type: payee.type,
payee_bankgiro: payee.type === 'bankgiro' ? payee.bankgiro : null,
payee_plusgiro: payee.type === 'plusgiro' ? payee.plusgiro : null,
payee_clearing: payee.type === 'bank_account' ? payee.clearing : null,
payee_account: payee.type === 'bank_account' ? payee.account : null,
payee_name: invoice.supplier.name,
reference_type: evaluation.reference.type,
reference: evaluation.reference.value,
})
}
if (ineligible.length > 0) return { ok: false, code: 'ineligible', details: ineligible }
if (invalidAmount.length > 0) return { ok: false, code: 'invalid_amount', details: invalidAmount }
if (excessive.length > 0) return { ok: false, code: 'amount_exceeds_remaining', details: excessive }
if (alreadyBatched.length > 0) return { ok: false, code: 'already_batched', details: alreadyBatched }
if (itemRows.length === 0) return { ok: false, code: 'create_failed' }
// The id is minted here (not by the DB default) because msg_id derives from
// it and both must land in the same INSERT.
const batchId = crypto.randomUUID()
const orgDigits = debtor.org_number.replace(/\D/g, '')
const msgId = `${getBranding().appName.toUpperCase()}-${orgDigits}-B${batchId.replace(/-/g, '').slice(0, 8).toUpperCase()}`.slice(0, 35)
const totalAmount = sumOre(itemRows.map((row) => row.amount))
const { data: batch, error: batchError } = await supabase
.from('supplier_payment_batches')
.insert({
id: batchId,
company_id: companyId,
user_id: userId,
format: input.format,
status: 'created',
currency: 'SEK',
total_amount: totalAmount,
item_count: itemRows.length,
msg_id: msgId,
debtor_snapshot: debtor,
})
.select()
.single()
if (batchError || !batch) return { ok: false, code: 'create_failed' }
const { error: itemsError } = await supabase
.from('supplier_payment_batch_items')
.insert(itemRows.map((row) => ({ ...row, batch_id: batchId })))
if (itemsError) {
// Best-effort rollback: without its items the batch must not exist. There
// is no DELETE policy, so flag it cancelled instead of leaving an empty
// "created" batch behind.
await supabase
.from('supplier_payment_batches')
.update({ status: 'cancelled', cancelled_at: new Date().toISOString(), cancelled_by: userId })
.eq('id', batchId)
.eq('company_id', companyId)
return { ok: false, code: 'create_failed' }
}
return { ok: true, batch: batch as SupplierPaymentBatch }
}
export interface RenderedBatchFile {
content: string
contentType: string
filename: string
}
/**
* Render the payment file for a stored batch. Deterministic: same rows, same
* bytes, on every call.
*/
export function renderSupplierPaymentBatchFile(
batch: Pick<SupplierPaymentBatch, 'id' | 'format' | 'msg_id' | 'debtor_snapshot' | 'created_at'>,
items: SupplierPaymentBatchItem[],
): RenderedBatchFile {
if (batch.format !== 'pain001') {
throw new Error(`Filformatet stöds inte: ${batch.format}`)
}
const payments: SupplierPain001Payment[] = items.map((item) => ({
payee:
item.payee_type === 'bankgiro'
? { type: 'bankgiro', bankgiro: item.payee_bankgiro ?? '' }
: item.payee_type === 'plusgiro'
? { type: 'plusgiro', plusgiro: item.payee_plusgiro ?? '' }
: {
type: 'bank_account',
clearing: item.payee_clearing ?? '',
account: item.payee_account ?? '',
},
payeeName: item.payee_name,
amount: item.amount,
paymentDate: item.payment_date,
reference: { type: item.reference_type, value: item.reference },
}))
const debtor = batch.debtor_snapshot
const content = generateSupplierPain001(
{ name: debtor.name, orgNumber: debtor.org_number, iban: debtor.iban, bic: debtor.bic },
payments,
{ messageId: batch.msg_id, createdAt: batch.created_at },
)
const datePart = batch.created_at.slice(0, 10).replace(/-/g, '')
const shortId = batch.id.replace(/-/g, '').slice(0, 8)
return {
content,
contentType: 'application/xml; charset=utf-8',
filename: `betalfil_${datePart}_${shortId}.xml`,
}
}
+274
View File
@@ -0,0 +1,274 @@
/**
* pain.001 (ISO 20022) payment file generator for supplier payments
* (leverantorsbetalningar).
*
* Dialect: Swedish DOMESTIC giro credit transfers per the Swedish Common
* Interpretation of ISO 20022 payment messages (Svenska Bankforeningen,
* "Common Payment Types in Sweden", Appendix 1: bankgiro, plusgiro and
* account payees), cross-checked against Nordea Corporate Access Payables
* pain.001 examples v2.6 (2026-06-22). Target banks: Swedbank, SEB,
* Handelsbanken, Nordea (pain.001.001.03 uploaded in the corporate portal).
*
* Wire-format constraints this file encodes (do not "improve" without a bank
* implementation guide in hand):
*
* - No SvcLvl element: SvcLvl SEPA means a SEPA credit transfer (EUR-only);
* the domestic default (NURG) applies when SvcLvl is omitted. No CtgyPurp:
* SALA/PENS mark salary rails; supplier giro payments are plain transfers.
* - Bankgiro payees are addressed through Bankgirot: CdtrAgt ClrSysMmbId
* SESBA member 9900, CdtrAcct/Id/Othr with the bare BG digits and
* SchmeNm/Prtry BGNR. Plusgiro payees route SESBA member 9960 with
* SchmeNm/Cd BBAN. Bank-account payees use the clearing number as the
* SESBA member and the account (without clearing) as BBAN, through the
* same splitDomesticBankAccount used by the salary generator so the two
* files can never route an account differently.
* - A Luhn-valid OCR reference rides RmtInf/Strd/CdtrRefInf with type code
* SCOR, exactly one per transaction. Anything else is an unstructured
* RmtInf/Ustrd message (the giro "meddelande" field).
* - MsgId, PmtInfId, InstrId and EndToEndId are Max35Text.
* - ReqdExctnDt sits on PmtInf, so payments are grouped into one PmtInf per
* distinct payment date.
* - Determinism: CreDtTm comes from the caller (the batch row's created_at),
* never from the clock, so re-generating a stored batch is byte-identical
* and bank-side duplicate detection (keyed on MsgId) stays meaningful.
*
* Per BFL: the generated file is rakenskapsinformation (underlag) for the
* payments it initiates. Subject to 7-year retention.
*/
import { roundOre } from '@/lib/money'
import { splitDomesticBankAccount } from '@/lib/salary/payment/bank-account'
import type { PaymentReference, SupplierPayee } from './supplier-payee'
export interface SupplierPain001Debtor {
name: string
orgNumber: string
iban: string
bic: string
}
export interface SupplierPain001Payment {
payee: SupplierPayee
payeeName: string
amount: number
/** YYYY-MM-DD requested execution date. */
paymentDate: string
reference: PaymentReference
}
export interface SupplierPain001Options {
/** Stored batch msg_id; reused verbatim on regeneration. */
messageId: string
/** Batch created_at (ISO timestamp): becomes CreDtTm, NOT the clock. */
createdAt: string
}
/** The receiver-side giro message field is 25 positions; keep Ustrd within it. */
const USTRD_MAX = 25
export function generateSupplierPain001(
debtor: SupplierPain001Debtor,
payments: SupplierPain001Payment[],
options: SupplierPain001Options,
): string {
if (payments.length === 0) {
throw new Error('Betalfilen måste innehålla minst en betalning')
}
const creDtTm = new Date(options.createdAt).toISOString().replace(/\.\d{3}Z$/, 'Z')
const msgId = max35(options.messageId)
const orgDigits = debtor.orgNumber.replace(/\D/g, '')
// Sum the per-transaction amounts exactly as they are rendered (rounded to
// ore): CtrlSum must equal the sum of the InstdAmt values or banks reject
// the file, and summing raw floats then rounding once can differ by an ore.
const totalAmount = sumRendered(payments)
// One PmtInf per distinct execution date, dates ascending; original order
// preserved within a date so the file reads like the batch it came from.
const byDate = new Map<string, SupplierPain001Payment[]>()
for (const payment of payments) {
const group = byDate.get(payment.paymentDate)
if (group) group.push(payment)
else byDate.set(payment.paymentDate, [payment])
}
const dates = [...byDate.keys()].sort()
const lines: string[] = []
lines.push('<?xml version="1.0" encoding="UTF-8"?>')
lines.push('<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03"')
lines.push(' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">')
lines.push(' <CstmrCdtTrfInitn>')
lines.push(' <GrpHdr>')
lines.push(` <MsgId>${escapeXml(msgId)}</MsgId>`)
lines.push(` <CreDtTm>${creDtTm}</CreDtTm>`)
lines.push(` <NbOfTxs>${payments.length}</NbOfTxs>`)
lines.push(` <CtrlSum>${formatDecimal(totalAmount)}</CtrlSum>`)
lines.push(' <InitgPty>')
lines.push(` <Nm>${escapeXml(debtor.name)}</Nm>`)
if (orgDigits) {
lines.push(' <Id>')
lines.push(' <OrgId>')
lines.push(` <Othr><Id>${escapeXml(orgDigits)}</Id></Othr>`)
lines.push(' </OrgId>')
lines.push(' </Id>')
}
lines.push(' </InitgPty>')
lines.push(' </GrpHdr>')
let txCounter = 0
for (let g = 0; g < dates.length; g++) {
const date = dates[g]
const group = byDate.get(date) as SupplierPain001Payment[]
const groupTotal = sumRendered(group)
lines.push(' <PmtInf>')
lines.push(` <PmtInfId>${escapeXml(suffixId(msgId, `-P${g + 1}`))}</PmtInfId>`)
lines.push(' <PmtMtd>TRF</PmtMtd>')
lines.push(' <BtchBookg>true</BtchBookg>')
lines.push(` <NbOfTxs>${group.length}</NbOfTxs>`)
lines.push(` <CtrlSum>${formatDecimal(groupTotal)}</CtrlSum>`)
lines.push(` <ReqdExctnDt>${date}</ReqdExctnDt>`)
lines.push(' <Dbtr>')
lines.push(` <Nm>${escapeXml(debtor.name)}</Nm>`)
if (orgDigits) {
lines.push(' <Id>')
lines.push(' <OrgId>')
lines.push(` <Othr><Id>${escapeXml(orgDigits)}</Id></Othr>`)
lines.push(' </OrgId>')
lines.push(' </Id>')
}
lines.push(' </Dbtr>')
lines.push(' <DbtrAcct>')
lines.push(' <Id>')
lines.push(` <IBAN>${escapeXml(debtor.iban)}</IBAN>`)
lines.push(' </Id>')
lines.push(' <Ccy>SEK</Ccy>')
lines.push(' </DbtrAcct>')
lines.push(' <DbtrAgt>')
lines.push(' <FinInstnId>')
lines.push(` <BIC>${escapeXml(debtor.bic)}</BIC>`)
lines.push(' </FinInstnId>')
lines.push(' </DbtrAgt>')
for (const payment of group) {
txCounter += 1
const txId = suffixId(msgId, `-TX${String(txCounter).padStart(4, '0')}`)
lines.push(' <CdtTrfTxInf>')
lines.push(' <PmtId>')
lines.push(` <InstrId>${escapeXml(txId)}</InstrId>`)
lines.push(` <EndToEndId>${escapeXml(txId)}</EndToEndId>`)
lines.push(' </PmtId>')
lines.push(' <Amt>')
lines.push(` <InstdAmt Ccy="SEK">${formatDecimal(payment.amount)}</InstdAmt>`)
lines.push(' </Amt>')
pushCreditor(lines, payment)
pushRemittance(lines, payment.reference)
lines.push(' </CdtTrfTxInf>')
}
lines.push(' </PmtInf>')
}
lines.push(' </CstmrCdtTrfInitn>')
lines.push('</Document>')
return lines.join('\n')
}
/** XSD order within CdtTrfTxInf: CdtrAgt before Cdtr before CdtrAcct. */
function pushCreditor(lines: string[], payment: SupplierPain001Payment): void {
const { payee } = payment
let memberId: string
let accountId: string
let scheme: string
switch (payee.type) {
case 'bankgiro':
memberId = '9900'
accountId = payee.bankgiro
scheme = '<SchmeNm><Prtry>BGNR</Prtry></SchmeNm>'
break
case 'plusgiro':
memberId = '9960'
accountId = payee.plusgiro
scheme = '<SchmeNm><Cd>BBAN</Cd></SchmeNm>'
break
case 'bank_account': {
const { clearing4, accountDigits } = splitDomesticBankAccount(payee.clearing, payee.account)
memberId = clearing4
accountId = accountDigits
scheme = '<SchmeNm><Cd>BBAN</Cd></SchmeNm>'
break
}
}
lines.push(' <CdtrAgt>')
lines.push(' <FinInstnId>')
lines.push(' <ClrSysMmbId>')
lines.push(' <ClrSysId><Cd>SESBA</Cd></ClrSysId>')
lines.push(` <MmbId>${memberId}</MmbId>`)
lines.push(' </ClrSysMmbId>')
lines.push(' </FinInstnId>')
lines.push(' </CdtrAgt>')
lines.push(' <Cdtr>')
lines.push(` <Nm>${escapeXml(payment.payeeName)}</Nm>`)
lines.push(' </Cdtr>')
lines.push(' <CdtrAcct>')
lines.push(' <Id>')
lines.push(' <Othr>')
lines.push(` <Id>${escapeXml(accountId)}</Id>`)
lines.push(` ${scheme}`)
lines.push(' </Othr>')
lines.push(' </Id>')
lines.push(' </CdtrAcct>')
}
function pushRemittance(lines: string[], reference: PaymentReference): void {
lines.push(' <RmtInf>')
if (reference.type === 'ocr') {
lines.push(' <Strd>')
lines.push(' <CdtrRefInf>')
lines.push(' <Tp>')
lines.push(' <CdOrPrtry><Cd>SCOR</Cd></CdOrPrtry>')
lines.push(' </Tp>')
lines.push(` <Ref>${escapeXml(reference.value)}</Ref>`)
lines.push(' </CdtrRefInf>')
lines.push(' </Strd>')
} else {
lines.push(` <Ustrd>${escapeXml(reference.value.slice(0, USTRD_MAX))}</Ustrd>`)
}
lines.push(' </RmtInf>')
}
// ============================================================
// Helpers (deliberately duplicated from the salary generator: that dialect is
// production-hardened and stays untouched; see DECISIONS.md 2026-08-10)
// ============================================================
/** Control sums add the amounts AS RENDERED: each rounded to öre first. */
function sumRendered(payments: readonly SupplierPain001Payment[]): number {
return payments.reduce((sum, p) => sum + roundOre(p.amount), 0)
}
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
function formatDecimal(amount: number): string {
return roundOre(amount).toFixed(2)
}
function max35(value: string): string {
return value.slice(0, 35)
}
function suffixId(base: string, suffix: string): string {
return base.slice(0, Math.max(1, 35 - suffix.length)) + suffix
}
+143
View File
@@ -0,0 +1,143 @@
/**
* 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,
}
}
+13
View File
@@ -850,6 +850,19 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
denormalize: { prefix: 'supplier_invoice_', columns: ['currency', 'exchange_rate'] },
},
{ name: 'supplier_invoice_payments', file: 'supplier_invoice_payments.json' },
// Payment batches (betalfil): the immutable instruction snapshots a
// generated bank payment file derives from; underlag for the payments it
// initiated, so they leave with the archive.
{
name: 'supplier_payment_batches',
file: 'supplier_payment_batches.json',
orderBy: 'created_at',
},
{
name: 'supplier_payment_batch_items',
file: 'supplier_payment_batch_items.json',
orderBy: 'created_at',
},
// Underlag intake: the chat answers behind a verifikat.
//
// A projection, not the whole table. `channel_context` holds the human