* 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>
88 lines
4.0 KiB
TypeScript
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))
|
|
}
|
|
})
|
|
})
|