Files
accounted/lib/invoices/__tests__/customer-share.test.ts
T
be0478c219 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>
2026-09-04 17:01:06 +02:00

88 lines
4.0 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { invoiceCustomerOutstanding, invoiceCustomerShare } from '../customer-share'
import { roundOre } from '@/lib/money'
import { makeInvoice } from '@/tests/helpers'
// Arbetskostnad 20 000 + moms 5 000 = 25 000, ROT 30 % of labor incl. moms
// = 7 500, customer share 17 500 (invoice-rules.md section 8, fakturamodellen).
const rot = makeInvoice({ subtotal: 20000, vat_amount: 5000, total: 25000, deduction_total: 7500 })
describe('invoiceCustomerShare', () => {
it('is the total on a plain invoice, returned exactly as stored', () => {
const plain = makeInvoice({ total: 12500 })
expect(invoiceCustomerShare(plain)).toBe(12500)
expect(invoiceCustomerShare({ total: 1234.56 })).toBe(1234.56)
})
it('treats a null, undefined or zero deduction as no deduction', () => {
expect(invoiceCustomerShare({ total: 25000, deduction_total: null })).toBe(25000)
expect(invoiceCustomerShare({ total: 25000, deduction_total: undefined })).toBe(25000)
expect(invoiceCustomerShare({ total: 25000, deduction_total: 0 })).toBe(25000)
expect(invoiceCustomerShare({ total: 25000, deduction_total: Number.NaN })).toBe(25000)
})
it('subtracts the ROT/RUT deduction: the 1513 share is Skatteverket\'s, not the customer\'s', () => {
expect(invoiceCustomerShare(rot)).toBe(17500)
})
it('follows the sign of the total on a credit note, whose deduction is stored as a positive magnitude', () => {
const credit = makeInvoice({
...rot, id: 'credit', total: -25000, vat_amount: -5000, subtotal: -20000, credited_invoice_id: rot.id,
})
expect(credit.deduction_total).toBe(7500)
expect(invoiceCustomerShare(credit)).toBe(-17500)
// An invoice and its full credit note net to zero for the customer.
expect(invoiceCustomerShare(rot) + invoiceCustomerShare(credit)).toBe(0)
})
it('rounds to the öre without float drift', () => {
expect(invoiceCustomerShare({ total: 1000.1, deduction_total: 300.2 })).toBe(699.9)
expect(invoiceCustomerShare({ total: 1234.56, deduction_total: 370.37 })).toBe(864.19)
})
})
describe('invoiceCustomerOutstanding', () => {
it('is zero once the customer has paid their share, even though the gross total is not covered', () => {
expect(invoiceCustomerOutstanding(rot, 17500)).toBe(0)
})
it('is the customer residual on a part-paid ROT/RUT invoice', () => {
expect(invoiceCustomerOutstanding(rot, 10000)).toBe(7500)
})
it('is total minus paid on a plain invoice, signed: overpayment goes negative, not floored', () => {
const plain = makeInvoice({ total: 12500 })
expect(invoiceCustomerOutstanding(plain, 0)).toBe(12500)
expect(invoiceCustomerOutstanding(plain, 5000)).toBe(7500)
expect(invoiceCustomerOutstanding(plain, 12500.4)).toBe(-0.4)
})
it('keeps the sign on a credit note so a refund settles it', () => {
const credit = makeInvoice({ ...rot, id: 'credit', total: -25000, credited_invoice_id: rot.id })
expect(invoiceCustomerOutstanding(credit, 0)).toBe(-17500)
expect(invoiceCustomerOutstanding(credit, -17500)).toBe(0)
})
it('stays in lockstep with the SQL guard once floored the way the guard does', () => {
// invoices_remaining_amount_guard (20260817191708):
// remaining_amount = GREATEST(0, ROUND(total - paid_amount - deduction_total, 2))
// The guard rounds the whole expression once; the helper rounds the
// share first. Both must land on the same öre.
const guard = (total: number, paid: number, deduction: number) =>
Math.max(0, roundOre(total - paid - deduction))
const rows: Array<[number, number, number | null]> = [
[25000, 0, 7500],
[25000, 17500, 7500],
[25000, 10000, 7500],
[25000, 17500.4, 7500],
[12500, 0, null],
[12500, 12500, 0],
[1234.56, 370.37, 100.1],
]
for (const [total, paid, deduction] of rows) {
expect(Math.max(0, invoiceCustomerOutstanding({ total, deduction_total: deduction }, paid)))
.toBe(guard(total, paid, deduction ?? 0))
}
})
})