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
@@ -21,8 +21,10 @@ import {
VILANDE_OUTPUT_VAT_ACCOUNTS,
} from '../kontantmetod-cutoff'
import type { CutoffPayable, CutoffReceivable } from '../kontantmetod-cutoff'
import type { Invoice } from '@/types'
import { roundOre } from '@/lib/money'
import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine'
import { makeInvoice } from '@/tests/helpers'
const sum = (lines: Array<{ debit_amount: number; credit_amount: number }>) => ({
debit: roundOre(lines.reduce((s, l) => s + l.debit_amount, 0)),
@@ -595,6 +597,118 @@ describe('collectKontantmetodCutoff', () => {
expect(result.receivables).toEqual([])
})
// Issue #2248: ROT/RUT under fakturamodellen. The skattereduktion is a
// fordran on Skatteverket (1513) booked by the payment voucher itself, and
// every settlement path records the CUSTOMER share as the payment row
// amount, so the cut-off must measure the outstanding against
// total - deduction_total, never the gross total.
describe('ROT/RUT deduction (fakturamodellen, #2248)', () => {
// Arbetskostnad 20 000 + moms 5 000 = 25 000, ROT 30 % of labor incl.
// moms = 7 500, customer share 17 500.
const rotInvoice = (over: Partial<Invoice> = {}) => ({
...makeInvoice({
id: 'inv-rot', invoice_number: 'F-ROT', invoice_date: '2026-08-01', status: 'paid',
subtotal: 20000, vat_amount: 5000, total: 25000, deduction_total: 7500,
remaining_amount: 0, vat_treatment: 'standard_25',
...over,
}),
})
const paymentOf = (amount: number) => [{
id: 'ip-rot', invoice_id: 'inv-rot', amount, payment_date: '2026-08-28',
}]
it('books nothing for a ROT invoice the customer has settled in full', async () => {
// Before the fix this produced Dr 1510 7 500 / Cr 3001 6 000 / Cr 2618
// 1 500 while 1513 already carried the 7 500 and revenue + 2611 were
// fully booked by the August payment voucher.
const result = await collectKontantmetodCutoff(makePagedSupabase({
invoices: [rotInvoice()],
invoice_payments: paymentOf(17500),
}) as never, 'co-1', '2026-01-01', '2026-12-31')
expect(result.receivables).toEqual([])
expect(buildCutoffLines(result.receivables, []).receivableLines).toEqual([])
})
it('carries only the customer residual on a part-paid ROT invoice, moms scaled by the customer share', async () => {
const result = await collectKontantmetodCutoff(makePagedSupabase({
invoices: [rotInvoice({ status: 'partially_paid', remaining_amount: 7500 })],
invoice_payments: paymentOf(10000),
}) as never, 'co-1', '2026-01-01', '2026-12-31')
// 17 500 - 10 000 = 7 500 still owed by the customer, carrying
// 5 000 * (7 500 / 17 500) = 2 142,86 of the invoice moms.
expect(result.receivables).toEqual([
expect.objectContaining({ id: 'inv-rot', outstanding: 7500, vat: 2142.86 }),
])
const { receivableLines } = buildCutoffLines(result.receivables, [])
expect(receivableLines.find((l) => l.account_number === '1510')?.debit_amount).toBe(7500)
expect(receivableLines.find((l) => l.account_number === '2618')?.credit_amount).toBe(2142.86)
expect(receivableLines.find((l) => l.account_number === '3001')?.credit_amount).toBe(5357.14)
expect(sum(receivableLines)).toEqual({ debit: 7500, credit: 7500 })
})
it('puts the whole invoice moms into the final period for an unpaid ROT invoice', async () => {
// Nothing has been booked yet, so all 5 000 of moms is unreported at
// year end; the fordran is the customer share only (1513 is not part
// of this cut-off).
const result = await collectKontantmetodCutoff(makePagedSupabase({
invoices: [rotInvoice({ status: 'sent', remaining_amount: 17500 })],
}) as never, 'co-1', '2026-01-01', '2026-12-31')
expect(result.receivables).toEqual([
expect.objectContaining({ outstanding: 17500, vat: 5000 }),
])
})
it('floors an over-collected customer share at zero instead of booking a negative fordran', async () => {
// Bank-match paths store cash received; an öre of rounding above the
// derived share is noise (same GREATEST(0, ...) as the DB guard).
const result = await collectKontantmetodCutoff(makePagedSupabase({
invoices: [rotInvoice()],
invoice_payments: paymentOf(17500.4),
}) as never, 'co-1', '2026-01-01', '2026-12-31')
expect(result.receivables).toEqual([])
})
it('nets an unpaid ROT invoice and its credit note to zero as of period end', async () => {
// A credit note keeps deduction_total as a positive magnitude (CHECK
// >= 0) against a negative total, so the customer share must follow
// the sign of the total for the pair to cancel.
const result = await collectKontantmetodCutoff(makePagedSupabase({
invoices: [
rotInvoice({ status: 'credited', remaining_amount: 17500 }),
rotInvoice({
id: 'inv-rot-credit', invoice_number: 'K-ROT', invoice_date: '2026-12-01', status: 'sent',
subtotal: -20000, vat_amount: -5000, total: -25000, remaining_amount: -17500,
credited_invoice_id: 'inv-rot',
}),
],
}) as never, 'co-1', '2026-01-01', '2026-12-31')
expect(result.receivables.map((r) => r.outstanding)).toEqual([17500, -17500])
expect(result.receivables.map((r) => r.vat)).toEqual([5000, -5000])
expect(buildCutoffLines(result.receivables, []).receivableLines).toEqual([])
})
it('leaves a plain invoice with the same figures exactly as before', async () => {
// Same 25 000 / 5 000 invoice without a deduction and 17 500 paid:
// 7 500 genuinely remains and carries 7 500 / 25 000 of the moms.
for (const deduction of [0, null, undefined]) {
const result = await collectKontantmetodCutoff(makePagedSupabase({
invoices: [{
...rotInvoice({ status: 'partially_paid', remaining_amount: 7500 }),
deduction_total: deduction,
}],
invoice_payments: paymentOf(17500),
}) as never, 'co-1', '2026-01-01', '2026-12-31')
expect(result.receivables).toEqual([
expect.objectContaining({ id: 'inv-rot', outstanding: 7500, vat: 1500 }),
])
const { receivableLines } = buildCutoffLines(result.receivables, [])
expect(receivableLines.find((l) => l.account_number === '1510')?.debit_amount).toBe(7500)
expect(receivableLines.find((l) => l.account_number === '3001')?.credit_amount).toBe(6000)
expect(receivableLines.find((l) => l.account_number === '2618')?.credit_amount).toBe(1500)
}
})
})
it('collects reverse-charge rate, supplier type, and scaled declaration basis', async () => {
const result = await collectKontantmetodCutoff(makePagedSupabase({
supplier_invoices: [{
+32 -4
View File
@@ -45,6 +45,7 @@ import {
resolveReverseChargeRate,
} from '@/lib/bookkeeping/vat-entries'
import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine'
import { invoiceCustomerOutstanding, invoiceCustomerShare } from '@/lib/invoices/customer-share'
import { createLogger } from '@/lib/logger'
import { ORE_TOLERANCE, roundOre } from '@/lib/money'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
@@ -717,7 +718,7 @@ export async function collectKontantmetodCutoff(
fetchAllRows<Record<string, unknown>>(
({ from, to }) => supabase
.from('invoices')
.select('id, invoice_number, invoice_date, status, total, total_sek, vat_amount, vat_amount_sek, vat_treatment, credited_invoice_id, document_type, currency, exchange_rate')
.select('id, invoice_number, invoice_date, status, total, total_sek, vat_amount, vat_amount_sek, vat_treatment, credited_invoice_id, document_type, currency, exchange_rate, deduction_total')
.eq('company_id', companyId)
.lte('invoice_date', periodEnd)
.in('status', ['sent', 'overdue', 'partially_paid', 'paid', 'credited'])
@@ -803,7 +804,29 @@ export async function collectKontantmetodCutoff(
const total = resolveHeaderSek(row, 'total', 'total_sek')
const vat = resolveHeaderSek(row, 'vat_amount', 'vat_amount_sek')
const paid = paidByInvoice.get(row.id as string) ?? 0
const outstandingOwn = roundOre(totalOwn - paid)
// ROT/RUT (fakturamodellen): the customer owes the total minus the
// skattereduktion. The deduction is a fordran on Skatteverket carried on
// 1513, booked by the same voucher that recognises the sale (the payment
// voucher under kontantmetoden), and every settlement path records the
// customer share as the payment row amount. Measuring the outstanding
// against the gross total left exactly deduction_total "open" on a fully
// paid invoice and booked it as a phantom 1510 fordran with phantom
// vilande moms (#2248). The share has ONE definition
// (lib/invoices/customer-share.ts, twin of the DB guard); only the as-of
// payment sum and the floor below belong to the cut-off.
const shareInput = { total: totalOwn, deduction_total: Number(row.deduction_total ?? 0) }
const hasDeduction = Math.abs(shareInput.deduction_total) > 0
const customerShareOwn = invoiceCustomerShare(shareInput)
let outstandingOwn = invoiceCustomerOutstanding(shareInput, paid)
if (hasDeduction) {
// Floored at zero on the invoice's own side, the guard's GREATEST(0, ...)
// made sign-aware so a credit note keeps its sign: an öre of
// over-collection against a derived customer share is noise, not a
// fordran the company owes back. A plain invoice keeps the unfloored
// gross computation it always had.
outstandingOwn = totalOwn < 0 ? Math.min(0, outstandingOwn) : Math.max(0, outstandingOwn)
}
const outstanding = totalOwn === 0 ? 0 : roundOre(total * (outstandingOwn / totalOwn))
if (Math.abs(outstanding) < ORE_TOLERANCE) continue
@@ -819,8 +842,13 @@ export async function collectKontantmetodCutoff(
}
// Scale the moms share to the part still outstanding: a half-paid invoice
// carries half its moms into the cut-off.
const ratio = totalOwn === 0 ? 0 : outstandingOwn / totalOwn
// carries half its moms into the cut-off. The base is the CUSTOMER share:
// under bokslutsmetoden the payment voucher credits the full invoice moms
// when the customer pays their share (the 1513 leg carries no moms of its
// own), so the moms still unreported follows the customer residual, and
// an unpaid ROT/RUT invoice puts its whole moms into the final period.
// On a plain invoice the customer share IS the total, so nothing moves.
const ratio = customerShareOwn === 0 ? 0 : outstandingOwn / customerShareOwn
const scaledVat = roundOre(vat * ratio)
// Moms on a treatment that cannot carry Swedish output moms is a real