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
@@ -323,6 +323,160 @@ describe('proposePaymentLines: öresavrundning (3740)', () => {
})
})
describe('proposePaymentLines: remaining-aware partials (#1717)', () => {
it('sub-krona remaining → bank-less öre write-off (Dr 3740 / Cr 1510)', () => {
const lines = proposePaymentLines({
invoice: {
...makeInvoiceInput({ total: 12500.4 }),
paid_amount: 12500,
remaining_amount: 0.4,
},
accountingMethod: 'accrual',
entityType: 'enskild_firma',
})
expect(lines).toEqual([
{
account_number: '3740',
debit_amount: '0.4',
credit_amount: '',
line_description: 'Öresavrundning',
},
{
account_number: '1510',
debit_amount: '',
credit_amount: '0.4',
line_description: 'Betalning faktura 2025-001',
},
])
})
it('remaining >= 1 kr → clears the remaining, not the total', () => {
const lines = proposePaymentLines({
invoice: {
...makeInvoiceInput({ total: 12500 }),
paid_amount: 5000,
remaining_amount: 7500,
},
accountingMethod: 'accrual',
entityType: 'enskild_firma',
paymentAccount: '1920',
})
expect(lines).toHaveLength(2)
expect(lines[0]).toMatchObject({ account_number: '1920', debit_amount: '7500' })
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '7500' })
})
it('derives remaining from paid_amount when remaining_amount is absent', () => {
const lines = proposePaymentLines({
invoice: { ...makeInvoiceInput({ total: 12500.4 }), paid_amount: 12500 },
accountingMethod: 'accrual',
entityType: 'enskild_firma',
})
expect(lines[0]).toMatchObject({ account_number: '3740', debit_amount: '0.4' })
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '0.4' })
})
it('fully unpaid invoice keeps the fresh proposal byte-identical (incl. öresavrundning)', () => {
const fresh = proposePaymentLines({
invoice: makeInvoiceInput({ total: 1234.75 }),
accountingMethod: 'accrual',
entityType: 'enskild_firma',
companyOreRounding: true,
})
const withZeroPaid = proposePaymentLines({
invoice: {
...makeInvoiceInput({ total: 1234.75 }),
paid_amount: 0,
remaining_amount: 1234.75,
},
accountingMethod: 'accrual',
entityType: 'enskild_firma',
companyOreRounding: true,
})
expect(withZeroPaid).toEqual(fresh)
expect(withZeroPaid[2]).toMatchObject({ account_number: '3740' })
})
it('cash method partial keeps the legacy full-invoice proposal', () => {
// The server refuses cash partial completion (cashPartialBlockReason), so
// a remaining-based cash proposal would only dress up a rejected booking.
const lines = proposePaymentLines({
invoice: {
...makeInvoiceInput(),
paid_amount: 5000,
remaining_amount: 7500,
},
accountingMethod: 'cash',
entityType: 'enskild_firma',
})
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '12500' })
})
it('foreign-currency partial keeps the legacy proposal', () => {
const lines = proposePaymentLines({
invoice: {
...makeInvoiceInput({
total: 1000,
total_sek: 10000,
currency: 'EUR',
exchange_rate: 10,
}),
paid_amount: 500,
remaining_amount: 500,
},
accountingMethod: 'accrual',
entityType: 'enskild_firma',
})
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '10000' })
expect(lines.every((l) => l.account_number !== '3740')).toBe(true)
})
it('ROT/RUT invoice with a partial keeps the deduction-aware proposal', () => {
// The outstanding remainder on a ROT/RUT invoice is Skatteverket's share
// on 1513, settled by the ROT/RUT payout flow: never proposed as a 1510
// clearing here.
const lines = proposePaymentLines({
invoice: {
...makeInvoiceInput(),
deduction_total: 3750,
paid_amount: 8750,
remaining_amount: 3750,
},
accountingMethod: 'accrual',
entityType: 'aktiebolag',
})
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '8750' })
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '8750' })
})
it('write-off lines carry the invoice default dimensions', () => {
const bag = { '6': 'P001' }
const lines = proposePaymentLines({
invoice: {
...makeInvoiceInput({ total: 1000.25 }),
paid_amount: 1000,
remaining_amount: 0.25,
default_dimensions: bag,
},
accountingMethod: 'accrual',
entityType: 'enskild_firma',
})
expect(lines).toHaveLength(2)
for (const line of lines) {
expect(line.dimensions).toEqual(bag)
expect(line.dimensions).not.toBe(bag)
}
})
})
describe('proposePaymentLines: dimensions propagation (PR7)', () => {
const bag = { '1': 'KS01', '6': 'P001' }
+100 -3
View File
@@ -5,7 +5,7 @@
* No DB or Supabase dependency: all inputs are plain data.
*/
import { resolveSekAmount, resolveSekAmountOrNull } from './currency-utils'
import { roundOre } from '@/lib/money'
import { roundOre, ORE_TOLERANCE, ORE_ROUNDING_SETTLEMENT_MAX } from '@/lib/money'
import {
getRevenueAccount,
getOutputVatAccount,
@@ -47,6 +47,15 @@ export interface ProposePaymentLinesInput {
* (the preview groups per rate); users can retag lines in the grid.
*/
default_dimensions?: Record<string, string> | null
/**
* Prior-payment state (#1717). When a partial payment exists the proposal
* must clear what actually remains, not the full total: a full-total
* proposal is rejected server-side with MATCH_AMOUNT_EXCEEDS_REMAINING,
* which left invoices stuck in partially_paid with an öre remaining.
* Absent or fully-unpaid values keep the proposal identical to before.
*/
paid_amount?: number | null
remaining_amount?: number | null
}
accountingMethod: 'accrual' | 'cash'
entityType: EntityType
@@ -96,6 +105,11 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[]
const paymentAccount = input.paymentAccount || '1930'
const desc = invoice.invoice_number ? `Betalning faktura ${invoice.invoice_number}` : 'Betalning faktura'
// #1717: an invoice with a prior partial payment gets a proposal that
// clears the actual remaining, never the full total.
const remainingAware = proposeRemainingAwareLines(invoice, accountingMethod, paymentAccount, desc)
if (remainingAware) return withInvoiceDimensions(remainingAware, invoice)
// Öresavrundning: when it applies (SEK, enabled, non-integer total) the
// customer pays the rounded "Att betala" from the PDF, not the stored öre
// total. Propose the bank leg at the rounded amount and let 3740 carry the
@@ -114,8 +128,91 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[]
? proposeAccrualLines(invoice, paymentAccount, desc, exchangeRateDifference, roundingDelta, deductionSek)
: proposeCashLines(invoice, paymentAccount, desc, entityType, roundingDelta, deductionSek)
// Dimensions PR7: re-propagate the invoice default onto every proposed leg
// (matches createInvoicePaymentJournalEntry/createInvoiceCashEntry).
return withInvoiceDimensions(lines, invoice)
}
/**
* Remaining-aware proposal for an invoice with a prior partial payment
* (#1717). Returns null whenever the legacy full-total proposal applies, so
* fresh unpaid invoices keep a byte-identical proposal.
*
* Scope: SEK + accrual + no ROT/RUT deduction only.
* - Cash-method partial completion is refused server-side
* (cashPartialBlockReason: the generated cash entry always books the full
* invoice), so a remaining-based cash proposal would only book a rejected
* entry with a nicer preview.
* - A foreign-currency remaining needs a payment-day FX conversion this
* pure function does not carry; the dialog's FX path already handles it.
* - On a ROT/RUT invoice the outstanding remainder is (or includes)
* Skatteverket's share, which sits on 1513 and is settled by the ROT/RUT
* payout flow, not by clearing 1510 here.
*
* Two shapes:
* - 0 < remaining < 1 kr (the stuck öresavrundning case): a bank-less
* write-off, Dr 3740 / Cr 1510, so one click closes the invoice. Polarity
* per buildInvoicePaymentClearingLines: the customer under-paid, so 3740
* takes the debit (öresavrundningsförlust).
* - remaining >= 1 kr: a normal clearing of the remaining, Dr bank /
* Cr 1510.
*/
function proposeRemainingAwareLines(
invoice: ProposePaymentLinesInput['invoice'],
accountingMethod: 'accrual' | 'cash',
paymentAccount: string,
desc: string,
): FormLine[] | null {
if (accountingMethod !== 'accrual') return null
if (invoice.currency !== 'SEK') return null
if ((invoice.deduction_total ?? 0) > 0) return null
const total = roundOre(invoice.total)
const remaining = roundOre(
invoice.remaining_amount ?? invoice.total - (invoice.paid_amount ?? 0),
)
const hasPartial = remaining > ORE_TOLERANCE && total - remaining > ORE_TOLERANCE
if (!hasPartial) return null
if (remaining < ORE_ROUNDING_SETTLEMENT_MAX) {
return [
{
account_number: '3740',
debit_amount: toFormAmount(remaining),
credit_amount: '',
line_description: 'Öresavrundning',
},
{
account_number: '1510',
debit_amount: '',
credit_amount: toFormAmount(remaining),
line_description: desc,
},
]
}
return [
{
account_number: paymentAccount,
debit_amount: toFormAmount(remaining),
credit_amount: '',
line_description: desc,
},
{
account_number: '1510',
debit_amount: '',
credit_amount: toFormAmount(remaining),
line_description: desc,
},
]
}
/**
* Dimensions PR7: re-propagate the invoice default onto every proposed leg
* (matches createInvoicePaymentJournalEntry/createInvoiceCashEntry).
*/
function withInvoiceDimensions(
lines: FormLine[],
invoice: ProposePaymentLinesInput['invoice'],
): FormLine[] {
const bag = invoice.default_dimensions
if (bag && Object.keys(bag).length > 0) {
return lines.map((line) => ({ ...line, dimensions: { ...bag } }))