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>
89 lines
3.7 KiB
TypeScript
89 lines
3.7 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { buildSupplierPaymentClearingLines } from '@/lib/bookkeeping/supplier-payment-lines'
|
|
import { sumOre } from '@/lib/money'
|
|
|
|
function sumDebit(lines: Array<{ debit_amount: number }>): number {
|
|
return sumOre(lines.map((l) => l.debit_amount))
|
|
}
|
|
function sumCredit(lines: Array<{ credit_amount: number }>): number {
|
|
return sumOre(lines.map((l) => l.credit_amount))
|
|
}
|
|
function line(lines: Array<{ account_number: string }>, acct: string) {
|
|
return lines.find((l) => l.account_number === acct)
|
|
}
|
|
|
|
describe('buildSupplierPaymentClearingLines', () => {
|
|
it('books the öre residual to 3740 (credit) when the bank paid a sub-krona LESS — the reported 11 231,25 / 11 231,00 case', () => {
|
|
const { lines, oreDiffSek } = buildSupplierPaymentClearingLines({
|
|
apSek: 11231.25,
|
|
bankSek: 11231,
|
|
paymentAccount: '1930',
|
|
})
|
|
expect(oreDiffSek).toBe(0.25)
|
|
// 2440 cleared in FULL so the invoice settles; bank leg = actual SEK paid.
|
|
expect(line(lines, '2440')?.debit_amount).toBe(11231.25)
|
|
expect(line(lines, '1930')?.credit_amount).toBe(11231)
|
|
expect(line(lines, '3740')?.credit_amount).toBe(0.25)
|
|
expect(line(lines, '3740')?.debit_amount).toBe(0)
|
|
// Balances to the öre.
|
|
expect(sumDebit(lines)).toBe(sumCredit(lines))
|
|
})
|
|
|
|
it('books the öre residual to 3740 (debit) when the bank paid a sub-krona MORE', () => {
|
|
const { lines, oreDiffSek } = buildSupplierPaymentClearingLines({
|
|
apSek: 11231,
|
|
bankSek: 11231.25,
|
|
paymentAccount: '1930',
|
|
})
|
|
expect(oreDiffSek).toBe(-0.25)
|
|
expect(line(lines, '2440')?.debit_amount).toBe(11231)
|
|
expect(line(lines, '1930')?.credit_amount).toBe(11231.25)
|
|
expect(line(lines, '3740')?.debit_amount).toBe(0.25)
|
|
expect(sumDebit(lines)).toBe(sumCredit(lines))
|
|
})
|
|
|
|
it('emits no 3740 line for an exact settlement', () => {
|
|
const { lines, oreDiffSek } = buildSupplierPaymentClearingLines({
|
|
apSek: 2390,
|
|
bankSek: 2390,
|
|
paymentAccount: '1930',
|
|
})
|
|
expect(oreDiffSek).toBe(0)
|
|
expect(lines).toHaveLength(2)
|
|
expect(line(lines, '3740')).toBeUndefined()
|
|
expect(line(lines, '2440')?.debit_amount).toBe(2390)
|
|
expect(line(lines, '1930')?.credit_amount).toBe(2390)
|
|
})
|
|
|
|
it('treats a ≥1 kr shortfall as a genuine partial — clamps to the bank amount, no 3740', () => {
|
|
const { lines, oreDiffSek } = buildSupplierPaymentClearingLines({
|
|
apSek: 11231.25,
|
|
bankSek: 5000,
|
|
paymentAccount: '1930',
|
|
})
|
|
expect(oreDiffSek).toBe(0)
|
|
expect(lines).toHaveLength(2)
|
|
expect(line(lines, '3740')).toBeUndefined()
|
|
// Only what actually moved clears 2440 — the remainder stays a partial.
|
|
expect(line(lines, '2440')?.debit_amount).toBe(5000)
|
|
expect(line(lines, '1930')?.credit_amount).toBe(5000)
|
|
})
|
|
|
|
it('honours the 1 kr band boundary: 0,99 absorbs, exactly 1,00 does not', () => {
|
|
const absorbed = buildSupplierPaymentClearingLines({ apSek: 1000.99, bankSek: 1000, paymentAccount: '1930' })
|
|
expect(absorbed.oreDiffSek).toBe(0.99)
|
|
expect(line(absorbed.lines, '3740')?.credit_amount).toBe(0.99)
|
|
|
|
const notAbsorbed = buildSupplierPaymentClearingLines({ apSek: 1001, bankSek: 1000, paymentAccount: '1930' })
|
|
expect(notAbsorbed.oreDiffSek).toBe(0)
|
|
expect(line(notAbsorbed.lines, '3740')).toBeUndefined()
|
|
expect(line(notAbsorbed.lines, '2440')?.debit_amount).toBe(1000) // clamped
|
|
})
|
|
|
|
it('credits the chosen payment account, not a hardcoded 1930', () => {
|
|
const { lines } = buildSupplierPaymentClearingLines({ apSek: 500, bankSek: 500, paymentAccount: '1932' })
|
|
expect(line(lines, '1932')?.credit_amount).toBe(500)
|
|
expect(line(lines, '1930')).toBeUndefined()
|
|
})
|
|
})
|