Files
accounted/lib/payments/batch-eligibility.ts
T
Jakob Wennberg e45218bcc6 fix(payments): creditor address on every payment (Validex round 3) (#1509)
Rule 237 fires on the BGNR-to-BGNR path too, so the round-2 reading of
rule 020 was wrong: it only relaxes the Ctry element, not the address.
Cdtr now always carries PstlAdr (TwnNm from the payee_city snapshot when
known, Ctry SE), and the preview warns when the supplier register lacks
a city, since rules 237 + 222 together make a town effectively mandatory
at Swedbank.

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:54:07 +02:00

124 lines
4.0 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'
// Swedbank rejects a creditor address without a town (Validex rules
// 237 + 222), and the address itself is mandatory; warn so the user fills
// in the supplier's city before the bank bounces the file.
| 'payee_city_missing'
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 & { city?: string | null },
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')
if (!supplier.city?.trim()) warnings.push('payee_city_missing')
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,
}
}