fix(rot-rut): surface drop-out reasons in payout request dialog and keep selectors usable (#1884) (#1891)

* fix(rot-rut): surface drop-out reasons in payout request dialog and keep selectors usable (#1884)

Four silent drop paths made a paid RUT invoice invisible in the begaran
dialog (neither eligible nor blocked), and the empty list hid the year
picker so the dialog looked dead:

1. deduction lines without a header deduction_total: a second line-based
   candidate query now finds them and they block as DEDUCTION_TOTAL_MISSING
   (also at file generation: the 1513 receivable was never booked).
2. partially_paid with the customer share settled: remaining_amount = 0
   (total - paid_amount - deduction_total, migration 20260817191708) now
   counts as paid in evaluateInvoiceForFile; a genuine partial blocks as
   NOT_PAID with the outstanding amount.
3. NO_DEDUCTION_OF_TYPE is no longer filtered out of blocked: the message
   points at the other type, and the dialog's empty state adds a
   switch-type hint.
4. invoices held by a generated/submitted begaran block as
   ALREADY_REQUESTED naming the request; decided requests stay omitted
   (finished business, visible in the history list).

The dialog keeps the year picker rendered when the list is empty (current
year as inert fallback) and opens the blocked list by default when nothing
is eligible.

Fixes #1884

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

* fix(rot-rut): skeptic hardening: decided requests vanish on both tabs, customer share derived from header fields (#1884)

Two skeptic refutations against the frozen PR head:

1. Regression: the wrong-type branch ran before the active-request lookup,
   so invoices of the OTHER type whose begaran was already decided
   resurfaced forever as NO_DEDUCTION_OF_TYPE in the opposite tab's blocked
   list, and the empty-state hint pointed at a tab where they never appear.
   The decided-request skip now runs first, on every tab.

2. Correctness: the paid gate and the NOT_PAID message trusted
   remaining_amount, but payment-sync's storno path recomputes it WITHOUT
   subtracting deduction_total, so the stored column can carry Skatteverkets
   1513 share and the dialog could assert a wrong customer-outstanding
   figure. The gate now derives the customer share as
   total - paid_amount - deduction_total (the buildInvoiceWriteData /
   migration 20260817191708 formula) from fields every settlement path
   maintains.

Tests pin both: decided+wrong-type omitted from both lists, corrupted
remaining still classified and reported from the derived share.

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

* docs(rot-rut): align CANDIDATE_STATUSES comment with the derived-share gate (#1884)

The skeptic-hardening commit moved the paid gate off remaining_amount to
the derived customer share (total - paid_amount - deduction_total); the
comment still named remaining_amount as the signal.

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

* fix(rot-rut): explicit decided-status set + correction-path wording (#1884)

Swedish accounting review findings on the candidate list:

1. The decided-begaran skip inferred 'decided' by exclusion (anything not
   generated/submitted), so a future request status would make an invoice
   vanish from both lists, exactly the silent drop the module forbids.
   DECIDED_REQUEST_STATUSES now names paid/partially_paid; any other
   status held by a request lands in blocked as ALREADY_REQUESTED with a
   generic message. Test pins it.

2. The DEDUCTION_TOTAL_MISSING message said only 'ratta fakturan', which
   could read as an invitation to edit a booked invoice directly. The
   invoice edit route already refuses sent/paid/booked invoices, and the
   message now names the sanctioned path: drafts edit directly, sent or
   paid invoices are corrected via credit note + new invoice.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-25 14:24:01 +02:00
committed by GitHub
parent 0bb482bf6e
commit cbfb2201ff
10 changed files with 688 additions and 30 deletions
+1
View File
@@ -1194,6 +1194,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-24] Single-call chat console (general.help, AskConsole → /api/agent/ask) now carries the thread's earlier turns into every model call, via a new optional `history` on the provider-agnostic GenerateTextRequest (real message turns before the prompt in BOTH adapters: Anthropic-family messages array, OpenAI-compatible via AI SDK `messages`; an absent/empty history leaves the request byte-identical to the single-turn call, so hosted extraction and every other caller are untouched). The 08-20 RIP-3 cutover made each turn stateless (conversationId was only the tool actor id), so a follow-up in a resumed thread was answered blind (user report: "frågar vad jag refererar till"). History is loaded server-side from agent_messages (loadChatHistory: text only, hidden + tool rows dropped, alternation repaired, newest 16 rows / 10k chars) rather than sent by the client, so the client cannot forge earlier turns and old streaming threads replay cleanly. Rejected: inlining a transcript into the prompt (works everywhere but weaker turn semantics and blurs data vs instructions) and loading history in AskConsole (client-trusted history). Separately: the docked assistant panel now remembers its open thread per tab in sessionStorage (lib/agent-panel/session-restore) and reopens it after a full reload (the deploy prompt's "Ladda om" wiped it); sessionStorage, not user_preferences, because this is this-tab-this-session state that must not follow the user to other devices or tabs. And DeployReloadPrompt's full-width wrapper gets pointer-events-none: at z-[60] after the panel in DOM order it swallowed clicks on the panel's composer ("går ej att skriva").
[2026-08-25] /reports/bank-reconciliation retired behind a redirect to /reconciliation instead of kept as a "power" page: everything it did (matcher, manual N:1 matching, residual booking, IB tag, move-to-account) lives on the account-keyed page, and two reconciliation surfaces meant two truths. The catalog slug stays so old links, the report library and ?autorun=1 deep links keep working.
[2026-08-25] reconciliation_residual staged op tiered 'medium', not create_voucher's 'high': it books one typed verifikat (6570/8410/8310/3740 vs bank) bounded by RESIDUAL_MAX_AMOUNT and is undone by storno + unmatch, i.e. the same blast radius as categorize_transaction. Scope is transactions:write (same as the v1 route) because it writes the ledger.
[2026-08-25] Rot/rut candidate list (#1884) treats a partially_paid invoice whose customer share is settled as claimable, instead of only surfacing it as blocked: the share outstanding is DERIVED as total - paid_amount - deduction_total (the buildInvoiceWriteData / migration 20260817191708 formula) rather than read off remaining_amount, because payment-sync's storno path recomputes remaining_amount without subtracting the deduction (skeptic-proven divergence), so the stored column is not a deterministic signal while the three header fields are maintained by every settlement path. Current settlement code flips such invoices to paid at exactly derived-share 0, and a legacy row stuck at partially_paid has NO user repair path (a 0-kr payment is rejected as overpayment), so blocked-with-reason would explain the dead end without opening it. The gate lives in evaluateInvoiceForFile so the list and file generation can never disagree. Decided (paid/partially_paid) begaran items are omitted from BOTH lists before any other classification, wrong-type included (skeptic-caught ordering hole): finished business, visible in the request history, and surfacing them would flood the list forever. DEDUCTION_TOTAL_MISSING (lines claim a deduction the header never recorded) blocks file generation too, not just the list: the 1513 receivable was never booked, so requesting the line amounts would claim money the ledger does not carry.
[2026-08-25] Notification recipient lookup is a two-step query (lib/notifications/member-email), not the company_members -> profiles!inner(email) embed and not a new FK: company_members.user_id references auth.users, so PostgREST has no relationship to traverse and the embed 400'd, silently killing all four notification emails (kvittens, drift, backup, connection-expired) since they shipped. Adding an FK to profiles would be a migration on a core tenancy table for zero functional gain. Second lesson recorded: the drift path DID log the failure and nobody read it, so the guard is post-deploy delivery verification, not more logging.
[2026-08-25] The skattekonto connection-expired email is deleted, not fixed: SKV's per-flow refresh tokens live 65 minutes, so per-consent-episode dedup means one "your connection expired" mail per connect, arriving an hour after every successful BankID login; that trains users to ignore mail. Rejected alternative (kept for revisit): fix + throttle to one mail per user per 7 days, fired only when a scheduled sync actually failed. Residual accepted knowingly: web-only users now have NO proactive channel for a dead SKV connection (banner needs a visit, briefing needs an agent, drift email needs a working sync); the agent briefing's skatteverket_connection block and the rewritten SKATTEVERKET_NOT_CONNECTED copy are the compensating surfaces. The skattekonto.connection.expired event and needs_reconsent flagging stay.
[2026-08-25] SKATTEVERKET_NOT_CONNECTED stays one code for both never-connected and expired: splitting would ripple through every consumer (error-map, v1 routes, MCP dispatch, UI), and the declaration-status path already differentiates in its message. The copy is agent-directive on purpose (only a person can run BankID; do not retry until the user confirms) because the old English copy "Reconnect with BankID before retrying" invited agents to retry something only a human can fix.
+21 -3
View File
@@ -124,7 +124,8 @@ describe('GET /api/rot-rut/eligible', () => {
invoice_number: 'F-BAD',
items: [makeRotItem({ labor_hours: null })],
})
enqueue({ data: [good, missingHours] })
enqueue({ data: [good, missingHours] }) // by header total
enqueue({ data: [] }) // by deduction lines
enqueue({ data: [] }) // no active request items
const response = await eligibleGET(
@@ -142,9 +143,26 @@ describe('GET /api/rot-rut/eligible', () => {
expect(body.data.blocked[0].code).toBe('MISSING_HOURS')
})
it('hides invoices already in an active request', async () => {
it('surfaces invoices held by an in-flight request as ALREADY_REQUESTED', async () => {
enqueue({ data: [makePaidRotInvoice()] })
enqueue({ data: [{ invoice_id: INVOICE_ID, request: { id: 'r', status: 'submitted', company_id: 'company-1' } }] })
enqueue({ data: [] })
enqueue({ data: [{ invoice_id: INVOICE_ID, request: { id: 'r', name: 'ROT juli', status: 'submitted', company_id: 'company-1' } }] })
const response = await eligibleGET(createMockRequest('/api/rot-rut/eligible'))
const { body } = await parseJsonResponse<{
data: { eligible: unknown[]; blocked: Array<{ code: string; message: string }> }
}>(response)
expect(body.data.eligible).toHaveLength(0)
expect(body.data.blocked).toHaveLength(1)
expect(body.data.blocked[0].code).toBe('ALREADY_REQUESTED')
expect(body.data.blocked[0].message).toContain('ROT juli')
})
it('hides invoices whose request is already decided', async () => {
enqueue({ data: [makePaidRotInvoice()] })
enqueue({ data: [] })
enqueue({ data: [{ invoice_id: INVOICE_ID, request: { id: 'r', name: 'ROT juli', status: 'paid', company_id: 'company-1' } }] })
const response = await eligibleGET(createMockRequest('/api/rot-rut/eligible'))
const { body } = await parseJsonResponse<{
+6 -3
View File
@@ -6,11 +6,14 @@ import { listRotRutCandidates } from '@/lib/invoices/rot-rut-service'
/**
* GET /api/rot-rut/eligible?type=rot|rut
*
* Lists paid invoices carrying a ROT/RUT claim that are NOT yet part of an
* active begäran om utbetalning, split into:
* Lists deduction-carrying invoices for the begäran om utbetalning dialog,
* split into:
* - eligible: ready for file generation (with the amounts the file will use)
* - blocked: excluded, with the exact blocker (same evaluation as the
* generator: what this endpoint approves, the file accepts)
* generator: what this endpoint approves, the file accepts).
* Includes wrong-type invoices (NO_DEDUCTION_OF_TYPE, pointing
* at the other list) and invoices held by an in-flight begäran
* (ALREADY_REQUESTED): nothing drops out silently (#1884).
*/
export const GET = withRouteContext('rot_rut.eligible', async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
+44 -11
View File
@@ -192,6 +192,21 @@ export default function RotRutPayoutDialog({
).sort((a, b) => b.localeCompare(a)),
[eligible],
)
// The year picker must never disappear: an empty eligible list used to hide
// it, making the whole dialog look dead even though the type picker worked
// (#1884). With no eligible years the current year stands in as the only,
// inert, choice.
const fallbackYear = String(new Date().getFullYear())
const yearItems = (years.length > 0 ? years : [fallbackYear]).map((year) => ({
id: year,
label: year,
}))
const yearValue = selectedYear || fallbackYear
const otherTypeBlockedCount = useMemo(
() => blocked.filter((candidate) => candidate.code === 'NO_DEDUCTION_OF_TYPE').length,
[blocked],
)
const otherBlockedCount = blocked.length - otherTypeBlockedCount
const visibleCandidates = useMemo(
() =>
eligible.filter((candidate) => candidate.betalnings_datum.startsWith(selectedYear)),
@@ -351,16 +366,14 @@ export default function RotRutPayoutDialog({
]}
disabled={loading || generating}
/>
{years.length > 0 && (
<ContextPicker
value={selectedYear}
onChange={changeYear}
triggerLabel={selectedYear}
ariaLabel={t('rot_rut_year_aria')}
items={years.map((year) => ({ id: year, label: year }))}
disabled={loading || generating}
/>
)}
<ContextPicker
value={yearValue}
onChange={changeYear}
triggerLabel={yearValue}
ariaLabel={t('rot_rut_year_aria')}
items={yearItems}
disabled={loading || generating}
/>
</div>
{loading ? (
@@ -401,6 +414,19 @@ export default function RotRutPayoutDialog({
<p className="mt-1 text-xs text-muted-foreground">
{t('rot_rut_no_eligible_description')}
</p>
{otherTypeBlockedCount > 0 && (
<p className="mt-2 text-xs text-muted-foreground">
{t('rot_rut_other_type_hint', {
count: otherTypeBlockedCount,
type: t(type === 'rot' ? 'rot_rut_type_rut' : 'rot_rut_type_rot'),
})}
</p>
)}
{otherBlockedCount > 0 && (
<p className="mt-2 text-xs text-muted-foreground">
{t('rot_rut_blocked_below_hint', { count: otherBlockedCount })}
</p>
)}
</div>
) : (
<div className="max-h-64 divide-y overflow-y-auto rounded-lg border">
@@ -465,7 +491,14 @@ export default function RotRutPayoutDialog({
</section>
{blocked.length > 0 && (
<details className="rounded-lg border border-dashed px-3 py-2.5">
<details
className="rounded-lg border border-dashed px-3 py-2.5"
// With nothing eligible the reasons ARE the content: start
// expanded so the dialog explains itself instead of looking
// empty. The element stays a plain details, so the user can
// still collapse it.
open={visibleCandidates.length === 0 || undefined}
>
<summary className="cursor-pointer text-sm font-medium">
{t('rot_rut_blocked_title', { count: blocked.length })}
</summary>
+112
View File
@@ -411,6 +411,118 @@ describe('eligibility blockers', () => {
if (!result.ok) expect(result.blocker.code).toBe('NO_DEDUCTION_OF_TYPE')
})
it('NO_DEDUCTION_OF_TYPE points at the other type when the deduction is the other kind', () => {
// A rot invoice evaluated as rut must say "this is ROT", not just "no
// rut lines": the dialog's empty list gave no pointer at all (#1884).
const result = evaluateInvoiceForFile('rut', makeRotInvoice())
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.blocker.message).toContain('ROT')
expect(result.blocker.message).toContain('hanteras under ROT')
}
})
it('NO_DEDUCTION_OF_TYPE keeps the plain message when no deduction lines exist at all', () => {
const items = [makeItem({ deduction_type: null, work_type: null, deduction_amount: 0 })]
const result = evaluateInvoiceForFile('rot', makeRotInvoice({}, items))
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.blocker.code).toBe('NO_DEDUCTION_OF_TYPE')
expect(result.blocker.message).toBe('Fakturan har inga ROT-rader.')
}
})
it('DEDUCTION_TOTAL_MISSING when lines carry a deduction the header never recorded', () => {
// Older imports left deduction_total NULL/0 despite deduction lines:
// those invoices previously fell out of the candidate query entirely.
for (const headerTotal of [0, undefined]) {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({ deduction_total: headerTotal }),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('DEDUCTION_TOTAL_MISSING')
}
})
it('DEDUCTION_TOTAL_MISSING wins over NOT_PAID (the missing header is why the status is stuck)', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({ deduction_total: 0, status: 'partially_paid', remaining_amount: 3000 }),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('DEDUCTION_TOTAL_MISSING')
})
it('accepts partially_paid when the customer share is settled (older settlement paths)', () => {
// Customer share = total - deduction_total = 9 500; paid_amount covers it
// even though the status never flipped to paid.
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({ status: 'partially_paid', remaining_amount: 0, paid_amount: 9500 }),
)
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.value.arende.begart_belopp).toBe(3000)
expect(result.value.arende.betalnings_datum).toBe('2026-06-20')
}
})
it('derives the customer share from header fields, not the stored remaining_amount', () => {
// payment-sync's storno path recomputes remaining_amount WITHOUT
// subtracting deduction_total (total - paid = 3 000 here), so the stored
// column can carry Skatteverkets share. The gate must key on
// total - paid_amount - deduction_total = 0 and accept anyway.
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({ status: 'partially_paid', remaining_amount: 3000, paid_amount: 9500 }),
)
expect(result.ok).toBe(true)
})
// Same formatting as the blocker message (sv-SE uses NBSP thousands
// separators, so a typed-out literal would never match).
const svAmount = (n: number): string =>
n.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
it('NOT_PAID with the outstanding amount for a genuinely partial payment', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({ status: 'partially_paid', remaining_amount: 4500, paid_amount: 5000 }),
)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.blocker.code).toBe('NOT_PAID')
expect(result.blocker.message).toContain('delbetald')
expect(result.blocker.message).toContain(`${svAmount(4500)} kr`)
}
})
it('reports the TRUE customer share even when remaining_amount is corrupted', () => {
// Stored remaining says 7 500 (payment-sync storno formula), but the
// customer share outstanding is 12 500 - 5 000 - 3 000 = 4 500: the
// message must not tell the user to collect Skatteverkets 3 000 kr.
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({ status: 'partially_paid', remaining_amount: 7500, paid_amount: 5000 }),
)
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.blocker.code).toBe('NOT_PAID')
expect(result.blocker.message).toContain(svAmount(4500))
expect(result.blocker.message).not.toContain(svAmount(7500))
}
})
it('MISSING_PAYMENT_DATE for a settled partially_paid invoice without paid_at', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({ status: 'partially_paid', remaining_amount: 0, paid_amount: 9500, paid_at: null }),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('MISSING_PAYMENT_DATE')
})
it('MIXED_DEDUCTION_TYPES when rot and rut lines share an invoice', () => {
const items = [
makeItem(),
@@ -0,0 +1,320 @@
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('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' })
})
})
+57 -1
View File
@@ -84,6 +84,7 @@ export type RotRutBlockerCode =
| 'MISSING_PAYMENT_DATE'
| 'FUTURE_PAYMENT_DATE'
| 'NO_DEDUCTION_OF_TYPE'
| 'DEDUCTION_TOTAL_MISSING'
| 'MIXED_DEDUCTION_TYPES'
| 'MIXED_PAYMENT_YEARS'
| 'TOO_MANY_CASES'
@@ -187,6 +188,15 @@ export function evaluateInvoiceForFile(
const otherLines = items.filter((i) => isDeductionLine(i, otherType))
if (typeLines.length === 0) {
// Point at the other list instead of a bare "no lines": a paid RUT
// invoice viewed as ROT (the dialog default) used to read as "no
// invoices" with no hint that it lives under the other type.
if (otherLines.length > 0) {
return block(
'NO_DEDUCTION_OF_TYPE',
`Fakturans avdrag är ${otherType.toUpperCase()}: fakturan hanteras under ${otherType.toUpperCase()}, inte ${type.toUpperCase()}.`,
)
}
return block('NO_DEDUCTION_OF_TYPE', `Fakturan har inga ${type.toUpperCase()}-rader.`)
}
// One invoice must map to exactly one ärende in exactly one file. Mixed
@@ -199,7 +209,53 @@ export function evaluateInvoiceForFile(
)
}
if (invoice.status !== 'paid') {
// Header/lines integrity: the lines claim a deduction but the invoice
// header never recorded it (older rows and import paths where
// computeInvoiceDeductionTotal never wrote deduction_total). Building the
// file from line amounts alone would request money the ledger never booked
// to 1513 (the 1513 debit is driven by the header total), and the missing
// header is also why such an invoice can never reach status paid: its
// remaining_amount wrongly includes the deduction. Refuse with the root
// cause instead of a misleading "not paid".
const lineDeductionTotal = typeLines.reduce((sum, l) => sum + (l.deduction_amount ?? 0), 0)
if (lineDeductionTotal > 0 && (invoice.deduction_total ?? 0) <= 0) {
return block(
'DEDUCTION_TOTAL_MISSING',
'Fakturan har ROT/RUT-rader men inget sparat avdragsbelopp: avdraget är inte bokfört mot Skatteverket. Ett utkast kan redigeras direkt; en skickad eller betald faktura rättas med kreditfaktura och en ny faktura. Kontakta supporten om ingen av vägarna fungerar.',
)
}
// "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
const customerSharePaid =
invoice.status === 'paid' ||
(invoice.status === 'partially_paid' && customerShareOutstanding <= 0)
if (!customerSharePaid) {
if (invoice.status === 'partially_paid') {
const currencyLabel = (invoice.currency ?? 'SEK').toUpperCase()
const amount = customerShareOutstanding.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
return block(
'NOT_PAID',
`Fakturan är delbetald: ${amount} ${currencyLabel === 'SEK' ? 'kr' : currencyLabel} av kundens del återstår innan utbetalning kan begäras.`,
)
}
return block('NOT_PAID', 'Kunden måste ha betalat sin del av fakturan innan utbetalning kan begäras.')
}
const paidDate = invoice.paid_at ? String(invoice.paid_at).slice(0, 10) : null
+123 -12
View File
@@ -6,6 +6,7 @@ import {
evaluateInvoiceForFile,
type BuildRotRutFileResult,
type RotRutBlocker,
type RotRutBlockerCode,
} from './rot-rut-file'
import type { DeductionType } from './rot-rut-rules'
@@ -30,16 +31,38 @@ export interface RotRutBlockedSummary {
invoice_id: string
invoice_number: string | null
customer_name: string | null
code: string
code: RotRutBlockerCode | 'ALREADY_REQUESTED'
message: string
}
type InvoiceWithCustomer = Invoice & { customer?: { name?: string | null } | null }
/** Invoice statuses a candidate may carry. partially_paid is included because
* invoices settled through older payment paths can hold a fully paid
* customer share while the status never flipped to paid:
* evaluateInvoiceForFile decides via the derived customer share
* (total - paid_amount - deduction_total). */
const CANDIDATE_STATUSES = ['paid', 'partially_paid']
/** Request statuses where Skatteverkets beslut has been recorded: the claim is
* finished business, visible in the request history, so the invoice is
* deliberately omitted from both lists (see DECISIONS.md, #1884). Enumerated
* explicitly so a request status outside the known lifecycle can never make
* an invoice vanish silently: anything not decided here, and not
* cancelled/rejected (filtered out in the query), surfaces as
* ALREADY_REQUESTED. */
const DECIDED_REQUEST_STATUSES = ['paid', 'partially_paid']
/**
* Paid deduction-carrying invoices not yet claimed by an active begäran,
* evaluated against the file rules. Invoices whose deduction belongs solely
* to the other type are omitted entirely (they're the other list's business).
* Deduction-carrying invoices evaluated against the file rules. Never drops
* an invoice silently: every fetched candidate lands in `eligible` or in
* `blocked` with the exact reason, including "the deduction is the other
* type" (NO_DEDUCTION_OF_TYPE, so the ROT view can point at the RUT list and
* vice versa) and "already part of an in-flight begäran" (ALREADY_REQUESTED,
* for generated/submitted requests). The single deliberate omission is an
* invoice whose begäran has been decided (request status paid or
* partially_paid): that claim is finished business, visible in the request
* history, not a drop-out anyone needs explained.
*/
export async function listRotRutCandidates(
supabase: SupabaseClient,
@@ -52,33 +75,121 @@ export async function listRotRutCandidates(
| { ok: true; eligible: RotRutCandidateSummary[]; blocked: RotRutBlockedSummary[] }
| { ok: false; dbError: unknown }
> {
const { data: invoices, error } = await supabase
// Two fetches so no candidate shape is invisible:
// - by header: deduction_total > 0, the classic shape;
// - by lines: invoices whose items carry deduction_type but whose header
// total was never written (older imports). The header filter would miss
// those entirely, which is exactly the silent drop this list must not
// have; they surface as DEDUCTION_TOTAL_MISSING.
const { data: byHeader, error: headerError } = await supabase
.from('invoices')
.select('*, items:invoice_items(*), customer:customers(id, name)')
.eq('company_id', companyId)
.eq('document_type', 'invoice')
.eq('status', 'paid')
.in('status', CANDIDATE_STATUSES)
.gt('deduction_total', 0)
.order('paid_at', { ascending: true })
if (error) return { ok: false, dbError: error }
if (headerError) return { ok: false, dbError: headerError }
const { data: byLines, error: linesError } = await supabase
.from('invoices')
.select(
'*, items:invoice_items(*), customer:customers(id, name), deduction_lines:invoice_items!inner(deduction_type)',
)
.eq('company_id', companyId)
.eq('document_type', 'invoice')
.in('status', CANDIDATE_STATUSES)
.not('deduction_lines.deduction_type', 'is', null)
.order('paid_at', { ascending: true })
if (linesError) return { ok: false, dbError: linesError }
const invoiceById = new Map<string, InvoiceWithCustomer>()
for (const row of [
...(byHeader ?? []),
...(byLines ?? []),
] as unknown as InvoiceWithCustomer[]) {
if (!invoiceById.has(row.id)) invoiceById.set(row.id, row)
}
// Deterministic order across the merged sets: oldest payment first
// (matching the old single-query order), date-less rows last.
const invoices = [...invoiceById.values()].sort((a, b) => {
const aKey = a.paid_at ? String(a.paid_at) : '9999'
const bKey = b.paid_at ? String(b.paid_at) : '9999'
return aKey === bKey ? a.id.localeCompare(b.id) : aKey < bKey ? -1 : 1
})
const { data: activeItems, error: activeError } = await supabase
.from('rot_rut_payout_request_items')
.select('invoice_id, request:rot_rut_payout_requests!inner(id, status, company_id)')
.select('invoice_id, request:rot_rut_payout_requests!inner(id, name, status, company_id)')
.eq('request.company_id', companyId)
.not('request.status', 'in', '("cancelled","rejected")')
if (activeError) return { ok: false, dbError: activeError }
const activeInvoiceIds = new Set((activeItems ?? []).map((r) => r.invoice_id))
const activeRequestByInvoice = new Map<string, { name: string | null; status: string }>()
for (const row of (activeItems ?? []) as unknown as Array<{
invoice_id: string
request: { name?: string | null; status?: string } | null
}>) {
activeRequestByInvoice.set(row.invoice_id, {
name: row.request?.name ?? null,
status: row.request?.status ?? '',
})
}
const eligible: RotRutCandidateSummary[] = []
const blocked: RotRutBlockedSummary[] = []
for (const invoice of (invoices ?? []) as unknown as InvoiceWithCustomer[]) {
if (activeInvoiceIds.has(invoice.id)) continue
for (const invoice of invoices) {
const activeRequest = activeRequestByInvoice.get(invoice.id)
// Decided begäran (request status paid/partially_paid, enumerated
// explicitly in DECIDED_REQUEST_STATUSES) first, before ANY
// classification: the claim is finished business on every tab, so the
// invoice must vanish from both lists. Checking wrong-type first would
// resurface every historically decided invoice forever in the OTHER
// type's blocked list.
if (activeRequest && DECIDED_REQUEST_STATUSES.includes(activeRequest.status)) continue
const holdingRequest = activeRequest ?? null
const result = evaluateInvoiceForFile(type, invoice, { today })
// Wrong-type next: even when the invoice sits in an in-flight begäran,
// the useful fact under THIS type is that it belongs to the other list
// (where it shows as ALREADY_REQUESTED).
if (!result.ok && result.blocker.code === 'NO_DEDUCTION_OF_TYPE') {
blocked.push({
invoice_id: invoice.id,
invoice_number: invoice.invoice_number ?? null,
customer_name: invoice.customer?.name ?? null,
code: result.blocker.code,
message: result.blocker.message,
})
continue
}
if (holdingRequest) {
// In-flight begäran (generated but maybe never uploaded, or awaiting
// beslut): the invoice is spoken for, say so instead of vanishing. A
// request status outside the known lifecycle gets the generic message:
// being held with a vague reason still beats disappearing.
const requestLabel = holdingRequest.name ? `begäran "${holdingRequest.name}"` : 'en begäran'
blocked.push({
invoice_id: invoice.id,
invoice_number: invoice.invoice_number ?? null,
customer_name: invoice.customer?.name ?? null,
code: 'ALREADY_REQUESTED',
message:
holdingRequest.status === 'generated'
? `Fakturan ingår redan i ${requestLabel} som är skapad men inte uppladdad. Ladda upp filen hos Skatteverket, eller avbryt begäran för att ta med fakturan i en ny fil.`
: holdingRequest.status === 'submitted'
? `Fakturan ingår redan i ${requestLabel} som väntar på Skatteverkets beslut.`
: `Fakturan ingår redan i ${requestLabel}.`,
})
continue
}
if (result.ok) {
eligible.push({
invoice_id: invoice.id,
@@ -89,7 +200,7 @@ export async function listRotRutCandidates(
pris_for_arbete: result.value.arende.pris_for_arbete,
begart_belopp: result.value.arende.begart_belopp,
})
} else if (result.blocker.code !== 'NO_DEDUCTION_OF_TYPE') {
} else {
blocked.push({
invoice_id: invoice.id,
invoice_number: invoice.invoice_number ?? null,
+2
View File
@@ -6461,6 +6461,8 @@
"rot_rut_clear_selection": "Clear selection",
"rot_rut_no_eligible_title": "No invoices are ready",
"rot_rut_no_eligible_description": "A paid ROT or RUT invoice appears here once its buyer details are complete.",
"rot_rut_other_type_hint": "{count, plural, one {1 invoice carries} other {# invoices carry}} {type} deductions and will appear when you switch to {type} above.",
"rot_rut_blocked_below_hint": "{count, plural, one {1 invoice cannot} other {# invoices cannot}} be included yet: see the reasons under \"Cannot be included\" below.",
"rot_rut_paid_at": "Paid {date}",
"rot_rut_max_cases_help": "Skatteverket allows at most {count} cases in one file. Create multiple files if you need to request more invoices.",
"rot_rut_generate_file": "Create and download file",
+2
View File
@@ -6461,6 +6461,8 @@
"rot_rut_clear_selection": "Rensa val",
"rot_rut_no_eligible_title": "Inga fakturor är redo",
"rot_rut_no_eligible_description": "När en ROT- eller RUT-faktura är betald och har fullständiga köparuppgifter visas den här.",
"rot_rut_other_type_hint": "{count, plural, one {1 faktura har} other {# fakturor har}} {type}-avdrag och visas när du byter till {type} ovan.",
"rot_rut_blocked_below_hint": "{count, plural, one {1 faktura kan} other {# fakturor kan}} inte tas med ännu: se orsakerna under \"Kan inte tas med\" nedan.",
"rot_rut_paid_at": "Betald {date}",
"rot_rut_max_cases_help": "Skatteverket tillåter högst {count} ärenden i samma fil. Skapa flera filer om fler fakturor ska begäras.",
"rot_rut_generate_file": "Skapa och hämta fil",