feat(payments): supplier payment batch schema + pain.001 domain lib (betalfil 1/3) (#1500)
* feat(payments): supplier payment batch schema + pain.001 domain lib Betalfil for leverantorsfakturor, part 1 of 3. New tables supplier_payment_batches + supplier_payment_batch_items (RLS, immutable item snapshots, FK RESTRICT on invoices), payee/reference resolution, eligibility rules shared by preview and create, and a supplier-dialect pain.001.001.03 generator (SESBA 9900 BGNR / 9960 BBAN / clearing BBAN, SCOR for Luhn-valid OCR, Ustrd fallback, no SvcLvl/CtgyPurp). Deterministic regeneration: msg_id derives from the batch id, CreDtTm from created_at, so re-downloads are byte-identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(payments): use lib/money helpers instead of raw ore rounding The naive-ore-round ratchet flags new Math.round(x*100)/100 sites; roundOre/sumOre/ORE_TOLERANCE are the sanctioned forms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(payments): classify batch tables in full-archive contract + fixture The no-phantom-columns contract requires every company-scoped table to be triaged in full-archive-export; the batch rows are underlag for the payments they initiated, so they dump with the archive. makeSupplier gains the clearing/account columns the Supplier type now carries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(payments): harden batch integrity per review Composite (id, company_id) FKs so items can never cross-link a batch and an invoice from different companies; BEFORE UPDATE trigger keeps batches immutable outside lifecycle + download metadata and one-way on created -> cancelled; active-batch lookup now fails closed (an error no longer reads as no active batches, which would have silently disabled the duplicate-batch guard); today derives from Europe/Stockholm, not UTC; pain.001 control sums add the amounts as rendered so CtrlSum always equals sum(InstdAmt); event-bus reset in test hooks; Danske LB date claim in DECISIONS verified against the primary page (the bot's 12 May date is the alias-initiation date, not LB retirement). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(payments): bind cancellation metadata to the cancel transition cancelled_at/cancelled_by may only be written by created -> cancelled; cancelled_by may still become NULL so the FK's ON DELETE SET NULL keeps working when the cancelling user's account is deleted (proven in pg). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -855,3 +855,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-10] Account 2012 reverted from BAS_REFERENCE and the EF F-skatt template moved to 2013 (#1409): the primary source (bas.se BAS 2026 v2 PDF) has no 2012 anywhere; the EF equity block is 2010/2011/2013/2017/2018/2019. 2012 "Avräkning för skatter och avgifter" is a Visma/Bokio/BL program convention that the swedish-year-end-closing skill had absorbed as if standard. Kept per-company charts that already have 2012 untouched (their history is legal and the account is a valid free slot); only the reference, the seeded template and the skill guidance change.
|
||||
[2026-08-10] committed_at overrides audited via a dedicated COMMITTED_AT_OVERRIDE action and a SECURITY DEFINER writer (#1444): reusing SECURITY_EVENT would overload attack semantics onto a sanctioned backdate, and a plain INSERT inside set_committed_at() fails under the pg harness where SET ROLE service_role has no BYPASSRLS and audit_log has no INSERT policy. EXECUTE revoked from anon/authenticated so PostgREST cannot expose the writer as an audit-noise RPC.
|
||||
[2026-08-10] Atom seed migrations gained a version-downgrade guard in the generator (#1483): each seed is a full 108-atom upsert built from its branch's tree, so two parallel PRs each carrying a seed meant the last-applied one silently reverted the other's atom bodies while skills:check stayed green (it compares files to the manifest, never the DB). The ON CONFLICT now skips rows whose registry version is newer than the seed's. Dance-event VAT stayed guidance-plus-descriptor only: no rate cutover logic in the engine, since 6% already exists as a treatment and the entry date decides the rate the user picks.
|
||||
[2026-08-10] Supplier betalfil (payment batches) ships pain.001.001.03 ONLY, no Bankgirot LB generator (founder call via scope questions): at ship time LB is dead at Handelsbanken/SEB and dies at Swedbank for supplier files 1 Sep 2026; Danske replaces LB with ISO 20022 during its Apr-Nov 2026 modernisation window without publishing a per-format date (27 Nov 2026 = all file communication must go via the bank; verified against danskebank.se 2026-08-10 after a bot review claimed 12 May, which is actually the bankgiro-alias initiation date). An LB path would have weeks of shelf life at the majors; the DB format CHECK still allows 'bg_lb' so a future LB or pain.001.001.09 addition needs no migration, the API Zod schema gates to 'pain001'. The supplier pain.001 generator (lib/payments/pain001-supplier.ts) reimplements the four tiny XML helpers instead of exporting them from lib/salary/payment/pain001-generator.ts: ~40 duplicated lines beat destabilizing a production-hardened salary dialect (2026-07-12 entry), and the two dialects genuinely differ (supplier files carry RmtInf SCOR/Ustrd and giro creditor addressing SESBA 9900 BGNR / 9960 BBAN; salary forbids RmtInf and marks CtgyPurp SALA). splitDomesticBankAccount IS shared so account routing can never diverge. Batches fix the documented no-regeneration-guard hazard (2026-07-26 entry) by construction: msg_id derives from the batch id at creation and CreDtTm from created_at, so re-download is byte-identical (bank dedup on MsgId works) and only a new batch mints a new MsgId. Generating/downloading a file books nothing: settlement stays in mark-paid/bank-match, matching the help_body direction that payment truth comes from the bank.
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { luhnCheckDigit } from '@/lib/bankgiro/luhn'
|
||||
import {
|
||||
evaluateInvoiceForBatch,
|
||||
PAYABLE_SUPPLIER_INVOICE_STATUSES,
|
||||
type BatchInvoiceFacts,
|
||||
} from '@/lib/payments/batch-eligibility'
|
||||
|
||||
const VALID_OCR = `1234567${luhnCheckDigit('1234567')}`
|
||||
const TODAY = '2026-08-10'
|
||||
|
||||
const supplier = {
|
||||
bankgiro: '5050-1055',
|
||||
plusgiro: null,
|
||||
bank_account: null,
|
||||
clearing_number: null,
|
||||
account_number: null,
|
||||
}
|
||||
|
||||
function invoice(overrides: Partial<BatchInvoiceFacts> = {}): BatchInvoiceFacts {
|
||||
return {
|
||||
id: 'inv-1',
|
||||
status: 'approved',
|
||||
approved_at: '2026-08-01T10:00:00Z',
|
||||
due_date: '2026-08-20',
|
||||
remaining_amount: 737.5,
|
||||
currency: 'SEK',
|
||||
is_credit_note: false,
|
||||
payment_reference: VALID_OCR,
|
||||
supplier_invoice_number: 'CD3014794407',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('evaluateInvoiceForBatch', () => {
|
||||
it('accepts every payable status and rejects the rest', () => {
|
||||
for (const status of PAYABLE_SUPPLIER_INVOICE_STATUSES) {
|
||||
const result = evaluateInvoiceForBatch(invoice({ status }), supplier, { today: TODAY })
|
||||
expect(result.eligible, status).toBe(true)
|
||||
}
|
||||
for (const status of ['paid', 'disputed', 'credited', 'reversed']) {
|
||||
const result = evaluateInvoiceForBatch(invoice({ status }), supplier, { today: TODAY })
|
||||
expect(result).toEqual({ eligible: false, reason: 'not_payable' })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects credit notes before anything else', () => {
|
||||
const result = evaluateInvoiceForBatch(
|
||||
invoice({ is_credit_note: true, status: 'paid' }),
|
||||
supplier,
|
||||
{ today: TODAY },
|
||||
)
|
||||
expect(result).toEqual({ eligible: false, reason: 'credit_note' })
|
||||
})
|
||||
|
||||
it('rejects a settled remainder at the epsilon boundary', () => {
|
||||
const atEpsilon = evaluateInvoiceForBatch(invoice({ remaining_amount: 0.005 }), supplier, {
|
||||
today: TODAY,
|
||||
})
|
||||
expect(atEpsilon).toEqual({ eligible: false, reason: 'nothing_remaining' })
|
||||
|
||||
const justAbove = evaluateInvoiceForBatch(invoice({ remaining_amount: 0.01 }), supplier, {
|
||||
today: TODAY,
|
||||
})
|
||||
expect(justAbove.eligible).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects foreign currency', () => {
|
||||
const result = evaluateInvoiceForBatch(invoice({ currency: 'EUR' }), supplier, { today: TODAY })
|
||||
expect(result).toEqual({ eligible: false, reason: 'foreign_currency' })
|
||||
})
|
||||
|
||||
it('rejects a supplier without payment details', () => {
|
||||
const result = evaluateInvoiceForBatch(
|
||||
invoice(),
|
||||
{ bankgiro: null, plusgiro: null, bank_account: null },
|
||||
{ today: TODAY },
|
||||
)
|
||||
expect(result).toEqual({ eligible: false, reason: 'payee_missing' })
|
||||
})
|
||||
|
||||
it('rejects a supplier with an invalid bankgiro', () => {
|
||||
const result = evaluateInvoiceForBatch(
|
||||
invoice(),
|
||||
{ ...supplier, bankgiro: '1234-5678' },
|
||||
{ today: TODAY },
|
||||
)
|
||||
expect(result).toEqual({ eligible: false, reason: 'payee_invalid' })
|
||||
})
|
||||
|
||||
it('defaults the payment date to the due date when it is in the future', () => {
|
||||
const result = evaluateInvoiceForBatch(invoice({ due_date: '2026-08-20' }), supplier, {
|
||||
today: TODAY,
|
||||
})
|
||||
expect(result.eligible && result.defaults.payment_date).toBe('2026-08-20')
|
||||
})
|
||||
|
||||
it('defaults the payment date to today when the due date has passed', () => {
|
||||
const result = evaluateInvoiceForBatch(
|
||||
invoice({ due_date: '2026-07-07', status: 'overdue' }),
|
||||
supplier,
|
||||
{ today: TODAY },
|
||||
)
|
||||
expect(result.eligible && result.defaults.payment_date).toBe(TODAY)
|
||||
})
|
||||
|
||||
it('defaults the amount to the rounded remaining amount', () => {
|
||||
const result = evaluateInvoiceForBatch(invoice({ remaining_amount: 199.291 }), supplier, {
|
||||
today: TODAY,
|
||||
})
|
||||
expect(result.eligible && result.defaults.amount).toBe(199.29)
|
||||
})
|
||||
|
||||
it('warns on an un-attested invoice instead of blocking it', () => {
|
||||
const result = evaluateInvoiceForBatch(
|
||||
invoice({ status: 'registered', approved_at: null }),
|
||||
supplier,
|
||||
{ today: TODAY },
|
||||
)
|
||||
expect(result.eligible && result.warnings).toContain('unattested')
|
||||
})
|
||||
|
||||
it('warns when the invoice already sits in an active batch', () => {
|
||||
const result = evaluateInvoiceForBatch(invoice(), supplier, {
|
||||
today: TODAY,
|
||||
activeBatchIdByInvoice: new Map([['inv-1', 'batch-9']]),
|
||||
})
|
||||
expect(result.eligible && result.warnings).toContain('already_batched')
|
||||
expect(result.eligible && result.activeBatchId).toBe('batch-9')
|
||||
})
|
||||
|
||||
it('warns on an invalid OCR and falls back to the invoice number', () => {
|
||||
const result = evaluateInvoiceForBatch(invoice({ payment_reference: '1234568' }), supplier, {
|
||||
today: TODAY,
|
||||
})
|
||||
expect(result.eligible && result.warnings).toContain('ocr_invalid')
|
||||
expect(result.eligible && result.reference).toEqual({
|
||||
type: 'invoice_number',
|
||||
value: 'CD3014794407',
|
||||
})
|
||||
})
|
||||
|
||||
it('carries a clean OCR through as the structured reference', () => {
|
||||
const result = evaluateInvoiceForBatch(invoice(), supplier, { today: TODAY })
|
||||
expect(result.eligible && result.reference).toEqual({ type: 'ocr', value: VALID_OCR })
|
||||
expect(result.eligible && result.warnings).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,413 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import {
|
||||
createSupplierPaymentBatch,
|
||||
previewSupplierPaymentBatch,
|
||||
renderSupplierPaymentBatchFile,
|
||||
} from '@/lib/payments/batch-service'
|
||||
import type { SupplierPaymentBatch, SupplierPaymentBatchItem } from '@/types'
|
||||
|
||||
const COMPANY_ID = 'c0000000-0000-0000-0000-000000000001'
|
||||
const USER_ID = 'u0000000-0000-0000-0000-000000000001'
|
||||
|
||||
const companyRow = { name: 'Testbolaget AB', org_number: '556677-8899' }
|
||||
const settingsRow = {
|
||||
company_name: 'Testbolaget AB',
|
||||
iban: 'SE3550000000054910000003',
|
||||
bic: 'ESSESESS',
|
||||
clearing_number: null,
|
||||
bank_name: null,
|
||||
}
|
||||
|
||||
function invoiceRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'inv-1',
|
||||
status: 'approved',
|
||||
approved_at: '2026-08-01T10:00:00Z',
|
||||
due_date: '2099-08-20',
|
||||
remaining_amount: 737.5,
|
||||
currency: 'SEK',
|
||||
is_credit_note: false,
|
||||
payment_reference: null,
|
||||
supplier_invoice_number: 'CD3014794407',
|
||||
supplier: {
|
||||
id: 'sup-1',
|
||||
name: 'Derome Bygg & Industri AB',
|
||||
bankgiro: '5050-1055',
|
||||
plusgiro: null,
|
||||
bank_account: null,
|
||||
clearing_number: null,
|
||||
account_number: null,
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function batchRow(overrides: Record<string, unknown> = {}): SupplierPaymentBatch {
|
||||
return {
|
||||
id: 'b0000000-0000-0000-0000-000000000001',
|
||||
company_id: COMPANY_ID,
|
||||
user_id: USER_ID,
|
||||
format: 'pain001',
|
||||
status: 'created',
|
||||
currency: 'SEK',
|
||||
total_amount: 737.5,
|
||||
item_count: 1,
|
||||
msg_id: 'ACCOUNTED-5566778899-BB0000000',
|
||||
debtor_snapshot: {
|
||||
name: 'Testbolaget AB',
|
||||
org_number: '556677-8899',
|
||||
iban: 'SE3550000000054910000003',
|
||||
bic: 'ESSESESS',
|
||||
},
|
||||
file_generated_at: null,
|
||||
download_count: 0,
|
||||
cancelled_at: null,
|
||||
cancelled_by: null,
|
||||
created_at: '2026-08-10T12:00:00Z',
|
||||
updated_at: '2026-08-10T12:00:00Z',
|
||||
...overrides,
|
||||
} as SupplierPaymentBatch
|
||||
}
|
||||
|
||||
describe('previewSupplierPaymentBatch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
it('returns eligible lines with payee, reference and totals', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
// from() order: supplier_invoices, batch items (active map), companies, settings
|
||||
mock.enqueueMany([
|
||||
{ data: [invoiceRow()] },
|
||||
{ data: [] },
|
||||
{ data: companyRow },
|
||||
{ data: settingsRow },
|
||||
])
|
||||
|
||||
const preview = await previewSupplierPaymentBatch(
|
||||
mock.supabase as unknown as SupabaseClient,
|
||||
COMPANY_ID,
|
||||
{ ids: ['inv-1'] },
|
||||
)
|
||||
|
||||
expect(preview.excluded).toEqual([])
|
||||
expect(preview.eligible).toHaveLength(1)
|
||||
expect(preview.eligible[0]).toMatchObject({
|
||||
id: 'inv-1',
|
||||
supplier_name: 'Derome Bygg & Industri AB',
|
||||
amount: 737.5,
|
||||
payment_date: '2099-08-20',
|
||||
payee: { type: 'bankgiro', label: 'BG 5050-1055' },
|
||||
reference: { type: 'invoice_number', value: 'CD3014794407' },
|
||||
warnings: [],
|
||||
})
|
||||
expect(preview.total).toBe(737.5)
|
||||
expect(preview.debtor_ok).toBe(true)
|
||||
})
|
||||
|
||||
it('excludes ineligible invoices with a reason and unknown ids as not_found', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
mock.enqueueMany([
|
||||
{ data: [invoiceRow({ status: 'paid' })] },
|
||||
{ data: [] },
|
||||
{ data: companyRow },
|
||||
{ data: settingsRow },
|
||||
])
|
||||
|
||||
const preview = await previewSupplierPaymentBatch(
|
||||
mock.supabase as unknown as SupabaseClient,
|
||||
COMPANY_ID,
|
||||
{ ids: ['inv-1', 'inv-ghost'] },
|
||||
)
|
||||
|
||||
expect(preview.eligible).toEqual([])
|
||||
expect(preview.excluded).toEqual([
|
||||
{ id: 'inv-1', reason: 'not_payable' },
|
||||
{ id: 'inv-ghost', reason: 'not_found' },
|
||||
])
|
||||
})
|
||||
|
||||
it('fails closed when the active-batch lookup errors', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
mock.enqueueMany([
|
||||
{ data: [invoiceRow()] },
|
||||
{ error: { message: 'relation missing' } },
|
||||
{ data: companyRow },
|
||||
{ data: settingsRow },
|
||||
])
|
||||
|
||||
await expect(
|
||||
previewSupplierPaymentBatch(mock.supabase as unknown as SupabaseClient, COMPANY_ID, {
|
||||
ids: ['inv-1'],
|
||||
}),
|
||||
).rejects.toBeTruthy()
|
||||
})
|
||||
|
||||
it('reports a missing debtor IBAN without blocking the preview', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
mock.enqueueMany([
|
||||
{ data: [invoiceRow()] },
|
||||
{ data: [] },
|
||||
{ data: companyRow },
|
||||
{ data: { ...settingsRow, iban: null } },
|
||||
])
|
||||
|
||||
const preview = await previewSupplierPaymentBatch(
|
||||
mock.supabase as unknown as SupabaseClient,
|
||||
COMPANY_ID,
|
||||
{ ids: ['inv-1'] },
|
||||
)
|
||||
|
||||
expect(preview.debtor_ok).toBe(false)
|
||||
expect(preview.debtor_missing).toBe('iban')
|
||||
expect(preview.eligible).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSupplierPaymentBatch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
// from() order for create: companies, settings (debtor first), then
|
||||
// supplier_invoices, batch items, then the two inserts.
|
||||
it('creates a batch with snapshotted payee rows and a derived msg_id', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
mock.enqueueMany([
|
||||
{ data: companyRow },
|
||||
{ data: settingsRow },
|
||||
{ data: [invoiceRow()] },
|
||||
{ data: [] },
|
||||
{ data: batchRow() },
|
||||
{ data: null },
|
||||
])
|
||||
|
||||
const result = await createSupplierPaymentBatch(
|
||||
mock.supabase as unknown as SupabaseClient,
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
{ format: 'pain001', items: [{ supplier_invoice_id: 'inv-1' }] },
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
|
||||
const batchInsert = mock.findCall('supplier_payment_batches', 'insert')?.[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect(batchInsert).toMatchObject({
|
||||
company_id: COMPANY_ID,
|
||||
user_id: USER_ID,
|
||||
format: 'pain001',
|
||||
status: 'created',
|
||||
currency: 'SEK',
|
||||
total_amount: 737.5,
|
||||
item_count: 1,
|
||||
debtor_snapshot: {
|
||||
name: 'Testbolaget AB',
|
||||
org_number: '556677-8899',
|
||||
iban: 'SE3550000000054910000003',
|
||||
bic: 'ESSESESS',
|
||||
},
|
||||
})
|
||||
const msgId = batchInsert.msg_id as string
|
||||
expect(msgId.startsWith('ACCOUNTED-5566778899-B')).toBe(true)
|
||||
expect(msgId.length).toBeLessThanOrEqual(35)
|
||||
|
||||
const itemsInsert = mock.findCall('supplier_payment_batch_items', 'insert')?.[0] as Array<
|
||||
Record<string, unknown>
|
||||
>
|
||||
expect(itemsInsert).toHaveLength(1)
|
||||
expect(itemsInsert[0]).toMatchObject({
|
||||
batch_id: batchInsert.id,
|
||||
company_id: COMPANY_ID,
|
||||
supplier_invoice_id: 'inv-1',
|
||||
amount: 737.5,
|
||||
payment_date: '2099-08-20',
|
||||
payee_type: 'bankgiro',
|
||||
payee_bankgiro: '50501055',
|
||||
payee_name: 'Derome Bygg & Industri AB',
|
||||
reference_type: 'invoice_number',
|
||||
reference: 'CD3014794407',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects the whole batch when any invoice is ineligible', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
mock.enqueueMany([
|
||||
{ data: companyRow },
|
||||
{ data: settingsRow },
|
||||
{ data: [invoiceRow(), invoiceRow({ id: 'inv-2', currency: 'EUR' })] },
|
||||
{ data: [] },
|
||||
])
|
||||
|
||||
const result = await createSupplierPaymentBatch(
|
||||
mock.supabase as unknown as SupabaseClient,
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
{
|
||||
format: 'pain001',
|
||||
items: [{ supplier_invoice_id: 'inv-1' }, { supplier_invoice_id: 'inv-2' }],
|
||||
},
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'ineligible',
|
||||
details: [{ id: 'inv-2', reason: 'foreign_currency' }],
|
||||
})
|
||||
expect(mock.findCall('supplier_payment_batches', 'insert')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects an amount override above the remaining amount', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
mock.enqueueMany([
|
||||
{ data: companyRow },
|
||||
{ data: settingsRow },
|
||||
{ data: [invoiceRow()] },
|
||||
{ data: [] },
|
||||
])
|
||||
|
||||
const result = await createSupplierPaymentBatch(
|
||||
mock.supabase as unknown as SupabaseClient,
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
{ format: 'pain001', items: [{ supplier_invoice_id: 'inv-1', amount: 800 }] },
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'amount_exceeds_remaining',
|
||||
details: [{ id: 'inv-1' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses an invoice already in an active batch unless confirmed', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
const activeItems = [
|
||||
{ supplier_invoice_id: 'inv-1', batch: { id: 'batch-9', status: 'created' } },
|
||||
]
|
||||
mock.enqueueMany([
|
||||
{ data: companyRow },
|
||||
{ data: settingsRow },
|
||||
{ data: [invoiceRow()] },
|
||||
{ data: activeItems },
|
||||
])
|
||||
|
||||
const result = await createSupplierPaymentBatch(
|
||||
mock.supabase as unknown as SupabaseClient,
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
{ format: 'pain001', items: [{ supplier_invoice_id: 'inv-1' }] },
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'already_batched',
|
||||
details: [{ id: 'inv-1', batch_id: 'batch-9' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('proceeds past an active batch when explicitly confirmed', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
const activeItems = [
|
||||
{ supplier_invoice_id: 'inv-1', batch: { id: 'batch-9', status: 'created' } },
|
||||
]
|
||||
mock.enqueueMany([
|
||||
{ data: companyRow },
|
||||
{ data: settingsRow },
|
||||
{ data: [invoiceRow()] },
|
||||
{ data: activeItems },
|
||||
{ data: batchRow() },
|
||||
{ data: null },
|
||||
])
|
||||
|
||||
const result = await createSupplierPaymentBatch(
|
||||
mock.supabase as unknown as SupabaseClient,
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
{
|
||||
format: 'pain001',
|
||||
items: [{ supplier_invoice_id: 'inv-1' }],
|
||||
confirm_already_batched: true,
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed when the active-batch lookup errors instead of skipping the guard', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
mock.enqueueMany([
|
||||
{ data: companyRow },
|
||||
{ data: settingsRow },
|
||||
{ data: [invoiceRow()] },
|
||||
{ error: { message: 'relation missing' } },
|
||||
])
|
||||
|
||||
await expect(
|
||||
createSupplierPaymentBatch(mock.supabase as unknown as SupabaseClient, COMPANY_ID, USER_ID, {
|
||||
format: 'pain001',
|
||||
items: [{ supplier_invoice_id: 'inv-1' }],
|
||||
}),
|
||||
).rejects.toBeTruthy()
|
||||
expect(mock.findCall('supplier_payment_batches', 'insert')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails up front when the debtor is incomplete', async () => {
|
||||
const mock = createQueuedMockSupabase()
|
||||
mock.enqueueMany([{ data: companyRow }, { data: { ...settingsRow, iban: null } }])
|
||||
|
||||
const result = await createSupplierPaymentBatch(
|
||||
mock.supabase as unknown as SupabaseClient,
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
{ format: 'pain001', items: [{ supplier_invoice_id: 'inv-1' }] },
|
||||
)
|
||||
|
||||
expect(result).toEqual({ ok: false, code: 'debtor_incomplete', missing: 'iban' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderSupplierPaymentBatchFile', () => {
|
||||
it('renders a deterministic pain.001 file from stored rows alone', () => {
|
||||
const batch = batchRow()
|
||||
const items: SupplierPaymentBatchItem[] = [
|
||||
{
|
||||
id: 'item-1',
|
||||
batch_id: batch.id,
|
||||
company_id: COMPANY_ID,
|
||||
supplier_invoice_id: 'inv-1',
|
||||
amount: 737.5,
|
||||
payment_date: '2026-08-15',
|
||||
payee_type: 'bankgiro',
|
||||
payee_bankgiro: '50501055',
|
||||
payee_plusgiro: null,
|
||||
payee_clearing: null,
|
||||
payee_account: null,
|
||||
payee_name: 'Derome Bygg & Industri AB',
|
||||
reference_type: 'invoice_number',
|
||||
reference: 'CD3014794407',
|
||||
created_at: '2026-08-10T12:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
const first = renderSupplierPaymentBatchFile(batch, items)
|
||||
const second = renderSupplierPaymentBatchFile(batch, items)
|
||||
|
||||
expect(first.content).toBe(second.content)
|
||||
expect(first.content).toContain(`<MsgId>${batch.msg_id}</MsgId>`)
|
||||
expect(first.content).toContain('<CreDtTm>2026-08-10T12:00:00Z</CreDtTm>')
|
||||
expect(first.contentType).toBe('application/xml; charset=utf-8')
|
||||
expect(first.filename).toBe('betalfil_20260810_b0000000.xml')
|
||||
})
|
||||
|
||||
it('refuses a format the renderer does not support', () => {
|
||||
expect(() => renderSupplierPaymentBatchFile(batchRow({ format: 'bg_lb' }), [])).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
generateSupplierPain001,
|
||||
type SupplierPain001Payment,
|
||||
} from '@/lib/payments/pain001-supplier'
|
||||
|
||||
const debtor = {
|
||||
name: 'Testbolaget AB',
|
||||
orgNumber: '556677-8899',
|
||||
iban: 'SE3550000000054910000003',
|
||||
bic: 'ESSESESS',
|
||||
}
|
||||
|
||||
const options = {
|
||||
messageId: 'ACCOUNTED-5566778899-B1A2B3C4D',
|
||||
createdAt: '2026-08-10T12:34:56.789Z',
|
||||
}
|
||||
|
||||
function bgPayment(overrides: Partial<SupplierPain001Payment> = {}): SupplierPain001Payment {
|
||||
return {
|
||||
payee: { type: 'bankgiro', bankgiro: '50501055' },
|
||||
payeeName: 'Derome Bygg & Industri AB',
|
||||
amount: 737.5,
|
||||
paymentDate: '2026-08-15',
|
||||
reference: { type: 'ocr', value: '12345678' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('generateSupplierPain001', () => {
|
||||
it('is byte-identical for identical inputs (deterministic regeneration)', () => {
|
||||
const a = generateSupplierPain001(debtor, [bgPayment()], options)
|
||||
const b = generateSupplierPain001(debtor, [bgPayment()], options)
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
|
||||
it('derives CreDtTm from createdAt, never the clock', () => {
|
||||
const xml = generateSupplierPain001(debtor, [bgPayment()], options)
|
||||
expect(xml).toContain('<CreDtTm>2026-08-10T12:34:56Z</CreDtTm>')
|
||||
})
|
||||
|
||||
it('carries no SvcLvl and no CtgyPurp (domestic NURG supplier transfer)', () => {
|
||||
const xml = generateSupplierPain001(debtor, [bgPayment()], options)
|
||||
expect(xml).not.toContain('<SvcLvl>')
|
||||
expect(xml).not.toContain('<CtgyPurp>')
|
||||
})
|
||||
|
||||
it('addresses a bankgiro payee through Bankgirot (SESBA 9900, BGNR proprietary scheme)', () => {
|
||||
const xml = generateSupplierPain001(debtor, [bgPayment()], options)
|
||||
expect(xml).toContain('<ClrSysId><Cd>SESBA</Cd></ClrSysId>')
|
||||
expect(xml).toContain('<MmbId>9900</MmbId>')
|
||||
expect(xml).toContain('<Id>50501055</Id>')
|
||||
expect(xml).toContain('<SchmeNm><Prtry>BGNR</Prtry></SchmeNm>')
|
||||
})
|
||||
|
||||
it('addresses a plusgiro payee via SESBA 9960 with BBAN scheme', () => {
|
||||
const xml = generateSupplierPain001(
|
||||
debtor,
|
||||
[bgPayment({ payee: { type: 'plusgiro', plusgiro: '1234567' } })],
|
||||
options,
|
||||
)
|
||||
expect(xml).toContain('<MmbId>9960</MmbId>')
|
||||
expect(xml).toContain('<Id>1234567</Id>')
|
||||
expect(xml).toContain('<SchmeNm><Cd>BBAN</Cd></SchmeNm>')
|
||||
})
|
||||
|
||||
it('addresses a bank-account payee via its clearing with the salary routing rules', () => {
|
||||
const xml = generateSupplierPain001(
|
||||
debtor,
|
||||
[
|
||||
bgPayment({
|
||||
// Swedbank 5-digit clearing: 5th digit shifts into the account.
|
||||
payee: { type: 'bank_account', clearing: '83279', account: '123456789' },
|
||||
}),
|
||||
],
|
||||
options,
|
||||
)
|
||||
expect(xml).toContain('<MmbId>8327</MmbId>')
|
||||
expect(xml).toContain('<Id>9123456789</Id>')
|
||||
expect(xml).toContain('<SchmeNm><Cd>BBAN</Cd></SchmeNm>')
|
||||
})
|
||||
|
||||
it('renders exactly one structured SCOR reference for an OCR payment', () => {
|
||||
const xml = generateSupplierPain001(debtor, [bgPayment()], options)
|
||||
expect(xml.match(/<CdtrRefInf>/g)).toHaveLength(1)
|
||||
expect(xml).toContain('<CdOrPrtry><Cd>SCOR</Cd></CdOrPrtry>')
|
||||
expect(xml).toContain('<Ref>12345678</Ref>')
|
||||
expect(xml).not.toContain('<Ustrd>')
|
||||
})
|
||||
|
||||
it('renders an invoice-number reference as unstructured text, truncated to 25 chars', () => {
|
||||
const xml = generateSupplierPain001(
|
||||
debtor,
|
||||
[
|
||||
bgPayment({
|
||||
reference: { type: 'invoice_number', value: 'F-12345678901234567890123456789' },
|
||||
}),
|
||||
],
|
||||
options,
|
||||
)
|
||||
expect(xml).toContain('<Ustrd>F-12345678901234567890123</Ustrd>')
|
||||
expect(xml).not.toContain('<Strd>')
|
||||
})
|
||||
|
||||
it('groups payments into one PmtInf per distinct execution date, dates ascending', () => {
|
||||
const xml = generateSupplierPain001(
|
||||
debtor,
|
||||
[
|
||||
bgPayment({ paymentDate: '2026-08-20', amount: 100 }),
|
||||
bgPayment({ paymentDate: '2026-08-15', amount: 200 }),
|
||||
bgPayment({ paymentDate: '2026-08-20', amount: 300 }),
|
||||
],
|
||||
options,
|
||||
)
|
||||
expect(xml.match(/<PmtInf>/g)).toHaveLength(2)
|
||||
const first15 = xml.indexOf('<ReqdExctnDt>2026-08-15</ReqdExctnDt>')
|
||||
const first20 = xml.indexOf('<ReqdExctnDt>2026-08-20</ReqdExctnDt>')
|
||||
expect(first15).toBeGreaterThan(-1)
|
||||
expect(first20).toBeGreaterThan(first15)
|
||||
// Group control sums: 200.00 for the 15th, 400.00 for the 20th, 600.00 total.
|
||||
expect(xml).toContain('<CtrlSum>600.00</CtrlSum>')
|
||||
expect(xml).toContain('<CtrlSum>200.00</CtrlSum>')
|
||||
expect(xml).toContain('<CtrlSum>400.00</CtrlSum>')
|
||||
// The tx counter runs across groups so ids stay unique file-wide.
|
||||
expect(xml).toContain('-TX0001')
|
||||
expect(xml).toContain('-TX0002')
|
||||
expect(xml).toContain('-TX0003')
|
||||
expect(xml.match(/<PmtInfId>[^<]*-P1<\/PmtInfId>/)).not.toBeNull()
|
||||
expect(xml.match(/<PmtInfId>[^<]*-P2<\/PmtInfId>/)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('escapes XML special characters in names', () => {
|
||||
const xml = generateSupplierPain001(debtor, [bgPayment()], options)
|
||||
expect(xml).toContain('<Nm>Derome Bygg & Industri AB</Nm>')
|
||||
})
|
||||
|
||||
it('keeps all ids within Max35Text with the suffix intact', () => {
|
||||
const xml = generateSupplierPain001(
|
||||
debtor,
|
||||
[bgPayment()],
|
||||
{ ...options, messageId: 'X'.repeat(60) },
|
||||
)
|
||||
const ids = [...xml.matchAll(/<(MsgId|PmtInfId|InstrId|EndToEndId)>([^<]+)<\/\1>/g)].map(
|
||||
(m) => m[2],
|
||||
)
|
||||
expect(ids.length).toBeGreaterThan(0)
|
||||
for (const id of ids) expect(id.length).toBeLessThanOrEqual(35)
|
||||
expect(xml).toContain('-TX0001</InstrId>')
|
||||
})
|
||||
|
||||
it('formats amounts with two decimals and a dot separator', () => {
|
||||
const xml = generateSupplierPain001(debtor, [bgPayment({ amount: 199.291 })], options)
|
||||
expect(xml).toContain('<InstdAmt Ccy="SEK">199.29</InstdAmt>')
|
||||
})
|
||||
|
||||
it('control sums equal the sum of the rendered amounts, not the raw floats', () => {
|
||||
// Raw floats: 0.014 + 0.014 = 0.028 -> rounded once = 0.03, but each
|
||||
// InstdAmt renders as 0.01. Banks reject CtrlSum != sum(InstdAmt).
|
||||
const xml = generateSupplierPain001(
|
||||
debtor,
|
||||
[bgPayment({ amount: 0.014 }), bgPayment({ amount: 0.014 })],
|
||||
options,
|
||||
)
|
||||
expect(xml.match(/<InstdAmt Ccy="SEK">0\.01<\/InstdAmt>/g)).toHaveLength(2)
|
||||
expect(xml).toContain('<CtrlSum>0.02</CtrlSum>')
|
||||
expect(xml).not.toContain('<CtrlSum>0.03</CtrlSum>')
|
||||
})
|
||||
|
||||
it('refuses an empty batch', () => {
|
||||
expect(() => generateSupplierPain001(debtor, [], options)).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { luhnCheckDigit } from '@/lib/bankgiro/luhn'
|
||||
import {
|
||||
formatPayeeLabel,
|
||||
resolvePaymentReference,
|
||||
resolveSupplierPayee,
|
||||
} from '@/lib/payments/supplier-payee'
|
||||
|
||||
// Luhn-valid fixtures built from the same check-digit routine the validators use.
|
||||
const VALID_BG = '5050-1055' // Skatteverket's bankgiro; known Luhn-valid
|
||||
const VALID_PG = `123456${luhnCheckDigit('123456')}`
|
||||
const VALID_OCR = `1234567${luhnCheckDigit('1234567')}`
|
||||
|
||||
const emptySource = {
|
||||
bankgiro: null,
|
||||
plusgiro: null,
|
||||
bank_account: null,
|
||||
clearing_number: null,
|
||||
account_number: null,
|
||||
}
|
||||
|
||||
describe('resolveSupplierPayee', () => {
|
||||
it('resolves a valid bankgiro first', () => {
|
||||
const result = resolveSupplierPayee({
|
||||
...emptySource,
|
||||
bankgiro: VALID_BG,
|
||||
plusgiro: VALID_PG,
|
||||
clearing_number: '3300',
|
||||
account_number: '1234567',
|
||||
})
|
||||
expect(result).toEqual({ ok: true, payee: { type: 'bankgiro', bankgiro: '50501055' } })
|
||||
})
|
||||
|
||||
it('fails on an invalid bankgiro instead of falling through to plusgiro', () => {
|
||||
const result = resolveSupplierPayee({
|
||||
...emptySource,
|
||||
bankgiro: '1234-5678',
|
||||
plusgiro: VALID_PG,
|
||||
})
|
||||
expect(result).toEqual({ ok: false, reason: 'payee_invalid' })
|
||||
})
|
||||
|
||||
it('resolves plusgiro when no bankgiro is set', () => {
|
||||
const result = resolveSupplierPayee({ ...emptySource, plusgiro: VALID_PG })
|
||||
expect(result).toEqual({ ok: true, payee: { type: 'plusgiro', plusgiro: VALID_PG } })
|
||||
})
|
||||
|
||||
it('resolves structured clearing + account columns', () => {
|
||||
const result = resolveSupplierPayee({
|
||||
...emptySource,
|
||||
clearing_number: '3300',
|
||||
account_number: '000123456',
|
||||
})
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
payee: { type: 'bank_account', clearing: '3300', account: '000123456' },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an invalid clearing in the structured columns', () => {
|
||||
const result = resolveSupplierPayee({
|
||||
...emptySource,
|
||||
clearing_number: '12',
|
||||
account_number: '1234567',
|
||||
})
|
||||
expect(result).toEqual({ ok: false, reason: 'payee_invalid' })
|
||||
})
|
||||
|
||||
it('parses free-text bank_account with an explicit separator', () => {
|
||||
const result = resolveSupplierPayee({ ...emptySource, bank_account: '3300-123 456 789' })
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
payee: { type: 'bank_account', clearing: '3300', account: '123456789' },
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a 5-digit Swedbank clearing in free text', () => {
|
||||
const result = resolveSupplierPayee({ ...emptySource, bank_account: '83279 123456789' })
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
payee: { type: 'bank_account', clearing: '83279', account: '123456789' },
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses an ambiguous free-text digit blob', () => {
|
||||
const result = resolveSupplierPayee({ ...emptySource, bank_account: '83279123456789' })
|
||||
expect(result).toEqual({ ok: false, reason: 'payee_invalid' })
|
||||
})
|
||||
|
||||
it('reports payee_missing when nothing is set', () => {
|
||||
expect(resolveSupplierPayee(emptySource)).toEqual({ ok: false, reason: 'payee_missing' })
|
||||
})
|
||||
|
||||
it('treats whitespace-only fields as missing', () => {
|
||||
expect(resolveSupplierPayee({ ...emptySource, bankgiro: ' ' })).toEqual({
|
||||
ok: false,
|
||||
reason: 'payee_missing',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatPayeeLabel', () => {
|
||||
it('formats each payee type', () => {
|
||||
expect(formatPayeeLabel({ type: 'bankgiro', bankgiro: '50501055' })).toBe('BG 5050-1055')
|
||||
expect(formatPayeeLabel({ type: 'plusgiro', plusgiro: '1234567' })).toBe('PG 123456-7')
|
||||
expect(formatPayeeLabel({ type: 'bank_account', clearing: '3300', account: '123456789' })).toBe(
|
||||
'3300 123456789',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolvePaymentReference', () => {
|
||||
it('uses a Luhn-valid OCR as a structured reference', () => {
|
||||
const { reference, ocrInvalid } = resolvePaymentReference({
|
||||
payment_reference: VALID_OCR,
|
||||
supplier_invoice_number: 'F-1001',
|
||||
})
|
||||
expect(reference).toEqual({ type: 'ocr', value: VALID_OCR })
|
||||
expect(ocrInvalid).toBe(false)
|
||||
})
|
||||
|
||||
it('strips formatting from the OCR before validating', () => {
|
||||
const { reference } = resolvePaymentReference({
|
||||
payment_reference: ` ${VALID_OCR.slice(0, 4)} ${VALID_OCR.slice(4)} `,
|
||||
supplier_invoice_number: 'F-1001',
|
||||
})
|
||||
expect(reference).toEqual({ type: 'ocr', value: VALID_OCR })
|
||||
})
|
||||
|
||||
it('falls back to the invoice number with a warning on an invalid OCR', () => {
|
||||
const { reference, ocrInvalid } = resolvePaymentReference({
|
||||
payment_reference: '1234568',
|
||||
supplier_invoice_number: 'F-1001',
|
||||
})
|
||||
expect(reference).toEqual({ type: 'invoice_number', value: 'F-1001' })
|
||||
expect(ocrInvalid).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the invoice number without a warning when no OCR is set', () => {
|
||||
const { reference, ocrInvalid } = resolvePaymentReference({
|
||||
payment_reference: null,
|
||||
supplier_invoice_number: 'F-1001',
|
||||
})
|
||||
expect(reference).toEqual({ type: 'invoice_number', value: 'F-1001' })
|
||||
expect(ocrInvalid).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Eligibility rules for including a supplier invoice in a payment batch.
|
||||
*
|
||||
* Single source of truth used by BOTH the preview route and the create route:
|
||||
* create re-evaluates every invoice against the same rules, so a row that
|
||||
* changed between preview and create (paid meanwhile, due date moved, supplier
|
||||
* details edited) is rejected instead of silently paid on stale terms.
|
||||
*
|
||||
* Warnings never block; exclusions always do. An un-attested invoice is a
|
||||
* warning (mark-paid pays registered invoices today, and sjalvbokforare have
|
||||
* no attest step), while a missing payee is an exclusion (there is nothing to
|
||||
* route the payment to).
|
||||
*/
|
||||
|
||||
import { ORE_TOLERANCE, roundOre } from '@/lib/money'
|
||||
import {
|
||||
resolvePaymentReference,
|
||||
resolveSupplierPayee,
|
||||
type PaymentReference,
|
||||
type SupplierPayee,
|
||||
type SupplierPayeeSource,
|
||||
} from './supplier-payee'
|
||||
|
||||
/**
|
||||
* Statuses a payment instruction may be created for: exactly the set the
|
||||
* mark-paid route accepts, so the batch can never contain an invoice the
|
||||
* settlement path would refuse.
|
||||
*/
|
||||
export const PAYABLE_SUPPLIER_INVOICE_STATUSES = [
|
||||
'registered',
|
||||
'approved',
|
||||
'partially_paid',
|
||||
'overdue',
|
||||
] as const
|
||||
|
||||
|
||||
export type BatchExclusionReason =
|
||||
| 'not_payable'
|
||||
| 'nothing_remaining'
|
||||
| 'credit_note'
|
||||
| 'foreign_currency'
|
||||
| 'payee_missing'
|
||||
| 'payee_invalid'
|
||||
|
||||
export type BatchItemWarning = 'unattested' | 'already_batched' | 'ocr_invalid'
|
||||
|
||||
export interface BatchInvoiceFacts {
|
||||
id: string
|
||||
status: string
|
||||
approved_at: string | null
|
||||
due_date: string
|
||||
remaining_amount: number
|
||||
currency: string
|
||||
is_credit_note: boolean
|
||||
payment_reference: string | null
|
||||
supplier_invoice_number: string
|
||||
}
|
||||
|
||||
export interface BatchEvaluationOptions {
|
||||
/** ISO yyyy-MM-dd. Passed in so preview and create agree within a request. */
|
||||
today: string
|
||||
/** invoice id -> active (created, not cancelled) batch id it already sits in. */
|
||||
activeBatchIdByInvoice?: ReadonlyMap<string, string>
|
||||
}
|
||||
|
||||
export type BatchInvoiceEvaluation =
|
||||
| {
|
||||
eligible: true
|
||||
defaults: { amount: number; payment_date: string }
|
||||
payee: SupplierPayee
|
||||
reference: PaymentReference
|
||||
warnings: BatchItemWarning[]
|
||||
activeBatchId: string | null
|
||||
}
|
||||
| { eligible: false; reason: BatchExclusionReason }
|
||||
|
||||
export function evaluateInvoiceForBatch(
|
||||
invoice: BatchInvoiceFacts,
|
||||
supplier: SupplierPayeeSource,
|
||||
options: BatchEvaluationOptions,
|
||||
): BatchInvoiceEvaluation {
|
||||
if (invoice.is_credit_note) return { eligible: false, reason: 'credit_note' }
|
||||
if (!(PAYABLE_SUPPLIER_INVOICE_STATUSES as readonly string[]).includes(invoice.status)) {
|
||||
return { eligible: false, reason: 'not_payable' }
|
||||
}
|
||||
if (invoice.remaining_amount <= ORE_TOLERANCE) {
|
||||
return { eligible: false, reason: 'nothing_remaining' }
|
||||
}
|
||||
if (invoice.currency !== 'SEK') return { eligible: false, reason: 'foreign_currency' }
|
||||
|
||||
const resolution = resolveSupplierPayee(supplier)
|
||||
if (!resolution.ok) return { eligible: false, reason: resolution.reason }
|
||||
|
||||
const { reference, ocrInvalid } = resolvePaymentReference(invoice)
|
||||
|
||||
const warnings: BatchItemWarning[] = []
|
||||
if (!invoice.approved_at) warnings.push('unattested')
|
||||
const activeBatchId = options.activeBatchIdByInvoice?.get(invoice.id) ?? null
|
||||
if (activeBatchId) warnings.push('already_batched')
|
||||
if (ocrInvalid) warnings.push('ocr_invalid')
|
||||
|
||||
return {
|
||||
eligible: true,
|
||||
defaults: {
|
||||
amount: roundOre(invoice.remaining_amount),
|
||||
// A due date in the future is honored; a passed one pays as soon as the
|
||||
// bank can execute.
|
||||
payment_date: invoice.due_date > options.today ? invoice.due_date : options.today,
|
||||
},
|
||||
payee: resolution.payee,
|
||||
reference,
|
||||
warnings,
|
||||
activeBatchId,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* Supplier payment batch orchestration: preview, create, render.
|
||||
*
|
||||
* A batch is an immutable snapshot of payment instructions. Preview and create
|
||||
* share evaluateInvoiceForBatch so nothing can be created that the preview
|
||||
* would not have shown; create re-reads and re-evaluates every invoice so a
|
||||
* row that changed since the preview (settled meanwhile, supplier edited) is
|
||||
* rejected rather than paid on stale terms.
|
||||
*
|
||||
* The file is rendered deterministically from the stored batch + item rows
|
||||
* alone: msg_id and created_at are fixed at creation, so every download of a
|
||||
* batch is byte-identical and bank-side duplicate detection (keyed on MsgId)
|
||||
* works. Generating or downloading a file books nothing and settles nothing.
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { getSwedishLocalDate } from '@/lib/bookkeeping/engine'
|
||||
import { ORE_TOLERANCE, roundOre, sumOre } from '@/lib/money'
|
||||
import {
|
||||
lookupBicByClearing,
|
||||
lookupBicByBankName,
|
||||
normalizeBankNumber,
|
||||
} from '@/lib/salary/payment/bank-account'
|
||||
import {
|
||||
evaluateInvoiceForBatch,
|
||||
type BatchExclusionReason,
|
||||
type BatchInvoiceFacts,
|
||||
type BatchItemWarning,
|
||||
} from './batch-eligibility'
|
||||
import { formatPayeeLabel, type SupplierPayeeSource } from './supplier-payee'
|
||||
import { generateSupplierPain001, type SupplierPain001Payment } from './pain001-supplier'
|
||||
import type { SupplierPaymentBatch, SupplierPaymentBatchItem } from '@/types'
|
||||
|
||||
type InvoiceRow = BatchInvoiceFacts & {
|
||||
supplier: (SupplierPayeeSource & { id: string; name: string }) | null
|
||||
}
|
||||
|
||||
const INVOICE_SELECT =
|
||||
'id, status, approved_at, due_date, remaining_amount, currency, is_credit_note, ' +
|
||||
'payment_reference, supplier_invoice_number, ' +
|
||||
'supplier:suppliers(id, name, bankgiro, plusgiro, bank_account, clearing_number, account_number)'
|
||||
|
||||
export interface BatchDebtor {
|
||||
name: string
|
||||
org_number: string
|
||||
iban: string
|
||||
bic: string
|
||||
}
|
||||
|
||||
export type DebtorResolution =
|
||||
| { ok: true; debtor: BatchDebtor }
|
||||
| { ok: false; missing: 'iban' | 'bic' }
|
||||
|
||||
/**
|
||||
* Resolve the paying company (pain.001 debtor) from settings, mirroring the
|
||||
* salary pain001 route: saved BIC first, then derivation from the clearing
|
||||
* number or bank name the company already entered, so most users only ever
|
||||
* fill in the IBAN.
|
||||
*/
|
||||
export async function resolveBatchDebtor(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<DebtorResolution> {
|
||||
const [{ data: company }, { data: settings }] = await Promise.all([
|
||||
supabase.from('companies').select('name, org_number').eq('id', companyId).single(),
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, iban, bic, clearing_number, bank_name')
|
||||
.eq('company_id', companyId)
|
||||
.single(),
|
||||
])
|
||||
|
||||
const iban = (settings?.iban ?? '').replace(/\s/g, '').toUpperCase()
|
||||
if (!iban) return { ok: false, missing: 'iban' }
|
||||
|
||||
const bic =
|
||||
settings?.bic?.trim() ||
|
||||
lookupBicByClearing(normalizeBankNumber(settings?.clearing_number)) ||
|
||||
lookupBicByBankName(settings?.bank_name)
|
||||
if (!bic) return { ok: false, missing: 'bic' }
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
debtor: {
|
||||
name: settings?.company_name || company?.name || '',
|
||||
org_number: company?.org_number || '',
|
||||
iban,
|
||||
bic,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* invoice id -> id of the active (created) batch it already sits in.
|
||||
*
|
||||
* Fails CLOSED: a lookup error must abort the caller, because treating it as
|
||||
* "no active batches" would silently disable the duplicate-batch guard and
|
||||
* let a second payable file be created without confirm_already_batched.
|
||||
*/
|
||||
export async function loadActiveBatchMap(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<Map<string, string>> {
|
||||
const { data, error } = await supabase
|
||||
.from('supplier_payment_batch_items')
|
||||
.select('supplier_invoice_id, batch:supplier_payment_batches!inner(id, status)')
|
||||
.eq('company_id', companyId)
|
||||
.eq('batch.status', 'created')
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const map = new Map<string, string>()
|
||||
for (const row of data ?? []) {
|
||||
const batch = row.batch as unknown as { id: string }
|
||||
if (!map.has(row.supplier_invoice_id)) map.set(row.supplier_invoice_id, batch.id)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export interface BatchPreviewLine {
|
||||
id: string
|
||||
supplier_name: string
|
||||
invoice_number: string
|
||||
amount: number
|
||||
payment_date: string
|
||||
payee: { type: string; label: string }
|
||||
reference: { type: 'ocr' | 'invoice_number'; value: string }
|
||||
warnings: BatchItemWarning[]
|
||||
active_batch_id: string | null
|
||||
}
|
||||
|
||||
export interface BatchPreview {
|
||||
eligible: BatchPreviewLine[]
|
||||
excluded: Array<{ id: string; reason: BatchExclusionReason | 'not_found' }>
|
||||
total: number
|
||||
debtor_ok: boolean
|
||||
debtor_missing?: 'iban' | 'bic'
|
||||
}
|
||||
|
||||
export async function previewSupplierPaymentBatch(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
input: { ids: string[] },
|
||||
): Promise<BatchPreview> {
|
||||
// Swedish calendar date, not UTC: between 00:00 and 01:59 Swedish summer
|
||||
// time a UTC slice is still yesterday, and "pay today" would produce an
|
||||
// execution date the bank rejects as passed.
|
||||
const today = getSwedishLocalDate()
|
||||
|
||||
const [{ data: invoices }, activeBatchIdByInvoice, debtorResolution] = await Promise.all([
|
||||
supabase
|
||||
.from('supplier_invoices')
|
||||
.select(INVOICE_SELECT)
|
||||
.eq('company_id', companyId)
|
||||
.in('id', input.ids),
|
||||
loadActiveBatchMap(supabase, companyId),
|
||||
resolveBatchDebtor(supabase, companyId),
|
||||
])
|
||||
|
||||
const rows = (invoices ?? []) as unknown as InvoiceRow[]
|
||||
const byId = new Map(rows.map((row) => [row.id, row]))
|
||||
|
||||
const eligible: BatchPreviewLine[] = []
|
||||
const excluded: BatchPreview['excluded'] = []
|
||||
|
||||
for (const id of input.ids) {
|
||||
const invoice = byId.get(id)
|
||||
if (!invoice || !invoice.supplier) {
|
||||
excluded.push({ id, reason: invoice ? 'payee_missing' : 'not_found' })
|
||||
continue
|
||||
}
|
||||
const evaluation = evaluateInvoiceForBatch(invoice, invoice.supplier, {
|
||||
today,
|
||||
activeBatchIdByInvoice,
|
||||
})
|
||||
if (!evaluation.eligible) {
|
||||
excluded.push({ id, reason: evaluation.reason })
|
||||
continue
|
||||
}
|
||||
eligible.push({
|
||||
id,
|
||||
supplier_name: invoice.supplier.name,
|
||||
invoice_number: invoice.supplier_invoice_number,
|
||||
amount: evaluation.defaults.amount,
|
||||
payment_date: evaluation.defaults.payment_date,
|
||||
payee: { type: evaluation.payee.type, label: formatPayeeLabel(evaluation.payee) },
|
||||
reference: evaluation.reference,
|
||||
warnings: evaluation.warnings,
|
||||
active_batch_id: evaluation.activeBatchId,
|
||||
})
|
||||
}
|
||||
|
||||
const total = sumOre(eligible.map((line) => line.amount))
|
||||
|
||||
return {
|
||||
eligible,
|
||||
excluded,
|
||||
total,
|
||||
debtor_ok: debtorResolution.ok,
|
||||
...(debtorResolution.ok ? {} : { debtor_missing: debtorResolution.missing }),
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateBatchItemInput {
|
||||
supplier_invoice_id: string
|
||||
amount?: number
|
||||
payment_date?: string
|
||||
}
|
||||
|
||||
export interface CreateBatchInput {
|
||||
format: 'pain001'
|
||||
items: CreateBatchItemInput[]
|
||||
confirm_already_batched?: boolean
|
||||
}
|
||||
|
||||
export type CreateBatchResult =
|
||||
| { ok: true; batch: SupplierPaymentBatch }
|
||||
| { ok: false; code: 'debtor_incomplete'; missing: 'iban' | 'bic' }
|
||||
| { ok: false; code: 'ineligible'; details: Array<{ id: string; reason: string }> }
|
||||
| { ok: false; code: 'amount_exceeds_remaining'; details: Array<{ id: string }> }
|
||||
| { ok: false; code: 'invalid_amount'; details: Array<{ id: string }> }
|
||||
| { ok: false; code: 'already_batched'; details: Array<{ id: string; batch_id: string }> }
|
||||
| { ok: false; code: 'create_failed' }
|
||||
|
||||
export async function createSupplierPaymentBatch(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
input: CreateBatchInput,
|
||||
): Promise<CreateBatchResult> {
|
||||
const today = getSwedishLocalDate()
|
||||
const ids = input.items.map((item) => item.supplier_invoice_id)
|
||||
|
||||
const debtorResolution = await resolveBatchDebtor(supabase, companyId)
|
||||
if (!debtorResolution.ok) {
|
||||
return { ok: false, code: 'debtor_incomplete', missing: debtorResolution.missing }
|
||||
}
|
||||
const { debtor } = debtorResolution
|
||||
|
||||
const [{ data: invoices }, activeBatchIdByInvoice] = await Promise.all([
|
||||
supabase
|
||||
.from('supplier_invoices')
|
||||
.select(INVOICE_SELECT)
|
||||
.eq('company_id', companyId)
|
||||
.in('id', ids),
|
||||
loadActiveBatchMap(supabase, companyId),
|
||||
])
|
||||
const rows = (invoices ?? []) as unknown as InvoiceRow[]
|
||||
const byId = new Map(rows.map((row) => [row.id, row]))
|
||||
|
||||
const ineligible: Array<{ id: string; reason: string }> = []
|
||||
const excessive: Array<{ id: string }> = []
|
||||
const invalidAmount: Array<{ id: string }> = []
|
||||
const alreadyBatched: Array<{ id: string; batch_id: string }> = []
|
||||
const itemRows: Array<Omit<SupplierPaymentBatchItem, 'id' | 'batch_id' | 'created_at'>> = []
|
||||
|
||||
for (const item of input.items) {
|
||||
const invoice = byId.get(item.supplier_invoice_id)
|
||||
if (!invoice || !invoice.supplier) {
|
||||
ineligible.push({ id: item.supplier_invoice_id, reason: invoice ? 'payee_missing' : 'not_found' })
|
||||
continue
|
||||
}
|
||||
const evaluation = evaluateInvoiceForBatch(invoice, invoice.supplier, {
|
||||
today,
|
||||
activeBatchIdByInvoice,
|
||||
})
|
||||
if (!evaluation.eligible) {
|
||||
ineligible.push({ id: invoice.id, reason: evaluation.reason })
|
||||
continue
|
||||
}
|
||||
if (evaluation.activeBatchId && !input.confirm_already_batched) {
|
||||
alreadyBatched.push({ id: invoice.id, batch_id: evaluation.activeBatchId })
|
||||
continue
|
||||
}
|
||||
|
||||
const amount = item.amount !== undefined ? roundOre(item.amount) : evaluation.defaults.amount
|
||||
if (amount <= 0) {
|
||||
invalidAmount.push({ id: invoice.id })
|
||||
continue
|
||||
}
|
||||
if (amount > invoice.remaining_amount + ORE_TOLERANCE) {
|
||||
excessive.push({ id: invoice.id })
|
||||
continue
|
||||
}
|
||||
|
||||
// A payment date in the past is normalized to today: banks reject passed
|
||||
// execution dates, and "pay now" is what an overdue due date means.
|
||||
const requestedDate = item.payment_date ?? evaluation.defaults.payment_date
|
||||
const paymentDate = requestedDate > today ? requestedDate : today
|
||||
|
||||
const { payee } = evaluation
|
||||
itemRows.push({
|
||||
company_id: companyId,
|
||||
supplier_invoice_id: invoice.id,
|
||||
amount,
|
||||
payment_date: paymentDate,
|
||||
payee_type: payee.type,
|
||||
payee_bankgiro: payee.type === 'bankgiro' ? payee.bankgiro : null,
|
||||
payee_plusgiro: payee.type === 'plusgiro' ? payee.plusgiro : null,
|
||||
payee_clearing: payee.type === 'bank_account' ? payee.clearing : null,
|
||||
payee_account: payee.type === 'bank_account' ? payee.account : null,
|
||||
payee_name: invoice.supplier.name,
|
||||
reference_type: evaluation.reference.type,
|
||||
reference: evaluation.reference.value,
|
||||
})
|
||||
}
|
||||
|
||||
if (ineligible.length > 0) return { ok: false, code: 'ineligible', details: ineligible }
|
||||
if (invalidAmount.length > 0) return { ok: false, code: 'invalid_amount', details: invalidAmount }
|
||||
if (excessive.length > 0) return { ok: false, code: 'amount_exceeds_remaining', details: excessive }
|
||||
if (alreadyBatched.length > 0) return { ok: false, code: 'already_batched', details: alreadyBatched }
|
||||
if (itemRows.length === 0) return { ok: false, code: 'create_failed' }
|
||||
|
||||
// The id is minted here (not by the DB default) because msg_id derives from
|
||||
// it and both must land in the same INSERT.
|
||||
const batchId = crypto.randomUUID()
|
||||
const orgDigits = debtor.org_number.replace(/\D/g, '')
|
||||
const msgId = `${getBranding().appName.toUpperCase()}-${orgDigits}-B${batchId.replace(/-/g, '').slice(0, 8).toUpperCase()}`.slice(0, 35)
|
||||
const totalAmount = sumOre(itemRows.map((row) => row.amount))
|
||||
|
||||
const { data: batch, error: batchError } = await supabase
|
||||
.from('supplier_payment_batches')
|
||||
.insert({
|
||||
id: batchId,
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
format: input.format,
|
||||
status: 'created',
|
||||
currency: 'SEK',
|
||||
total_amount: totalAmount,
|
||||
item_count: itemRows.length,
|
||||
msg_id: msgId,
|
||||
debtor_snapshot: debtor,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (batchError || !batch) return { ok: false, code: 'create_failed' }
|
||||
|
||||
const { error: itemsError } = await supabase
|
||||
.from('supplier_payment_batch_items')
|
||||
.insert(itemRows.map((row) => ({ ...row, batch_id: batchId })))
|
||||
|
||||
if (itemsError) {
|
||||
// Best-effort rollback: without its items the batch must not exist. There
|
||||
// is no DELETE policy, so flag it cancelled instead of leaving an empty
|
||||
// "created" batch behind.
|
||||
await supabase
|
||||
.from('supplier_payment_batches')
|
||||
.update({ status: 'cancelled', cancelled_at: new Date().toISOString(), cancelled_by: userId })
|
||||
.eq('id', batchId)
|
||||
.eq('company_id', companyId)
|
||||
return { ok: false, code: 'create_failed' }
|
||||
}
|
||||
|
||||
return { ok: true, batch: batch as SupplierPaymentBatch }
|
||||
}
|
||||
|
||||
export interface RenderedBatchFile {
|
||||
content: string
|
||||
contentType: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the payment file for a stored batch. Deterministic: same rows, same
|
||||
* bytes, on every call.
|
||||
*/
|
||||
export function renderSupplierPaymentBatchFile(
|
||||
batch: Pick<SupplierPaymentBatch, 'id' | 'format' | 'msg_id' | 'debtor_snapshot' | 'created_at'>,
|
||||
items: SupplierPaymentBatchItem[],
|
||||
): RenderedBatchFile {
|
||||
if (batch.format !== 'pain001') {
|
||||
throw new Error(`Filformatet stöds inte: ${batch.format}`)
|
||||
}
|
||||
|
||||
const payments: SupplierPain001Payment[] = items.map((item) => ({
|
||||
payee:
|
||||
item.payee_type === 'bankgiro'
|
||||
? { type: 'bankgiro', bankgiro: item.payee_bankgiro ?? '' }
|
||||
: item.payee_type === 'plusgiro'
|
||||
? { type: 'plusgiro', plusgiro: item.payee_plusgiro ?? '' }
|
||||
: {
|
||||
type: 'bank_account',
|
||||
clearing: item.payee_clearing ?? '',
|
||||
account: item.payee_account ?? '',
|
||||
},
|
||||
payeeName: item.payee_name,
|
||||
amount: item.amount,
|
||||
paymentDate: item.payment_date,
|
||||
reference: { type: item.reference_type, value: item.reference },
|
||||
}))
|
||||
|
||||
const debtor = batch.debtor_snapshot
|
||||
const content = generateSupplierPain001(
|
||||
{ name: debtor.name, orgNumber: debtor.org_number, iban: debtor.iban, bic: debtor.bic },
|
||||
payments,
|
||||
{ messageId: batch.msg_id, createdAt: batch.created_at },
|
||||
)
|
||||
|
||||
const datePart = batch.created_at.slice(0, 10).replace(/-/g, '')
|
||||
const shortId = batch.id.replace(/-/g, '').slice(0, 8)
|
||||
return {
|
||||
content,
|
||||
contentType: 'application/xml; charset=utf-8',
|
||||
filename: `betalfil_${datePart}_${shortId}.xml`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* pain.001 (ISO 20022) payment file generator for supplier payments
|
||||
* (leverantorsbetalningar).
|
||||
*
|
||||
* Dialect: Swedish DOMESTIC giro credit transfers per the Swedish Common
|
||||
* Interpretation of ISO 20022 payment messages (Svenska Bankforeningen,
|
||||
* "Common Payment Types in Sweden", Appendix 1: bankgiro, plusgiro and
|
||||
* account payees), cross-checked against Nordea Corporate Access Payables
|
||||
* pain.001 examples v2.6 (2026-06-22). Target banks: Swedbank, SEB,
|
||||
* Handelsbanken, Nordea (pain.001.001.03 uploaded in the corporate portal).
|
||||
*
|
||||
* Wire-format constraints this file encodes (do not "improve" without a bank
|
||||
* implementation guide in hand):
|
||||
*
|
||||
* - No SvcLvl element: SvcLvl SEPA means a SEPA credit transfer (EUR-only);
|
||||
* the domestic default (NURG) applies when SvcLvl is omitted. No CtgyPurp:
|
||||
* SALA/PENS mark salary rails; supplier giro payments are plain transfers.
|
||||
* - Bankgiro payees are addressed through Bankgirot: CdtrAgt ClrSysMmbId
|
||||
* SESBA member 9900, CdtrAcct/Id/Othr with the bare BG digits and
|
||||
* SchmeNm/Prtry BGNR. Plusgiro payees route SESBA member 9960 with
|
||||
* SchmeNm/Cd BBAN. Bank-account payees use the clearing number as the
|
||||
* SESBA member and the account (without clearing) as BBAN, through the
|
||||
* same splitDomesticBankAccount used by the salary generator so the two
|
||||
* files can never route an account differently.
|
||||
* - A Luhn-valid OCR reference rides RmtInf/Strd/CdtrRefInf with type code
|
||||
* SCOR, exactly one per transaction. Anything else is an unstructured
|
||||
* RmtInf/Ustrd message (the giro "meddelande" field).
|
||||
* - MsgId, PmtInfId, InstrId and EndToEndId are Max35Text.
|
||||
* - ReqdExctnDt sits on PmtInf, so payments are grouped into one PmtInf per
|
||||
* distinct payment date.
|
||||
* - Determinism: CreDtTm comes from the caller (the batch row's created_at),
|
||||
* never from the clock, so re-generating a stored batch is byte-identical
|
||||
* and bank-side duplicate detection (keyed on MsgId) stays meaningful.
|
||||
*
|
||||
* Per BFL: the generated file is rakenskapsinformation (underlag) for the
|
||||
* payments it initiates. Subject to 7-year retention.
|
||||
*/
|
||||
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { splitDomesticBankAccount } from '@/lib/salary/payment/bank-account'
|
||||
import type { PaymentReference, SupplierPayee } from './supplier-payee'
|
||||
|
||||
export interface SupplierPain001Debtor {
|
||||
name: string
|
||||
orgNumber: string
|
||||
iban: string
|
||||
bic: string
|
||||
}
|
||||
|
||||
export interface SupplierPain001Payment {
|
||||
payee: SupplierPayee
|
||||
payeeName: string
|
||||
amount: number
|
||||
/** YYYY-MM-DD requested execution date. */
|
||||
paymentDate: string
|
||||
reference: PaymentReference
|
||||
}
|
||||
|
||||
export interface SupplierPain001Options {
|
||||
/** Stored batch msg_id; reused verbatim on regeneration. */
|
||||
messageId: string
|
||||
/** Batch created_at (ISO timestamp): becomes CreDtTm, NOT the clock. */
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** The receiver-side giro message field is 25 positions; keep Ustrd within it. */
|
||||
const USTRD_MAX = 25
|
||||
|
||||
export function generateSupplierPain001(
|
||||
debtor: SupplierPain001Debtor,
|
||||
payments: SupplierPain001Payment[],
|
||||
options: SupplierPain001Options,
|
||||
): string {
|
||||
if (payments.length === 0) {
|
||||
throw new Error('Betalfilen måste innehålla minst en betalning')
|
||||
}
|
||||
|
||||
const creDtTm = new Date(options.createdAt).toISOString().replace(/\.\d{3}Z$/, 'Z')
|
||||
const msgId = max35(options.messageId)
|
||||
const orgDigits = debtor.orgNumber.replace(/\D/g, '')
|
||||
// Sum the per-transaction amounts exactly as they are rendered (rounded to
|
||||
// ore): CtrlSum must equal the sum of the InstdAmt values or banks reject
|
||||
// the file, and summing raw floats then rounding once can differ by an ore.
|
||||
const totalAmount = sumRendered(payments)
|
||||
|
||||
// One PmtInf per distinct execution date, dates ascending; original order
|
||||
// preserved within a date so the file reads like the batch it came from.
|
||||
const byDate = new Map<string, SupplierPain001Payment[]>()
|
||||
for (const payment of payments) {
|
||||
const group = byDate.get(payment.paymentDate)
|
||||
if (group) group.push(payment)
|
||||
else byDate.set(payment.paymentDate, [payment])
|
||||
}
|
||||
const dates = [...byDate.keys()].sort()
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push('<?xml version="1.0" encoding="UTF-8"?>')
|
||||
lines.push('<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03"')
|
||||
lines.push(' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">')
|
||||
lines.push(' <CstmrCdtTrfInitn>')
|
||||
|
||||
lines.push(' <GrpHdr>')
|
||||
lines.push(` <MsgId>${escapeXml(msgId)}</MsgId>`)
|
||||
lines.push(` <CreDtTm>${creDtTm}</CreDtTm>`)
|
||||
lines.push(` <NbOfTxs>${payments.length}</NbOfTxs>`)
|
||||
lines.push(` <CtrlSum>${formatDecimal(totalAmount)}</CtrlSum>`)
|
||||
lines.push(' <InitgPty>')
|
||||
lines.push(` <Nm>${escapeXml(debtor.name)}</Nm>`)
|
||||
if (orgDigits) {
|
||||
lines.push(' <Id>')
|
||||
lines.push(' <OrgId>')
|
||||
lines.push(` <Othr><Id>${escapeXml(orgDigits)}</Id></Othr>`)
|
||||
lines.push(' </OrgId>')
|
||||
lines.push(' </Id>')
|
||||
}
|
||||
lines.push(' </InitgPty>')
|
||||
lines.push(' </GrpHdr>')
|
||||
|
||||
let txCounter = 0
|
||||
for (let g = 0; g < dates.length; g++) {
|
||||
const date = dates[g]
|
||||
const group = byDate.get(date) as SupplierPain001Payment[]
|
||||
const groupTotal = sumRendered(group)
|
||||
|
||||
lines.push(' <PmtInf>')
|
||||
lines.push(` <PmtInfId>${escapeXml(suffixId(msgId, `-P${g + 1}`))}</PmtInfId>`)
|
||||
lines.push(' <PmtMtd>TRF</PmtMtd>')
|
||||
lines.push(' <BtchBookg>true</BtchBookg>')
|
||||
lines.push(` <NbOfTxs>${group.length}</NbOfTxs>`)
|
||||
lines.push(` <CtrlSum>${formatDecimal(groupTotal)}</CtrlSum>`)
|
||||
lines.push(` <ReqdExctnDt>${date}</ReqdExctnDt>`)
|
||||
lines.push(' <Dbtr>')
|
||||
lines.push(` <Nm>${escapeXml(debtor.name)}</Nm>`)
|
||||
if (orgDigits) {
|
||||
lines.push(' <Id>')
|
||||
lines.push(' <OrgId>')
|
||||
lines.push(` <Othr><Id>${escapeXml(orgDigits)}</Id></Othr>`)
|
||||
lines.push(' </OrgId>')
|
||||
lines.push(' </Id>')
|
||||
}
|
||||
lines.push(' </Dbtr>')
|
||||
lines.push(' <DbtrAcct>')
|
||||
lines.push(' <Id>')
|
||||
lines.push(` <IBAN>${escapeXml(debtor.iban)}</IBAN>`)
|
||||
lines.push(' </Id>')
|
||||
lines.push(' <Ccy>SEK</Ccy>')
|
||||
lines.push(' </DbtrAcct>')
|
||||
lines.push(' <DbtrAgt>')
|
||||
lines.push(' <FinInstnId>')
|
||||
lines.push(` <BIC>${escapeXml(debtor.bic)}</BIC>`)
|
||||
lines.push(' </FinInstnId>')
|
||||
lines.push(' </DbtrAgt>')
|
||||
|
||||
for (const payment of group) {
|
||||
txCounter += 1
|
||||
const txId = suffixId(msgId, `-TX${String(txCounter).padStart(4, '0')}`)
|
||||
|
||||
lines.push(' <CdtTrfTxInf>')
|
||||
lines.push(' <PmtId>')
|
||||
lines.push(` <InstrId>${escapeXml(txId)}</InstrId>`)
|
||||
lines.push(` <EndToEndId>${escapeXml(txId)}</EndToEndId>`)
|
||||
lines.push(' </PmtId>')
|
||||
lines.push(' <Amt>')
|
||||
lines.push(` <InstdAmt Ccy="SEK">${formatDecimal(payment.amount)}</InstdAmt>`)
|
||||
lines.push(' </Amt>')
|
||||
pushCreditor(lines, payment)
|
||||
pushRemittance(lines, payment.reference)
|
||||
lines.push(' </CdtTrfTxInf>')
|
||||
}
|
||||
|
||||
lines.push(' </PmtInf>')
|
||||
}
|
||||
|
||||
lines.push(' </CstmrCdtTrfInitn>')
|
||||
lines.push('</Document>')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/** XSD order within CdtTrfTxInf: CdtrAgt before Cdtr before CdtrAcct. */
|
||||
function pushCreditor(lines: string[], payment: SupplierPain001Payment): void {
|
||||
const { payee } = payment
|
||||
|
||||
let memberId: string
|
||||
let accountId: string
|
||||
let scheme: string
|
||||
switch (payee.type) {
|
||||
case 'bankgiro':
|
||||
memberId = '9900'
|
||||
accountId = payee.bankgiro
|
||||
scheme = '<SchmeNm><Prtry>BGNR</Prtry></SchmeNm>'
|
||||
break
|
||||
case 'plusgiro':
|
||||
memberId = '9960'
|
||||
accountId = payee.plusgiro
|
||||
scheme = '<SchmeNm><Cd>BBAN</Cd></SchmeNm>'
|
||||
break
|
||||
case 'bank_account': {
|
||||
const { clearing4, accountDigits } = splitDomesticBankAccount(payee.clearing, payee.account)
|
||||
memberId = clearing4
|
||||
accountId = accountDigits
|
||||
scheme = '<SchmeNm><Cd>BBAN</Cd></SchmeNm>'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(' <CdtrAgt>')
|
||||
lines.push(' <FinInstnId>')
|
||||
lines.push(' <ClrSysMmbId>')
|
||||
lines.push(' <ClrSysId><Cd>SESBA</Cd></ClrSysId>')
|
||||
lines.push(` <MmbId>${memberId}</MmbId>`)
|
||||
lines.push(' </ClrSysMmbId>')
|
||||
lines.push(' </FinInstnId>')
|
||||
lines.push(' </CdtrAgt>')
|
||||
lines.push(' <Cdtr>')
|
||||
lines.push(` <Nm>${escapeXml(payment.payeeName)}</Nm>`)
|
||||
lines.push(' </Cdtr>')
|
||||
lines.push(' <CdtrAcct>')
|
||||
lines.push(' <Id>')
|
||||
lines.push(' <Othr>')
|
||||
lines.push(` <Id>${escapeXml(accountId)}</Id>`)
|
||||
lines.push(` ${scheme}`)
|
||||
lines.push(' </Othr>')
|
||||
lines.push(' </Id>')
|
||||
lines.push(' </CdtrAcct>')
|
||||
}
|
||||
|
||||
function pushRemittance(lines: string[], reference: PaymentReference): void {
|
||||
lines.push(' <RmtInf>')
|
||||
if (reference.type === 'ocr') {
|
||||
lines.push(' <Strd>')
|
||||
lines.push(' <CdtrRefInf>')
|
||||
lines.push(' <Tp>')
|
||||
lines.push(' <CdOrPrtry><Cd>SCOR</Cd></CdOrPrtry>')
|
||||
lines.push(' </Tp>')
|
||||
lines.push(` <Ref>${escapeXml(reference.value)}</Ref>`)
|
||||
lines.push(' </CdtrRefInf>')
|
||||
lines.push(' </Strd>')
|
||||
} else {
|
||||
lines.push(` <Ustrd>${escapeXml(reference.value.slice(0, USTRD_MAX))}</Ustrd>`)
|
||||
}
|
||||
lines.push(' </RmtInf>')
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helpers (deliberately duplicated from the salary generator: that dialect is
|
||||
// production-hardened and stays untouched; see DECISIONS.md 2026-08-10)
|
||||
// ============================================================
|
||||
|
||||
/** Control sums add the amounts AS RENDERED: each rounded to öre first. */
|
||||
function sumRendered(payments: readonly SupplierPain001Payment[]): number {
|
||||
return payments.reduce((sum, p) => sum + roundOre(p.amount), 0)
|
||||
}
|
||||
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -850,6 +850,19 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
|
||||
denormalize: { prefix: 'supplier_invoice_', columns: ['currency', 'exchange_rate'] },
|
||||
},
|
||||
{ name: 'supplier_invoice_payments', file: 'supplier_invoice_payments.json' },
|
||||
// Payment batches (betalfil): the immutable instruction snapshots a
|
||||
// generated bank payment file derives from; underlag for the payments it
|
||||
// initiated, so they leave with the archive.
|
||||
{
|
||||
name: 'supplier_payment_batches',
|
||||
file: 'supplier_payment_batches.json',
|
||||
orderBy: 'created_at',
|
||||
},
|
||||
{
|
||||
name: 'supplier_payment_batch_items',
|
||||
file: 'supplier_payment_batch_items.json',
|
||||
orderBy: 'created_at',
|
||||
},
|
||||
// Underlag intake: the chat answers behind a verifikat.
|
||||
//
|
||||
// A projection, not the whole table. `channel_context` holds the human
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
-- Supplier payment batches (betalfil for leverantorsfakturor).
|
||||
--
|
||||
-- A batch is a snapshot of payment instructions handed to the user's bank as a
|
||||
-- pain.001 file. The file regenerates deterministically from these rows alone
|
||||
-- (msg_id stored at creation, CreDtTm derived from created_at), so a
|
||||
-- re-download is byte-identical and bank-side duplicate detection works; a new
|
||||
-- batch gets a new msg_id. This closes, for supplier payments, the
|
||||
-- no-regeneration-guard hazard documented for the salary payment files
|
||||
-- (DECISIONS.md 2026-07-26).
|
||||
--
|
||||
-- Generating a file moves no money and books nothing: settlement stays in the
|
||||
-- existing mark-paid / bank-match flows. Batch settlement progress is derived
|
||||
-- at read time by joining items to live supplier_invoices; it is never stored.
|
||||
--
|
||||
-- format allows 'bg_lb' at the DB level so a future LB or pain.001.001.09
|
||||
-- addition needs no migration; the v1 API accepts only 'pain001'.
|
||||
|
||||
CREATE TABLE public.supplier_payment_batches (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
format text NOT NULL CHECK (format IN ('pain001', 'bg_lb')),
|
||||
status text NOT NULL DEFAULT 'created' CHECK (status IN ('created', 'cancelled')),
|
||||
currency text NOT NULL DEFAULT 'SEK',
|
||||
total_amount numeric NOT NULL CHECK (total_amount > 0),
|
||||
item_count integer NOT NULL CHECK (item_count > 0),
|
||||
-- pain.001 MsgId, derived from the batch id at creation (Max35Text). Stored
|
||||
-- so regeneration never recomputes it differently.
|
||||
msg_id text NOT NULL,
|
||||
-- Debtor snapshot at creation: {name, org_number, iban, bic}. Later changes
|
||||
-- to company_settings never mutate an existing batch.
|
||||
debtor_snapshot jsonb NOT NULL,
|
||||
file_generated_at timestamptz,
|
||||
download_count integer NOT NULL DEFAULT 0,
|
||||
cancelled_at timestamptz,
|
||||
cancelled_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- Composite-FK target so items can enforce company agreement with their
|
||||
-- batch. Trivially unique (id is the PK).
|
||||
CONSTRAINT uq_supplier_payment_batches_id_company UNIQUE (id, company_id)
|
||||
);
|
||||
|
||||
-- Composite-FK target on the invoice side, same reasoning as above.
|
||||
CREATE UNIQUE INDEX uq_supplier_invoices_id_company
|
||||
ON public.supplier_invoices (id, company_id);
|
||||
|
||||
CREATE TABLE public.supplier_payment_batch_items (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
batch_id uuid NOT NULL,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
-- RESTRICT: a batch item documents a payment instruction possibly already
|
||||
-- handed to a bank; the invoice behind it must not vanish. The supplier
|
||||
-- invoice DELETE route pre-checks and returns a friendly error.
|
||||
supplier_invoice_id uuid NOT NULL,
|
||||
amount numeric NOT NULL CHECK (amount > 0),
|
||||
payment_date date NOT NULL,
|
||||
-- Payee and reference snapshot at creation; supplier edits after batch
|
||||
-- creation never change what the generated file says.
|
||||
payee_type text NOT NULL CHECK (payee_type IN ('bankgiro', 'plusgiro', 'bank_account')),
|
||||
payee_bankgiro text,
|
||||
payee_plusgiro text,
|
||||
payee_clearing text,
|
||||
payee_account text,
|
||||
payee_name text NOT NULL,
|
||||
reference_type text NOT NULL CHECK (reference_type IN ('ocr', 'invoice_number')),
|
||||
reference text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_supplier_payment_batch_invoice UNIQUE (batch_id, supplier_invoice_id),
|
||||
-- Composite FKs: company_id must agree with BOTH parents, so a user who
|
||||
-- belongs to two companies can never cross-link a batch in one company to
|
||||
-- an invoice in another (plain per-column FKs would allow it).
|
||||
CONSTRAINT fk_supplier_payment_batch_items_batch
|
||||
FOREIGN KEY (batch_id, company_id)
|
||||
REFERENCES public.supplier_payment_batches (id, company_id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_supplier_payment_batch_items_invoice
|
||||
FOREIGN KEY (supplier_invoice_id, company_id)
|
||||
REFERENCES public.supplier_invoices (id, company_id) ON DELETE RESTRICT,
|
||||
CONSTRAINT supplier_payment_batch_items_payee_fields_match CHECK (
|
||||
(payee_type = 'bankgiro' AND payee_bankgiro IS NOT NULL)
|
||||
OR (payee_type = 'plusgiro' AND payee_plusgiro IS NOT NULL)
|
||||
OR (payee_type = 'bank_account' AND payee_clearing IS NOT NULL AND payee_account IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
ALTER TABLE public.supplier_payment_batches ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.supplier_payment_batch_items ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "view own-company supplier_payment_batches"
|
||||
ON public.supplier_payment_batches FOR SELECT
|
||||
USING (company_id IN (SELECT user_company_ids()));
|
||||
CREATE POLICY "insert own-company supplier_payment_batches"
|
||||
ON public.supplier_payment_batches FOR INSERT
|
||||
WITH CHECK (company_id IN (SELECT user_company_ids()));
|
||||
CREATE POLICY "update own-company supplier_payment_batches"
|
||||
ON public.supplier_payment_batches FOR UPDATE
|
||||
USING (company_id IN (SELECT user_company_ids()));
|
||||
-- No DELETE policy on batches: a batch documents a payment instruction that
|
||||
-- may already sit at the bank. Lifecycle ends at status 'cancelled'.
|
||||
|
||||
CREATE POLICY "view own-company supplier_payment_batch_items"
|
||||
ON public.supplier_payment_batch_items FOR SELECT
|
||||
USING (company_id IN (SELECT user_company_ids()));
|
||||
CREATE POLICY "insert own-company supplier_payment_batch_items"
|
||||
ON public.supplier_payment_batch_items FOR INSERT
|
||||
WITH CHECK (company_id IN (SELECT user_company_ids()));
|
||||
-- No UPDATE or DELETE policies on items: rows are immutable snapshots. The
|
||||
-- only removal path is the batch CASCADE.
|
||||
|
||||
CREATE INDEX idx_supplier_payment_batches_company_created
|
||||
ON public.supplier_payment_batches (company_id, created_at DESC);
|
||||
CREATE INDEX idx_supplier_payment_batches_company_status
|
||||
ON public.supplier_payment_batches (company_id, status);
|
||||
CREATE INDEX idx_supplier_payment_batch_items_batch_id
|
||||
ON public.supplier_payment_batch_items (batch_id);
|
||||
CREATE INDEX idx_supplier_payment_batch_items_supplier_invoice_id
|
||||
ON public.supplier_payment_batch_items (supplier_invoice_id);
|
||||
CREATE INDEX idx_supplier_payment_batch_items_company_id
|
||||
ON public.supplier_payment_batch_items (company_id);
|
||||
|
||||
-- The RLS UPDATE policy cannot compare OLD and NEW, so column-level
|
||||
-- immutability is a trigger: a batch is a snapshot a bank file regenerates
|
||||
-- from, and rewriting msg_id/amounts/debtor after creation would break the
|
||||
-- byte-identical re-download contract. Only lifecycle (created -> cancelled,
|
||||
-- with its who/when) and download metadata may change.
|
||||
CREATE OR REPLACE FUNCTION public.enforce_supplier_payment_batch_immutability()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.id IS DISTINCT FROM OLD.id
|
||||
OR NEW.company_id IS DISTINCT FROM OLD.company_id
|
||||
OR NEW.user_id IS DISTINCT FROM OLD.user_id
|
||||
OR NEW.format IS DISTINCT FROM OLD.format
|
||||
OR NEW.currency IS DISTINCT FROM OLD.currency
|
||||
OR NEW.total_amount IS DISTINCT FROM OLD.total_amount
|
||||
OR NEW.item_count IS DISTINCT FROM OLD.item_count
|
||||
OR NEW.msg_id IS DISTINCT FROM OLD.msg_id
|
||||
OR NEW.debtor_snapshot IS DISTINCT FROM OLD.debtor_snapshot
|
||||
OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN
|
||||
RAISE EXCEPTION 'supplier_payment_batches are immutable snapshots: only lifecycle and download metadata may change';
|
||||
END IF;
|
||||
IF NEW.status IS DISTINCT FROM OLD.status
|
||||
AND NOT (OLD.status = 'created' AND NEW.status = 'cancelled') THEN
|
||||
RAISE EXCEPTION 'supplier_payment_batches: the only status transition is created -> cancelled';
|
||||
END IF;
|
||||
-- Cancellation metadata is audit data: it may only be written as part of
|
||||
-- the created -> cancelled transition. Exception: cancelled_by may become
|
||||
-- NULL at any time, because the FK's ON DELETE SET NULL fires this same
|
||||
-- trigger when the cancelling user's account is deleted.
|
||||
IF NOT (OLD.status = 'created' AND NEW.status = 'cancelled') THEN
|
||||
IF NEW.cancelled_at IS DISTINCT FROM OLD.cancelled_at THEN
|
||||
RAISE EXCEPTION 'supplier_payment_batches: cancelled_at may only be set by the created -> cancelled transition';
|
||||
END IF;
|
||||
IF NEW.cancelled_by IS DISTINCT FROM OLD.cancelled_by AND NEW.cancelled_by IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'supplier_payment_batches: cancelled_by may only be set by the created -> cancelled transition';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER enforce_supplier_payment_batch_immutability
|
||||
BEFORE UPDATE ON public.supplier_payment_batches
|
||||
FOR EACH ROW EXECUTE FUNCTION public.enforce_supplier_payment_batch_immutability();
|
||||
|
||||
CREATE TRIGGER set_updated_at_supplier_payment_batches
|
||||
BEFORE UPDATE ON public.supplier_payment_batches
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER audit_supplier_payment_batches
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.supplier_payment_batches
|
||||
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
|
||||
|
||||
CREATE TRIGGER audit_supplier_payment_batch_items
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.supplier_payment_batch_items
|
||||
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -453,6 +453,8 @@ export function makeSupplier(overrides: Partial<Supplier> = {}): Supplier {
|
||||
bank_account: null,
|
||||
iban: null,
|
||||
bic: null,
|
||||
clearing_number: null,
|
||||
account_number: null,
|
||||
default_expense_account: '6200',
|
||||
default_payment_terms: 30,
|
||||
default_currency: 'SEK',
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getPool, withUserContext } from './setup'
|
||||
import { seedCompany, insertAuthUser } from './fixtures'
|
||||
|
||||
// pg-real coverage for 20260810160748_supplier_payment_batches.sql: RLS
|
||||
// isolation on both tables, the FK RESTRICT that keeps invoices referenced by
|
||||
// a payment instruction undeletable, the payee_fields_match CHECK, the
|
||||
// per-batch invoice uniqueness, item immutability (no UPDATE/DELETE policies),
|
||||
// and the updated_at trigger.
|
||||
|
||||
async function insertSupplier(companyId: string, userId: string): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.suppliers (id, user_id, company_id, name, bankgiro)
|
||||
VALUES ($1, $2, $3, 'Derome Bygg AB', '5050-1055')`,
|
||||
[id, userId, companyId],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertSupplierInvoice(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
supplierId: string,
|
||||
): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.supplier_invoices
|
||||
(id, user_id, company_id, supplier_id, arrival_number,
|
||||
supplier_invoice_number, invoice_date, due_date,
|
||||
subtotal, vat_amount, total, remaining_amount, status)
|
||||
VALUES ($1, $2, $3, $4, floor(random() * 1000000)::int,
|
||||
$5, '2026-06-23', '2026-07-07',
|
||||
590, 147.5, 737.5, 737.5, 'approved')`,
|
||||
[id, userId, companyId, supplierId, `CD-${id.slice(0, 8)}`],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertBatch(companyId: string, userId: string): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.supplier_payment_batches
|
||||
(id, company_id, user_id, format, total_amount, item_count, msg_id, debtor_snapshot)
|
||||
VALUES ($1, $2, $3, 'pain001', 737.5, 1, $4,
|
||||
'{"name":"Test AB","org_number":"556677-8899","iban":"SE3550000000054910000003","bic":"ESSESESS"}')`,
|
||||
[id, companyId, userId, `ACCOUNTED-5566778899-B${id.slice(0, 8)}`],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertItem(params: {
|
||||
batchId: string
|
||||
companyId: string
|
||||
supplierInvoiceId: string
|
||||
payeeType?: string
|
||||
payeeBankgiro?: string | null
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.supplier_payment_batch_items
|
||||
(id, batch_id, company_id, supplier_invoice_id, amount, payment_date,
|
||||
payee_type, payee_bankgiro, payee_name, reference_type, reference)
|
||||
VALUES ($1, $2, $3, $4, 737.5, '2026-08-15',
|
||||
$5, $6, 'Derome Bygg AB', 'invoice_number', 'CD3014794407')`,
|
||||
[
|
||||
id,
|
||||
params.batchId,
|
||||
params.companyId,
|
||||
params.supplierInvoiceId,
|
||||
params.payeeType ?? 'bankgiro',
|
||||
params.payeeBankgiro === undefined ? '50501055' : params.payeeBankgiro,
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function seedBatchWithItem() {
|
||||
const ctx = await seedCompany()
|
||||
const supplierId = await insertSupplier(ctx.companyId, ctx.userId)
|
||||
const invoiceId = await insertSupplierInvoice(ctx.companyId, ctx.userId, supplierId)
|
||||
const batchId = await insertBatch(ctx.companyId, ctx.userId)
|
||||
const itemId = await insertItem({
|
||||
batchId,
|
||||
companyId: ctx.companyId,
|
||||
supplierInvoiceId: invoiceId,
|
||||
})
|
||||
return { ...ctx, supplierId, invoiceId, batchId, itemId }
|
||||
}
|
||||
|
||||
describe('supplier_payment_batches RLS', () => {
|
||||
it('isolates batches and items to company members', async () => {
|
||||
const ctx = await seedBatchWithItem()
|
||||
const stranger = await insertAuthUser()
|
||||
|
||||
const ownerBatches = await withUserContext(ctx.userId, (client) =>
|
||||
client.query(`SELECT id FROM public.supplier_payment_batches WHERE id = $1`, [ctx.batchId]),
|
||||
)
|
||||
expect(ownerBatches.rows).toHaveLength(1)
|
||||
|
||||
const strangerBatches = await withUserContext(stranger, (client) =>
|
||||
client.query(`SELECT id FROM public.supplier_payment_batches WHERE id = $1`, [ctx.batchId]),
|
||||
)
|
||||
expect(strangerBatches.rows).toHaveLength(0)
|
||||
|
||||
const ownerItems = await withUserContext(ctx.userId, (client) =>
|
||||
client.query(`SELECT id FROM public.supplier_payment_batch_items WHERE id = $1`, [
|
||||
ctx.itemId,
|
||||
]),
|
||||
)
|
||||
expect(ownerItems.rows).toHaveLength(1)
|
||||
|
||||
const strangerItems = await withUserContext(stranger, (client) =>
|
||||
client.query(`SELECT id FROM public.supplier_payment_batch_items WHERE id = $1`, [
|
||||
ctx.itemId,
|
||||
]),
|
||||
)
|
||||
expect(strangerItems.rows).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('blocks a stranger from inserting into another company', async () => {
|
||||
const ctx = await seedBatchWithItem()
|
||||
const stranger = await insertAuthUser()
|
||||
|
||||
await expect(
|
||||
withUserContext(stranger, (client) =>
|
||||
client.query(
|
||||
`INSERT INTO public.supplier_payment_batches
|
||||
(company_id, user_id, format, total_amount, item_count, msg_id, debtor_snapshot)
|
||||
VALUES ($1, $2, 'pain001', 1, 1, 'X', '{}')`,
|
||||
[ctx.companyId, stranger],
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(/row-level security/)
|
||||
})
|
||||
|
||||
it('lets a member cancel (update) a batch but never update or delete items', async () => {
|
||||
const ctx = await seedBatchWithItem()
|
||||
|
||||
const cancel = await withUserContext(ctx.userId, (client) =>
|
||||
client.query(
|
||||
`UPDATE public.supplier_payment_batches
|
||||
SET status = 'cancelled', cancelled_at = now(), cancelled_by = $2
|
||||
WHERE id = $1 AND status = 'created'`,
|
||||
[ctx.batchId, ctx.userId],
|
||||
),
|
||||
)
|
||||
expect(cancel.rowCount).toBe(1)
|
||||
|
||||
// Items are immutable snapshots: no UPDATE/DELETE policies exist, so the
|
||||
// statements succeed but match zero rows.
|
||||
const update = await withUserContext(ctx.userId, (client) =>
|
||||
client.query(`UPDATE public.supplier_payment_batch_items SET amount = 1 WHERE id = $1`, [
|
||||
ctx.itemId,
|
||||
]),
|
||||
)
|
||||
expect(update.rowCount).toBe(0)
|
||||
|
||||
const del = await withUserContext(ctx.userId, (client) =>
|
||||
client.query(`DELETE FROM public.supplier_payment_batch_items WHERE id = $1`, [ctx.itemId]),
|
||||
)
|
||||
expect(del.rowCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('supplier_payment_batches constraints', () => {
|
||||
it('FK RESTRICT keeps an invoice referenced by a batch item undeletable', async () => {
|
||||
const ctx = await seedBatchWithItem()
|
||||
|
||||
await expect(
|
||||
getPool().query(`DELETE FROM public.supplier_invoices WHERE id = $1`, [ctx.invoiceId]),
|
||||
).rejects.toThrow(/violates foreign key constraint/)
|
||||
|
||||
// Removing the batch cascades the item away, after which the invoice can go.
|
||||
await getPool().query(`DELETE FROM public.supplier_payment_batches WHERE id = $1`, [
|
||||
ctx.batchId,
|
||||
])
|
||||
await getPool().query(`DELETE FROM public.supplier_invoices WHERE id = $1`, [ctx.invoiceId])
|
||||
})
|
||||
|
||||
it('payee_fields_match rejects a payee type without its fields', async () => {
|
||||
const ctx = await seedBatchWithItem()
|
||||
const otherInvoice = await insertSupplierInvoice(ctx.companyId, ctx.userId, ctx.supplierId)
|
||||
|
||||
await expect(
|
||||
insertItem({
|
||||
batchId: ctx.batchId,
|
||||
companyId: ctx.companyId,
|
||||
supplierInvoiceId: otherInvoice,
|
||||
payeeType: 'bankgiro',
|
||||
payeeBankgiro: null,
|
||||
}),
|
||||
).rejects.toThrow(/payee_fields_match/)
|
||||
|
||||
await expect(
|
||||
insertItem({
|
||||
batchId: ctx.batchId,
|
||||
companyId: ctx.companyId,
|
||||
supplierInvoiceId: otherInvoice,
|
||||
payeeType: 'bank_account',
|
||||
payeeBankgiro: null,
|
||||
}),
|
||||
).rejects.toThrow(/payee_fields_match/)
|
||||
})
|
||||
|
||||
it('rejects the same invoice twice in one batch', async () => {
|
||||
const ctx = await seedBatchWithItem()
|
||||
|
||||
await expect(
|
||||
insertItem({
|
||||
batchId: ctx.batchId,
|
||||
companyId: ctx.companyId,
|
||||
supplierInvoiceId: ctx.invoiceId,
|
||||
}),
|
||||
).rejects.toThrow(/uq_supplier_payment_batch_invoice/)
|
||||
})
|
||||
|
||||
it('rejects amounts and counts outside their CHECKs', async () => {
|
||||
const ctx = await seedCompany()
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`INSERT INTO public.supplier_payment_batches
|
||||
(company_id, user_id, format, total_amount, item_count, msg_id, debtor_snapshot)
|
||||
VALUES ($1, $2, 'pain001', 0, 1, 'X', '{}')`,
|
||||
[ctx.companyId, ctx.userId],
|
||||
),
|
||||
).rejects.toThrow(/total_amount/)
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`INSERT INTO public.supplier_payment_batches
|
||||
(company_id, user_id, format, total_amount, item_count, msg_id, debtor_snapshot)
|
||||
VALUES ($1, $2, 'swish', 1, 1, 'X', '{}')`,
|
||||
[ctx.companyId, ctx.userId],
|
||||
),
|
||||
).rejects.toThrow(/format/)
|
||||
})
|
||||
|
||||
it('rejects an item whose company differs from its batch or invoice', async () => {
|
||||
const ctx = await seedBatchWithItem()
|
||||
const other = await seedCompany()
|
||||
const otherSupplier = await insertSupplier(other.companyId, other.userId)
|
||||
const otherInvoice = await insertSupplierInvoice(other.companyId, other.userId, otherSupplier)
|
||||
|
||||
// Batch in ctx's company, invoice + company_id from the other company:
|
||||
// the composite FK on (batch_id, company_id) must refuse the cross-link.
|
||||
await expect(
|
||||
insertItem({
|
||||
batchId: ctx.batchId,
|
||||
companyId: other.companyId,
|
||||
supplierInvoiceId: otherInvoice,
|
||||
}),
|
||||
).rejects.toThrow(/fk_supplier_payment_batch_items_batch/)
|
||||
|
||||
// Invoice from the other company under ctx's company_id: the composite FK
|
||||
// on (supplier_invoice_id, company_id) must refuse it too.
|
||||
await expect(
|
||||
insertItem({
|
||||
batchId: ctx.batchId,
|
||||
companyId: ctx.companyId,
|
||||
supplierInvoiceId: otherInvoice,
|
||||
}),
|
||||
).rejects.toThrow(/fk_supplier_payment_batch_items_invoice/)
|
||||
})
|
||||
|
||||
it('keeps batches immutable outside lifecycle and download metadata', async () => {
|
||||
const ctx = await seedBatchWithItem()
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.supplier_payment_batches SET total_amount = 999 WHERE id = $1`,
|
||||
[ctx.batchId],
|
||||
),
|
||||
).rejects.toThrow(/immutable snapshots/)
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.supplier_payment_batches SET msg_id = 'REWRITTEN' WHERE id = $1`,
|
||||
[ctx.batchId],
|
||||
),
|
||||
).rejects.toThrow(/immutable snapshots/)
|
||||
|
||||
// Cancellation metadata cannot be written outside the transition.
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.supplier_payment_batches SET cancelled_at = now() WHERE id = $1`,
|
||||
[ctx.batchId],
|
||||
),
|
||||
).rejects.toThrow(/cancelled_at may only be set/)
|
||||
|
||||
// The sanctioned transition works, and cannot be reversed. The canceller
|
||||
// is deliberately NOT the batch owner: deleting the owner would CASCADE
|
||||
// the batch away, and the SET NULL assertion below needs it to survive.
|
||||
const cancellerId = await insertAuthUser()
|
||||
await getPool().query(
|
||||
`UPDATE public.supplier_payment_batches
|
||||
SET status = 'cancelled', cancelled_at = now(), cancelled_by = $2 WHERE id = $1`,
|
||||
[ctx.batchId, cancellerId],
|
||||
)
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.supplier_payment_batches SET status = 'created' WHERE id = $1`,
|
||||
[ctx.batchId],
|
||||
),
|
||||
).rejects.toThrow(/created -> cancelled/)
|
||||
|
||||
// Audit data on a cancelled batch cannot be rewritten to another user...
|
||||
const otherUser = await insertAuthUser()
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.supplier_payment_batches SET cancelled_by = $2 WHERE id = $1`,
|
||||
[ctx.batchId, otherUser],
|
||||
),
|
||||
).rejects.toThrow(/cancelled_by may only be set/)
|
||||
|
||||
// ...but the FK's ON DELETE SET NULL path must stay open: deleting the
|
||||
// cancelling user's account nulls the reference through this trigger.
|
||||
await getPool().query(`DELETE FROM auth.users WHERE id = $1`, [cancellerId])
|
||||
const after = await getPool().query(
|
||||
`SELECT cancelled_by FROM public.supplier_payment_batches WHERE id = $1`,
|
||||
[ctx.batchId],
|
||||
)
|
||||
expect(after.rows[0].cancelled_by).toBeNull()
|
||||
})
|
||||
|
||||
it('touches updated_at on batch update', async () => {
|
||||
const ctx = await seedBatchWithItem()
|
||||
|
||||
const before = await getPool().query(
|
||||
`SELECT updated_at FROM public.supplier_payment_batches WHERE id = $1`,
|
||||
[ctx.batchId],
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
await getPool().query(
|
||||
`UPDATE public.supplier_payment_batches SET download_count = download_count + 1 WHERE id = $1`,
|
||||
[ctx.batchId],
|
||||
)
|
||||
const after = await getPool().query(
|
||||
`SELECT updated_at FROM public.supplier_payment_batches WHERE id = $1`,
|
||||
[ctx.batchId],
|
||||
)
|
||||
expect(new Date(after.rows[0].updated_at).getTime()).toBeGreaterThan(
|
||||
new Date(before.rows[0].updated_at).getTime(),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user