diff --git a/app/api/transactions/[id]/match-invoice/preview/route.ts b/app/api/transactions/[id]/match-invoice/preview/route.ts
index 1b3aeeb4..97958d74 100644
--- a/app/api/transactions/[id]/match-invoice/preview/route.ts
+++ b/app/api/transactions/[id]/match-invoice/preview/route.ts
@@ -18,7 +18,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
-import { roundOre } from '@/lib/money'
+import { roundOre, ORE_ROUNDING_SETTLEMENT_MAX } from '@/lib/money'
import { getRevenueAccount, getOutputVatAccount } from '@/lib/bookkeeping/invoice-entries'
import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
@@ -170,7 +170,13 @@ export const GET = withRouteContext(
// a guess — the dialog blocks confirm until a manual rate is entered and
// the POST recomputes the real figure.
const fxRateUnavailable = fxConversion.required && 'error' in fxConversion
- const isFullyPaid = !fxRateUnavailable && newRemaining <= 0
+ // Pure-SEK whole-krona settlements absorb a sub-krona remainder as
+ // öresavrundning (3740) and settle in full — mirror that here so the
+ // preview's fully-paid signal matches the committed verifikat.
+ const pureSek = transaction.currency === 'SEK' && invoice.currency === 'SEK'
+ const isFullyPaid =
+ !fxRateUnavailable &&
+ (newRemaining <= 0 || (pureSek && newRemaining < ORE_ROUNDING_SETTLEMENT_MAX))
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid
diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts
index 9e3c970f..d40c3c3a 100644
--- a/app/api/transactions/[id]/match-invoice/route.ts
+++ b/app/api/transactions/[id]/match-invoice/route.ts
@@ -300,7 +300,12 @@ export const POST = withRouteContext(
// Overshoot guard + paid/remaining math — shared with the v1 and agent
// (commit) paths via planInvoicePayment so they cannot drift again. Runs
// before any JE is created, so a doomed match never burns a voucher number.
- const payment = planInvoicePayment(invoice, paidAmountInInvoiceCurrency)
+ // Pure-SEK settlements absorb sub-krona öresavrundning (booked to 3740 by
+ // buildInvoicePaymentClearingLines) so a whole-krona payment settles in full.
+ const pureSek = transaction.currency === 'SEK' && invoice.currency === 'SEK'
+ const payment = planInvoicePayment(invoice, paidAmountInInvoiceCurrency, {
+ absorbOreRounding: pureSek,
+ })
if (!payment.ok) {
return errorResponseFromCode('MATCH_AMOUNT_EXCEEDS_REMAINING', txLog, {
requestId,
diff --git a/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts
index 27608ab3..6a781842 100644
--- a/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts
+++ b/app/api/transactions/[id]/match-supplier-invoice/__tests__/route.test.ts
@@ -38,6 +38,16 @@ vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
createSupplierInvoiceCashEntry: (...args: unknown[]) => mockCreateCashEntry(...args),
}))
+// Pure-SEK clearing now posts via the shared builder + createJournalEntry
+// (not createSupplierInvoicePaymentEntry). Mock the engine so that path doesn't
+// hit the queued Supabase mock.
+const mockCreateJournalEntry = vi.fn()
+const mockFindFiscalPeriod = vi.fn()
+vi.mock('@/lib/bookkeeping/engine', () => ({
+ createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
+ findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args),
+}))
+
import { POST } from '../route'
const mockUser = { id: 'user-1', email: 'test@test.se' }
@@ -48,6 +58,8 @@ beforeEach(() => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
mockCreatePaymentEntry.mockResolvedValue({ id: 'je-1' })
mockCreateCashEntry.mockResolvedValue({ id: 'je-1' })
+ mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' })
+ mockFindFiscalPeriod.mockResolvedValue('fp-1')
})
const TX_UUID = '11111111-1111-4111-8111-111111111111'
@@ -109,17 +121,26 @@ function enqueueHappyPath(opts: {
}
describe('POST /api/transactions/[id]/match-supplier-invoice — FX residual', () => {
- it('passes no exchangeRateDifference for a SEK transaction paying a SEK invoice', async () => {
+ it('books a clean SEK clearing entry (no FX) for a SEK tx paying a SEK invoice', async () => {
+ // SEK/SEK now routes through buildSupplierPaymentClearingLines +
+ // createJournalEntry, not createSupplierInvoicePaymentEntry. An exact
+ // payment yields just Dr 2440 / Cr 1930 — no 3960/7960 FX line, no 3740.
enqueueHappyPath({
transaction: { amount: -2390, currency: 'SEK' },
invoice: { currency: 'SEK', remaining_amount: 2390 },
})
await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
- expect(mockCreatePaymentEntry).toHaveBeenCalledTimes(1)
- const args = mockCreatePaymentEntry.mock.calls[0]
- // (supabase, companyId, userId, invoice, paymentAmountSek, paymentDate, exchangeRateDifference?)
- expect(args[4]).toBe(2390) // paymentAmountSek = actual bank SEK
- expect(args[6]).toBeUndefined() // no FX diff
+ expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
+ expect(mockCreateJournalEntry).toHaveBeenCalledTimes(1)
+ const input = mockCreateJournalEntry.mock.calls[0][3] as {
+ lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
+ }
+ expect(input.lines).toHaveLength(2)
+ expect(input.lines.find((l) => l.account_number === '2440')?.debit_amount).toBe(2390)
+ expect(input.lines.find((l) => l.account_number === '1930')?.credit_amount).toBe(2390)
+ expect(
+ input.lines.some((l) => ['3960', '7960', '3740'].includes(l.account_number)),
+ ).toBe(false)
})
it('computes a loss when the SEK paid exceeds the AP booked SEK (EUR invoice)', async () => {
@@ -197,6 +218,32 @@ describe('POST /api/transactions/[id]/match-supplier-invoice — non-FX paths',
expect(body.remaining_amount).toBe(0)
})
+ it('öresavrundning: a whole-krona Bankgiro payment settles an öre-bearing invoice in full via 3740', async () => {
+ // The reported bug: invoice 11 231,25, bank paid 11 231 → previously left
+ // 0,25 stranded as partially_paid. Now → paid, with 0,25 booked to 3740.
+ enqueueHappyPath({
+ transaction: { amount: -11231, currency: 'SEK' },
+ invoice: { currency: 'SEK', remaining_amount: 11231.25 },
+ })
+ const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
+ const { status, body } = await parseJsonResponse<{
+ invoice_status: string
+ paid_amount: number
+ remaining_amount: number
+ }>(res)
+ expect(status).toBe(200)
+ expect(body.invoice_status).toBe('paid')
+ expect(body.remaining_amount).toBe(0)
+ expect(body.paid_amount).toBe(11231.25)
+ expect(mockCreatePaymentEntry).not.toHaveBeenCalled()
+ const input = mockCreateJournalEntry.mock.calls[0][3] as {
+ lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
+ }
+ expect(input.lines.find((l) => l.account_number === '2440')?.debit_amount).toBe(11231.25)
+ expect(input.lines.find((l) => l.account_number === '1930')?.credit_amount).toBe(11231)
+ expect(input.lines.find((l) => l.account_number === '3740')?.credit_amount).toBe(0.25)
+ })
+
it('returns 400 MATCH_SI_AMOUNT_EXCEEDS_REMAINING when tx exceeds invoice remaining (same currency)', async () => {
// Tx pays out 6 000 SEK, invoice has 5 000 SEK remaining. Legacy code path
// would push paid_amount past invoice.total. The new guard rejects so the
@@ -314,4 +361,25 @@ describe('POST /api/transactions/[id]/match-supplier-invoice — cash method + F
expect(body.error.code).toBe('MATCH_SI_CASH_FX_UNSUPPORTED')
expect(mockCreateCashEntry).not.toHaveBeenCalled()
})
+
+ it('does NOT absorb öre under the cash method — a SEK sub-krona diff stays partial', async () => {
+ // Kontantmetoden books the full invoice via the cash entry (not the bank
+ // amount), so folding the 0,25 to 3740 would hide a 1930 discrepancy. The
+ // öre band is accrual-only; here the invoice stays partially_paid.
+ enqueueHappyPath({
+ transaction: { amount: -11231, currency: 'SEK' },
+ invoice: { currency: 'SEK', remaining_amount: 11231.25 },
+ accountingMethod: 'cash',
+ })
+ const res = await POST(makeReq(), createMockRouteParams({ id: TX_UUID }))
+ const { status, body } = await parseJsonResponse<{
+ invoice_status: string
+ remaining_amount: number
+ }>(res)
+ expect(status).toBe(200)
+ expect(mockCreateCashEntry).toHaveBeenCalledTimes(1)
+ expect(mockCreateJournalEntry).not.toHaveBeenCalled() // no 3740 clearing entry
+ expect(body.invoice_status).toBe('partially_paid')
+ expect(body.remaining_amount).toBe(0.25)
+ })
})
diff --git a/app/api/transactions/[id]/match-supplier-invoice/preview/route.ts b/app/api/transactions/[id]/match-supplier-invoice/preview/route.ts
index 07172ca8..bca46143 100644
--- a/app/api/transactions/[id]/match-supplier-invoice/preview/route.ts
+++ b/app/api/transactions/[id]/match-supplier-invoice/preview/route.ts
@@ -12,6 +12,8 @@ import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
+import { buildSupplierPaymentClearingLines } from '@/lib/bookkeeping/supplier-payment-lines'
+import { ORE_TOLERANCE } from '@/lib/money'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
type PreviewLine = {
@@ -81,6 +83,10 @@ export const GET = withRouteContext(
const lines: PreviewLine[] = []
let entryType: 'clearing' | 'cash' = 'clearing'
+ // Drives the dialog's "markeras som betald" / öresavrundning copy. Cash
+ // entries always book the full invoice, so they default to fully paid.
+ let isFullyPaid = true
+ let oreRounding = false
if (useCashEntry) {
entryType = 'cash'
@@ -158,26 +164,52 @@ export const GET = withRouteContext(
} else {
// Clearing: Dr 2440 / Cr 1930 (or chosen payment account).
const si = invoice as SupplierInvoice
- const amountSek = resolveSekAmount(
- Math.abs(transaction.amount),
- null,
- transaction.currency,
- null,
- )
- const total = resolveSekAmount(si.total, si.total_sek, si.currency, si.exchange_rate)
- const amount = Math.round(Math.min(amountSek, total) * 100) / 100
- lines.push({
- account_number: '2440',
- debit_amount: amount,
- credit_amount: 0,
- description: 'Kvittning leverantörsskuld',
- })
- lines.push({
- account_number: paymentAccount,
- debit_amount: 0,
- credit_amount: amount,
- description: 'Utbetalning från bank',
- })
+ const isPureSek = transaction.currency === 'SEK' && si.currency === 'SEK'
+ if (isPureSek) {
+ // Shared builder so the previewed lines — including any 3740
+ // öresavrundning row — are byte-identical to what the POST commits.
+ const remainingSek = si.remaining_amount ?? si.total
+ const bankSek = Math.abs(transaction.amount)
+ const { lines: clearingLines, oreDiffSek } = buildSupplierPaymentClearingLines({
+ apSek: remainingSek,
+ bankSek,
+ paymentAccount,
+ })
+ for (const l of clearingLines) {
+ lines.push({
+ account_number: l.account_number,
+ debit_amount: l.debit_amount,
+ credit_amount: l.credit_amount,
+ description: l.line_description ?? '',
+ })
+ }
+ oreRounding = oreDiffSek !== 0
+ // Full settlement when the öre residual is absorbed or the bank covers
+ // the whole remaining; a ≥1 kr short payment leaves a partial.
+ isFullyPaid = oreRounding || bankSek >= remainingSek - ORE_TOLERANCE
+ } else {
+ const amountSek = resolveSekAmount(
+ Math.abs(transaction.amount),
+ null,
+ transaction.currency,
+ null,
+ )
+ const total = resolveSekAmount(si.total, si.total_sek, si.currency, si.exchange_rate)
+ const amount = Math.round(Math.min(amountSek, total) * 100) / 100
+ lines.push({
+ account_number: '2440',
+ debit_amount: amount,
+ credit_amount: 0,
+ description: 'Kvittning leverantörsskuld',
+ })
+ lines.push({
+ account_number: paymentAccount,
+ debit_amount: 0,
+ credit_amount: amount,
+ description: 'Utbetalning från bank',
+ })
+ isFullyPaid = amount >= total - ORE_TOLERANCE
+ }
}
return NextResponse.json({
@@ -185,6 +217,8 @@ export const GET = withRouteContext(
lines,
invoice_already_booked: siAlreadyBooked,
accounting_method: accountingMethod,
+ is_fully_paid: isFullyPaid,
+ ore_rounding: oreRounding,
})
},
)
diff --git a/app/api/transactions/[id]/match-supplier-invoice/route.ts b/app/api/transactions/[id]/match-supplier-invoice/route.ts
index 102b135d..007e4064 100644
--- a/app/api/transactions/[id]/match-supplier-invoice/route.ts
+++ b/app/api/transactions/[id]/match-supplier-invoice/route.ts
@@ -3,6 +3,8 @@ import {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
+import { buildSupplierPaymentClearingLines } from '@/lib/bookkeeping/supplier-payment-lines'
+import { planSupplierPayment } from '@/lib/invoices/apply-supplier-payment'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -82,26 +84,10 @@ export const POST = withRouteContext(
const txAmountAbs = Math.abs(transaction.amount)
- // Overshoot guard for the same-currency branch. The legacy code path used
- // txAmountAbs wholesale and would push supplier_invoices.paid_amount past
- // invoice.total whenever the bank transaction was larger than what was
- // owed. Reject and direct the user at the split-payment flow which can
- // allocate the excess to additional supplier invoices.
- // FX branch (currency mismatch) is already clamped below to
- // invoice.remaining_amount, so it cannot overshoot.
- if (
- transaction.currency === invoice.currency &&
- txAmountAbs > invoice.remaining_amount + 0.005
- ) {
- return errorResponseFromCode('MATCH_SI_AMOUNT_EXCEEDS_REMAINING', txLog, {
- requestId,
- details: {
- transaction_amount: txAmountAbs,
- remaining_amount: Math.round(invoice.remaining_amount * 100) / 100,
- excess: Math.round((txAmountAbs - invoice.remaining_amount) * 100) / 100,
- },
- })
- }
+ // Pure SEK settlements route through the shared clearing builder so öre
+ // rounding lands on 3740 and the invoice settles in full; foreign legs keep
+ // the kursvinst/kursförlust path in createSupplierInvoicePaymentEntry.
+ const isPureSek = transaction.currency === 'SEK' && invoice.currency === 'SEK'
// Amount in the *invoice's* currency — used to update
// supplier_invoices.paid_amount/remaining_amount and the
@@ -116,6 +102,43 @@ export const POST = withRouteContext(
? txAmountAbs
: invoice.remaining_amount
+ const { data: settings } = await supabase
+ .from('company_settings')
+ .select('accounting_method, last_supplier_payment_account')
+ .eq('company_id', companyId)
+ .single()
+
+ const accountingMethod = settings?.accounting_method || 'accrual'
+ // Same default the preview route uses, so the committed verifikat credits the
+ // same account the user saw previewed (the old path hardcoded 1930 here).
+ const paymentAccount =
+ (settings as { last_supplier_payment_account?: string } | null)?.last_supplier_payment_account || '1930'
+
+ // Route on the supplier invoice's actual booking state — if 2440 was posted
+ // at receipt (accrual), the match clears 2440 regardless of the company's
+ // current setting. Only true kontantmetoden invoices (no registration JE)
+ // book expense + input VAT here.
+ const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
+ const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
+
+ // Ledger math + overshoot guard in one place (mirrors planInvoicePayment on
+ // the customer side). Öre absorption applies ONLY to the accrual SEK clearing
+ // path: there a whole-krona payment within 1 kr settles the invoice in full
+ // and the residual is booked to 3740 by the line builder. Cash-method entries
+ // book the full invoice total (not the bank amount), so absorbing there would
+ // mark the invoice paid while leaving a hidden 1930 discrepancy — keep strict.
+ // Rejecting here, BEFORE any JE is created, keeps a doomed overshoot from
+ // burning a voucher number.
+ const paymentPlan = planSupplierPayment(invoice, paymentAmountInvoiceCurrency, {
+ absorbOreRounding: isPureSek && !useCashEntry,
+ })
+ if (!paymentPlan.ok) {
+ return errorResponseFromCode('MATCH_SI_AMOUNT_EXCEEDS_REMAINING', txLog, {
+ requestId,
+ details: paymentPlan.details,
+ })
+ }
+
// SEK that actually left the bank, when we know it. SEK transaction → the
// absolute amount; foreign transaction with a stored amount_sek → that
// value; foreign transaction WITHOUT amount_sek → unknown (null). The raw
@@ -162,21 +185,6 @@ export const POST = withRouteContext(
const now = new Date().toISOString()
- const { data: settings } = await supabase
- .from('company_settings')
- .select('accounting_method')
- .eq('company_id', companyId)
- .single()
-
- const accountingMethod = settings?.accounting_method || 'accrual'
-
- // Route on the supplier invoice's actual booking state — if 2440 was
- // posted at receipt (accrual), the match must clear 2440 regardless of
- // the company's current setting. Only true kontantmetoden invoices
- // (no registration JE) book expense + input VAT here.
- const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id
- const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash'
-
// A full settlement pays off the whole remaining balance. Cross-currency
// matches always do (paymentAmountInvoiceCurrency is clamped to
// invoice.remaining_amount above); same-currency does when the bank amount
@@ -205,6 +213,11 @@ export const POST = withRouteContext(
})
}
+ // Verifikat header description, shared by every booking branch below.
+ const desc = invoice.supplier?.name
+ ? `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}, ${invoice.supplier.name}`
+ : `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}`
+
let journalEntryId: string | null = null
let journalEntryError: string | null = null
@@ -226,9 +239,6 @@ export const POST = withRouteContext(
})
}
const sourceType = useCashEntry ? 'supplier_invoice_cash_payment' : 'supplier_invoice_paid'
- const desc = invoice.supplier?.name
- ? `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}, ${invoice.supplier.name}`
- : `Utbetalning leverantörsfaktura ${invoice.supplier_invoice_number}`
const journalEntry = await createJournalEntry(supabase, companyId!, user.id, {
fiscal_period_id: fiscalPeriodId,
entry_date: transaction.date,
@@ -252,6 +262,32 @@ export const POST = withRouteContext(
exchangeRateDifference !== 0 && fullSettlement ? actualBankSek : undefined,
)
if (journalEntry) journalEntryId = journalEntry.id
+ } else if (isPureSek) {
+ // SEK clearing through the shared builder: a sub-krona difference is
+ // booked to 3740 and 2440 is cleared in full (invoice → paid); an exact
+ // or ≥1 kr-short payment clears what moved. Byte-identical to the preview
+ // (same payment account + line descriptions). No FX here by definition.
+ const fiscalPeriodId = await findFiscalPeriod(supabase, companyId!, transaction.date)
+ if (!fiscalPeriodId) {
+ return errorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', txLog, {
+ requestId,
+ details: { paymentDate: transaction.date },
+ })
+ }
+ const { lines } = buildSupplierPaymentClearingLines({
+ apSek: invoice.remaining_amount,
+ bankSek: txAmountAbs,
+ paymentAccount,
+ })
+ const journalEntry = await createJournalEntry(supabase, companyId!, user.id, {
+ fiscal_period_id: fiscalPeriodId,
+ entry_date: transaction.date,
+ description: desc,
+ source_type: 'supplier_invoice_paid',
+ source_id: invoice.id,
+ lines,
+ })
+ if (journalEntry) journalEntryId = journalEntry.id
} else {
const journalEntry = await createSupplierInvoicePaymentEntry(
supabase, companyId, user.id, invoice as SupplierInvoice,
@@ -271,10 +307,10 @@ export const POST = withRouteContext(
}
}
- const newRemaining = Math.max(0, Math.round((invoice.remaining_amount - paymentAmountInvoiceCurrency) * 100) / 100)
- const newPaidAmount = Math.round((invoice.paid_amount + paymentAmountInvoiceCurrency) * 100) / 100
- const isFullyPaid = newRemaining <= 0
- const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
+ // Ledger update from the plan computed up front. An öre-absorbed settlement
+ // reports remaining 0 / status paid even though the bank paid a sub-krona
+ // less (or more) — the residual lives on 3740, not the supplier ledger.
+ const { newRemaining, newPaidAmount, isFullyPaid, newStatus } = paymentPlan.plan
const { data: updatedRows, error: updateInvError } = await supabase
.from('supplier_invoices')
diff --git a/components/transactions/InvoiceMatchDialog.tsx b/components/transactions/InvoiceMatchDialog.tsx
index 9db1d912..084b9af4 100644
--- a/components/transactions/InvoiceMatchDialog.tsx
+++ b/components/transactions/InvoiceMatchDialog.tsx
@@ -449,7 +449,14 @@ export default function InvoiceMatchDialog({
// built by buildInvoicePaymentClearingLines, which posts the
// FX diff to 3960/7960 so the books balance correctly even
// when the on-screen numbers can't be naively compared.
- const amountsMatch = sameCurrency && Math.abs(txAbs - invRemaining) < 0.01
+ const diff = Math.abs(txAbs - invRemaining)
+ const amountsMatch = sameCurrency && diff < 0.01
+ // A sub-krona SEK difference is öresavrundning: the backend books
+ // it to 3740 and settles the invoice in full instead of leaving it
+ // delbetald (see ORE_ROUNDING_SETTLEMENT_MAX). SEK only — keep the
+ // 1 kr band in sync with the server constant.
+ const isOreRounding =
+ sameCurrency && transaction.currency === 'SEK' && diff >= 0.01 && diff < 1.0
if (amountsMatch) {
return (
@@ -460,6 +467,19 @@ export default function InvoiceMatchDialog({
)
}
+ if (isOreRounding) {
+ return (
+
+
+
+ {t('ore_rounding_note', {
+ amount: formatCurrency(diff, transaction.currency),
+ })}
+
+
+ )
+ }
+
return (
diff --git a/lib/bookkeeping/__tests__/invoice-payment-lines.test.ts b/lib/bookkeeping/__tests__/invoice-payment-lines.test.ts
index 3c51833e..7b27f4da 100644
--- a/lib/bookkeeping/__tests__/invoice-payment-lines.test.ts
+++ b/lib/bookkeeping/__tests__/invoice-payment-lines.test.ts
@@ -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
diff --git a/lib/bookkeeping/__tests__/supplier-payment-lines.test.ts b/lib/bookkeeping/__tests__/supplier-payment-lines.test.ts
new file mode 100644
index 00000000..5f684695
--- /dev/null
+++ b/lib/bookkeeping/__tests__/supplier-payment-lines.test.ts
@@ -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()
+ })
+})
diff --git a/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts b/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts
index 9cf01ae1..60679b6d 100644
--- a/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts
+++ b/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts
@@ -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
[] = []
+ 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) => {
+ 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()
+ })
+ })
})
diff --git a/lib/bookkeeping/handlers/supplier-invoice-handler.ts b/lib/bookkeeping/handlers/supplier-invoice-handler.ts
index 17ed1596..1babfea5 100644
--- a/lib/bookkeeping/handlers/supplier-invoice-handler.ts
+++ b/lib/bookkeeping/handlers/supplier-invoice-handler.ts
@@ -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 {
+ 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())
}
diff --git a/lib/bookkeeping/invoice-payment-lines.ts b/lib/bookkeeping/invoice-payment-lines.ts
index 7f8a3efb..bde700c2 100644
--- a/lib/bookkeeping/invoice-payment-lines.ts
+++ b/lib/bookkeeping/invoice-payment-lines.ts
@@ -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 }
}
diff --git a/lib/bookkeeping/supplier-payment-lines.ts b/lib/bookkeeping/supplier-payment-lines.ts
new file mode 100644
index 00000000..3cd19cda
--- /dev/null
+++ b/lib/bookkeeping/supplier-payment-lines.ts
@@ -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 .
+ *
+ * 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 }
+}
diff --git a/lib/invoices/__tests__/apply-invoice-payment.test.ts b/lib/invoices/__tests__/apply-invoice-payment.test.ts
index e6642778..a21db868 100644
--- a/lib/invoices/__tests__/apply-invoice-payment.test.ts
+++ b/lib/invoices/__tests__/apply-invoice-payment.test.ts
@@ -14,6 +14,7 @@ describe('planInvoicePayment', () => {
newRemaining: 0,
isFullyPaid: true,
newStatus: 'paid',
+ oreSettled: false,
})
}
})
diff --git a/lib/invoices/__tests__/apply-supplier-payment.test.ts b/lib/invoices/__tests__/apply-supplier-payment.test.ts
new file mode 100644
index 00000000..e4df5338
--- /dev/null
+++ b/lib/invoices/__tests__/apply-supplier-payment.test.ts
@@ -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)
+ })
+ })
+})
diff --git a/lib/invoices/__tests__/supplier-invoice-matching.test.ts b/lib/invoices/__tests__/supplier-invoice-matching.test.ts
index c07b99ac..de131937 100644
--- a/lib/invoices/__tests__/supplier-invoice-matching.test.ts
+++ b/lib/invoices/__tests__/supplier-invoice-matching.test.ts
@@ -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')
+ })
})
diff --git a/lib/invoices/apply-invoice-payment.ts b/lib/invoices/apply-invoice-payment.ts
index 70262d4f..fc472f5a 100644
--- a/lib/invoices/apply-invoice-payment.ts
+++ b/lib/invoices/apply-invoice-payment.ts
@@ -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,
},
}
}
diff --git a/lib/invoices/apply-supplier-payment.ts b/lib/invoices/apply-supplier-payment.ts
new file mode 100644
index 00000000..f77428b1
--- /dev/null
+++ b/lib/invoices/apply-supplier-payment.ts
@@ -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,
+ },
+ }
+}
diff --git a/lib/invoices/supplier-invoice-matching.ts b/lib/invoices/supplier-invoice-matching.ts
index 51589d36..88ec283b 100644
--- a/lib/invoices/supplier-invoice-matching.ts
+++ b/lib/invoices/supplier-invoice-matching.ts
@@ -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
}
diff --git a/lib/money.ts b/lib/money.ts
index 32bc14b5..89a986c3 100644
--- a/lib/money.ts
+++ b/lib/money.ts
@@ -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.
diff --git a/lib/transactions/ingest.ts b/lib/transactions/ingest.ts
index fa49c186..ed0b3869 100644
--- a/lib/transactions/ingest.ts
+++ b/lib/transactions/ingest.ts
@@ -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')
diff --git a/messages/en.json b/messages/en.json
index 0ada5724..46c80976 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1826,6 +1826,7 @@
"amount_diff": "Difference: {amount}",
"different_currencies": " (different currencies)",
"partial_payment_note": " — the invoice will become partially paid.",
+ "ore_rounding_note": "The {amount} difference is booked as rounding (account 3740). The invoice is marked as paid.",
"fx_title": "Currency conversion",
"fx_rate_description": "Riksbanken mid-rate {date}: 1 {invoiceCurrency} = {rate} SEK",
"fx_paid_in_invoice_currency": "Payment equals: {amount}",
diff --git a/messages/sv.json b/messages/sv.json
index 78294783..05f71a4d 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -1826,6 +1826,7 @@
"amount_diff": "Differens: {amount}",
"different_currencies": " (olika valutor)",
"partial_payment_note": " — fakturan blir delbetald.",
+ "ore_rounding_note": "Differens {amount} bokförs som öresavrundning (konto 3740). Fakturan markeras som betald.",
"fx_title": "Valutaomräkning",
"fx_rate_description": "Riksbankens mittkurs {date}: 1 {invoiceCurrency} = {rate} SEK",
"fx_paid_in_invoice_currency": "Inbetalning motsvarar: {amount}",
diff --git a/vitest.config.ts b/vitest.config.ts
index 949970ba..b2f51e8f 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -10,7 +10,9 @@ const unitProject = {
globals: true,
environment: 'node' as const,
include: ['**/*.test.ts'],
- exclude: ['**/node_modules/**', '**/*.pg.test.ts'],
+ // `.claude/worktrees/*` are ephemeral agent checkouts whose `@/*` imports
+ // resolve back to this root — never part of the suite.
+ exclude: ['**/node_modules/**', '**/*.pg.test.ts', '**/.claude/**'],
},
}
@@ -21,7 +23,7 @@ const pgRealProject = {
globals: true,
environment: 'node' as const,
include: ['**/*.pg.test.ts'],
- exclude: ['**/node_modules/**'],
+ exclude: ['**/node_modules/**', '**/.claude/**'],
setupFiles: ['tests/pg/setup.ts'],
// One-connection-at-a-time to avoid cross-file DB contention.
fileParallelism: false,