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
@@ -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' }
|
||||
|
||||
|
||||
@@ -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 } }))
|
||||
|
||||
Reference in New Issue
Block a user