From f776e375c52e347d8fe974b414718870a7cf8aca Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Mon, 10 Aug 2026 19:53:39 +0200 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + .../__tests__/batch-eligibility.test.ts | 148 +++++++ lib/payments/__tests__/batch-service.test.ts | 413 ++++++++++++++++++ .../__tests__/pain001-supplier.test.ts | 172 ++++++++ lib/payments/__tests__/supplier-payee.test.ts | 147 +++++++ lib/payments/batch-eligibility.ts | 115 +++++ lib/payments/batch-service.ts | 409 +++++++++++++++++ lib/payments/pain001-supplier.ts | 274 ++++++++++++ lib/payments/supplier-payee.ts | 143 ++++++ lib/reports/full-archive-export.ts | 13 + ...0260810160748_supplier_payment_batches.sql | 177 ++++++++ tests/helpers.ts | 2 + tests/pg/supplier-payment-batches.pg.test.ts | 348 +++++++++++++++ types/index.ts | 58 +++ 14 files changed, 2420 insertions(+) create mode 100644 lib/payments/__tests__/batch-eligibility.test.ts create mode 100644 lib/payments/__tests__/batch-service.test.ts create mode 100644 lib/payments/__tests__/pain001-supplier.test.ts create mode 100644 lib/payments/__tests__/supplier-payee.test.ts create mode 100644 lib/payments/batch-eligibility.ts create mode 100644 lib/payments/batch-service.ts create mode 100644 lib/payments/pain001-supplier.ts create mode 100644 lib/payments/supplier-payee.ts create mode 100644 supabase/migrations/20260810160748_supplier_payment_batches.sql create mode 100644 tests/pg/supplier-payment-batches.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 4502e605..3d0565bb 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -855,3 +855,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/lib/payments/__tests__/batch-eligibility.test.ts b/lib/payments/__tests__/batch-eligibility.test.ts new file mode 100644 index 00000000..76bd6569 --- /dev/null +++ b/lib/payments/__tests__/batch-eligibility.test.ts @@ -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 { + 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([]) + }) +}) diff --git a/lib/payments/__tests__/batch-service.test.ts b/lib/payments/__tests__/batch-service.test.ts new file mode 100644 index 00000000..db09c4ba --- /dev/null +++ b/lib/payments/__tests__/batch-service.test.ts @@ -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 = {}) { + 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 = {}): 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 + > + 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(`${batch.msg_id}`) + expect(first.content).toContain('2026-08-10T12:00:00Z') + 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() + }) +}) diff --git a/lib/payments/__tests__/pain001-supplier.test.ts b/lib/payments/__tests__/pain001-supplier.test.ts new file mode 100644 index 00000000..4349e5bd --- /dev/null +++ b/lib/payments/__tests__/pain001-supplier.test.ts @@ -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 { + 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('2026-08-10T12:34:56Z') + }) + + it('carries no SvcLvl and no CtgyPurp (domestic NURG supplier transfer)', () => { + const xml = generateSupplierPain001(debtor, [bgPayment()], options) + expect(xml).not.toContain('') + expect(xml).not.toContain('') + }) + + it('addresses a bankgiro payee through Bankgirot (SESBA 9900, BGNR proprietary scheme)', () => { + const xml = generateSupplierPain001(debtor, [bgPayment()], options) + expect(xml).toContain('SESBA') + expect(xml).toContain('9900') + expect(xml).toContain('50501055') + expect(xml).toContain('BGNR') + }) + + 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('9960') + expect(xml).toContain('1234567') + expect(xml).toContain('BBAN') + }) + + 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('8327') + expect(xml).toContain('9123456789') + expect(xml).toContain('BBAN') + }) + + it('renders exactly one structured SCOR reference for an OCR payment', () => { + const xml = generateSupplierPain001(debtor, [bgPayment()], options) + expect(xml.match(//g)).toHaveLength(1) + expect(xml).toContain('SCOR') + expect(xml).toContain('12345678') + expect(xml).not.toContain('') + }) + + 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('F-12345678901234567890123') + expect(xml).not.toContain('') + }) + + 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(//g)).toHaveLength(2) + const first15 = xml.indexOf('2026-08-15') + const first20 = xml.indexOf('2026-08-20') + 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('600.00') + expect(xml).toContain('200.00') + expect(xml).toContain('400.00') + // 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(/[^<]*-P1<\/PmtInfId>/)).not.toBeNull() + expect(xml.match(/[^<]*-P2<\/PmtInfId>/)).not.toBeNull() + }) + + it('escapes XML special characters in names', () => { + const xml = generateSupplierPain001(debtor, [bgPayment()], options) + expect(xml).toContain('Derome Bygg & Industri AB') + }) + + 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') + }) + + it('formats amounts with two decimals and a dot separator', () => { + const xml = generateSupplierPain001(debtor, [bgPayment({ amount: 199.291 })], options) + expect(xml).toContain('199.29') + }) + + 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(/0\.01<\/InstdAmt>/g)).toHaveLength(2) + expect(xml).toContain('0.02') + expect(xml).not.toContain('0.03') + }) + + it('refuses an empty batch', () => { + expect(() => generateSupplierPain001(debtor, [], options)).toThrow() + }) +}) diff --git a/lib/payments/__tests__/supplier-payee.test.ts b/lib/payments/__tests__/supplier-payee.test.ts new file mode 100644 index 00000000..d3701b4c --- /dev/null +++ b/lib/payments/__tests__/supplier-payee.test.ts @@ -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) + }) +}) diff --git a/lib/payments/batch-eligibility.ts b/lib/payments/batch-eligibility.ts new file mode 100644 index 00000000..a71c308a --- /dev/null +++ b/lib/payments/batch-eligibility.ts @@ -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 +} + +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, + } +} diff --git a/lib/payments/batch-service.ts b/lib/payments/batch-service.ts new file mode 100644 index 00000000..73b4b4a9 --- /dev/null +++ b/lib/payments/batch-service.ts @@ -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 { + 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> { + 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() + 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 { + // 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 { + 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> = [] + + 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, + 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`, + } +} diff --git a/lib/payments/pain001-supplier.ts b/lib/payments/pain001-supplier.ts new file mode 100644 index 00000000..49db5510 --- /dev/null +++ b/lib/payments/pain001-supplier.ts @@ -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() + 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('') + lines.push('') + lines.push(' ') + + lines.push(' ') + lines.push(` ${escapeXml(msgId)}`) + lines.push(` ${creDtTm}`) + lines.push(` ${payments.length}`) + lines.push(` ${formatDecimal(totalAmount)}`) + lines.push(' ') + lines.push(` ${escapeXml(debtor.name)}`) + if (orgDigits) { + lines.push(' ') + lines.push(' ') + lines.push(` ${escapeXml(orgDigits)}`) + lines.push(' ') + lines.push(' ') + } + lines.push(' ') + lines.push(' ') + + 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(' ') + lines.push(` ${escapeXml(suffixId(msgId, `-P${g + 1}`))}`) + lines.push(' TRF') + lines.push(' true') + lines.push(` ${group.length}`) + lines.push(` ${formatDecimal(groupTotal)}`) + lines.push(` ${date}`) + lines.push(' ') + lines.push(` ${escapeXml(debtor.name)}`) + if (orgDigits) { + lines.push(' ') + lines.push(' ') + lines.push(` ${escapeXml(orgDigits)}`) + lines.push(' ') + lines.push(' ') + } + lines.push(' ') + lines.push(' ') + lines.push(' ') + lines.push(` ${escapeXml(debtor.iban)}`) + lines.push(' ') + lines.push(' SEK') + lines.push(' ') + lines.push(' ') + lines.push(' ') + lines.push(` ${escapeXml(debtor.bic)}`) + lines.push(' ') + lines.push(' ') + + for (const payment of group) { + txCounter += 1 + const txId = suffixId(msgId, `-TX${String(txCounter).padStart(4, '0')}`) + + lines.push(' ') + lines.push(' ') + lines.push(` ${escapeXml(txId)}`) + lines.push(` ${escapeXml(txId)}`) + lines.push(' ') + lines.push(' ') + lines.push(` ${formatDecimal(payment.amount)}`) + lines.push(' ') + pushCreditor(lines, payment) + pushRemittance(lines, payment.reference) + lines.push(' ') + } + + lines.push(' ') + } + + lines.push(' ') + lines.push('') + + 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 = 'BGNR' + break + case 'plusgiro': + memberId = '9960' + accountId = payee.plusgiro + scheme = 'BBAN' + break + case 'bank_account': { + const { clearing4, accountDigits } = splitDomesticBankAccount(payee.clearing, payee.account) + memberId = clearing4 + accountId = accountDigits + scheme = 'BBAN' + break + } + } + + lines.push(' ') + lines.push(' ') + lines.push(' ') + lines.push(' SESBA') + lines.push(` ${memberId}`) + lines.push(' ') + lines.push(' ') + lines.push(' ') + lines.push(' ') + lines.push(` ${escapeXml(payment.payeeName)}`) + lines.push(' ') + lines.push(' ') + lines.push(' ') + lines.push(' ') + lines.push(` ${escapeXml(accountId)}`) + lines.push(` ${scheme}`) + lines.push(' ') + lines.push(' ') + lines.push(' ') +} + +function pushRemittance(lines: string[], reference: PaymentReference): void { + lines.push(' ') + if (reference.type === 'ocr') { + lines.push(' ') + lines.push(' ') + lines.push(' ') + lines.push(' SCOR') + lines.push(' ') + lines.push(` ${escapeXml(reference.value)}`) + lines.push(' ') + lines.push(' ') + } else { + lines.push(` ${escapeXml(reference.value.slice(0, USTRD_MAX))}`) + } + lines.push(' ') +} + +// ============================================================ +// 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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +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 +} diff --git a/lib/payments/supplier-payee.ts b/lib/payments/supplier-payee.ts new file mode 100644 index 00000000..e201fd45 --- /dev/null +++ b/lib/payments/supplier-payee.ts @@ -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, + } +} diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index 7c316cef..46bf0159 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -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 diff --git a/supabase/migrations/20260810160748_supplier_payment_batches.sql b/supabase/migrations/20260810160748_supplier_payment_batches.sql new file mode 100644 index 00000000..d80ca4e3 --- /dev/null +++ b/supabase/migrations/20260810160748_supplier_payment_batches.sql @@ -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'; diff --git a/tests/helpers.ts b/tests/helpers.ts index 028c7270..39c13bbd 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -453,6 +453,8 @@ export function makeSupplier(overrides: Partial = {}): 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', diff --git a/tests/pg/supplier-payment-batches.pg.test.ts b/tests/pg/supplier-payment-batches.pg.test.ts new file mode 100644 index 00000000..429afbb1 --- /dev/null +++ b/tests/pg/supplier-payment-batches.pg.test.ts @@ -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 { + 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 { + 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 { + 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 { + 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(), + ) + }) +}) diff --git a/types/index.ts b/types/index.ts index 9804020d..d64590c8 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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