diff --git a/DECISIONS.md b/DECISIONS.md index d854befa..5912d706 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1194,6 +1194,7 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/api/rot-rut/__tests__/routes.test.ts b/app/api/rot-rut/__tests__/routes.test.ts index a74b0185..79f1cd2f 100644 --- a/app/api/rot-rut/__tests__/routes.test.ts +++ b/app/api/rot-rut/__tests__/routes.test.ts @@ -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<{ diff --git a/app/api/rot-rut/eligible/route.ts b/app/api/rot-rut/eligible/route.ts index 63b7c71a..0be9be07 100644 --- a/app/api/rot-rut/eligible/route.ts +++ b/app/api/rot-rut/eligible/route.ts @@ -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 diff --git a/components/invoices/RotRutPayoutDialog.tsx b/components/invoices/RotRutPayoutDialog.tsx index e7953a86..298d8c28 100644 --- a/components/invoices/RotRutPayoutDialog.tsx +++ b/components/invoices/RotRutPayoutDialog.tsx @@ -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 && ( - ({ id: year, label: year }))} - disabled={loading || generating} - /> - )} + {loading ? ( @@ -401,6 +414,19 @@ export default function RotRutPayoutDialog({

{t('rot_rut_no_eligible_description')}

+ {otherTypeBlockedCount > 0 && ( +

+ {t('rot_rut_other_type_hint', { + count: otherTypeBlockedCount, + type: t(type === 'rot' ? 'rot_rut_type_rut' : 'rot_rut_type_rot'), + })} +

+ )} + {otherBlockedCount > 0 && ( +

+ {t('rot_rut_blocked_below_hint', { count: otherBlockedCount })} +

+ )} ) : (
@@ -465,7 +491,14 @@ export default function RotRutPayoutDialog({ {blocked.length > 0 && ( -
+
{t('rot_rut_blocked_title', { count: blocked.length })} diff --git a/lib/invoices/__tests__/rot-rut-file.test.ts b/lib/invoices/__tests__/rot-rut-file.test.ts index 40d4c9b5..665437af 100644 --- a/lib/invoices/__tests__/rot-rut-file.test.ts +++ b/lib/invoices/__tests__/rot-rut-file.test.ts @@ -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(), diff --git a/lib/invoices/__tests__/rot-rut-service.test.ts b/lib/invoices/__tests__/rot-rut-service.test.ts new file mode 100644 index 00000000..30315ca1 --- /dev/null +++ b/lib/invoices/__tests__/rot-rut-service.test.ts @@ -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 { + 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 & { + customer?: { id: string; name: string | null } | null +} + +function makeRotRow(overrides: Partial = {}, 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 { + 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' }) + }) +}) diff --git a/lib/invoices/rot-rut-file.ts b/lib/invoices/rot-rut-file.ts index 2cc543f3..f1d89ca9 100644 --- a/lib/invoices/rot-rut-file.ts +++ b/lib/invoices/rot-rut-file.ts @@ -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 diff --git a/lib/invoices/rot-rut-service.ts b/lib/invoices/rot-rut-service.ts index f49a2f20..6a8a9824 100644 --- a/lib/invoices/rot-rut-service.ts +++ b/lib/invoices/rot-rut-service.ts @@ -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() + 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() + 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, diff --git a/messages/en.json b/messages/en.json index 41fc8223..1cc26cdc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index ce3fdd2c..4c896362 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",