fix(bookkeeping): settle öre differences to 3740 and improve supplier-invoice matching (#699)

Whole-krona Bankgiro/Swish payments of öre-bearing invoices were stranded
as partially_paid forever (e.g. 11 231 paid on an 11 231,25 invoice left
0,25 kr open). Book the sub-krona residual to BAS 3740 (Öres- och
kronutjämning) and settle the invoice in full, on both the supplier- and
customer-invoice match flows.

New shared pure helpers buildSupplierPaymentClearingLines +
planSupplierPayment mirror the customer-side primitives; routing preview
and commit through the same builder also fixes two pre-existing
preview↔commit drifts (payment account + line descriptions). Öre
absorption is accrual-only — cash entries book the full invoice, so
absorbing there would hide a 1930 discrepancy.

Also improves supplier-invoice ↔ bank matching:
- Pass-3 date window now spans [invoice_date-5, due_date+5] instead of
  due_date ±5, so early payments auto-match; an ambiguity guard demotes
  non-unique amount matches to suggestions.
- New retroactive matcher (on supplier_invoice.registered/.approved)
  surfaces the settling bank payment when the invoice is registered after
  the payment was imported. Matches are written as suggestions for
  one-click confirm-to-book, never silently auto-booked.

Tests: new unit tests for both pure helpers; extended matching, handler,
customer öre, and route suites. Full suite green (407 files / 5364 tests).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-09 19:09:39 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 4dfd790de5
commit b38b3d0230
23 changed files with 1157 additions and 90 deletions
@@ -14,6 +14,7 @@ describe('planInvoicePayment', () => {
newRemaining: 0,
isFullyPaid: true,
newStatus: 'paid',
oreSettled: false,
})
}
})
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest'
import { planSupplierPayment } from '@/lib/invoices/apply-supplier-payment'
describe('planSupplierPayment', () => {
const invoice = { total: 11231.25, paid_amount: 0, remaining_amount: 11231.25 }
it('settles in full and flags öre when a whole-krona payment is a sub-krona short (absorbOreRounding)', () => {
const r = planSupplierPayment(invoice, 11231, { absorbOreRounding: true })
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.plan.newStatus).toBe('paid')
expect(r.plan.newRemaining).toBe(0)
expect(r.plan.newPaidAmount).toBe(11231.25) // the AP, not the cash, is fully cleared
expect(r.plan.oreSettled).toBe(true)
}
})
it('accepts a sub-krona OVERpayment as öresavrundning instead of rejecting it', () => {
const inv = { total: 11231, paid_amount: 0, remaining_amount: 11231 }
const r = planSupplierPayment(inv, 11231.25, { absorbOreRounding: true })
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.plan.newStatus).toBe('paid')
expect(r.plan.oreSettled).toBe(true)
}
})
it('leaves a ≥1 kr shortfall as a genuine partial', () => {
const r = planSupplierPayment(invoice, 5000, { absorbOreRounding: true })
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.plan.newStatus).toBe('partially_paid')
expect(r.plan.newRemaining).toBe(6231.25)
expect(r.plan.oreSettled).toBe(false)
}
})
it('rejects an overpayment beyond the 1 kr öre band', () => {
const r = planSupplierPayment(invoice, 12000, { absorbOreRounding: true })
expect(r.ok).toBe(false)
if (!r.ok) {
expect(r.code).toBe('MATCH_SI_AMOUNT_EXCEEDS_REMAINING')
expect(r.details.remaining_amount).toBe(11231.25)
}
})
it('exact payment settles fully without flagging öre', () => {
const inv = { total: 1000, paid_amount: 0, remaining_amount: 1000 }
const r = planSupplierPayment(inv, 1000, { absorbOreRounding: true })
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.plan.newStatus).toBe('paid')
expect(r.plan.oreSettled).toBe(false)
}
})
describe('without öre absorption (default — preserves legacy behaviour)', () => {
it('strands the sub-krona remainder as a partial', () => {
const r = planSupplierPayment(invoice, 11231)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.plan.newStatus).toBe('partially_paid')
expect(r.plan.newRemaining).toBe(0.25)
expect(r.plan.oreSettled).toBe(false)
}
})
it('rejects even a sub-krona overpayment (strict half-öre tolerance)', () => {
const inv = { total: 11231, paid_amount: 0, remaining_amount: 11231 }
const r = planSupplierPayment(inv, 11231.25)
expect(r.ok).toBe(false)
})
})
})
@@ -194,4 +194,64 @@ describe('findSupplierInvoiceMatch', () => {
// "AB" is filtered out (length < 3), so no name match
expect(result).toBeNull()
})
// Pass 3, widened window: early payments (the reported RosholmDell case)
it('auto-matches an EARLY exact payment near the invoice date, weeks before due', () => {
// Paid 2026-06-08, invoice issued 2026-06-05, due 2026-07-05 (27 days out).
// The old due-date-only ±5d window missed this; the issue→due window catches it.
const tx = makeTransaction({ amount: -29890, date: '2026-06-08', description: 'RosholmDell Advo BG 0000007746514' })
const inv = makeSupplierInvoice({
status: 'registered',
remaining_amount: 29890,
invoice_date: '2026-06-05',
due_date: '2026-07-05',
})
const result = findSupplierInvoiceMatch(tx, [inv])
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.85)
expect(result!.matchMethod).toBe('amount_date')
expect(result!.ambiguous).toBeFalsy()
})
it('still matches a few days AFTER the due date', () => {
const tx = makeTransaction({ amount: -29890, date: '2026-07-09' }) // due + 4d
const inv = makeSupplierInvoice({
status: 'registered',
remaining_amount: 29890,
invoice_date: '2026-06-05',
due_date: '2026-07-05',
})
expect(findSupplierInvoiceMatch(tx, [inv])!.matchMethod).toBe('amount_date')
})
it('flags amount_date as AMBIGUOUS when two invoices share the amount in-window', () => {
const tx = makeTransaction({ amount: -29890, date: '2026-06-08', description: 'bankgiro-betalning' })
const a = makeSupplierInvoice({
id: 'inv-a', status: 'registered', remaining_amount: 29890,
invoice_date: '2026-06-05', due_date: '2026-07-05',
})
const b = makeSupplierInvoice({
id: 'inv-b', status: 'registered', remaining_amount: 29890,
invoice_date: '2026-06-04', due_date: '2026-07-04',
})
const result = findSupplierInvoiceMatch(tx, [a, b])
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.85)
expect(result!.ambiguous).toBe(true) // caller must demote to a suggestion
})
it('uses an invoice_date-only window when there is no due_date', () => {
const tx = makeTransaction({ amount: -29890, date: '2026-06-20' }) // 15 days after issue
const inv = makeSupplierInvoice({
status: 'registered',
remaining_amount: 29890,
invoice_date: '2026-06-05',
due_date: null,
})
expect(findSupplierInvoiceMatch(tx, [inv])!.matchMethod).toBe('amount_date')
})
})
+30 -2
View File
@@ -22,7 +22,7 @@
* guard rail #9 — identical to the route's previous `Math.round(x*100)/100`
* except on exact-half-öre amounts, where `roundOre` rounds correctly.
*/
import { roundOre, ORE_TOLERANCE } from '@/lib/money'
import { roundOre, ORE_TOLERANCE, ORE_ROUNDING_SETTLEMENT_MAX } from '@/lib/money'
/** Half an öre — anything over the remaining by more than this is a real overpayment. */
export const PAYMENT_OVERSHOOT_TOLERANCE = ORE_TOLERANCE
@@ -38,6 +38,10 @@ export interface InvoicePaymentPlan {
newRemaining: number
isFullyPaid: boolean
newStatus: 'paid' | 'partially_paid'
/** True when a sub-krona öre residual was absorbed (full settlement of an
* inexact amount) — the 3740 line carries it. Always false unless the caller
* opts in via `absorbOreRounding`. */
oreSettled: boolean
}
export type PlanInvoicePaymentResult =
@@ -51,11 +55,17 @@ export type PlanInvoicePaymentResult =
export function planInvoicePayment(
invoice: InvoicePaymentTotals,
paymentAmountInInvoiceCurrency: number,
opts?: { absorbOreRounding?: boolean },
): PlanInvoicePaymentResult {
const absorbOre = opts?.absorbOreRounding === true
const currentRemaining =
invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0)
if (paymentAmountInInvoiceCurrency > currentRemaining + PAYMENT_OVERSHOOT_TOLERANCE) {
// A rounded-up whole-krona payment is not an overpayment — widen the reject
// band to one krona when absorbing öre; otherwise keep the strict half-öre
// float tolerance the three legacy callers rely on.
const overshootTolerance = absorbOre ? ORE_ROUNDING_SETTLEMENT_MAX : PAYMENT_OVERSHOOT_TOLERANCE
if (paymentAmountInInvoiceCurrency > currentRemaining + overshootTolerance) {
return {
ok: false,
code: 'MATCH_AMOUNT_EXCEEDS_REMAINING',
@@ -67,6 +77,23 @@ export function planInvoicePayment(
}
}
const diff = roundOre(currentRemaining - paymentAmountInInvoiceCurrency)
// Within the öre band (and absorbing) → settle in full; the 3740 line carries
// the residual. Covers both a short whole-krona payment and a rounded-up one.
if (absorbOre && Math.abs(diff) < ORE_ROUNDING_SETTLEMENT_MAX) {
return {
ok: true,
plan: {
newPaidAmount: roundOre((invoice.paid_amount || 0) + currentRemaining),
newRemaining: 0,
isFullyPaid: true,
newStatus: 'paid',
oreSettled: Math.abs(diff) >= ORE_TOLERANCE,
},
}
}
const newPaidAmount = roundOre((invoice.paid_amount || 0) + paymentAmountInInvoiceCurrency)
const newRemaining = Math.max(0, roundOre(currentRemaining - paymentAmountInInvoiceCurrency))
const isFullyPaid = newRemaining <= 0
@@ -78,6 +105,7 @@ export function planInvoicePayment(
newRemaining,
isFullyPaid,
newStatus: isFullyPaid ? 'paid' : 'partially_paid',
oreSettled: false,
},
}
}
+108
View File
@@ -0,0 +1,108 @@
/**
* Single source of truth for applying a payment amount to a SUPPLIER invoice —
* the supplier-side mirror of `planInvoicePayment` (@/lib/invoices/apply-invoice-payment).
*
* Computes the new paid/remaining/status and REJECTS overpayment before the
* caller creates any journal entry, so a doomed match never burns a voucher
* number. The supplier match route previously inlined this math (and its
* overshoot guard) directly; centralizing it keeps the two off-by-one tolerances
* (overshoot vs öre absorption) honest and unit-testable without a DB.
*
* # Öresavrundning (opt-in)
*
* When `absorbOreRounding` is set (callers pass it only for same-currency SEK
* settlements), a payment within `ORE_ROUNDING_SETTLEMENT_MAX` of the remaining
* — short OR over — settles the invoice IN FULL; the residual is booked to BAS
* 3740 by the line builder (`buildSupplierPaymentClearingLines`). Without the
* flag the behaviour is the strict legacy one (half-öre overshoot tolerance,
* any real shortfall left as a partial), preserving every other caller.
*
* FX: `paymentAmountInInvoiceCurrency` MUST already be in the invoice's currency.
* The caller owns any conversion, keeping this helper FX-agnostic.
*/
import { roundOre, ORE_TOLERANCE, ORE_ROUNDING_SETTLEMENT_MAX } from '@/lib/money'
export interface SupplierPaymentTotals {
total: number
paid_amount?: number | null
remaining_amount?: number | null
}
export interface SupplierPaymentPlan {
newPaidAmount: number
newRemaining: number
isFullyPaid: boolean
newStatus: 'paid' | 'partially_paid'
/** True when an öre residual was absorbed (full settlement of an inexact
* amount). Lets callers/tests assert the 3740 path without re-deriving it. */
oreSettled: boolean
}
export type PlanSupplierPaymentResult =
| { ok: true; plan: SupplierPaymentPlan }
| {
ok: false
code: 'MATCH_SI_AMOUNT_EXCEEDS_REMAINING'
details: { transaction_amount: number; remaining_amount: number; excess: number }
}
export function planSupplierPayment(
invoice: SupplierPaymentTotals,
paymentAmountInInvoiceCurrency: number,
opts?: { absorbOreRounding?: boolean },
): PlanSupplierPaymentResult {
const absorbOre = opts?.absorbOreRounding === true
const currentRemaining =
invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0)
// Overpayment past the tolerated band is a real overshoot → reject. With öre
// absorption the band is one krona (a rounded-up whole-krona payment is not an
// overpayment); otherwise it's the strict half-öre float tolerance.
const overshootTolerance = absorbOre ? ORE_ROUNDING_SETTLEMENT_MAX : ORE_TOLERANCE
if (paymentAmountInInvoiceCurrency > currentRemaining + overshootTolerance) {
return {
ok: false,
code: 'MATCH_SI_AMOUNT_EXCEEDS_REMAINING',
details: {
transaction_amount: paymentAmountInInvoiceCurrency,
remaining_amount: roundOre(currentRemaining),
excess: roundOre(paymentAmountInInvoiceCurrency - currentRemaining),
},
}
}
const diff = roundOre(currentRemaining - paymentAmountInInvoiceCurrency)
// Within the öre band (and absorbing) → settle in full; the 3740 line carries
// the residual. Covers both a short whole-krona payment and a rounded-up one.
if (absorbOre && Math.abs(diff) < ORE_ROUNDING_SETTLEMENT_MAX) {
const newPaidAmount = roundOre((invoice.paid_amount || 0) + currentRemaining)
return {
ok: true,
plan: {
newPaidAmount,
newRemaining: 0,
isFullyPaid: true,
newStatus: 'paid',
// Only flag öre settlement when there is an actual residual to book —
// an exact payment needs no 3740 line.
oreSettled: Math.abs(diff) >= ORE_TOLERANCE,
},
}
}
const newPaidAmount = roundOre((invoice.paid_amount || 0) + paymentAmountInInvoiceCurrency)
const newRemaining = Math.max(0, roundOre(currentRemaining - paymentAmountInInvoiceCurrency))
const isFullyPaid = newRemaining <= 0
return {
ok: true,
plan: {
newPaidAmount,
newRemaining,
isFullyPaid,
newStatus: isFullyPaid ? 'paid' : 'partially_paid',
oreSettled: false,
},
}
}
+40 -7
View File
@@ -4,11 +4,18 @@
* 4-pass matching algorithm (ordered by confidence):
* 1. Payment reference/OCR exact match → 0.98
* 2. Exact amount + bankgiro/plusgiro match → 0.92
* 3. Exact amount + date ±5 days → 0.85
* 3. Exact amount + payment date within [invoice_date − 5, due_date + 5] → 0.85
* 4. Fuzzy amount (±0.01) + supplier name in description → 0.70
*
* Auto-match threshold: ≥0.85 → applied automatically
* Suggestion threshold: 0.70–0.85 → stored as potential_supplier_invoice_id
*
* The Pass-3 window spans the whole credit period (issue → due, ±5d) so an
* early payment — common when a bank pays a Bankgiro the day the invoice lands,
* weeks before the due date — still auto-matches. To contain the false-positive
* risk of the wider window, a Pass-3 hit where more than one invoice matches the
* same amount in-window is flagged `ambiguous`; callers must downgrade an
* ambiguous auto-match to a mere suggestion.
*/
import type { Transaction, SupplierInvoice } from '@/types'
@@ -17,6 +24,13 @@ export interface SupplierInvoiceMatch {
supplierInvoice: SupplierInvoice
confidence: number
matchMethod: 'payment_reference' | 'amount_bankgiro' | 'amount_date' | 'fuzzy_name'
/**
* True when this is a Pass-3 (amount + date-window) match but more than one
* invoice matched the same amount in-window — the date heuristic alone can't
* disambiguate. Callers must treat an ambiguous 0.85 as a suggestion, never an
* auto-link. Undefined/false for the unique passes (OCR, bankgiro).
*/
ambiguous?: boolean
}
/**
@@ -43,6 +57,10 @@ export function findSupplierInvoiceMatch(
if (txAmount === 0) return null
let bestMatch: SupplierInvoiceMatch | null = null
// How many invoices matched the exact amount within their date window. >1
// makes a Pass-3 (amount_date) winner ambiguous — the date can't pick between
// same-amount invoices, so the caller must not auto-link it.
let amountDateMatchCount = 0
for (const invoice of unpaidInvoices) {
// Only match against registered/approved invoices with remaining amount
@@ -81,13 +99,21 @@ export function findSupplierInvoiceMatch(
}
}
// Pass 3: Exact amount + date ±5 days → 0.85
if (amountMatch && invoice.due_date) {
const txDate = new Date(transaction.date)
const dueDate = new Date(invoice.due_date)
const diffDays = Math.abs((txDate.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24))
// Pass 3: Exact amount + payment date within the credit period → 0.85.
// Window = [invoice_date − 5, due_date + 5]; when only one date is known,
// span ~30 days on the missing side (typical net terms). This catches early
// payments (paid near the invoice date, weeks before due) that a due-date-
// only window missed.
if (amountMatch && (invoice.invoice_date || invoice.due_date)) {
const DAY = 24 * 60 * 60 * 1000
const txMs = new Date(transaction.date).getTime()
const invoiceMs = invoice.invoice_date ? new Date(invoice.invoice_date).getTime() : null
const dueMs = invoice.due_date ? new Date(invoice.due_date).getTime() : null
const startMs = invoiceMs !== null ? invoiceMs - 5 * DAY : (dueMs as number) - 35 * DAY
const endMs = dueMs !== null ? dueMs + 5 * DAY : (invoiceMs as number) + 35 * DAY
if (diffDays <= 5) {
if (txMs >= startMs && txMs <= endMs) {
amountDateMatchCount++
const confidence = 0.85
if (!bestMatch || confidence > bestMatch.confidence) {
bestMatch = {
@@ -128,5 +154,12 @@ export function findSupplierInvoiceMatch(
}
}
// A Pass-3 winner is only trustworthy enough to auto-link when its amount was
// unique in-window. If several invoices shared the amount, the date can't
// disambiguate — flag it so the caller demotes it to a suggestion.
if (bestMatch && bestMatch.matchMethod === 'amount_date' && amountDateMatchCount > 1) {
bestMatch.ambiguous = true
}
return bestMatch
}