Files
accounted/lib/payments/batch-service.ts
T
Jakob Wennberg 4655f3da48 fix(payments): creditor address per Swedbank TwnNm rule (Validex round 2) (#1508)
A present PstlAdr must carry TwnNm from November 2026 (PFH_222), so
BGNR-to-BGNR payments now carry no creditor address at all (rule 020
requires none there), IBAN-debited payments carry the supplier's town
(snapshotted as payee_city) plus Ctry SE, and the debtor address comes
from company settings, clearing the info-level rule 236 as well.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 21:19:44 +02:00

436 lines
15 KiB
TypeScript

/**
* 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 { validateBankgiroNumber } from '@/lib/bankgiro/luhn'
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; city: string | null }) | 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, city, bankgiro, plusgiro, bank_account, clearing_number, account_number)'
export interface BatchDebtor {
name: string
org_number: string
iban: string
bic: string
/** Company bankgiro digits; enables the BGNR-to-BGNR debit Swedbank wants. */
bankgiro: string | null
/** Company town; Dbtr/PstlAdr/TwnNm (mandatory from Nov 2026 when present). */
city: string | null
}
export type DebtorResolution =
| { ok: true; debtor: BatchDebtor }
| { ok: false; missing: 'iban' | 'bic' | 'org_number' }
/**
* 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. The org number is required: InitgPty must carry an OrgId
* (Swedbank Validex PFH_002). The bankgiro rides along when valid so
* bankgiro payees can be debited BGNR-to-BGNR.
*/
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, org_number, city, iban, bic, bankgiro, 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' }
// Settings first: it is the maintained value; companies.org_number is the
// write-once onboarding snapshot and may be empty.
const orgNumber = settings?.org_number?.trim() || company?.org_number?.trim() || ''
if (!orgNumber.replace(/\D/g, '')) return { ok: false, missing: 'org_number' }
const bankgiroRaw = settings?.bankgiro ?? ''
const bankgiro = validateBankgiroNumber(bankgiroRaw) ? bankgiroRaw.replace(/\D/g, '') : null
return {
ok: true,
debtor: {
name: settings?.company_name || company?.name || '',
org_number: orgNumber,
iban,
bic,
bankgiro,
city: settings?.city?.trim() || null,
},
}
}
/**
* 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' | 'org_number'
}
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' | 'org_number' }
| { 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,
payee_city: invoice.supplier.city?.trim() || null,
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,
payeeCity: item.payee_city ?? null,
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,
bankgiro: debtor.bankgiro ?? null,
city: debtor.city ?? null,
},
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`,
}
}