fix(invoices): ROT/RUT kontantmetoden invoices could not be marked paid (#2040)

* fix(invoices): derive mark-paid amount as customer settlement, not gross debit sum

remaining_amount on a ROT/RUT invoice is stored net of the deduction
(total - deduction_total): the customer owes only their share, and
Skatteverket's share sits on 1513 until the payout flow clears it. The
kontantmetoden payment entry correctly books two debit legs (bank =
customer share, 1513 = deduction), but both mark-paid routes summed ALL
debit lines as the payment amount, so the gross total was compared
against the net remaining and every ROT/RUT cash invoice was rejected
with MATCH_AMOUNT_EXCEEDS_REMAINING by exactly deduction_total,
stalling the whole ROT chain (unpaid invoice never becomes a payout
candidate).

New deriveCustomerSettlementAmount in lib/invoices/apply-invoice-payment
excludes the net 1513 debit, capped at the invoice's own deduction (so
invoices without a deduction keep byte-identical behavior, including
rejecting a hand-added 1513 overshoot), and both the dashboard and v1
mark-paid routes use it. The verifikat still books the full entry
including the 1513 leg; only the settlement math changes.

Reported by a user unable to mark ROT invoice 1123 as paid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uy5xt3nCKwL1vhRFPYjAjJ

* fix(invoices): harden ROT settlement derivation per skeptic refutations

Three fixes from adversarial review of the previous commit:

1. v1 mark-paid never fetched deduction_total (the pre-flight select
   projects explicit columns), so the exclusion cap was always 0 and the
   v1 API still failed with MATCH_AMOUNT_EXCEEDS_REMAINING. Fetch it ad
   hoc next to journal_entry_id (kept out of the response contract) and
   assert the projection in the route test, since the mock harness
   ignores select strings.

2. Gate the 1513 exclusion on the invoice NOT being booked yet, in both
   routes. An invoice booked at send already debited 1513 in its
   registration entry; ungated, a cash-shaped payment entry on such an
   invoice would post (orphaned 1510, doubled 1513, double revenue and
   VAT) where the gross guard used to reject it.

3. payment-sync's reversal recompute now stores remaining_amount net of
   deduction_total, matching build-invoice-write and the DB guard.
   Recomputing gross made a storno'd ROT cash invoice permanently
   un-payable under the net settlement derivation (net payment can never
   reach a gross remaining; the cash-partial block rejects the rest).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uy5xt3nCKwL1vhRFPYjAjJ

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-30 15:12:37 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 355723c566
commit e313bfa8ec
8 changed files with 497 additions and 23 deletions
@@ -239,6 +239,34 @@ describe('syncInvoiceStatusFromPaymentEntry', () => {
expect(wasDeleted('invoice_payments')).toBe(true)
})
// ROT/RUT: remaining_amount is the CUSTOMER share (total - deduction_total),
// as build-invoice-write stores it. Recomputing it gross on reversal used to
// inflate remaining to total, after which the net customer settlement could
// never reach it again and the invoice was permanently un-payable.
it('customer cash-payment reversal keeps remaining net of the ROT/RUT deduction', async () => {
const { supabase, updatePayload } = createRecordingSupabase([
{ data: null }, // invoice_payments select amount → none (cash entry)
{ data: { paid_amount: 86800, total: 124000, deduction_total: 37200, due_date: '2099-12-31' } }, // invoices select
{ data: null }, // invoices update
{ data: [] }, // invoice_payments select transaction_id
{ data: null }, // invoice_payments delete
{ data: null }, // transactions update
])
await syncInvoiceStatusFromPaymentEntry(
supabase,
'co-1',
entry({ source_type: 'invoice_cash_payment', source_id: 'invoice-1' }),
)
expect(updatePayload('invoices')).toEqual({
status: 'sent',
paid_at: null,
paid_amount: 0,
remaining_amount: 86800,
})
})
// Partial reversal (clearing entry with a payment row): only the reversed
// amount comes off, remaining = total - newPaid, status stays partially_paid.
it('customer partial reversal keeps remaining_amount = total - newPaid', async () => {
+17 -2
View File
@@ -152,7 +152,7 @@ export async function syncInvoiceStatusFromPaymentEntry(
const { data: customerInvoice } = await supabase
.from('invoices')
.select('paid_amount, total, due_date')
.select('paid_amount, total, due_date, deduction_total')
.eq('id', entry.source_id)
.eq('company_id', companyId)
.single()
@@ -172,7 +172,22 @@ export async function syncInvoiceStatusFromPaymentEntry(
// .in('status', …) guard below can leave status/remaining un-updated if
// the invoice isn't paid/partially_paid: only reachable on a non-storno
// path; the payment-row delete + tx release still run, freeing the line.)
const newRemaining = roundOre(customerInvoice.total - safePaidAmount)
//
// remaining_amount is the CUSTOMER share: net of the ROT/RUT deduction,
// exactly as build-invoice-write stores it at creation (total -
// deduction_total) and as the invoices_remaining_amount_guard trigger
// derives it. Recomputing gross here inflated remaining on ROT/RUT
// invoices after a storno, which made them permanently un-settleable
// once mark-paid started comparing the net customer settlement against
// remaining (a net payment can never reach a gross remaining, and the
// cash-partial block rejects the "partial"). Mirrors rot-rut-file's
// derivation, which distrusted this very writer.
const deductionTotal =
(customerInvoice as { deduction_total?: number | null }).deduction_total ?? 0
const newRemaining = Math.max(
0,
roundOre(customerInvoice.total - deductionTotal - safePaidAmount),
)
const revertStatus = newPaidAmount > 0
? 'partially_paid'
: customerInvoice.due_date && new Date(customerInvoice.due_date) < new Date()
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import {
deriveCustomerSettlementAmount,
planInvoicePayment,
planInvoicePaymentForLines,
PAYMENT_OVERSHOOT_TOLERANCE,
@@ -247,3 +248,81 @@ describe('planInvoicePaymentForLines', () => {
expect(planInvoicePaymentForLines(INV, 1235, undefined, 'SEK').ok).toBe(false)
})
})
describe('deriveCustomerSettlementAmount', () => {
// The kontantmetoden ROT shape from proposeCashLines: total 124 000,
// deduction 37 200 (30 %), customer share 86 800.
const ROT_CASH_LINES = [
{ account_number: '1930', debit_amount: 86800, credit_amount: 0 },
{ account_number: '1513', debit_amount: 37200, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 99200 },
{ account_number: '2611', debit_amount: 0, credit_amount: 24800 },
]
it('excludes the 1513 leg on a ROT invoice (the reported bug)', () => {
expect(deriveCustomerSettlementAmount(ROT_CASH_LINES, 37200)).toBe(86800)
})
it('is the plain debit sum without any 1513 line', () => {
const lines = [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1000 },
]
expect(deriveCustomerSettlementAmount(lines, 0)).toBe(1000)
expect(deriveCustomerSettlementAmount(lines, 37200)).toBe(1000)
})
it('with cap 0 a hand-added 1513 debit still counts as payment (non-ROT unchanged)', () => {
const lines = [
{ account_number: '1930', debit_amount: 900, credit_amount: 0 },
{ account_number: '1513', debit_amount: 100, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1000 },
]
expect(deriveCustomerSettlementAmount(lines, 0)).toBe(1000)
})
it('excludes at most the deduction cap when the 1513 debit overshoots it', () => {
const lines = [
{ account_number: '1930', debit_amount: 86800, credit_amount: 0 },
{ account_number: '1513', debit_amount: 40000, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 102000 },
{ account_number: '2611', debit_amount: 0, credit_amount: 24800 },
]
// Only 37 200 of the 40 000 is deduction; the surplus stays in the amount
// so the overpayment guard still sees it.
expect(deriveCustomerSettlementAmount(lines, 37200)).toBe(89600)
})
it('nets 1513 debits against 1513 credits before excluding', () => {
// A self-canceling 1513 debit/credit pair does not raise the exclusion:
// net 1513 stays 37 200, so the extra 500 debit stays in the settlement
// amount and the overpayment guard sees it (safe direction: rejects
// rather than silently excludes).
const lines = [
...ROT_CASH_LINES,
{ account_number: '1513', debit_amount: 500, credit_amount: 0 },
{ account_number: '1513', debit_amount: 0, credit_amount: 500 },
]
expect(deriveCustomerSettlementAmount(lines, 37200)).toBe(87300)
})
it('a net 1513 credit is not added to the settlement', () => {
const lines = [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1513', debit_amount: 0, credit_amount: 200 },
{ account_number: '1510', debit_amount: 0, credit_amount: 800 },
]
expect(deriveCustomerSettlementAmount(lines, 37200)).toBe(1000)
})
it('leaves 3740 öre legs untouched and rounds float sums', () => {
const lines = [
{ account_number: '1930', debit_amount: 86800.4, credit_amount: 0 },
{ account_number: '1513', debit_amount: 37200, credit_amount: 0 },
{ account_number: '3740', debit_amount: 0.1, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 99200.5 },
{ account_number: '2611', debit_amount: 0, credit_amount: 24800 },
]
expect(deriveCustomerSettlementAmount(lines, 37200)).toBe(86800.5)
})
})
+38
View File
@@ -120,6 +120,44 @@ export function planInvoicePayment(
/** BAS öres- och kronutjämning: the only account that may carry an absorbed residual. */
const ORE_ROUNDING_ACCOUNT = '3740'
/** BAS 1513, Kundfordringar delad faktura: Skatteverket's ROT/RUT share. */
export const ROT_RUT_RECEIVABLE_ACCOUNT = '1513'
/**
* Derives the customer-settlement amount from caller-supplied booking lines.
*
* `remaining_amount` on a ROT/RUT invoice is stored NET of the deduction
* (total - deduction_total): the customer owes only their share, and
* Skatteverket's share sits on 1513 until the payout-request flow clears it.
* The kontantmetoden payment entry, however, correctly books TWO debit legs
* (bank = customer share, 1513 = deduction), so a naive sum of all debits
* yields the gross total and every ROT/RUT cash invoice fails the
* overpayment guard by exactly the deduction.
*
* The 1513 exclusion is netted against 1513 credits (a debit/credit
* correction pair in one entry must not shrink the settlement) and capped at
* the invoice's own deduction, so on an invoice without a deduction the
* result is the plain debit sum and behavior is unchanged, including the
* rejection of a hand-added 1513 debit that overshoots the remaining.
*
* `deductionCapSek` must be in SEK (the lines' currency); the caller owns
* converting a foreign-currency invoice's deduction_total.
*/
export function deriveCustomerSettlementAmount(
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>,
deductionCapSek: number,
): number {
const totalDebit = lines.reduce((s, l) => s + l.debit_amount, 0)
if (deductionCapSek <= 0) return totalDebit
const net1513 = roundOre(
lines
.filter((l) => l.account_number === ROT_RUT_RECEIVABLE_ACCOUNT)
.reduce((s, l) => s + l.debit_amount - l.credit_amount, 0),
)
const excluded = Math.min(Math.max(net1513, 0), deductionCapSek)
return roundOre(totalDebit - excluded)
}
/**
* `planInvoicePayment` for caller-supplied booking lines (the mark-paid
* dialog and the v1 API), where the server does NOT build the verifikat.