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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
6a68ecb4d4
commit
be0478c219
@@ -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: [{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user