diff --git a/lib/bookkeeping/payment-sync.ts b/lib/bookkeeping/payment-sync.ts index 07e3968b..b95a4c7e 100644 --- a/lib/bookkeeping/payment-sync.ts +++ b/lib/bookkeeping/payment-sync.ts @@ -1,6 +1,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { createLogger } from '@/lib/logger' import { roundOre } from '@/lib/money' +import { invoiceCustomerOutstanding } from '@/lib/invoices/customer-share' import type { JournalEntry } from '@/types' const log = createLogger('payment-sync') @@ -180,13 +181,18 @@ export async function syncInvoiceStatusFromPaymentEntry( // invoices after a storno, which made them permanently un-settleable // once mark-paid started comparing the net customer settlement against // remaining (a net payment can never reach a gross remaining, and the - // cash-partial block rejects the "partial"). Mirrors rot-rut-file's - // derivation, which distrusted this very writer. + // cash-partial block rejects the "partial"). The share comes from the + // one shared definition (lib/invoices/customer-share.ts); the + // Math.max(0, ...) mirrors the guard's GREATEST(0, ...) because this + // value is persisted into the column. const deductionTotal = (customerInvoice as { deduction_total?: number | null }).deduction_total ?? 0 const newRemaining = Math.max( 0, - roundOre(customerInvoice.total - deductionTotal - safePaidAmount), + invoiceCustomerOutstanding( + { total: customerInvoice.total, deduction_total: deductionTotal }, + safePaidAmount, + ), ) const revertStatus = newPaidAmount > 0 ? 'partially_paid' diff --git a/lib/core/bookkeeping/__tests__/kontantmetod-cutoff.test.ts b/lib/core/bookkeeping/__tests__/kontantmetod-cutoff.test.ts index 9c2fe217..0c627e90 100644 --- a/lib/core/bookkeeping/__tests__/kontantmetod-cutoff.test.ts +++ b/lib/core/bookkeeping/__tests__/kontantmetod-cutoff.test.ts @@ -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 = {}) => ({ + ...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: [{ diff --git a/lib/core/bookkeeping/kontantmetod-cutoff.ts b/lib/core/bookkeeping/kontantmetod-cutoff.ts index 2f6e4506..31d447fd 100644 --- a/lib/core/bookkeeping/kontantmetod-cutoff.ts +++ b/lib/core/bookkeeping/kontantmetod-cutoff.ts @@ -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>( ({ 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 diff --git a/lib/invoices/__tests__/customer-share.test.ts b/lib/invoices/__tests__/customer-share.test.ts new file mode 100644 index 00000000..4844639e --- /dev/null +++ b/lib/invoices/__tests__/customer-share.test.ts @@ -0,0 +1,87 @@ +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)) + } + }) +}) diff --git a/lib/invoices/customer-share.ts b/lib/invoices/customer-share.ts new file mode 100644 index 00000000..e0d9f374 --- /dev/null +++ b/lib/invoices/customer-share.ts @@ -0,0 +1,62 @@ +/** + * 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) +} diff --git a/lib/invoices/rot-rut-file.ts b/lib/invoices/rot-rut-file.ts index 9b57a76c..66f77ff4 100644 --- a/lib/invoices/rot-rut-file.ts +++ b/lib/invoices/rot-rut-file.ts @@ -2,6 +2,7 @@ import { escapeXml } from '@/lib/xml/escape' import type { Invoice, InvoiceItem } from '@/types' import { truncateToWholeKronor } from '@/lib/money' import { decryptPersonnummer } from '@/lib/salary/personnummer' +import { invoiceCustomerOutstanding } from './customer-share' import { deductionSekConverter, SCHABLON_WORK_TYPES, type DeductionType } from './rot-rut-rules' /** @@ -229,20 +230,17 @@ export function evaluateInvoiceForFile( // "Paid" for a rot/rut claim means the BUYER has paid their share: the // deduction itself is Skatteverket's to pay (fakturamodellen). The customer - // share outstanding is DERIVED from the header fields here, with the same - // formula as buildInvoiceWriteData and migration 20260817191708 - // (total - paid_amount - deduction_total), deliberately NOT read off - // remaining_amount: at least one writer (payment-sync's storno path) - // recomputes remaining_amount without subtracting the deduction, so the - // stored column is not a deterministic signal, while total, paid_amount and - // deduction_total are maintained by every settlement path. Invoices settled - // through older payment paths can sit at partially_paid although the - // customer share is fully paid; those are accepted here instead of being - // dropped as unpaid. Amounts are invoice currency throughout. - const customerShareOutstanding = - Math.round( - (invoice.total - (invoice.paid_amount ?? 0) - (invoice.deduction_total ?? 0)) * 100, - ) / 100 + // share outstanding is DERIVED from the header fields through the one + // shared definition (lib/invoices/customer-share.ts, twin of migration + // 20260817191708), deliberately NOT read off remaining_amount: at least one + // writer (payment-sync's storno path) once recomputed remaining_amount + // without subtracting the deduction, so the stored column is not a + // deterministic signal, while total, paid_amount and deduction_total are + // maintained by every settlement path. Invoices settled through older + // payment paths can sit at partially_paid although the customer share is + // fully paid; those are accepted here instead of being dropped as unpaid. + // Amounts are invoice currency throughout. + const customerShareOutstanding = invoiceCustomerOutstanding(invoice, invoice.paid_amount ?? 0) const customerSharePaid = invoice.status === 'paid' || (invoice.status === 'partially_paid' && customerShareOutstanding <= 0)