Files
accounted/lib/payments/batch-eligibility.ts
T
f776e375c5 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>
2026-08-10 19:53:39 +02:00

116 lines
3.7 KiB
TypeScript

/**
* 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,
}
}