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
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user