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
parent f0ce322a7c
commit f776e375c5
14 changed files with 2420 additions and 0 deletions
+1
View File
@@ -855,3 +855,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-10] Account 2012 reverted from BAS_REFERENCE and the EF F-skatt template moved to 2013 (#1409): the primary source (bas.se BAS 2026 v2 PDF) has no 2012 anywhere; the EF equity block is 2010/2011/2013/2017/2018/2019. 2012 "Avräkning för skatter och avgifter" is a Visma/Bokio/BL program convention that the swedish-year-end-closing skill had absorbed as if standard. Kept per-company charts that already have 2012 untouched (their history is legal and the account is a valid free slot); only the reference, the seeded template and the skill guidance change.
[2026-08-10] committed_at overrides audited via a dedicated COMMITTED_AT_OVERRIDE action and a SECURITY DEFINER writer (#1444): reusing SECURITY_EVENT would overload attack semantics onto a sanctioned backdate, and a plain INSERT inside set_committed_at() fails under the pg harness where SET ROLE service_role has no BYPASSRLS and audit_log has no INSERT policy. EXECUTE revoked from anon/authenticated so PostgREST cannot expose the writer as an audit-noise RPC.
[2026-08-10] Atom seed migrations gained a version-downgrade guard in the generator (#1483): each seed is a full 108-atom upsert built from its branch's tree, so two parallel PRs each carrying a seed meant the last-applied one silently reverted the other's atom bodies while skills:check stayed green (it compares files to the manifest, never the DB). The ON CONFLICT now skips rows whose registry version is newer than the seed's. Dance-event VAT stayed guidance-plus-descriptor only: no rate cutover logic in the engine, since 6% already exists as a treatment and the entry date decides the rate the user picks.
[2026-08-10] Supplier betalfil (payment batches) ships pain.001.001.03 ONLY, no Bankgirot LB generator (founder call via scope questions): at ship time LB is dead at Handelsbanken/SEB and dies at Swedbank for supplier files 1 Sep 2026; Danske replaces LB with ISO 20022 during its Apr-Nov 2026 modernisation window without publishing a per-format date (27 Nov 2026 = all file communication must go via the bank; verified against danskebank.se 2026-08-10 after a bot review claimed 12 May, which is actually the bankgiro-alias initiation date). An LB path would have weeks of shelf life at the majors; the DB format CHECK still allows 'bg_lb' so a future LB or pain.001.001.09 addition needs no migration, the API Zod schema gates to 'pain001'. The supplier pain.001 generator (lib/payments/pain001-supplier.ts) reimplements the four tiny XML helpers instead of exporting them from lib/salary/payment/pain001-generator.ts: ~40 duplicated lines beat destabilizing a production-hardened salary dialect (2026-07-12 entry), and the two dialects genuinely differ (supplier files carry RmtInf SCOR/Ustrd and giro creditor addressing SESBA 9900 BGNR / 9960 BBAN; salary forbids RmtInf and marks CtgyPurp SALA). splitDomesticBankAccount IS shared so account routing can never diverge. Batches fix the documented no-regeneration-guard hazard (2026-07-26 entry) by construction: msg_id derives from the batch id at creation and CreDtTm from created_at, so re-download is byte-identical (bank dedup on MsgId works) and only a new batch mints a new MsgId. Generating/downloading a file books nothing: settlement stays in mark-paid/bank-match, matching the help_body direction that payment truth comes from the bank.
@@ -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
@@ -0,0 +1,177 @@
-- Supplier payment batches (betalfil for leverantorsfakturor).
--
-- A batch is a snapshot of payment instructions handed to the user's bank as a
-- pain.001 file. The file regenerates deterministically from these rows alone
-- (msg_id stored at creation, CreDtTm derived from created_at), so a
-- re-download is byte-identical and bank-side duplicate detection works; a new
-- batch gets a new msg_id. This closes, for supplier payments, the
-- no-regeneration-guard hazard documented for the salary payment files
-- (DECISIONS.md 2026-07-26).
--
-- Generating a file moves no money and books nothing: settlement stays in the
-- existing mark-paid / bank-match flows. Batch settlement progress is derived
-- at read time by joining items to live supplier_invoices; it is never stored.
--
-- format allows 'bg_lb' at the DB level so a future LB or pain.001.001.09
-- addition needs no migration; the v1 API accepts only 'pain001'.
CREATE TABLE public.supplier_payment_batches (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
format text NOT NULL CHECK (format IN ('pain001', 'bg_lb')),
status text NOT NULL DEFAULT 'created' CHECK (status IN ('created', 'cancelled')),
currency text NOT NULL DEFAULT 'SEK',
total_amount numeric NOT NULL CHECK (total_amount > 0),
item_count integer NOT NULL CHECK (item_count > 0),
-- pain.001 MsgId, derived from the batch id at creation (Max35Text). Stored
-- so regeneration never recomputes it differently.
msg_id text NOT NULL,
-- Debtor snapshot at creation: {name, org_number, iban, bic}. Later changes
-- to company_settings never mutate an existing batch.
debtor_snapshot jsonb NOT NULL,
file_generated_at timestamptz,
download_count integer NOT NULL DEFAULT 0,
cancelled_at timestamptz,
cancelled_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Composite-FK target so items can enforce company agreement with their
-- batch. Trivially unique (id is the PK).
CONSTRAINT uq_supplier_payment_batches_id_company UNIQUE (id, company_id)
);
-- Composite-FK target on the invoice side, same reasoning as above.
CREATE UNIQUE INDEX uq_supplier_invoices_id_company
ON public.supplier_invoices (id, company_id);
CREATE TABLE public.supplier_payment_batch_items (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
batch_id uuid NOT NULL,
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
-- RESTRICT: a batch item documents a payment instruction possibly already
-- handed to a bank; the invoice behind it must not vanish. The supplier
-- invoice DELETE route pre-checks and returns a friendly error.
supplier_invoice_id uuid NOT NULL,
amount numeric NOT NULL CHECK (amount > 0),
payment_date date NOT NULL,
-- Payee and reference snapshot at creation; supplier edits after batch
-- creation never change what the generated file says.
payee_type text NOT NULL CHECK (payee_type IN ('bankgiro', 'plusgiro', 'bank_account')),
payee_bankgiro text,
payee_plusgiro text,
payee_clearing text,
payee_account text,
payee_name text NOT NULL,
reference_type text NOT NULL CHECK (reference_type IN ('ocr', 'invoice_number')),
reference text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_supplier_payment_batch_invoice UNIQUE (batch_id, supplier_invoice_id),
-- Composite FKs: company_id must agree with BOTH parents, so a user who
-- belongs to two companies can never cross-link a batch in one company to
-- an invoice in another (plain per-column FKs would allow it).
CONSTRAINT fk_supplier_payment_batch_items_batch
FOREIGN KEY (batch_id, company_id)
REFERENCES public.supplier_payment_batches (id, company_id) ON DELETE CASCADE,
CONSTRAINT fk_supplier_payment_batch_items_invoice
FOREIGN KEY (supplier_invoice_id, company_id)
REFERENCES public.supplier_invoices (id, company_id) ON DELETE RESTRICT,
CONSTRAINT supplier_payment_batch_items_payee_fields_match CHECK (
(payee_type = 'bankgiro' AND payee_bankgiro IS NOT NULL)
OR (payee_type = 'plusgiro' AND payee_plusgiro IS NOT NULL)
OR (payee_type = 'bank_account' AND payee_clearing IS NOT NULL AND payee_account IS NOT NULL)
)
);
ALTER TABLE public.supplier_payment_batches ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.supplier_payment_batch_items ENABLE ROW LEVEL SECURITY;
CREATE POLICY "view own-company supplier_payment_batches"
ON public.supplier_payment_batches FOR SELECT
USING (company_id IN (SELECT user_company_ids()));
CREATE POLICY "insert own-company supplier_payment_batches"
ON public.supplier_payment_batches FOR INSERT
WITH CHECK (company_id IN (SELECT user_company_ids()));
CREATE POLICY "update own-company supplier_payment_batches"
ON public.supplier_payment_batches FOR UPDATE
USING (company_id IN (SELECT user_company_ids()));
-- No DELETE policy on batches: a batch documents a payment instruction that
-- may already sit at the bank. Lifecycle ends at status 'cancelled'.
CREATE POLICY "view own-company supplier_payment_batch_items"
ON public.supplier_payment_batch_items FOR SELECT
USING (company_id IN (SELECT user_company_ids()));
CREATE POLICY "insert own-company supplier_payment_batch_items"
ON public.supplier_payment_batch_items FOR INSERT
WITH CHECK (company_id IN (SELECT user_company_ids()));
-- No UPDATE or DELETE policies on items: rows are immutable snapshots. The
-- only removal path is the batch CASCADE.
CREATE INDEX idx_supplier_payment_batches_company_created
ON public.supplier_payment_batches (company_id, created_at DESC);
CREATE INDEX idx_supplier_payment_batches_company_status
ON public.supplier_payment_batches (company_id, status);
CREATE INDEX idx_supplier_payment_batch_items_batch_id
ON public.supplier_payment_batch_items (batch_id);
CREATE INDEX idx_supplier_payment_batch_items_supplier_invoice_id
ON public.supplier_payment_batch_items (supplier_invoice_id);
CREATE INDEX idx_supplier_payment_batch_items_company_id
ON public.supplier_payment_batch_items (company_id);
-- The RLS UPDATE policy cannot compare OLD and NEW, so column-level
-- immutability is a trigger: a batch is a snapshot a bank file regenerates
-- from, and rewriting msg_id/amounts/debtor after creation would break the
-- byte-identical re-download contract. Only lifecycle (created -> cancelled,
-- with its who/when) and download metadata may change.
CREATE OR REPLACE FUNCTION public.enforce_supplier_payment_batch_immutability()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.id IS DISTINCT FROM OLD.id
OR NEW.company_id IS DISTINCT FROM OLD.company_id
OR NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.format IS DISTINCT FROM OLD.format
OR NEW.currency IS DISTINCT FROM OLD.currency
OR NEW.total_amount IS DISTINCT FROM OLD.total_amount
OR NEW.item_count IS DISTINCT FROM OLD.item_count
OR NEW.msg_id IS DISTINCT FROM OLD.msg_id
OR NEW.debtor_snapshot IS DISTINCT FROM OLD.debtor_snapshot
OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN
RAISE EXCEPTION 'supplier_payment_batches are immutable snapshots: only lifecycle and download metadata may change';
END IF;
IF NEW.status IS DISTINCT FROM OLD.status
AND NOT (OLD.status = 'created' AND NEW.status = 'cancelled') THEN
RAISE EXCEPTION 'supplier_payment_batches: the only status transition is created -> cancelled';
END IF;
-- Cancellation metadata is audit data: it may only be written as part of
-- the created -> cancelled transition. Exception: cancelled_by may become
-- NULL at any time, because the FK's ON DELETE SET NULL fires this same
-- trigger when the cancelling user's account is deleted.
IF NOT (OLD.status = 'created' AND NEW.status = 'cancelled') THEN
IF NEW.cancelled_at IS DISTINCT FROM OLD.cancelled_at THEN
RAISE EXCEPTION 'supplier_payment_batches: cancelled_at may only be set by the created -> cancelled transition';
END IF;
IF NEW.cancelled_by IS DISTINCT FROM OLD.cancelled_by AND NEW.cancelled_by IS NOT NULL THEN
RAISE EXCEPTION 'supplier_payment_batches: cancelled_by may only be set by the created -> cancelled transition';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER enforce_supplier_payment_batch_immutability
BEFORE UPDATE ON public.supplier_payment_batches
FOR EACH ROW EXECUTE FUNCTION public.enforce_supplier_payment_batch_immutability();
CREATE TRIGGER set_updated_at_supplier_payment_batches
BEFORE UPDATE ON public.supplier_payment_batches
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
CREATE TRIGGER audit_supplier_payment_batches
AFTER INSERT OR UPDATE OR DELETE ON public.supplier_payment_batches
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
CREATE TRIGGER audit_supplier_payment_batch_items
AFTER INSERT OR UPDATE OR DELETE ON public.supplier_payment_batch_items
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
NOTIFY pgrst, 'reload schema';
+2
View File
@@ -453,6 +453,8 @@ export function makeSupplier(overrides: Partial<Supplier> = {}): Supplier {
bank_account: null,
iban: null,
bic: null,
clearing_number: null,
account_number: null,
default_expense_account: '6200',
default_payment_terms: 30,
default_currency: 'SEK',
@@ -0,0 +1,348 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool, withUserContext } from './setup'
import { seedCompany, insertAuthUser } from './fixtures'
// pg-real coverage for 20260810160748_supplier_payment_batches.sql: RLS
// isolation on both tables, the FK RESTRICT that keeps invoices referenced by
// a payment instruction undeletable, the payee_fields_match CHECK, the
// per-batch invoice uniqueness, item immutability (no UPDATE/DELETE policies),
// and the updated_at trigger.
async function insertSupplier(companyId: string, userId: string): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.suppliers (id, user_id, company_id, name, bankgiro)
VALUES ($1, $2, $3, 'Derome Bygg AB', '5050-1055')`,
[id, userId, companyId],
)
return id
}
async function insertSupplierInvoice(
companyId: string,
userId: string,
supplierId: string,
): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.supplier_invoices
(id, user_id, company_id, supplier_id, arrival_number,
supplier_invoice_number, invoice_date, due_date,
subtotal, vat_amount, total, remaining_amount, status)
VALUES ($1, $2, $3, $4, floor(random() * 1000000)::int,
$5, '2026-06-23', '2026-07-07',
590, 147.5, 737.5, 737.5, 'approved')`,
[id, userId, companyId, supplierId, `CD-${id.slice(0, 8)}`],
)
return id
}
async function insertBatch(companyId: string, userId: string): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.supplier_payment_batches
(id, company_id, user_id, format, total_amount, item_count, msg_id, debtor_snapshot)
VALUES ($1, $2, $3, 'pain001', 737.5, 1, $4,
'{"name":"Test AB","org_number":"556677-8899","iban":"SE3550000000054910000003","bic":"ESSESESS"}')`,
[id, companyId, userId, `ACCOUNTED-5566778899-B${id.slice(0, 8)}`],
)
return id
}
async function insertItem(params: {
batchId: string
companyId: string
supplierInvoiceId: string
payeeType?: string
payeeBankgiro?: string | null
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.supplier_payment_batch_items
(id, batch_id, company_id, supplier_invoice_id, amount, payment_date,
payee_type, payee_bankgiro, payee_name, reference_type, reference)
VALUES ($1, $2, $3, $4, 737.5, '2026-08-15',
$5, $6, 'Derome Bygg AB', 'invoice_number', 'CD3014794407')`,
[
id,
params.batchId,
params.companyId,
params.supplierInvoiceId,
params.payeeType ?? 'bankgiro',
params.payeeBankgiro === undefined ? '50501055' : params.payeeBankgiro,
],
)
return id
}
async function seedBatchWithItem() {
const ctx = await seedCompany()
const supplierId = await insertSupplier(ctx.companyId, ctx.userId)
const invoiceId = await insertSupplierInvoice(ctx.companyId, ctx.userId, supplierId)
const batchId = await insertBatch(ctx.companyId, ctx.userId)
const itemId = await insertItem({
batchId,
companyId: ctx.companyId,
supplierInvoiceId: invoiceId,
})
return { ...ctx, supplierId, invoiceId, batchId, itemId }
}
describe('supplier_payment_batches RLS', () => {
it('isolates batches and items to company members', async () => {
const ctx = await seedBatchWithItem()
const stranger = await insertAuthUser()
const ownerBatches = await withUserContext(ctx.userId, (client) =>
client.query(`SELECT id FROM public.supplier_payment_batches WHERE id = $1`, [ctx.batchId]),
)
expect(ownerBatches.rows).toHaveLength(1)
const strangerBatches = await withUserContext(stranger, (client) =>
client.query(`SELECT id FROM public.supplier_payment_batches WHERE id = $1`, [ctx.batchId]),
)
expect(strangerBatches.rows).toHaveLength(0)
const ownerItems = await withUserContext(ctx.userId, (client) =>
client.query(`SELECT id FROM public.supplier_payment_batch_items WHERE id = $1`, [
ctx.itemId,
]),
)
expect(ownerItems.rows).toHaveLength(1)
const strangerItems = await withUserContext(stranger, (client) =>
client.query(`SELECT id FROM public.supplier_payment_batch_items WHERE id = $1`, [
ctx.itemId,
]),
)
expect(strangerItems.rows).toHaveLength(0)
})
it('blocks a stranger from inserting into another company', async () => {
const ctx = await seedBatchWithItem()
const stranger = await insertAuthUser()
await expect(
withUserContext(stranger, (client) =>
client.query(
`INSERT INTO public.supplier_payment_batches
(company_id, user_id, format, total_amount, item_count, msg_id, debtor_snapshot)
VALUES ($1, $2, 'pain001', 1, 1, 'X', '{}')`,
[ctx.companyId, stranger],
),
),
).rejects.toThrow(/row-level security/)
})
it('lets a member cancel (update) a batch but never update or delete items', async () => {
const ctx = await seedBatchWithItem()
const cancel = await withUserContext(ctx.userId, (client) =>
client.query(
`UPDATE public.supplier_payment_batches
SET status = 'cancelled', cancelled_at = now(), cancelled_by = $2
WHERE id = $1 AND status = 'created'`,
[ctx.batchId, ctx.userId],
),
)
expect(cancel.rowCount).toBe(1)
// Items are immutable snapshots: no UPDATE/DELETE policies exist, so the
// statements succeed but match zero rows.
const update = await withUserContext(ctx.userId, (client) =>
client.query(`UPDATE public.supplier_payment_batch_items SET amount = 1 WHERE id = $1`, [
ctx.itemId,
]),
)
expect(update.rowCount).toBe(0)
const del = await withUserContext(ctx.userId, (client) =>
client.query(`DELETE FROM public.supplier_payment_batch_items WHERE id = $1`, [ctx.itemId]),
)
expect(del.rowCount).toBe(0)
})
})
describe('supplier_payment_batches constraints', () => {
it('FK RESTRICT keeps an invoice referenced by a batch item undeletable', async () => {
const ctx = await seedBatchWithItem()
await expect(
getPool().query(`DELETE FROM public.supplier_invoices WHERE id = $1`, [ctx.invoiceId]),
).rejects.toThrow(/violates foreign key constraint/)
// Removing the batch cascades the item away, after which the invoice can go.
await getPool().query(`DELETE FROM public.supplier_payment_batches WHERE id = $1`, [
ctx.batchId,
])
await getPool().query(`DELETE FROM public.supplier_invoices WHERE id = $1`, [ctx.invoiceId])
})
it('payee_fields_match rejects a payee type without its fields', async () => {
const ctx = await seedBatchWithItem()
const otherInvoice = await insertSupplierInvoice(ctx.companyId, ctx.userId, ctx.supplierId)
await expect(
insertItem({
batchId: ctx.batchId,
companyId: ctx.companyId,
supplierInvoiceId: otherInvoice,
payeeType: 'bankgiro',
payeeBankgiro: null,
}),
).rejects.toThrow(/payee_fields_match/)
await expect(
insertItem({
batchId: ctx.batchId,
companyId: ctx.companyId,
supplierInvoiceId: otherInvoice,
payeeType: 'bank_account',
payeeBankgiro: null,
}),
).rejects.toThrow(/payee_fields_match/)
})
it('rejects the same invoice twice in one batch', async () => {
const ctx = await seedBatchWithItem()
await expect(
insertItem({
batchId: ctx.batchId,
companyId: ctx.companyId,
supplierInvoiceId: ctx.invoiceId,
}),
).rejects.toThrow(/uq_supplier_payment_batch_invoice/)
})
it('rejects amounts and counts outside their CHECKs', async () => {
const ctx = await seedCompany()
await expect(
getPool().query(
`INSERT INTO public.supplier_payment_batches
(company_id, user_id, format, total_amount, item_count, msg_id, debtor_snapshot)
VALUES ($1, $2, 'pain001', 0, 1, 'X', '{}')`,
[ctx.companyId, ctx.userId],
),
).rejects.toThrow(/total_amount/)
await expect(
getPool().query(
`INSERT INTO public.supplier_payment_batches
(company_id, user_id, format, total_amount, item_count, msg_id, debtor_snapshot)
VALUES ($1, $2, 'swish', 1, 1, 'X', '{}')`,
[ctx.companyId, ctx.userId],
),
).rejects.toThrow(/format/)
})
it('rejects an item whose company differs from its batch or invoice', async () => {
const ctx = await seedBatchWithItem()
const other = await seedCompany()
const otherSupplier = await insertSupplier(other.companyId, other.userId)
const otherInvoice = await insertSupplierInvoice(other.companyId, other.userId, otherSupplier)
// Batch in ctx's company, invoice + company_id from the other company:
// the composite FK on (batch_id, company_id) must refuse the cross-link.
await expect(
insertItem({
batchId: ctx.batchId,
companyId: other.companyId,
supplierInvoiceId: otherInvoice,
}),
).rejects.toThrow(/fk_supplier_payment_batch_items_batch/)
// Invoice from the other company under ctx's company_id: the composite FK
// on (supplier_invoice_id, company_id) must refuse it too.
await expect(
insertItem({
batchId: ctx.batchId,
companyId: ctx.companyId,
supplierInvoiceId: otherInvoice,
}),
).rejects.toThrow(/fk_supplier_payment_batch_items_invoice/)
})
it('keeps batches immutable outside lifecycle and download metadata', async () => {
const ctx = await seedBatchWithItem()
await expect(
getPool().query(
`UPDATE public.supplier_payment_batches SET total_amount = 999 WHERE id = $1`,
[ctx.batchId],
),
).rejects.toThrow(/immutable snapshots/)
await expect(
getPool().query(
`UPDATE public.supplier_payment_batches SET msg_id = 'REWRITTEN' WHERE id = $1`,
[ctx.batchId],
),
).rejects.toThrow(/immutable snapshots/)
// Cancellation metadata cannot be written outside the transition.
await expect(
getPool().query(
`UPDATE public.supplier_payment_batches SET cancelled_at = now() WHERE id = $1`,
[ctx.batchId],
),
).rejects.toThrow(/cancelled_at may only be set/)
// The sanctioned transition works, and cannot be reversed. The canceller
// is deliberately NOT the batch owner: deleting the owner would CASCADE
// the batch away, and the SET NULL assertion below needs it to survive.
const cancellerId = await insertAuthUser()
await getPool().query(
`UPDATE public.supplier_payment_batches
SET status = 'cancelled', cancelled_at = now(), cancelled_by = $2 WHERE id = $1`,
[ctx.batchId, cancellerId],
)
await expect(
getPool().query(
`UPDATE public.supplier_payment_batches SET status = 'created' WHERE id = $1`,
[ctx.batchId],
),
).rejects.toThrow(/created -> cancelled/)
// Audit data on a cancelled batch cannot be rewritten to another user...
const otherUser = await insertAuthUser()
await expect(
getPool().query(
`UPDATE public.supplier_payment_batches SET cancelled_by = $2 WHERE id = $1`,
[ctx.batchId, otherUser],
),
).rejects.toThrow(/cancelled_by may only be set/)
// ...but the FK's ON DELETE SET NULL path must stay open: deleting the
// cancelling user's account nulls the reference through this trigger.
await getPool().query(`DELETE FROM auth.users WHERE id = $1`, [cancellerId])
const after = await getPool().query(
`SELECT cancelled_by FROM public.supplier_payment_batches WHERE id = $1`,
[ctx.batchId],
)
expect(after.rows[0].cancelled_by).toBeNull()
})
it('touches updated_at on batch update', async () => {
const ctx = await seedBatchWithItem()
const before = await getPool().query(
`SELECT updated_at FROM public.supplier_payment_batches WHERE id = $1`,
[ctx.batchId],
)
await new Promise((resolve) => setTimeout(resolve, 20))
await getPool().query(
`UPDATE public.supplier_payment_batches SET download_count = download_count + 1 WHERE id = $1`,
[ctx.batchId],
)
const after = await getPool().query(
`SELECT updated_at FROM public.supplier_payment_batches WHERE id = $1`,
[ctx.batchId],
)
expect(new Date(after.rows[0].updated_at).getTime()).toBeGreaterThan(
new Date(before.rows[0].updated_at).getTime(),
)
})
})
+58
View File
@@ -763,6 +763,8 @@ export interface Supplier {
bank_account: string | null
iban: string | null
bic: string | null
clearing_number: string | null
account_number: string | null
default_expense_account: string | null
default_payment_terms: number
@@ -774,6 +776,60 @@ export interface Supplier {
updated_at: string
}
// Supplier payment batch (betalfil): an immutable snapshot of payment
// instructions handed to the bank as a file. Generating or downloading a
// batch books nothing; settlement stays in mark-paid / bank matching.
export type SupplierPaymentBatchFormat = 'pain001' | 'bg_lb'
export type SupplierPaymentBatchStatus = 'created' | 'cancelled'
export interface SupplierPaymentBatchDebtor {
name: string
org_number: string
iban: string
bic: string
}
export interface SupplierPaymentBatch {
id: string
company_id: string
user_id: string
format: SupplierPaymentBatchFormat
status: SupplierPaymentBatchStatus
currency: string
total_amount: number
item_count: number
/** pain.001 MsgId, fixed at creation; re-downloads reuse it verbatim. */
msg_id: string
debtor_snapshot: SupplierPaymentBatchDebtor
file_generated_at: string | null
download_count: number
cancelled_at: string | null
cancelled_by: string | null
created_at: string
updated_at: string
}
export type SupplierPaymentBatchPayeeType = 'bankgiro' | 'plusgiro' | 'bank_account'
export type SupplierPaymentBatchReferenceType = 'ocr' | 'invoice_number'
export interface SupplierPaymentBatchItem {
id: string
batch_id: string
company_id: string
supplier_invoice_id: string
amount: number
payment_date: string
payee_type: SupplierPaymentBatchPayeeType
payee_bankgiro: string | null
payee_plusgiro: string | null
payee_clearing: string | null
payee_account: string | null
payee_name: string
reference_type: SupplierPaymentBatchReferenceType
reference: string
created_at: string
}
// Article (artikelregister): reusable invoice-line preset. NON-INVENTORY:
// no stock fields and no inventory postings, by deliberate design.
export type ArticleType = 'vara' | 'tjanst'
@@ -1413,6 +1469,8 @@ export interface CreateSupplierInput {
bank_account?: string
iban?: string
bic?: string
clearing_number?: string
account_number?: string
default_expense_account?: string
default_payment_terms?: number
default_currency?: string