Files
accounted/lib/invoices/__tests__/rot-rut-service.test.ts
T
MattssonandClaude Fable 5 638a25a11a fix(rot-rut): floor BegartBelopp so a half-krona deduction cannot exceed the cap (#1910)
* fix(rot-rut): floor BegartBelopp so a half-krona deduction cannot exceed the cap

The payout file is whole kronor and skattereduktionen is capped at a
share of the work price (HUSFL: 50% RUT / 30% ROT). Math.round pushed an
exact half-krona deduction up: 125 kr work -> 62,50 kr RUT became
begart 63 with betalt 125 - 63 = 62, so the DEDUCTION_EXCEEDS_PAYMENT
guard blocked a perfectly correct invoice. Every work price that is an
odd number of kronor hits this.

BegartBelopp now floors the ore-rounded sum: 62,50 -> 62, betalt 63,
valid file. Flooring can never create begart > betalt (2*floor(D) is an
integer <= pris <= round(pris) whenever the ledger deduction respects
the cap), so the guard becomes a pure corruption check. Invoices that
pass today are unchanged: round and floor only differ at fraction >= .5,
and those were all blocked. The ore-rounding before the floor keeps
float noise (62.499999...) from dropping a whole krona; a test pins it.

Regression tests cover the 125-kr half-krona case end to end (evaluate,
XML amounts, eligible list).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(rot-rut): use roundOre helper for the pre-floor ore rounding

check:guards naive-ore-round flags inline Math.round(x*100)/100; the
sanctioned @/lib/money roundOre does the same with an EPSILON nudge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(rot-rut): pin the ROT half-ore class and use truncateToWholeKronor

Skeptic findings on the frozen head (fbc3bb0c8):

1. The 'previously-passing invoices are byte-identical' claim was false
   for ROT: at 30% the deduction sits far below the begart > betalt
   guard, so e.g. 500 kr labor + 125 moms (ROT 187,50) previously
   PASSED and emitted 188, exceeding both the statutory cap and the
   1513 fordran. The floor now emits 187/438: deliberately 1 kr lower.
   A test pins the case, and DECISIONS.md states the real blast radius.

2. lib/money.ts already ships truncateToWholeKronor, documented as the
   amount rule for everything Skatteverket-bound, with the same
   ore-round-then-truncate semantics; use it instead of hand-rolling
   Math.floor(roundOre(...)).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 21:32:06 +02:00

351 lines
13 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Invoice, InvoiceItem } from '@/types'
import { makeInvoice, createQueuedMockSupabase } from '@/tests/helpers'
import { encryptPersonnummer } from '@/lib/salary/personnummer'
import { listRotRutCandidates } from '@/lib/invoices/rot-rut-service'
/**
* listRotRutCandidates: the begäran-dialog list must never drop an invoice
* silently (#1884). Each of the four historical drop paths lands in blocked
* with a reason, or in eligible where the drop was wrong to begin with:
* 1. deduction lines without a header deduction_total → DEDUCTION_TOTAL_MISSING
* 2. partially_paid with the customer share settled → eligible
* 3. deduction of the other type → NO_DEDUCTION_OF_TYPE (kept, with pointer)
* 4. held by an in-flight begäran → ALREADY_REQUESTED
*/
// Synthetic test identity from Skatteverket's official example files.
const PNR = '198406012388'
const TODAY = '2026-07-02'
function makeRotItem(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
return {
id: 'item-1',
invoice_id: 'invoice-1',
sort_order: 0,
description: 'Arbete',
quantity: 1,
unit: 'tim',
unit_price: 10000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
deduction_type: 'rot',
deduction_amount: 3000,
labor_hours: 25,
work_type: 'BYGG',
housing_designation: 'Stockholm Vasastan 1:23',
apartment_number: null,
brf_org_number: null,
created_at: '2026-06-01T00:00:00Z',
...overrides,
}
}
type Row = Omit<Invoice, 'customer'> & {
customer?: { id: string; name: string | null } | null
}
function makeRotRow(overrides: Partial<Row> = {}, items?: InvoiceItem[]): Row {
return {
...makeInvoice({
status: 'paid',
paid_at: '2026-06-20T10:00:00Z',
deduction_total: 3000,
deduction_personnummer_encrypted: encryptPersonnummer(PNR),
deduction_personnummer_last4: PNR.slice(-4),
items: items ?? [makeRotItem()],
}),
customer: { id: 'customer-1', name: 'Kund AB' },
...overrides,
}
}
function makeRutRow(overrides: Partial<Row> = {}): Row {
return makeRotRow(
{ deduction_total: 6250, ...overrides },
[
makeRotItem({
deduction_type: 'rut',
work_type: 'STAD',
deduction_amount: 6250,
labor_hours: 10,
housing_designation: null,
}),
],
)
}
type ActiveItemRow = {
invoice_id: string
request: { id: string; name: string | null; status: string; company_id: string }
}
function activeItem(invoiceId: string, status: string, name = 'ROT 2026-01-15'): ActiveItemRow {
return {
invoice_id: invoiceId,
request: { id: 'request-1', name, status, company_id: 'company-1' },
}
}
/** Queue the three queries the service runs: byHeader, byLines, activeItems. */
function mockedSupabase(
byHeader: Row[],
byLines: Row[],
active: ActiveItemRow[],
): SupabaseClient {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([{ data: byHeader }, { data: byLines }, { data: active }])
return supabase as unknown as SupabaseClient
}
describe('listRotRutCandidates', () => {
it('lists a paid rot invoice as eligible with the file amounts', async () => {
const invoice = makeRotRow()
const result = await listRotRutCandidates(mockedSupabase([invoice], [invoice], []), 'company-1', 'rot', TODAY)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.blocked).toHaveLength(0)
expect(result.eligible).toHaveLength(1)
expect(result.eligible[0]).toMatchObject({
invoice_id: invoice.id,
customer_name: 'Kund AB',
personnummer_last4: PNR.slice(-4),
betalnings_datum: '2026-06-20',
pris_for_arbete: 12500,
begart_belopp: 3000,
})
})
it('keeps a paid RUT invoice visible under rot as blocked with a pointer to RUT', async () => {
// Drop path 3: NO_DEDUCTION_OF_TYPE used to be filtered out entirely, so
// the ROT view (the dialog default) showed nothing at all.
const invoice = makeRutRow()
const result = await listRotRutCandidates(mockedSupabase([invoice], [invoice], []), 'company-1', 'rot', TODAY)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.eligible).toHaveLength(0)
expect(result.blocked).toHaveLength(1)
expect(result.blocked[0].code).toBe('NO_DEDUCTION_OF_TYPE')
expect(result.blocked[0].message).toContain('RUT')
})
it('lists the same RUT invoice as eligible under rut', async () => {
const invoice = makeRutRow()
const result = await listRotRutCandidates(mockedSupabase([invoice], [invoice], []), 'company-1', 'rut', TODAY)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.blocked).toHaveLength(0)
expect(result.eligible).toHaveLength(1)
expect(result.eligible[0].begart_belopp).toBe(6250)
})
it('lists a half-krona RUT deduction as eligible with begärt floored', async () => {
// 125 kr work incl moms → 62,50 kr deduction. Whole-kronor file amounts
// must floor to 62 (the 50 % cap); half-up rounding used to block this
// invoice as DEDUCTION_EXCEEDS_PAYMENT (63 > 62).
const invoice = makeRotRow(
{ deduction_total: 62.5 },
[
makeRotItem({
deduction_type: 'rut',
work_type: 'STAD',
line_total: 100,
vat_amount: 25,
deduction_amount: 62.5,
labor_hours: 1,
housing_designation: null,
}),
],
)
const result = await listRotRutCandidates(mockedSupabase([invoice], [invoice], []), 'company-1', 'rut', TODAY)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.blocked).toHaveLength(0)
expect(result.eligible).toHaveLength(1)
expect(result.eligible[0]).toMatchObject({
pris_for_arbete: 125,
begart_belopp: 62,
})
})
it('accepts partially_paid with remaining 0 and blocks a genuine partial with the amount', async () => {
// Drop path 2: the customer paid their share but the status never
// flipped to paid (older settlement paths). remaining_amount decides.
const settled = makeRotRow({ id: 'inv-settled', status: 'partially_paid', remaining_amount: 0, paid_amount: 9500 })
const partial = makeRotRow({ id: 'inv-partial', status: 'partially_paid', remaining_amount: 4000, paid_amount: 5500 })
const result = await listRotRutCandidates(
mockedSupabase([settled, partial], [], []),
'company-1',
'rot',
TODAY,
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.eligible.map((e) => e.invoice_id)).toEqual(['inv-settled'])
expect(result.blocked).toHaveLength(1)
expect(result.blocked[0]).toMatchObject({ invoice_id: 'inv-partial', code: 'NOT_PAID' })
expect(result.blocked[0].message).toContain('delbetald')
})
it('surfaces header-less deduction invoices from the line query as DEDUCTION_TOTAL_MISSING', async () => {
// Drop path 1: deduction lines but deduction_total NULL/0 on the header.
// The old query filtered on deduction_total > 0, so these never appeared.
const invoice = makeRotRow({ deduction_total: 0 })
const result = await listRotRutCandidates(mockedSupabase([], [invoice], []), 'company-1', 'rot', TODAY)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.eligible).toHaveLength(0)
expect(result.blocked).toHaveLength(1)
expect(result.blocked[0].code).toBe('DEDUCTION_TOTAL_MISSING')
})
it('blocks an invoice held by a generated begäran as ALREADY_REQUESTED, naming the request', async () => {
// Drop path 4: generated-but-never-uploaded requests silently consumed
// the invoice.
const invoice = makeRotRow()
const result = await listRotRutCandidates(
mockedSupabase([invoice], [invoice], [activeItem(invoice.id, 'generated')]),
'company-1',
'rot',
TODAY,
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.eligible).toHaveLength(0)
expect(result.blocked).toHaveLength(1)
expect(result.blocked[0].code).toBe('ALREADY_REQUESTED')
expect(result.blocked[0].message).toContain('ROT 2026-01-15')
expect(result.blocked[0].message).toContain('inte uppladdad')
})
it('says a submitted begäran awaits Skatteverket instead of suggesting cancellation', async () => {
const invoice = makeRotRow()
const result = await listRotRutCandidates(
mockedSupabase([invoice], [invoice], [activeItem(invoice.id, 'submitted')]),
'company-1',
'rot',
TODAY,
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.blocked).toHaveLength(1)
expect(result.blocked[0].code).toBe('ALREADY_REQUESTED')
expect(result.blocked[0].message).toContain('väntar på Skatteverkets beslut')
expect(result.blocked[0].message).not.toContain('avbryt')
})
it('blocks, not drops, an invoice whose request carries an unknown status', async () => {
// The decided set is enumerated (paid/partially_paid): a future or
// unexpected request status must land in blocked with the generic
// ALREADY_REQUESTED message, never vanish from both lists.
const invoice = makeRotRow()
const result = await listRotRutCandidates(
mockedSupabase([invoice], [invoice], [activeItem(invoice.id, 'queued')]),
'company-1',
'rot',
TODAY,
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.eligible).toHaveLength(0)
expect(result.blocked).toHaveLength(1)
expect(result.blocked[0].code).toBe('ALREADY_REQUESTED')
expect(result.blocked[0].message).toContain('ingår redan i')
})
it('omits invoices whose begäran is decided: finished business, not a drop-out', async () => {
const invoice = makeRotRow()
const result = await listRotRutCandidates(
mockedSupabase([invoice], [invoice], [activeItem(invoice.id, 'paid')]),
'company-1',
'rot',
TODAY,
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.eligible).toHaveLength(0)
expect(result.blocked).toHaveLength(0)
})
it('omits decided invoices of the OTHER type too: no eternal wrong-type pointer', async () => {
// A rut invoice whose begäran was decided years ago must not resurface
// forever as NO_DEDUCTION_OF_TYPE under the rot view: decided means
// finished on every tab (skeptic finding on #1884).
const invoice = makeRutRow()
const result = await listRotRutCandidates(
mockedSupabase([invoice], [invoice], [activeItem(invoice.id, 'paid', 'RUT gammal')]),
'company-1',
'rot',
TODAY,
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.eligible).toHaveLength(0)
expect(result.blocked).toHaveLength(0)
})
it('lets the other-type pointer win over ALREADY_REQUESTED under the wrong type', async () => {
const invoice = makeRutRow()
const result = await listRotRutCandidates(
mockedSupabase([invoice], [invoice], [activeItem(invoice.id, 'generated', 'RUT jan')]),
'company-1',
'rot',
TODAY,
)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.blocked).toHaveLength(1)
expect(result.blocked[0].code).toBe('NO_DEDUCTION_OF_TYPE')
})
it('merges the header and line queries without duplicating an invoice', async () => {
const invoice = makeRotRow()
const result = await listRotRutCandidates(mockedSupabase([invoice], [invoice], []), 'company-1', 'rot', TODAY)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.eligible).toHaveLength(1)
expect(result.blocked).toHaveLength(0)
})
it('orders merged candidates by payment date', async () => {
const older = makeRotRow({ id: 'inv-old', paid_at: '2026-03-01T10:00:00Z' })
const newer = makeRotRow({ id: 'inv-new', paid_at: '2026-06-20T10:00:00Z' })
// The header query returns the newer one, the line query the older one:
// the merged list must still come out oldest first.
const result = await listRotRutCandidates(mockedSupabase([newer], [older], []), 'company-1', 'rot', TODAY)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.eligible.map((e) => e.invoice_id)).toEqual(['inv-old', 'inv-new'])
})
it('propagates a database error', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([{ data: null, error: { message: 'boom' } }])
const result = await listRotRutCandidates(
supabase as unknown as SupabaseClient,
'company-1',
'rot',
TODAY,
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.dbError).toMatchObject({ message: 'boom' })
})
})