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:
co-authored by
Claude Opus 4.8
parent
4dfd790de5
commit
b38b3d0230
@@ -201,6 +201,50 @@ describe('buildInvoicePaymentClearingLines', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('öresavrundning (pure SEK, sub-krona difference → 3740)', () => {
|
||||
it('customer paid a sub-krona SHORT: clears full 1510, books 3740 debit (förlust)', () => {
|
||||
const result = buildInvoicePaymentClearingLines(
|
||||
{ amount: 1000, amount_sek: null, currency: 'SEK', exchange_rate: null },
|
||||
{ currency: 'SEK', exchange_rate: null, remaining_amount: 1000.25, total: 1000.25, paid_amount: 0 },
|
||||
'Inbetalning kundfaktura',
|
||||
)
|
||||
expect(result.arSek).toBe(1000.25) // full remaining cleared → invoice settles
|
||||
expect(result.oreRoundingSek).toBe(0.25)
|
||||
expect(result.lines).toHaveLength(3)
|
||||
expect(result.lines.find((l) => l.account_number === '1930')?.debit_amount).toBe(1000)
|
||||
expect(result.lines.find((l) => l.account_number === '1510')?.credit_amount).toBe(1000.25)
|
||||
expect(result.lines.find((l) => l.account_number === '3740')?.debit_amount).toBe(0.25)
|
||||
const debit = result.lines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
const credit = result.lines.reduce((s, l) => s + l.credit_amount, 0)
|
||||
expect(Math.round((debit - credit) * 100)).toBe(0)
|
||||
})
|
||||
|
||||
it('customer paid a sub-krona OVER: clears full 1510, books 3740 credit (vinst)', () => {
|
||||
const result = buildInvoicePaymentClearingLines(
|
||||
{ amount: 1000.25, amount_sek: null, currency: 'SEK', exchange_rate: null },
|
||||
{ currency: 'SEK', exchange_rate: null, remaining_amount: 1000, total: 1000, paid_amount: 0 },
|
||||
'Inbetalning kundfaktura',
|
||||
)
|
||||
expect(result.arSek).toBe(1000)
|
||||
expect(result.oreRoundingSek).toBe(-0.25)
|
||||
expect(result.lines.find((l) => l.account_number === '3740')?.credit_amount).toBe(0.25)
|
||||
const debit = result.lines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
const credit = result.lines.reduce((s, l) => s + l.credit_amount, 0)
|
||||
expect(Math.round((debit - credit) * 100)).toBe(0)
|
||||
})
|
||||
|
||||
it('a ≥1 kr shortfall stays a partial (no 3740, AR = bank)', () => {
|
||||
const result = buildInvoicePaymentClearingLines(
|
||||
{ amount: 600, amount_sek: null, currency: 'SEK', exchange_rate: null },
|
||||
{ currency: 'SEK', exchange_rate: null, remaining_amount: 1000, total: 1000, paid_amount: 0 },
|
||||
'Delbetalning kundfaktura',
|
||||
)
|
||||
expect(result.arSek).toBe(600)
|
||||
expect(result.oreRoundingSek).toBe(0)
|
||||
expect(result.lines).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cross currency (USD invoice + USD tx)', () => {
|
||||
it('uses tx amount_sek for the bank-leg when populated', () => {
|
||||
// USD-denominated bank account paying a USD invoice — ingest converts
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -13,12 +13,72 @@ vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
|
||||
createSupplierInvoiceRegistrationEntry: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/invoices/match-log', () => ({
|
||||
logMatchEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { makeTransaction } from '@/tests/helpers'
|
||||
import { registerSupplierInvoiceHandler } from '../supplier-invoice-handler'
|
||||
|
||||
const mockCreateClient = vi.mocked(createClient)
|
||||
const mockCreateEntry = vi.mocked(createSupplierInvoiceRegistrationEntry)
|
||||
const mockLogMatch = vi.mocked(logMatchEvent)
|
||||
|
||||
/**
|
||||
* Minimal Supabase mock for the retro-match handler that RECORDS the
|
||||
* transactions `.update()` payloads (the queued proxy mock can't), so we can
|
||||
* assert it writes a suggestion column rather than an auto-link.
|
||||
*/
|
||||
function makeRetroMock(opts: {
|
||||
invoice: unknown
|
||||
linkedCount: number
|
||||
candidates: unknown[]
|
||||
}) {
|
||||
const updates: Record<string, unknown>[] = []
|
||||
const chain = (result: unknown): unknown =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') return (resolve: (v: unknown) => void) => resolve(result)
|
||||
return () => chain(result)
|
||||
},
|
||||
},
|
||||
)
|
||||
const supabase = {
|
||||
from: vi.fn((table: string) => {
|
||||
if (table === 'supplier_invoices') return chain({ data: opts.invoice, error: null })
|
||||
if (table === 'transactions') {
|
||||
return {
|
||||
select: (_cols: string, selOpts?: { count?: string }) =>
|
||||
selOpts?.count
|
||||
? chain({ count: opts.linkedCount, data: null, error: null })
|
||||
: chain({ data: opts.candidates, error: null }),
|
||||
update: (payload: Record<string, unknown>) => {
|
||||
updates.push(payload)
|
||||
return chain({ data: null, error: null })
|
||||
},
|
||||
}
|
||||
}
|
||||
return chain({ data: null, error: null })
|
||||
}),
|
||||
}
|
||||
return { supabase, updates }
|
||||
}
|
||||
|
||||
function emitRegistered(invoiceId: string) {
|
||||
return eventBus.emit({
|
||||
type: 'supplier_invoice.registered',
|
||||
payload: {
|
||||
supplierInvoice: { id: invoiceId } as never,
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('Supplier Invoice Core Handler', () => {
|
||||
let unsubscribe: () => void
|
||||
@@ -189,4 +249,90 @@ describe('Supplier Invoice Core Handler', () => {
|
||||
|
||||
expect(mockCreateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('retroactive match on supplier_invoice.registered', () => {
|
||||
const baseInvoice = () =>
|
||||
makeSupplierInvoice({
|
||||
id: 'si-retro',
|
||||
status: 'registered',
|
||||
remaining_amount: 29890,
|
||||
total: 29890,
|
||||
invoice_date: '2026-06-05',
|
||||
due_date: '2026-07-05',
|
||||
transaction_id: null,
|
||||
payment_reference: null,
|
||||
})
|
||||
|
||||
const matchingTx = () =>
|
||||
makeTransaction({
|
||||
id: 'tx-retro',
|
||||
amount: -29890, // exact, in-window → Pass 3 amount_date (0.85)
|
||||
date: '2026-06-08',
|
||||
description: 'Bg-bet via internet',
|
||||
reference: null,
|
||||
supplier_invoice_id: null,
|
||||
journal_entry_id: null,
|
||||
})
|
||||
|
||||
it('writes a SUGGESTION (never an auto-link) for an exact in-window payment', async () => {
|
||||
const { supabase, updates } = makeRetroMock({
|
||||
invoice: baseInvoice(),
|
||||
linkedCount: 0,
|
||||
candidates: [matchingTx()],
|
||||
})
|
||||
mockCreateClient.mockResolvedValue(supabase as never)
|
||||
|
||||
await emitRegistered('si-retro')
|
||||
|
||||
// The whole point of "pre-fill, confirm to book": suggestion column only.
|
||||
expect(updates).toEqual([{ potential_supplier_invoice_id: 'si-retro' }])
|
||||
expect(mockLogMatch).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'user-1',
|
||||
'tx-retro',
|
||||
'auto_suggested',
|
||||
expect.objectContaining({
|
||||
supplierInvoiceId: 'si-retro',
|
||||
matchMethod: 'amount_date',
|
||||
matchConfidence: 0.85,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('no-ops when the invoice is already tied to a transaction', async () => {
|
||||
const { supabase, updates } = makeRetroMock({
|
||||
invoice: { ...baseInvoice(), transaction_id: 'tx-existing' },
|
||||
linkedCount: 0,
|
||||
candidates: [matchingTx()],
|
||||
})
|
||||
mockCreateClient.mockResolvedValue(supabase as never)
|
||||
await emitRegistered('si-retro')
|
||||
expect(updates).toHaveLength(0)
|
||||
expect(mockLogMatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('no-ops (idempotent) when a transaction is already linked to the invoice', async () => {
|
||||
const { supabase, updates } = makeRetroMock({
|
||||
invoice: baseInvoice(),
|
||||
linkedCount: 1,
|
||||
candidates: [matchingTx()],
|
||||
})
|
||||
mockCreateClient.mockResolvedValue(supabase as never)
|
||||
await emitRegistered('si-retro')
|
||||
expect(updates).toHaveLength(0)
|
||||
expect(mockLogMatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('no-ops when no candidate transaction matches', async () => {
|
||||
const { supabase, updates } = makeRetroMock({
|
||||
invoice: baseInvoice(),
|
||||
linkedCount: 0,
|
||||
candidates: [],
|
||||
})
|
||||
mockCreateClient.mockResolvedValue(supabase as never)
|
||||
await emitRegistered('si-retro')
|
||||
expect(updates).toHaveLength(0)
|
||||
expect(mockLogMatch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,8 +2,10 @@ import { eventBus } from '@/lib/events/bus'
|
||||
import type { EventPayload } from '@/lib/events/types'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matching'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type { SupplierInvoiceItem } from '@/types'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem, Transaction } from '@/types'
|
||||
|
||||
const log = createLogger('supplier-invoice-handler')
|
||||
|
||||
@@ -88,9 +90,122 @@ async function handleSupplierInvoiceConfirmed(
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the core supplier invoice handler on the event bus.
|
||||
* Returns an unsubscribe function.
|
||||
* Retroactive match: when a supplier invoice is registered or approved, scan
|
||||
* recent unmatched expense transactions for the bank payment that settles it.
|
||||
*
|
||||
* The forward direction (a freshly imported tx scanning existing invoices) lives
|
||||
* in lib/transactions/ingest.ts. This is the mirror — needed because a Bankgiro
|
||||
* payment is often imported BEFORE the invoice is registered, and nothing
|
||||
* re-matched it afterwards (the reported RosholmDell case). Reuses the same
|
||||
* `findSupplierInvoiceMatch` scorer (one invoice, many txs) so the two
|
||||
* directions can never score differently.
|
||||
*
|
||||
* Writes a SUGGESTION (potential_supplier_invoice_id), never an auto-link
|
||||
* (supplier_invoice_id): the match card / confirm dialog only surfaces for the
|
||||
* suggestion column (transactions page + lib/worklist), and the product choice
|
||||
* is "pre-fill, confirm to book" — the user reviews and posts the verifikat.
|
||||
* Setting supplier_invoice_id directly would skip that confirmation and strand
|
||||
* the payment unbooked. This handler therefore never creates a journal entry.
|
||||
*/
|
||||
async function handleSupplierInvoiceRetroMatch(
|
||||
// .registered and .approved share this payload shape.
|
||||
payload: EventPayload<'supplier_invoice.registered'>
|
||||
): Promise<void> {
|
||||
const { supplierInvoice, userId, companyId } = payload
|
||||
|
||||
try {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Re-fetch with the supplier relation (the scorer reads bankgiro/plusgiro/
|
||||
// name) — the emitted payload can be stale or lack the join.
|
||||
const { data: invoice } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('*, supplier:suppliers(*)')
|
||||
.eq('id', supplierInvoice.id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!invoice) return
|
||||
if (!['registered', 'approved'].includes(invoice.status)) return
|
||||
if ((invoice.remaining_amount ?? invoice.total) <= 0) return
|
||||
if (invoice.transaction_id) return // already settled by a bank tx
|
||||
|
||||
// Idempotency: if a tx already points at this invoice (a prior retro run, or
|
||||
// ingest's forward match), don't add a competing suggestion.
|
||||
const { count: linkedCount } = await supabase
|
||||
.from('transactions')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.eq('supplier_invoice_id', invoice.id)
|
||||
if (linkedCount && linkedCount > 0) return
|
||||
|
||||
// Bound the scan: ~90 days before the invoice/due date covers normal terms
|
||||
// and the early-payment case, without trawling the whole ledger.
|
||||
const anchor = invoice.invoice_date || invoice.due_date
|
||||
if (!anchor) return
|
||||
const anchorMs = new Date(anchor).getTime()
|
||||
const lowDate = new Date(anchorMs - 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
|
||||
|
||||
const { data: candidates } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.is('supplier_invoice_id', null)
|
||||
.is('potential_supplier_invoice_id', null)
|
||||
.is('journal_entry_id', null)
|
||||
.lt('amount', 0)
|
||||
.gte('date', lowDate)
|
||||
.order('date', { ascending: false })
|
||||
.limit(200)
|
||||
|
||||
if (!candidates || candidates.length === 0) return
|
||||
|
||||
// Pick the best candidate: highest confidence, tie-break on the payment
|
||||
// closest to the invoice date.
|
||||
let best: { tx: Transaction; confidence: number; matchMethod: string } | null = null
|
||||
for (const tx of candidates) {
|
||||
const match = findSupplierInvoiceMatch(tx as Transaction, [invoice as SupplierInvoice])
|
||||
if (!match) continue
|
||||
const closer =
|
||||
best !== null &&
|
||||
match.confidence === best.confidence &&
|
||||
Math.abs(new Date(tx.date).getTime() - anchorMs) <
|
||||
Math.abs(new Date(best.tx.date).getTime() - anchorMs)
|
||||
if (!best || match.confidence > best.confidence || closer) {
|
||||
best = { tx: tx as Transaction, confidence: match.confidence, matchMethod: match.matchMethod }
|
||||
}
|
||||
}
|
||||
|
||||
if (!best) return
|
||||
|
||||
// Suggestion only. The `.is('supplier_invoice_id', null)` guard avoids a
|
||||
// race where the tx was linked between the scan and this write.
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ potential_supplier_invoice_id: invoice.id })
|
||||
.eq('id', best.tx.id)
|
||||
.is('supplier_invoice_id', null)
|
||||
|
||||
logMatchEvent(supabase, userId, best.tx.id, 'auto_suggested', {
|
||||
supplierInvoiceId: invoice.id,
|
||||
matchConfidence: best.confidence,
|
||||
matchMethod: best.matchMethod,
|
||||
})
|
||||
} catch (err) {
|
||||
// Never break invoice registration — this is a best-effort convenience.
|
||||
log.error('Retroactive supplier-invoice match failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the core supplier invoice handlers on the event bus.
|
||||
* Returns a combined unsubscribe function.
|
||||
*/
|
||||
export function registerSupplierInvoiceHandler(): () => void {
|
||||
return eventBus.on('supplier_invoice.confirmed', handleSupplierInvoiceConfirmed)
|
||||
const unsubscribers = [
|
||||
eventBus.on('supplier_invoice.confirmed', handleSupplierInvoiceConfirmed),
|
||||
eventBus.on('supplier_invoice.registered', handleSupplierInvoiceRetroMatch),
|
||||
eventBus.on('supplier_invoice.approved', handleSupplierInvoiceRetroMatch),
|
||||
]
|
||||
return () => unsubscribers.forEach((unsub) => unsub())
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
* partially_paid.
|
||||
*/
|
||||
import type { CreateJournalEntryLineInput } from '@/types'
|
||||
import { ORE_TOLERANCE, ORE_ROUNDING_SETTLEMENT_MAX } from '@/lib/money'
|
||||
import { resolveSekAmount } from './currency-utils'
|
||||
|
||||
const TWO_DP = (n: number): number => Math.round(n * 100) / 100
|
||||
@@ -92,6 +93,14 @@ export interface PaymentClearingLines {
|
||||
* in caller logic without reading this paragraph.
|
||||
*/
|
||||
fxDiffSek: number
|
||||
/**
|
||||
* Öresavrundning residual (SEK), pure-SEK same-currency settlements only.
|
||||
* remainingSek − bankSek: >0 → customer paid a sub-krona short (3740 debit,
|
||||
* förlust); <0 → paid a sub-krona over (3740 credit, vinst); 0 → no 3740 line.
|
||||
* When non-zero the AR leg (1510) is credited the FULL remaining so the
|
||||
* invoice settles, and the residual balances the verifikat via 3740.
|
||||
*/
|
||||
oreRoundingSek: number
|
||||
lines: CreateJournalEntryLineInput[]
|
||||
}
|
||||
|
||||
@@ -141,11 +150,27 @@ export function buildInvoicePaymentClearingLines(
|
||||
|
||||
const sameCurrency = tx.currency === invoice.currency
|
||||
const invoiceIsForeign = invoice.currency !== 'SEK'
|
||||
// Pure SEK both sides: the only place whole-krona öresavrundning applies.
|
||||
const pureSek = sameCurrency && invoice.currency === 'SEK'
|
||||
|
||||
let arSek: number
|
||||
let fxDiffSek: number
|
||||
let oreRoundingSek = 0
|
||||
|
||||
if (sameCurrency || !invoiceIsForeign) {
|
||||
if (pureSek) {
|
||||
// A whole-krona bank settlement of an öre-bearing SEK invoice leaves a
|
||||
// sub-krona residual. Clear the FULL remaining off 1510 (invoice → paid)
|
||||
// and let 3740 absorb the öre; a ≥1 kr short payment stays a real partial.
|
||||
const remainingSek = TWO_DP(invoice.remaining_amount ?? invoice.total - (invoice.paid_amount ?? 0))
|
||||
const oreDiff = TWO_DP(remainingSek - bankSek)
|
||||
if (oreDiff !== 0 && Math.abs(oreDiff) < ORE_ROUNDING_SETTLEMENT_MAX) {
|
||||
arSek = remainingSek
|
||||
oreRoundingSek = oreDiff
|
||||
} else {
|
||||
arSek = bankSek
|
||||
}
|
||||
fxDiffSek = 0
|
||||
} else if (sameCurrency || !invoiceIsForeign) {
|
||||
// Same currency (or SEK invoice paid by SEK tx): the customer-debt
|
||||
// reduction equals what hit the bank. No FX diff possible.
|
||||
arSek = bankSek
|
||||
@@ -212,5 +237,27 @@ export function buildInvoicePaymentClearingLines(
|
||||
}
|
||||
}
|
||||
|
||||
return { bankSek, arSek, fxDiffSek, lines }
|
||||
// Öresavrundning (3740) — pure-SEK only, mutually exclusive with an FX diff.
|
||||
// The AR leg above is already the full remaining, so 3740 balances the
|
||||
// verifikat: customer paid a sub-krona short → 3740 debit (förlust); over →
|
||||
// credit (vinst). Opposite polarity to the supplier side (AP cleared by a Dr).
|
||||
if (Math.abs(oreRoundingSek) >= ORE_TOLERANCE) {
|
||||
if (oreRoundingSek > 0) {
|
||||
lines.push({
|
||||
account_number: '3740',
|
||||
debit_amount: Math.abs(oreRoundingSek),
|
||||
credit_amount: 0,
|
||||
line_description: 'Öresavrundning',
|
||||
})
|
||||
} else {
|
||||
lines.push({
|
||||
account_number: '3740',
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.abs(oreRoundingSek),
|
||||
line_description: 'Öresavrundning',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { bankSek, arSek, fxDiffSek, oreRoundingSek, lines }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Builds the journal-entry lines for the clearing entry that closes (fully or
|
||||
* partially) a supplier invoice against an actual bank transaction under
|
||||
* faktureringsmetoden (accrual) — Dr 2440 / Cr <payment account>.
|
||||
*
|
||||
* Shared between:
|
||||
* - GET /api/transactions/[id]/match-supplier-invoice/preview (read-only,
|
||||
* drives the dialog the user confirms against)
|
||||
* - POST /api/transactions/[id]/match-supplier-invoice (the commit path)
|
||||
*
|
||||
* Single source of truth so the preview and the committed verifikat are
|
||||
* byte-identical — including the payment account and the per-line descriptions,
|
||||
* which previously drifted (the preview used `last_supplier_payment_account` and
|
||||
* "Kvittning leverantörsskuld" / "Utbetalning från bank", while the commit path
|
||||
* defaulted to 1930 and "Utbetalning leverantörsfaktura …").
|
||||
*
|
||||
* # Öresavrundning (3740)
|
||||
*
|
||||
* A whole-krona Bankgiro/Swish settlement of an öre-bearing invoice total leaves
|
||||
* a sub-krona residual (e.g. paying 11 231,25 with a rounded 11 231,00). Rather
|
||||
* than strand that 0,25 kr as a permanent partial, the difference is booked to
|
||||
* BAS 3740 (Öres- och kronutjämning) and 2440 is cleared in full so the invoice
|
||||
* reaches `paid`. The residual sign drives the 3740 side:
|
||||
*
|
||||
* bank paid LESS than owed (apSek > bankSek) → öresavrundningsvinst → Cr 3740
|
||||
* bank paid MORE than owed (apSek < bankSek) → öresavrundningsförlust → Dr 3740
|
||||
*
|
||||
* This polarity is the mirror of the customer side (`buildInvoicePaymentClearingLines`,
|
||||
* where AR is cleared with a credit and 3740 takes the opposite side).
|
||||
*
|
||||
* # SEK only
|
||||
*
|
||||
* `apSek`/`bankSek` are home-currency (SEK). Cross-currency settlement carries a
|
||||
* kursvinst/kursförlust (3960/7960) handled by `createSupplierInvoicePaymentEntry`,
|
||||
* not here — öresavrundning is the residual AFTER FX and only meaningful in whole
|
||||
* SEK kronor, so callers route only same-currency SEK payments through this helper.
|
||||
*/
|
||||
import type { CreateJournalEntryLineInput } from '@/types'
|
||||
import { roundOre, ORE_ROUNDING_SETTLEMENT_MAX } from '@/lib/money'
|
||||
|
||||
export interface SupplierClearingArgs {
|
||||
/** SEK on 2440 to clear for this settlement — the full remaining when an öre
|
||||
* diff is absorbed, so the invoice reaches `paid`. */
|
||||
apSek: number
|
||||
/** Actual SEK that left the bank — the payment-account credit. */
|
||||
bankSek: number
|
||||
/** Bank/clearing account credited (e.g. 1930). */
|
||||
paymentAccount: string
|
||||
}
|
||||
|
||||
export interface SupplierClearingResult {
|
||||
apSek: number
|
||||
bankSek: number
|
||||
/** roundOre(apSek − bankSek): >0 → 3740 credit (vinst); <0 → 3740 debit
|
||||
* (förlust); 0 → no 3740 line. Non-zero only within ORE_ROUNDING_SETTLEMENT_MAX. */
|
||||
oreDiffSek: number
|
||||
lines: CreateJournalEntryLineInput[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the verifikat lines for a supplier-invoice payment matched against a
|
||||
* SEK bank tx. Pure — no DB calls. Caller decides how to persist.
|
||||
*
|
||||
* |apSek − bankSek| < ORE_ROUNDING_SETTLEMENT_MAX (and ≠ 0)
|
||||
* → clear the full apSek off 2440, credit the actual bankSek, book the
|
||||
* residual to 3740. Invoice settles fully.
|
||||
* otherwise (exact, or a genuine ≥ 1 kr partial)
|
||||
* → clear min(bankSek, apSek), no 3740 line (unchanged legacy behaviour).
|
||||
*/
|
||||
export function buildSupplierPaymentClearingLines(
|
||||
args: SupplierClearingArgs,
|
||||
): SupplierClearingResult {
|
||||
const apSek = roundOre(args.apSek)
|
||||
const bankSek = roundOre(args.bankSek)
|
||||
const diff = roundOre(apSek - bankSek)
|
||||
|
||||
const isOreRounding = diff !== 0 && Math.abs(diff) < ORE_ROUNDING_SETTLEMENT_MAX
|
||||
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
if (isOreRounding) {
|
||||
// Clear the FULL debt off 2440 so the invoice → paid; the bank leg is the
|
||||
// actual SEK paid; 3740 absorbs the öre residual.
|
||||
lines.push({
|
||||
account_number: '2440',
|
||||
debit_amount: apSek,
|
||||
credit_amount: 0,
|
||||
line_description: 'Kvittning leverantörsskuld',
|
||||
})
|
||||
lines.push({
|
||||
account_number: args.paymentAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: bankSek,
|
||||
line_description: 'Utbetalning från bank',
|
||||
})
|
||||
if (diff > 0) {
|
||||
// Paid fewer kronor than owed → öresavrundningsvinst → 3740 credit.
|
||||
lines.push({
|
||||
account_number: '3740',
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.abs(diff),
|
||||
line_description: 'Öresavrundning',
|
||||
})
|
||||
} else {
|
||||
// Paid more kronor than owed → öresavrundningsförlust → 3740 debit.
|
||||
lines.push({
|
||||
account_number: '3740',
|
||||
debit_amount: Math.abs(diff),
|
||||
credit_amount: 0,
|
||||
line_description: 'Öresavrundning',
|
||||
})
|
||||
}
|
||||
return { apSek, bankSek, oreDiffSek: diff, lines }
|
||||
}
|
||||
|
||||
// Exact settlement, or a genuine partial payment (≥ 1 kr short): clear what
|
||||
// was actually moved, leave any remainder on the supplier ledger.
|
||||
const amount = roundOre(Math.min(bankSek, apSek))
|
||||
lines.push({
|
||||
account_number: '2440',
|
||||
debit_amount: amount,
|
||||
credit_amount: 0,
|
||||
line_description: 'Kvittning leverantörsskuld',
|
||||
})
|
||||
lines.push({
|
||||
account_number: args.paymentAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: amount,
|
||||
line_description: 'Utbetalning från bank',
|
||||
})
|
||||
return { apSek, bankSek, oreDiffSek: 0, lines }
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -44,6 +44,21 @@ export function roundOre(n: number): number {
|
||||
*/
|
||||
export const ORE_TOLERANCE = 0.005
|
||||
|
||||
/**
|
||||
* Maximum |bank payment − invoice remaining| (in SEK) that is treated as
|
||||
* öresavrundning — booked to BAS 3740 (Öres- och kronutjämning) so the invoice
|
||||
* settles fully — rather than left as a genuine partial payment.
|
||||
*
|
||||
* Swedish whole-krona settlements (Bankgiro, Swish, kort) pay an öre-bearing
|
||||
* invoice total rounded to the nearest krona, so the residual is always strictly
|
||||
* under 1 krona. A real shortfall is ≥ 1 krona, so this band can never hide one.
|
||||
*
|
||||
* NOTE: deliberately looser than `ORE_TOLERANCE` (0,005). That constant is
|
||||
* float-equalisation; this is an accounting policy band. Keep them distinct —
|
||||
* never reuse `ORE_TOLERANCE` for settlement rounding.
|
||||
*/
|
||||
export const ORE_ROUNDING_SETTLEMENT_MAX = 1.0
|
||||
|
||||
/**
|
||||
* True when two amounts are equal to the öre (within `ORE_TOLERANCE`). Prefer
|
||||
* this over `a === b` for money — direct equality on floats fails on drift.
|
||||
|
||||
@@ -449,7 +449,10 @@ export async function ingestTransactions(
|
||||
)
|
||||
|
||||
if (match && !matchedSupplierInvoiceIds.has(match.supplierInvoice.id)) {
|
||||
if (match.confidence >= 0.85) {
|
||||
// Ambiguous amount_date hits (several same-amount invoices in-window)
|
||||
// are demoted to suggestions — auto-linking the wrong one is worse
|
||||
// than asking the user to pick.
|
||||
if (match.confidence >= 0.85 && !match.ambiguous) {
|
||||
// Auto-link at high confidence
|
||||
await supabase
|
||||
.from('transactions')
|
||||
|
||||
Reference in New Issue
Block a user