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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
6aecbdc9b7
commit
62135c0c02
@@ -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).
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user