fix(invoices): settle öresavrundning in batch match and close stuck partially_paid invoices (#1829)

A whole-krona payment (Bankgiro/Swish/kort) of an öre-bearing invoice
left the invoice hanging: the match_batch_allocate RPC (samlingsbetalning
dialog + MCP path) had no öre handling, so a sub-krona overshoot was
rejected as BATCH_OVERSHOOT and a sub-krona shortfall parked the invoice
in partially_paid forever. Invoices already stuck that way had no exit:
the mark-paid dialog proposed clearing the full total (rejected with
MATCH_AMOUNT_EXCEEDS_REMAINING) and the route refused partially_paid
outright.

Fix, both halves:

1. New migration 20260824120000 replaces match_batch_allocate with the
   same öresavrundning band every single-payment path already uses
   (ORE_ROUNDING_SETTLEMENT_MAX = 1.00 kr, lib/money.ts): overshoot
   rejected only at >= 1 kr; a 0 < |remaining - allocation| < 1 kr diff
   clears the FULL remaining off 1510/2440, books the residual to 3740
   with correct polarity per side, records the full remaining as paid
   and flips the status to paid. >= 1 kr diffs keep today's behaviour.

2. proposePaymentLines is remaining-aware: a partially_paid SEK accrual
   invoice gets a proposal clearing the actual remaining, and a
   sub-krona remaining gets a bank-less Dr 3740 / Cr 1510 write-off so
   one click closes a stuck invoice. The mark-paid route and the
   invoice-detail button now accept partially_paid (the settle layer's
   CAS guard always did).

Forward-only: already-stuck invoices are not auto-repaired; they are
closed via the new dialog proposal.


Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-24 13:19:32 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 6aecbdc9b7
commit 62135c0c02
9 changed files with 1302 additions and 8 deletions
@@ -567,6 +567,52 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
expect(body.journal_entry_id).toBe('je-ore')
})
it('accepts a partially_paid invoice and closes it with a bank-less öre write-off (#1717)', async () => {
// An invoice stuck with a sub-krona remaining (öresavrundning on an
// earlier payment path). The dialog proposes Dr 3740 / Cr 1510 for the
// remaining; booking it flips the invoice to paid. No customer →
// duplicate guard skips.
const invoice = makeInvoice({
id: 'inv-1',
status: 'partially_paid',
total: 12500.4,
paid_amount: 12500,
remaining_amount: 0.4,
})
enqueue({ data: invoice, error: null })
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS update matched
mockFindFiscalPeriod.mockResolvedValue('fp-1')
mockCreateJournalEntry.mockResolvedValue({ id: 'je-writeoff' })
const writeOffLines = [
{ account_number: '3740', debit_amount: 0.4, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 0.4 },
]
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
method: 'POST',
body: { lines: writeOffLines },
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
success: boolean
status: string
paid_amount: number
remaining_amount: number
journal_entry_id: string
}>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.status).toBe('paid')
expect(body.paid_amount).toBe(12500.4)
expect(body.remaining_amount).toBe(0)
expect(body.journal_entry_id).toBe('je-writeoff')
})
it('returns 400 MATCH_AMOUNT_EXCEEDS_REMAINING when custom lines overpay the invoice', async () => {
// No customer → duplicate guard skips; the overpayment guard must reject
// BEFORE any journal entry is created (planInvoicePayment runs first).
+9 -1
View File
@@ -60,7 +60,15 @@ export const POST = withRouteContext(
})
}
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
// partially_paid is payable (#1717): an invoice stuck with a sub-krona
// remaining (öresavrundning) or an ordinary open partial is completed
// here. settleInvoicePayment's plan math and CAS guard already handle
// the state; only this route-level gate excluded it.
if (
invoice.status !== 'sent' &&
invoice.status !== 'overdue' &&
invoice.status !== 'partially_paid'
) {
return errorResponseFromCode('INVOICE_PAID_NOT_PAYABLE', opLog, {
requestId,
details: { currentStatus: invoice.status },