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

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

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

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

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-09 19:09:39 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 4dfd790de5
commit b38b3d0230
23 changed files with 1157 additions and 90 deletions
@@ -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
@@ -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,
@@ -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)
})
})
@@ -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,
})
},
)
@@ -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')