4655f3da48
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>
436 lines
15 KiB
TypeScript
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`,
|
|
}
|
|
}
|