fix(bokslut): kontantmetod cut-off treats the ROT/RUT share as settled on 1513 (#2278)

* fix(bokslut): kontantmetod cut-off treats the ROT/RUT share as settled on 1513

Root cause: collectKontantmetodCutoff compared invoice_payments against
the gross invoice total and never read deduction_total. Under
fakturamodellen the customer owes total minus the skattereduktion; the
deduction is a fordran on Skatteverket carried on 1513 by the payment
voucher (Dr 1930 customer share / Dr 1513 deduction / Cr 30xx / Cr 26xx
in full), and every settlement path records the customer share as the
payment row amount. A fully paid ROT/RUT invoice therefore showed exactly
deduction_total as outstanding, and the year-end cut-off booked a phantom
Dr 1510 / Cr 30xx / Cr 2618 on top of a sale whose revenue and moms were
already fully recognised: revenue overstated and vilande moms invented.

Fix: the customer's outstanding is total - deduction_total - paid (the
deduction follows the sign of the total, since credit notes store it as
a positive magnitude), floored at zero on the invoice's own side for
over-collection noise, mirroring the invoices_remaining_amount_guard
formula. The moms carried into the cut-off is scaled by the customer
share, not the gross total, so an unpaid ROT/RUT invoice reports its
whole moms in the final period and a part-paid one the matching
fraction. Invoices without a deduction take the unchanged gross path.

The readiness gate, the pending-operation executor and the MCP tool all
consume this collector, so they inherit the fix. The Skatteverket share
itself (an unpaid ROT/RUT invoice's 1513 fordran at year end) is not
part of the cut-off and stays a separate change, as is the 1513 point
already deferred on the currency revaluation.

Fixes #2248

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

* refactor(invoices): one definition of the customer share of an invoice

The customer share of an invoice (total minus the ROT/RUT deduction,
and what remains of it after payments) was re-derived by hand at three
TypeScript sites plus the SQL guard invoices_remaining_amount_guard, and
the kontantmetod cut-off's copy had drifted to the gross total (#2248).
Fixing the cut-off's arithmetic alone would leave the next copy free to
drift the same way.

lib/invoices/customer-share.ts now holds the definition:
invoiceCustomerShare(invoice) returns total minus sign(total) times
deduction_total (the deduction is stored as a positive magnitude, also
on credit notes), and invoiceCustomerOutstanding(invoice, paid) returns
the signed residual after payments. The module comment names the SQL
twin (migration 20260817191708) so the two stay in lockstep; the SQL is
untouched.

Call sites moved onto the helper with byte-identical behaviour:
kontantmetod-cutoff.ts (keeps its as-of payment sum, the sign-aware zero
floor for ROT/RUT rows and the moms scaling), rot-rut-file.ts (the
customer-share-paid test) and payment-sync.ts (the storno path, which
still floors with Math.max(0, ...) because it persists the column).
Plain invoices get their total back exactly as stored, so their paths
do not change by a bit. New unit tests cover plain, ROT/RUT, credit-note
sign, null deduction and the lockstep with the guard's formula.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-04 17:01:06 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 6a68ecb4d4
commit be0478c219
6 changed files with 316 additions and 21 deletions
+9 -3
View File
@@ -1,6 +1,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import { invoiceCustomerOutstanding } from '@/lib/invoices/customer-share'
import type { JournalEntry } from '@/types'
const log = createLogger('payment-sync')
@@ -180,13 +181,18 @@ export async function syncInvoiceStatusFromPaymentEntry(
// 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.
// cash-partial block rejects the "partial"). The share comes from the
// one shared definition (lib/invoices/customer-share.ts); the
// Math.max(0, ...) mirrors the guard's GREATEST(0, ...) because this
// value is persisted into the column.
const deductionTotal =
(customerInvoice as { deduction_total?: number | null }).deduction_total ?? 0
const newRemaining = Math.max(
0,
roundOre(customerInvoice.total - deductionTotal - safePaidAmount),
invoiceCustomerOutstanding(
{ total: customerInvoice.total, deduction_total: deductionTotal },
safePaidAmount,
),
)
const revertStatus = newPaidAmount > 0
? 'partially_paid'