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:
Jakob Wennberg
2026-09-04 17:01:06 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 6a68ecb4d4
commit be0478c219
6 changed files with 316 additions and 21 deletions
+9 -3
View File
@@ -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: [{
+32 -4
View File
@@ -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))
}
})
})
+62
View File
@@ -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)
}
+12 -14
View File
@@ -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)