* 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>
63 lines
2.8 KiB
TypeScript
63 lines
2.8 KiB
TypeScript
/**
|
|
* The customer's share of an invoice: ONE definition.
|
|
*
|
|
* Under ROT/RUT fakturamodellen the company invoices the full amount and the
|
|
* customer pays the total minus the skattereduktion; the deduction is a
|
|
* fordran on Skatteverket carried on 1513, never on the customer
|
|
* (swedish-invoice-compliance, invoice-rules.md section 8). Every settlement
|
|
* path records the customer share as the payment row amount, so "what is
|
|
* still outstanding" must be measured against that share.
|
|
*
|
|
* This arithmetic used to be re-derived by hand at every reader and one copy
|
|
* drifted (#2248: the kontantmetod cut-off compared payments against the
|
|
* gross total). It now lives here and in exactly one SQL twin:
|
|
*
|
|
* invoices_remaining_amount_guard (migration 20260817191708)
|
|
* remaining_amount = GREATEST(0, ROUND(total - paid_amount - deduction_total, 2))
|
|
*
|
|
* Change both or neither. The guard floors at zero because it persists the
|
|
* column; the functions here return the signed value and each writer applies
|
|
* the floor it needs (payment-sync mirrors GREATEST(0, ...), the kontantmetod
|
|
* cut-off floors on the invoice's own side so credit notes keep their sign).
|
|
*/
|
|
import { roundOre } from '@/lib/money'
|
|
|
|
/** The invoice header fields the customer-share arithmetic reads. */
|
|
export interface CustomerShareInvoice {
|
|
/** Invoice total including moms, in invoice currency. Negative on a credit note. */
|
|
total: number
|
|
/**
|
|
* ROT/RUT skattereduktion in invoice currency. Stored as a positive
|
|
* magnitude under CHECK (deduction_total >= 0), also on a credit note.
|
|
* Null, undefined and 0 all mean "no deduction".
|
|
*/
|
|
deduction_total?: number | null
|
|
}
|
|
|
|
/**
|
|
* What the customer owes on the invoice: total minus the ROT/RUT deduction.
|
|
*
|
|
* The deduction follows the sign of the total, so a credited ROT invoice
|
|
* (total -25 000, deduction_total 7 500) owes the customer -17 500 back and
|
|
* nets to zero against its original. An invoice without a deduction returns
|
|
* its total exactly as stored.
|
|
*/
|
|
export function invoiceCustomerShare(invoice: CustomerShareInvoice): number {
|
|
const deduction = Math.abs(invoice.deduction_total ?? 0)
|
|
// `!(x > 0)` also catches NaN: a non-numeric deduction reads as none rather
|
|
// than poisoning every downstream amount.
|
|
if (!(deduction > 0)) return invoice.total
|
|
return roundOre(invoice.total - Math.sign(invoice.total) * deduction)
|
|
}
|
|
|
|
/**
|
|
* The customer's share still unpaid after `paid`, signed and unfloored:
|
|
* positive is a fordran on the customer, negative means over-collected (or,
|
|
* on a credit note, still owed back). `paid` is the sum of the payment rows
|
|
* the caller considers settled (all of them, or only those on or before a
|
|
* cut-off date), in invoice currency.
|
|
*/
|
|
export function invoiceCustomerOutstanding(invoice: CustomerShareInvoice, paid: number): number {
|
|
return roundOre(invoiceCustomerShare(invoice) - paid)
|
|
}
|