diff --git a/DECISIONS.md b/DECISIONS.md index 1112b601..dcd6a784 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1649,5 +1649,10 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-07] WooCommerce wc-auth return_url is built on the initiating request's trusted origin (resolveRequestAppOrigin, brands-table validated, canonical on unknown host or lookup failure) while callback_url stays on the canonical app URL: sessions are per domain, so a white-label user returned to the canonical host hit the initiator check with no session and a foreign-branded login. No stored origin column and no provider_otc handoff (PR #2305 pattern) needed, because unlike a registered OAuth redirect URI the wc-auth return_url is free-form per handshake and can simply point at the brand host; the return route resolves its redirect base from the host it was reached on the same way. [2026-09-07] PR #2373 review: the Skatteverket callback now peeks the flow row for its initiator and binds the session BEFORE consuming state or handoff (Superagent P2: a DELETE-before-auth let a signed-out or wrong-user arrival burn a live consent). A session-less arrival is sent to /login on the initiating origin and resumes into the same URL; a wrong user is refused with the row left claimable. This reverses the earlier 'no login resume' decision for this flow; the consume stays the atomic gate, the peek only decides who may attempt it. Handoff TTL raised from two to five minutes to fit a sign-in. [2026-09-07] BankID confirmation mail and the Send Email hook resolve their link host through lib/domains/trusted-app-origin instead of the raw forwarded host / GoTrue's redirect_to: the BankID mail is the one auth link GoTrue's redirect allowlist never sees (built here, sent via Resend), and the hook's signature proves the sender, not the destination, while the GoTrue allowlist is a hand-configured glob. Unknown, lookalike, credential-bearing, non-default-port and malformed destinations collapse to the canonical /auth/callback with no next path; a registered brand host over http is upgraded to https. Brand sender identity is resolved from the RESOLVED host so mail branding and link destination always agree. A brands-table read failure refuses (BankID: step resolve_origin, signup rolls back; hook: 500 so Supabase retries) rather than mailing a canonical link to a white-label user. Dropped from the audit's plan 7 as already in place after #2376: signup route, HTTPS enforcement, credential/port checks, recovery/invite/email-change coverage. +[2026-09-07] ROT/RUT avslag reopens the original invoice instead of issuing a new one: the refused share is not new revenue but a fordran that moves from Skatteverket (1513) to the buyer (1510), so a new invoice would book revenue and moms a second time; the reopen keeps one fordran per job and the payment matcher works unchanged. The invoice document is left as issued (deduction_total untouched) and the refused share lives in a new invoices.deduction_reclaimed_total column; the customer share formula in customer-share.ts and its SQL twin gained the same term. A multi-invoice beslut recorded as one total (PATCH partially_paid) is refused with SPLIT_UNKNOWN rather than split pro rata: the split is Skatteverket's, the beslutsfil carries it. +[2026-09-07] ROT/RUT overview page (/invoices/rot-rut) instead of growing the file dialog: begäran, beslut import, utbetalning and nekat belopp are one lifecycle and the dialog was action-only. The 31 January begäran deadline stays where it is (tax deadlines + file-time notice); no second reminder surface. A company default RUT work type was dropped from the plan: articles already carry housework_type, and the invoice editor already falls back to the customer's stored personnummer. +[2026-09-07] MCP: gnubok_list_rot_rut_payout_requests ships search-only (READ, bridged by gnubok_call_tool); gnubok_settle_rot_rut_payout stays in the default catalog as a WRITE and the tools/list ceiling moved 60K to 60.5K (measured 60 428) rather than demoting a read without usage data. The dashboard match route and the MCP commit executor share one pre-flight + settle in lib/invoices/rot-rut-match-transaction.ts. No MCP reclaim tool yet: the reclaim is a dashboard action until an agent asks for it. [2026-09-07] Klarmarkera (markPeriodClosedExternally) now asks whether the period has any lines on result accounts (BAS class 3-8) instead of whether it has any non-imported verifikat at all. The old test was a proxy for the thing the guard protects (3xxx-8xxx transferred by a bokslutsverifikat, BFL 5-6 kap) and it shut out the migrated first year whose only native voucher re-keys the opening balance (1930/2081 aktiekapital) after a failed SIE import; with the next year's IB already imported the normal year-end refused too (NEXT_PERIOD_HAS_IB), so the year had no closing path at all (EHAL AB support case 2026-09-07). Existence check via id-only entry fetch plus per-chunk head counts with early exit, no line fetch, no journal_entries!inner embed. Same session: the loaded årsredovisning view got the FyPicker in its header, because the no-period branch auto-jumps to the remembered scope before its own picker is ever seen, which left users with no way to reach another year's årsredovisning. [2026-09-07] Klarmarkera's new balance-sheet-only leg additionally requires the NEXT period to already carry IB (opening_balance_entry_id set). Without that, a natively bookkept year with only balance-sheet vouchers (dormant AB with just the aktiekapital deposit) could be klarmarkerad and its balances would never reach the next year, since klarmarkera skips the IB posting the normal year-end does. With the IB in place the normal year-end refuses (NEXT_PERIOD_HAS_IB) and klarmarkera is the only path; without it the normal year-end works and is the right path. Raised by the swedish-compliance bot on PR #2393 (its suggested alternative, a dedicated IB-correction tag on entries, was rejected: it adds a concept for the user to know about where the ledger already answers the question). Same round: the result-account range is bounded to class 3-8 (lt '9'), class 9 interna poster are not transferred by a bokslut. +[2026-09-07] PR #2397 skeptic + review pass: the reclaim is refused while any of its invoices sits in a later live begäran (avslag → new file is Skatteverket's retry; booking the refused share onto the customer meanwhile would clear 1513 twice), a reclaimed invoice is blocked from a new begäran (DEDUCTION_RECLAIMED) until the reclaim voucher is reversed, and a storno of the reclaim voucher syncs the invoices and the begäran back (lib/invoices/rot-rut-reclaim-reversal.ts, hooked into reverseEntry next to the payment sync). Per-invoice reopen goes through one idempotent RPC (apply_rot_rut_reclaim_invoice, item marker + invoice row in one transaction) so a failure after the voucher is resumable instead of stuck at ALREADY_DONE. Crediting an invoice with a reclaimed share is refused (reverse the reclaim first): the credit note reverses the issue-time 1510/1513 split. Declined: CONCURRENTLY for the partial unique index (Supabase migrations run in one transaction; same shape as the shipped rot_rut_payout index in 20260904021000, partial predicate on a source_type that few rows match). +[2026-09-08] PR #2397 review cycle 3: the reclaim RPCs own the accounting values. apply_rot_rut_reclaim_invoice takes only the refused share and validates it against the locked item, request and invoice (never above the item's requested amount, the beslut's refused total, or the 1513 headroom), then derives remaining_amount and status from the same formula as the INSERT guard (rot_rut_customer_outstanding); revert_rot_rut_reclaim_invoice mirrors it for a reversed reclaim voucher and the request link is cleared only after every leg succeeded. Reason: a SECURITY INVOKER function that accepted caller-supplied remaining/status was an unchecked accounting write for any writer-role member (CWE-862). The 20260907160300 signature is dropped in 20260907160400 rather than edited: the preview branch had already applied it. diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 9a5f3467..86df47da 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -2333,9 +2333,23 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st {t('deduction_row', { kind: deductionKindLabel })} {formatCurrency(-Math.abs(invoice.deduction_total ?? 0), invoice.currency)} + {/* Skatteverket refused (part of) the deduction and the reclaim + voucher moved it back onto the customer: the printed + deduction stands, the refused share is the customer's again. */} + {(invoice.deduction_reclaimed_total ?? 0) > 0 && ( +
+ {t('deduction_reclaimed_row')} + {formatCurrency(Math.abs(invoice.deduction_reclaimed_total ?? 0), invoice.currency)} +
+ )}
{t('amount_to_pay')} - {formatCurrency(amountToPay.toPay, invoice.currency)} + + {formatCurrency( + Math.round((amountToPay.toPay + Math.abs(invoice.deduction_reclaimed_total ?? 0)) * 100) / 100, + invoice.currency, + )} +
) : ( diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index 97d48b72..d0a4cf86 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -355,8 +355,6 @@ export default function InvoicesPage() { ) const closeRotRutPayout = () => router.replace(invoicesUrl((p) => p.delete('rot-rut')), { scroll: false }) - const openRotRutPayout = () => - router.push(invoicesUrl((p) => p.set('rot-rut', '1')), { scroll: false }) // Begäran om utbetalning (Lag 2009:194 8 §) only concerns companies selling // ROT/RUT-eligible work to consumers, so the action stays out of the header @@ -771,12 +769,13 @@ export default function InvoicesPage() {

{t('title')}

{showRotRutAction && ( + // The ROT/RUT overview (begäran, beslut, utbetalning, nekat + // belopp) has its own page; the file dialog still opens from + // ?rot-rut=1 here for existing links and the Att göra rows. + +
+ } + /> + + {/* Summary tiles: what sits at Skatteverket, what waits, what came back refused. */} +
+
+

{t('tile_at_skv')}

+

{formatCurrency(summary.atSkv)}

+

{t('tile_at_skv_help')}

+
+
+

{t('tile_awaiting')}

+

{summary.awaitingDecision}

+

{t('tile_awaiting_help')}

+
+
+

{t('tile_refused')}

+

{formatCurrency(summary.refusedOpen)}

+

+ {t('tile_refused_help', { count: summary.refusedOpenCount })} +

+
+
+

{t('tile_ready')}

+

{readyCount}

+

+ {t('tile_ready_help', { rot: eligibleCounts.rot, rut: eligibleCounts.rut })} +

+
+
+ + {isLoading ? ( +
+ + + +
+ ) : requests.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + + + {requests.map((request) => { + const state = refusedState(request, requests) + const isBusy = busyId === request.id + // Waiting = at Skatteverket without a beslut. A decided begäran + // (beslutsfil imported, status may still read submitted) is + // waiting for money, not for a decision: no counter. + const waitingSince = + !request.settlement_journal_entry_id && + !request.decided_at && + request.status !== 'cancelled' && + request.status !== 'rejected' + ? request.submitted_at ?? request.created_at + : null + return ( + + + + + + + + + + + ) + })} + +
{t('col_name')}{t('col_status')}{t('col_created')}{t('col_requested')}{t('col_decided')}{t('col_refused')}{t('col_waiting')}{t('col_actions')}
+
+ + {request.name} + + {request.deduction_type.toUpperCase()} · {t('cases', { count: request.items.length })} + + +
    + {request.items.map((item) => ( +
  • + + {item.invoice?.invoice_number ?? item.invoice_id.slice(0, 8)} + + + {formatCurrency(Number(item.requested_amount))} + {item.decided_amount != null && ` · ${t('item_decided', { amount: formatCurrency(Number(item.decided_amount)) })}`} + {item.reclaimed_amount != null && Number(item.reclaimed_amount) > 0 && ` · ${t('item_reclaimed', { amount: formatCurrency(Number(item.reclaimed_amount)) })}`} + +
  • + ))} +
+
+
+
+ + {t(`status_${request.status}`)} + + {request.settlement_journal_entry_id && ( + + {t('settled_link')} + + )} + {request.reclaim_journal_entry_id && ( + + {t('reclaimed_link')} + + )} +
+
{formatDate(request.created_at)}{formatCurrency(Number(request.requested_total))} + {request.decided_total == null ? - : formatCurrency(Number(request.decided_total))} + + {state.splitUnknown ? ( + + + {t('split_unknown')} + + ) : state.refused > 0 ? ( + + {formatCurrency(state.refused)} + {state.rerequested && ( + {t('rerequested')} + )} + + ) : ( + - + )} + + {waitingSince ? t('waiting_days', { days: daysSince(waitingSince) }) : -} + +
+ {request.file_document_id && ( + + )} + {request.status === 'generated' && canWrite && ( + <> + + + + )} + {state.needsReclaim && canWrite && ( + + )} +
+
+
+ )} + + + + {showNewRequest && ( + { + if (!open) { + closeNewRequest() + void load() + } + }} + canWrite={canWrite} + /> + )} + + ) +} diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index a49ede4c..2d027154 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -351,6 +351,18 @@ async function createCreditNote( }) } + // A refused ROT/RUT share booked onto the customer (rot_rut_reclaim) moved + // kronor from 1513 to 1510 after issue; the credit note reverses the + // issue-time split (1510 total minus deduction, 1513 deduction) and would + // leave a phantom fordran on 1510 and a phantom liability on 1513. Reverse + // the reclaim voucher first; the storno sync closes the invoice again. + if (Number((originalInvoice as { deduction_reclaimed_total?: number | null }).deduction_reclaimed_total ?? 0) > 0) { + return errorResponseFromCode('INVOICE_CREDIT_ROT_RUT_RECLAIMED', log, { + requestId, + details: { deduction_reclaimed_total: originalInvoice.deduction_reclaimed_total }, + }) + } + // Self-billed originals have invoice_number null by design (the DB // constraint invoices_self_billed_numbering enforces it); their number // lives in external_invoice_number. Without this fallback the credit note diff --git a/app/api/rot-rut/payout-requests/[id]/reclaim/__tests__/route.test.ts b/app/api/rot-rut/payout-requests/[id]/reclaim/__tests__/route.test.ts new file mode 100644 index 00000000..b5188510 --- /dev/null +++ b/app/api/rot-rut/payout-requests/[id]/reclaim/__tests__/route.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createMockRequest, + createMockRouteParams, + parseJsonResponse, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +const mockReclaim = vi.fn() +vi.mock('@/lib/invoices/rot-rut-reclaim', () => ({ + reclaimRotRutRefusal: (...args: unknown[]) => mockReclaim(...args), +})) + +import { POST } from '../route' + +const REQUEST_ID = '22222222-2222-4222-8222-222222222222' +const mockUser = { id: 'user-1', email: 'test@test.se' } +const routeParams = createMockRouteParams({ id: REQUEST_ID }) + +function makeReq(body: unknown = { booking_date: '2026-08-21' }) { + return createMockRequest(`/api/rot-rut/payout-requests/${REQUEST_ID}/reclaim`, { + method: 'POST', + body, + }) +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase }) + mockReclaim.mockResolvedValue({ + ok: true, + journalEntryId: 'je-reclaim', + reclaimedTotal: 2000, + invoices: [ + { invoice_id: 'inv-1', invoice_number: '2026-002', reclaimed_amount: 2000, remaining_amount: 2000, status: 'partially_paid' }, + ], + }) +}) + +describe('POST /api/rot-rut/payout-requests/[id]/reclaim', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const response = await POST(makeReq(), routeParams) + expect(response.status).toBe(401) + expect(mockReclaim).not.toHaveBeenCalled() + }) + + it('returns 400 on an invalid body', async () => { + const response = await POST(makeReq({ booking_date: 'yesterday' }), routeParams) + expect(response.status).toBe(400) + expect(mockReclaim).not.toHaveBeenCalled() + }) + + it('returns 404 when the request is not in the company', async () => { + mockReclaim.mockResolvedValue({ ok: false, kind: 'code', code: 'ROT_RUT_REQUEST_NOT_FOUND' }) + const response = await POST(makeReq(), routeParams) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(404) + expect(body.error.code).toBe('ROT_RUT_REQUEST_NOT_FOUND') + }) + + it('maps service refusals to their structured codes', async () => { + mockReclaim.mockResolvedValue({ + ok: false, + kind: 'code', + code: 'ROT_RUT_RECLAIM_SPLIT_UNKNOWN', + details: { request_id: REQUEST_ID }, + }) + const response = await POST(makeReq(), routeParams) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(400) + expect(body.error.code).toBe('ROT_RUT_RECLAIM_SPLIT_UNKNOWN') + + mockReclaim.mockResolvedValue({ ok: false, kind: 'code', code: 'ROT_RUT_RECLAIM_ALREADY_DONE' }) + const conflict = await POST(makeReq(), routeParams) + expect(conflict.status).toBe(409) + }) + + it('books the reclaim with the caller as acting user and the body date', async () => { + const response = await POST(makeReq(), routeParams) + const { status, body } = await parseJsonResponse<{ + data: { journal_entry_id: string; reclaimed_total: number; invoices: unknown[] } + }>(response) + expect(status).toBe(200) + expect(body.data).toEqual({ + journal_entry_id: 'je-reclaim', + reclaimed_total: 2000, + invoices: [ + { invoice_id: 'inv-1', invoice_number: '2026-002', reclaimed_amount: 2000, remaining_amount: 2000, status: 'partially_paid' }, + ], + }) + expect(mockReclaim).toHaveBeenCalledWith(mockSupabase, 'user-1', 'company-1', { + requestId: REQUEST_ID, + bookingDate: '2026-08-21', + }) + }) + + it('returns 500 when the engine refuses to book', async () => { + mockReclaim.mockResolvedValue({ + ok: false, + kind: 'error', + error: new Error('No open fiscal period'), + stage: 'book', + }) + const response = await POST(makeReq(), routeParams) + expect(response.status).toBeGreaterThanOrEqual(400) + }) +}) diff --git a/app/api/rot-rut/payout-requests/[id]/reclaim/route.ts b/app/api/rot-rut/payout-requests/[id]/reclaim/route.ts new file mode 100644 index 00000000..d7c49928 --- /dev/null +++ b/app/api/rot-rut/payout-requests/[id]/reclaim/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { RotRutReclaimSchema } from '@/lib/api/schemas' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { reclaimRotRutRefusal } from '@/lib/invoices/rot-rut-reclaim' + +/** + * POST /api/rot-rut/payout-requests/[id]/reclaim + * + * Books the share of a begäran that Skatteverket refused back onto the + * customer(s) and reopens their invoices for it: + * + * Debit 1510 Kundfordringar [refused share, per invoice] + * Credit 1513 Skattereduktion rot/rut [refused share, per invoice] + * + * The amounts come from the recorded beslut (PATCH or beslutsfil import); + * the body carries only the booking date. See lib/invoices/rot-rut-reclaim.ts + * for every refusal. + */ +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'rot_rut.requests.reclaim', + async (request, ctx, { params }) => { + const { user, supabase, companyId, log, requestId } = ctx + const { id } = await params + + const validation = await validateBody(request, RotRutReclaimSchema) + if (!validation.success) return validation.response + + const outcome = await reclaimRotRutRefusal(supabase, user.id, companyId!, { + requestId: id, + bookingDate: validation.data.booking_date, + }) + + if (!outcome.ok) { + if (outcome.kind === 'code') { + return errorResponseFromCode(outcome.code, log, { requestId, details: outcome.details }) + } + if (outcome.stage === 'book') { + log.error('failed to book rot/rut reclaim entry', outcome.error as Error) + } else { + log.error('rot/rut reclaim failed', outcome.error as Error, { stage: outcome.stage }) + } + return errorResponse(outcome.error, log, { requestId }) + } + + log.info('rot/rut refused share reclaimed', { + userId: user.id, + payoutRequestId: id, + journalEntryId: outcome.journalEntryId, + reclaimedTotal: outcome.reclaimedTotal, + }) + + return NextResponse.json({ + data: { + journal_entry_id: outcome.journalEntryId, + reclaimed_total: outcome.reclaimedTotal, + invoices: outcome.invoices, + }, + }) + }, + { requireWrite: true }, +) diff --git a/app/api/rot-rut/payout-requests/[id]/settle/route.ts b/app/api/rot-rut/payout-requests/[id]/settle/route.ts index d7dd7751..aa9736a1 100644 --- a/app/api/rot-rut/payout-requests/[id]/settle/route.ts +++ b/app/api/rot-rut/payout-requests/[id]/settle/route.ts @@ -50,7 +50,12 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( } else if (outcome.stage === 'book') { log.error('failed to book rot/rut payout entry', outcome.error as Error) } - return errorResponse(outcome.error, log, { requestId }) + // At stage 'update' the voucher is posted: name it so nobody books the + // payout twice while repairing the request row. + return errorResponse(outcome.error, log, { + requestId, + ...(outcome.journalEntryId ? { details: { journal_entry_id: outcome.journalEntryId } } : {}), + }) } log.info('rot/rut payout settled', { diff --git a/app/api/rot-rut/payout-requests/route.ts b/app/api/rot-rut/payout-requests/route.ts index 95435eb1..8cc3b46f 100644 --- a/app/api/rot-rut/payout-requests/route.ts +++ b/app/api/rot-rut/payout-requests/route.ts @@ -20,8 +20,9 @@ export const GET = withRouteContext('rot_rut.requests.list', async (request, ctx .select( 'id, name, deduction_type, status, requested_total, decided_total, file_name, file_document_id, ' + 'created_at, submitted_at, decided_at, settlement_journal_entry_id, ' + - 'items:rot_rut_payout_request_items(id, invoice_id, requested_amount, decided_amount, ' + - 'invoice:invoices(id, invoice_number))', + 'reclaim_journal_entry_id, reclaimed_at, skv_referensnummer, ' + + 'items:rot_rut_payout_request_items(id, invoice_id, requested_amount, decided_amount, reclaimed_amount, ' + + 'invoice:invoices(id, invoice_number, status, remaining_amount))', ) .eq('company_id', companyId!) .order('created_at', { ascending: false }) diff --git a/app/api/transactions/[id]/match-rot-rut-payout/route.ts b/app/api/transactions/[id]/match-rot-rut-payout/route.ts index eb8e72e4..f207687f 100644 --- a/app/api/transactions/[id]/match-rot-rut-payout/route.ts +++ b/app/api/transactions/[id]/match-rot-rut-payout/route.ts @@ -3,13 +3,7 @@ import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { MatchRotRutPayoutSchema } from '@/lib/api/schemas' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' -import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account' -import { - settleRotRutPayoutRequest, - settleRotRutPayoutRequestSet, -} from '@/lib/invoices/rot-rut-settle' -import { hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry' -import { hasBankLineJunctionRow } from '@/lib/transactions/is-booked' +import { matchTransactionToRotRutPayout } from '@/lib/invoices/rot-rut-match-transaction' import { ensureInitialized } from '@/lib/init' ensureInitialized() @@ -31,6 +25,10 @@ ensureInitialized() * body may name several begäran (`request_ids`, #2239): then ONE voucher * carries one 1513 credit per begäran and the row is linked to it, provided * the expected payouts sum to the row exactly. + * + * The pre-flight and the settle live in lib/invoices/rot-rut-match-transaction + * (shared with the MCP settle_rot_rut_payout executor); this route only maps + * the outcome to HTTP. */ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( 'transaction.match_rot_rut_payout', @@ -49,88 +47,14 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( const txLog = log.child({ transactionId, payoutRequestIds }) - // transaction_voucher_links rides along: a row bulk-booked into a - // samlingsverifikat carries journal_entry_id = NULL and must still refuse. - const { data: transactionRow, error: fetchTxError } = await supabase - .from('transactions') - .select( - 'id, date, amount, currency, journal_entry_id, cash_account_id, transaction_voucher_links(journal_entry_id, role)', - ) - .eq('id', transactionId) - .eq('company_id', companyId!) - .single() - - if (fetchTxError || !transactionRow) { - return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', txLog, { requestId }) - } - const { transaction_voucher_links: junctionLinks, ...transaction } = transactionRow as { - id: string - date: string - amount: number - currency: string | null - journal_entry_id: string | null - cash_account_id: string | null - transaction_voucher_links?: Array<{ journal_entry_id: string; role?: string | null }> | null - } - - if (!(transaction.amount > 0)) { - return errorResponseFromCode('ROT_RUT_MATCH_NOT_INCOME', txLog, { - requestId, - details: { amount: transaction.amount }, - }) - } - - if ((transaction.currency || 'SEK').toUpperCase() !== 'SEK') { - return errorResponseFromCode('ROT_RUT_MATCH_CURRENCY', txLog, { - requestId, - details: { currency: transaction.currency }, - }) - } - - // Only a LIVE (posted) pointer or a bank_line junction row blocks: a - // pointer left behind by a storno reads as "utan koppling" in the UI and - // must stay matchable (same predicate as link-journal-entry, issue #988). - if ( - hasBankLineJunctionRow(junctionLinks) || - (await hasLiveJournalEntryLink(supabase, companyId!, transaction.journal_entry_id)) - ) { - return errorResponseFromCode('ROT_RUT_MATCH_TX_ALREADY_LINKED', txLog, { - requestId, - details: { existingJournalEntryId: transaction.journal_entry_id }, - }) - } - - // Debit the cash account THIS transaction belongs to, never a company-wide - // default (mirrors match-supplier-invoice). - const bankAccount = await resolveSettlementAccount( + const outcome = await matchTransactionToRotRutPayout( supabase, + user.id, companyId!, - transaction.cash_account_id, + { transactionId, requestIds: payoutRequestIds }, txLog, ) - // Shared by both shapes: amount, date and account come from the bank row; - // the link CAS locks on the pointer read above (null for a free row, or - // the stale pointer of a reversed entry the guard let through). - const settleParams = { - paymentDate: transaction.date, - amount: transaction.amount, - bankAccount, - transactionId, - previousJournalEntryId: transaction.journal_entry_id, - } - - const outcome = - payoutRequestIds.length === 1 - ? await settleRotRutPayoutRequest(supabase, user.id, companyId!, { - requestId: payoutRequestIds[0], - ...settleParams, - }) - : await settleRotRutPayoutRequestSet(supabase, user.id, companyId!, { - requestIds: payoutRequestIds, - ...settleParams, - }) - if (!outcome.ok) { if (outcome.kind === 'code') { return errorResponseFromCode(outcome.code, txLog, { requestId, details: outcome.details }) @@ -138,20 +62,25 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( if (outcome.stage === 'book') { txLog.error('failed to book rot/rut payout entry', outcome.error as Error) } - return errorResponse(outcome.error, txLog, { requestId }) + // At stage 'update' the voucher is posted: name it so nobody books the + // payout twice while repairing the request row. + return errorResponse(outcome.error, txLog, { + requestId, + ...(outcome.journalEntryId ? { details: { journal_entry_id: outcome.journalEntryId } } : {}), + }) } txLog.info('rot/rut payout matched from bank transaction', { userId: user.id, journalEntryId: outcome.journalEntryId, amount: outcome.amount, - fullyPaid: 'fullyPaid' in outcome ? outcome.fullyPaid : true, + fullyPaid: outcome.fullyPaid, }) return NextResponse.json({ success: true, journal_entry_id: outcome.journalEntryId, - ...('request' in outcome ? { request: outcome.request } : { requests: outcome.requests }), + ...(outcome.request ? { request: outcome.request } : { requests: outcome.requests }), category: 'income_other', }) }, diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts index 5181205b..ba6e25c3 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts @@ -39,7 +39,7 @@ const CreditNoteRequest = z.object({ }) const ORIGINAL_INVOICE_COLUMNS = - 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, invoice_marking, notes, reverse_charge_text, credited_invoice_id, document_type, default_dimensions' + 'id, invoice_number, customer_id, invoice_date, due_date, delivery_date, status, currency, exchange_rate, exchange_rate_date, subtotal, subtotal_sek, vat_amount, vat_amount_sek, total, total_sek, vat_treatment, vat_rate, moms_ruta, your_reference, our_reference, invoice_marking, notes, reverse_charge_text, credited_invoice_id, document_type, default_dimensions, deduction_reclaimed_total' // default_dimensions stays in this projection: the inserted credit-note row is // handed to createCreditNoteJournalEntry, which reads the bag off the row so @@ -199,6 +199,14 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string requestId: ctx.requestId, }) } + // Same guard as the dashboard credit route: a reclaimed ROT/RUT share + // (rot_rut_reclaim) must be reversed before the issue-time split is credited. + if (Number((original as { deduction_reclaimed_total?: number | null }).deduction_reclaimed_total ?? 0) > 0) { + return v1ErrorResponseFromCode('INVOICE_CREDIT_ROT_RUT_RECLAIMED', ctx.log, { + requestId: ctx.requestId, + details: { deduction_reclaimed_total: original.deduction_reclaimed_total }, + }) + } if (!['sent', 'paid', 'overdue'].includes(original.status)) { return v1ErrorResponseFromCode('INVOICE_CREDIT_NOT_SENT', ctx.log, { requestId: ctx.requestId, diff --git a/components/invoices/PaymentBookingDialog.tsx b/components/invoices/PaymentBookingDialog.tsx index 131fa47f..c55ae5ca 100644 --- a/components/invoices/PaymentBookingDialog.tsx +++ b/components/invoices/PaymentBookingDialog.tsx @@ -106,6 +106,13 @@ export default function PaymentBookingDialog({ // debit, accrual against a 1510 credit. const accountingMethod: 'accrual' | 'cash' = companySettings?.accounting_method === 'cash' ? 'cash' : 'accrual' + // An invoice that already carries a verifikat (booked at issue, or a + // kontantmetod invoice whose payment entry recognised the revenue and that + // a ROT/RUT reclaim later reopened) is settled by clearing 1510: proposing + // the cash shape again would recognise the revenue twice, and the + // existing-voucher picker must look for a 1510 clearing for the same + // reason. Same rule as resolveInvoicePaymentSourceType. + const proposalMethod: 'accrual' | 'cash' = invoice.journal_entry_id ? 'accrual' : accountingMethod // The bank account the invoice asked to be paid to (1930 when none was // chosen): the proposed debit lands there, same as the route's default. const { cashAccounts, isLoading: cashAccountsLoading } = useCashAccounts() @@ -178,12 +185,13 @@ export default function PaymentBookingDialog({ default_dimensions: invoice.default_dimensions, ore_rounding: invoice.ore_rounding, deduction_total: invoice.deduction_total, + deduction_reclaimed_total: invoice.deduction_reclaimed_total, // #1717: lets the proposal clear the actual remaining on a // partially_paid invoice (öre write-off when < 1 kr remains). paid_amount: invoice.paid_amount, remaining_amount: invoice.remaining_amount, }, - accountingMethod, + accountingMethod: proposalMethod, entityType, paymentAccount: chosenPaymentAccount, companyOreRounding: @@ -452,7 +460,7 @@ export default function PaymentBookingDialog({ { onOpenChange(false) onSuccess() diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts index 1c981731..cb74738e 100644 --- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts +++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts @@ -429,7 +429,19 @@ describe('tools/list payload size guard', () => { // search-only: versions exist only once a report is rendered for // signing or filing, which is the same switched-off iXBRL path as its // sibling filing_status tool. Ceiling unchanged. - expect(approxTokens).toBeLessThan(60_000) + // * 60K to 60.5K, 2026-09-07, ROT/RUT payout flow (#2239 follow-up): + // gnubok_settle_rot_rut_payout is a WRITE (books Skatteverkets + // utbetalning against its begäran from the bank row) so it must stay + // in the default catalog; its READ sibling + // gnubok_list_rot_rut_payout_requests shipped search-only from day + // one. Measured 60 428 on the accounted projection after trimming + // the write's description to one sentence per fact and dropping its + // property descriptions (two ids are the whole contract). Same + // deliberate skip of the read-demotion rule as set_run_salary and + // ignore_transaction: picking a read to demote needs prod usage data, + // not a guess inside a ROT/RUT PR; do that demotion as its own change + // and ratchet this ceiling back down. + expect(approxTokens).toBeLessThan(60_500) }) /** diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index b46d8f92..794cffde 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -57,6 +57,7 @@ import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account' import { buildTransactionEntryLines, createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' import { upsertCounterpartyTemplate, findCounterpartyTemplatesBatch, formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates' import { formatVoucherLabel, hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry' +import { hasBankLineJunctionRow } from '@/lib/transactions/is-booked' import { setTransactionIgnored } from '@/lib/transactions/ignore' import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle' import { eventBus } from '@/lib/events/bus' @@ -233,6 +234,13 @@ import { findMatchingInvoices } from '@/lib/invoices/invoice-matching' import { sanitizeDeliveryRecipientStatuses } from '@/lib/invoices/delivery-recipient-statuses' import { listRotRutCandidates, createRotRutPayoutRequest } from '@/lib/invoices/rot-rut-service' import { importRotRutBeslutFile } from '@/lib/invoices/rot-rut-beslut-import' +import { computeRefusedShares } from '@/lib/invoices/rot-rut-reclaim' +import { + expectedRotRutPayoutAmount, + isMatchableRotRutPayoutRequest, + OPEN_ROT_RUT_PAYOUT_STATUSES, + type RotRutPayoutRequestCandidate, +} from '@/lib/invoices/rot-rut-payout-matching' import { CreateInvoiceFromSalesOrderSchema, CreateSalesOrderSchema, @@ -17222,6 +17230,297 @@ export const tools: McpTool[] = [ }, }, + { + name: 'gnubok_list_rot_rut_payout_requests', + keywords: ['rotavdrag', 'rutavdrag', 'begäran om utbetalning', 'skatteverket', '1513', 'husavdrag'], + title: 'List Rot/Rut Payout Requests', + description: + 'List ROT/RUT begäran om utbetalning: status, requested and decided amounts, expected payout, settlement and reclaim vouchers, refused share per invoice. Use to see what Skatteverket owes on 1513 and which begäran a bank row settles (gnubok_settle_rot_rut_payout).', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + deduction_type: { type: 'string', enum: ['rot', 'rut'] }, + status: { + type: 'string', + enum: ['generated', 'submitted', 'paid', 'partially_paid', 'rejected', 'cancelled'], + }, + open_only: { + type: 'boolean', + description: 'Only begäran without a settlement voucher (what a Skatteverket bank row can still settle)', + }, + limit: { type: 'number', description: 'Max results (default 50, max 100)' }, + offset: { type: 'integer', minimum: 0, description: 'Number of results to skip (default 0)' }, + }, + }, + outputSchema: paginatedSchema('payout_requests', { + type: 'object', + properties: { + request_id: { type: 'string' }, + name: { type: 'string', description: 'NamnPaBegaran as shown in Skatteverkets e-tjänst' }, + deduction_type: { type: 'string', enum: ['rot', 'rut'] }, + status: { type: 'string' }, + requested_total: { type: 'number' }, + decided_total: { type: ['number', 'null'], description: 'Godkänt belopp per Skatteverkets beslut; null until recorded' }, + decided_at: { type: ['string', 'null'] }, + expected_payout: { type: 'number', description: 'decided_total when recorded, else requested_total: what the bank row must equal' }, + settlement_journal_entry_id: { type: ['string', 'null'], description: 'Voucher that booked Skatteverkets utbetalning (debit 19xx / credit 1513)' }, + refused_total: { type: 'number', description: 'Share Skatteverket refused (requested minus decided), 0 until a beslut is recorded' }, + reclaim_journal_entry_id: { type: ['string', 'null'], description: 'Voucher that moved the refused share back onto the customers (debit 1510 / credit 1513); null while still to book' }, + refused_needs_reclaim: { type: 'boolean', description: 'True when a refused share exists, no reclaim voucher is booked yet, and no invoice of the begäran is re-requested in a later live begäran (book it in the dashboard, Fakturor > ROT/RUT)' }, + skv_referensnummer: { type: ['string', 'null'] }, + created_at: { type: 'string' }, + submitted_at: { type: ['string', 'null'] }, + items: { + type: 'array', + items: { + type: 'object', + properties: { + item_id: { type: 'string' }, + invoice_id: { type: 'string' }, + invoice_number: { type: ['string', 'null'] }, + requested_amount: { type: 'number' }, + decided_amount: { type: ['number', 'null'] }, + reclaimed_amount: { type: ['number', 'null'] }, + }, + }, + }, + }, + }), + annotations: ANNOTATIONS_READ_ONLY, + // Search-only: a READ reachable through gnubok_call_tool, and ROT/RUT + // begäran are a niche flow (a few companies, a few rows a month), so it + // does not earn a slot in the default tools/list payload budget. + catalogVisibility: 'search', + async execute(args, companyId, _userId, supabase) { + const limit = Math.min(Math.max(1, Number(args.limit) || 50), 100) + const offset = Math.max(0, Math.floor(Number(args.offset) || 0)) + const deductionType = args.deduction_type as string | undefined + const status = args.status as string | undefined + const openOnly = args.open_only === true + + let query = supabase + .from('rot_rut_payout_requests') + .select( + 'id, name, deduction_type, status, requested_total, decided_total, decided_at, settlement_journal_entry_id, reclaim_journal_entry_id, skv_referensnummer, created_at, submitted_at, items:rot_rut_payout_request_items(id, invoice_id, requested_amount, decided_amount, reclaimed_amount, invoice:invoices(invoice_number))', + { count: 'exact' }, + ) + .eq('company_id', companyId) + if (deductionType === 'rot' || deductionType === 'rut') query = query.eq('deduction_type', deductionType) + if (status) query = query.eq('status', status) + if (openOnly) { + query = query.in('status', [...OPEN_ROT_RUT_PAYOUT_STATUSES]).is('settlement_journal_entry_id', null) + } + + const { data, error, count } = await query + .order('created_at', { ascending: false }) + .order('id', { ascending: false }) + .range(offset, offset + limit) + if (error) throw dbError(error) + + type Row = { + id: string + name: string + deduction_type: 'rot' | 'rut' + status: string + requested_total: number | string + decided_total: number | string | null + decided_at: string | null + settlement_journal_entry_id: string | null + reclaim_journal_entry_id: string | null + skv_referensnummer: string | null + created_at: string + submitted_at: string | null + items: Array<{ + id: string + invoice_id: string + requested_amount: number | string + decided_amount: number | string | null + reclaimed_amount: number | string | null + invoice: { invoice_number: string | null } | null + }> | null + } + const rows = ((data ?? []) as unknown as Row[]) + const pageRows = rows.slice(0, limit) + // An invoice re-requested in a later live begäran keeps its refused + // share at Skatteverket: the reclaim service refuses it, so the list + // must not advertise it either (one query for the whole page). + const rerequestedByRequest = new Map>() + const pageInvoiceIds = [...new Set(pageRows.flatMap((row) => (row.items ?? []).map((item) => item.invoice_id)))] + if (pageInvoiceIds.length > 0) { + const { data: siblings, error: siblingsError } = await supabase + .from('rot_rut_payout_request_items') + .select('invoice_id, request_id, request:rot_rut_payout_requests!inner(id, status, company_id)') + .eq('request.company_id', companyId) + .in('invoice_id', pageInvoiceIds) + .not('request.status', 'in', '("cancelled","rejected")') + if (siblingsError) throw dbError(siblingsError) + for (const row of pageRows) { + const own = new Set((row.items ?? []).map((item) => item.invoice_id)) + const hits = new Set() + for (const sibling of (siblings ?? []) as Array<{ invoice_id: string; request_id: string }>) { + if (sibling.request_id !== row.id && own.has(sibling.invoice_id)) hits.add(sibling.invoice_id) + } + rerequestedByRequest.set(row.id, hits) + } + } + const payoutRequests = pageRows.map((row) => { + const items = row.items ?? [] + const refused = computeRefusedShares(row, items) + const refusedTotal = refused.ok ? refused.total : 0 + const rerequested = rerequestedByRequest.get(row.id)?.size ?? 0 + return { + request_id: row.id, + name: row.name, + deduction_type: row.deduction_type, + status: row.status, + requested_total: Number(row.requested_total), + decided_total: row.decided_total == null ? null : Number(row.decided_total), + decided_at: row.decided_at, + expected_payout: expectedRotRutPayoutAmount(row), + settlement_journal_entry_id: row.settlement_journal_entry_id, + refused_total: refusedTotal, + reclaim_journal_entry_id: row.reclaim_journal_entry_id, + refused_needs_reclaim: refusedTotal > 0 && !row.reclaim_journal_entry_id && rerequested === 0, + skv_referensnummer: row.skv_referensnummer, + created_at: row.created_at, + submitted_at: row.submitted_at, + items: items.map((item) => ({ + item_id: item.id, + invoice_id: item.invoice_id, + invoice_number: item.invoice?.invoice_number ?? null, + requested_amount: Number(item.requested_amount), + decided_amount: item.decided_amount == null ? null : Number(item.decided_amount), + reclaimed_amount: item.reclaimed_amount == null ? null : Number(item.reclaimed_amount), + })), + } + }) + + const hasMore = count == null ? rows.length > limit : offset + payoutRequests.length < count + const total = count ?? offset + payoutRequests.length + (hasMore ? 1 : 0) + return { + payout_requests: payoutRequests, + count: payoutRequests.length, + total_count: total, + has_more: hasMore, + ...(hasMore ? { next_offset: offset + payoutRequests.length } : {}), + } + }, + }, + + { + name: 'gnubok_settle_rot_rut_payout', + keywords: ['rotavdrag', 'rutavdrag', 'utbetalning skatteverket', '1513', 'matcha utbetalning'], + title: 'Settle Rot/Rut Payout from Bank Row', + description: + 'Match 1 income bank tx from Skatteverket to the ROT/RUT begäran it pays (several when one transfer covers several: their payouts must sum to the row). Books debit 19xx / credit 1513, links the row. List begäran via gnubok_list_rot_rut_payout_requests first. Stages.', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + transaction_id: { type: 'string' }, + request_ids: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 10 }, + }, + required: ['transaction_id', 'request_ids'], + }, + outputSchema: STAGED_OPERATION_SCHEMA, + annotations: ANNOTATIONS_STAGED_WRITE, + async execute(args, companyId, userId, supabase, actor) { + const transactionId = args.transaction_id as string + const rawIds = Array.isArray(args.request_ids) ? (args.request_ids as unknown[]) : [] + const requestIds = [...new Set(rawIds.filter((id): id is string => typeof id === 'string' && id.length > 0))] + if (!transactionId || requestIds.length === 0) { + throw codedError('VALIDATION_ERROR', 'transaction_id and request_ids are required') + } + if (requestIds.length > 10) { + throw codedError('VALIDATION_ERROR', 'request_ids: at most 10 begäran per transfer') + } + + const { data: transaction, error: txError } = await supabase + .from('transactions') + .select('id, description, merchant_name, amount, currency, date, journal_entry_id, transaction_voucher_links(journal_entry_id, role)') + .eq('id', transactionId) + .eq('company_id', companyId) + .single() + if (txError || !transaction) throw registryError('TX_CATEGORIZE_TX_NOT_FOUND') + if (!(transaction.amount > 0)) throw registryError('ROT_RUT_MATCH_NOT_INCOME') + if ((transaction.currency || 'SEK').toUpperCase() !== 'SEK') throw registryError('ROT_RUT_MATCH_CURRENCY') + // Same predicate as the commit writer: a live pointer OR a bank_line + // junction row (samlingsverifikat) means the row is already booked. + if ( + hasBankLineJunctionRow( + (transaction as { transaction_voucher_links?: Array<{ journal_entry_id: string; role?: string | null }> | null }) + .transaction_voucher_links, + ) || + (await hasLiveJournalEntryLink(supabase, companyId, transaction.journal_entry_id)) + ) { + throw registryError('ROT_RUT_MATCH_TX_ALREADY_LINKED') + } + + const { data: requestRows, error: reqError } = await supabase + .from('rot_rut_payout_requests') + .select('id, name, deduction_type, status, requested_total, decided_total, settlement_journal_entry_id') + .eq('company_id', companyId) + .in('id', requestIds) + if (reqError) throw dbError(reqError) + const requests = (requestRows ?? []) as Array + const missing = requestIds.filter((id) => !requests.some((r) => r.id === id)) + if (missing.length > 0) throw registryError('ROT_RUT_REQUEST_NOT_FOUND') + const blocked = requests.find((r) => !isMatchableRotRutPayoutRequest(r)) + if (blocked) { + throw Object.assign( + new Error( + `Begäran "${blocked.name}" är ${blocked.settlement_journal_entry_id ? 'redan bokförd som utbetald' : blocked.status} och kan inte matchas.`, + ), + { code: 'ROT_RUT_SETTLE_INVALID_STATE' }, + ) + } + + // Same rule the commit enforces (settleRotRutPayoutRequestSet): the + // bundle must equal the decided sums to the öre. Refuse at stage so the + // approver never sees an op that can only fail. + const expectedTotal = roundOre(requests.reduce((sum, r) => sum + expectedRotRutPayoutAmount(r), 0)) + const txAmount = roundOre(transaction.amount) + if (Math.abs(txAmount - expectedTotal) > 0.005) { + throw Object.assign( + new Error( + `Transaktionen är ${txAmount} kr men ${requestIds.length === 1 ? 'begäran väntar' : 'de valda begäran väntar tillsammans'} ${expectedTotal} kr. Skatteverket betalar exakt beslutade belopp: välj de begäran som summerar till transaktionen, eller registrera beslutet först.`, + ), + { code: requestIds.length === 1 ? 'ROT_RUT_SETTLE_AMOUNT_EXCEEDS' : 'ROT_RUT_SETTLE_SET_AMOUNT' }, + ) + } + + const txDesc = transaction.merchant_name || transaction.description || transactionId + // Booking order: largest first, as the matcher offers them. + const ordered = [...requests].sort((a, b) => expectedRotRutPayoutAmount(b) - expectedRotRutPayoutAmount(a)) + + return stagePendingOperation(supabase, companyId, userId, 'settle_rot_rut_payout', + `ROT/RUT-utbetalning: ${txDesc} → ${ordered.map((r) => r.name).join(', ')}`, + { transaction_id: transactionId, request_ids: ordered.map((r) => r.id) }, + { + transaction_description: txDesc, + transaction_amount: transaction.amount, + transaction_currency: transaction.currency, + transaction_date: transaction.date, + expected_total: expectedTotal, + requests: ordered.map((r) => ({ + request_id: r.id, + name: r.name, + deduction_type: r.deduction_type, + status: r.status, + expected_payout: expectedRotRutPayoutAmount(r), + })), + }, + actor, + { + description: 'After approval the transfer is booked debit 19xx / credit 1513 (one leg per begäran), every begäran is marked paid and the row is linked. Verify with gnubok_list_rot_rut_payout_requests.', + tool: 'gnubok_list_rot_rut_payout_requests', + }, + { dateForPeriodCheck: transaction.date }, + ) + }, + }, + { name: 'gnubok_import_rot_rut_beslut', keywords: ['rotavdrag', 'rutavdrag', 'beslutsfil', 'skatteverket'], diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 5341b58b..cc21abd8 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -294,6 +294,7 @@ export const JournalEntrySourceTypeSchema = z.enum([ 'webshop_order', 'expense_claim', 'expense_payout', + 'rot_rut_reclaim', ]) /** Query params for GET /api/bookkeeping/voucher-sequences/next. */ @@ -712,6 +713,15 @@ export const RotRutSettleSchema = z.object({ .optional(), }) +/** + * POST /api/rot-rut/payout-requests/[id]/reclaim: book the share Skatteverket + * refused back onto the customer(s). The amounts come from the recorded + * beslut, never from the body; only the booking date is the caller's. + */ +export const RotRutReclaimSchema = z.object({ + booking_date: isoDate, +}) + // The beslutsfil JSON downloaded from Skatteverkets rot/rut e-tjänst // (dev_docs/skatteverket/husavdrag/exempel_beslut.json + ht.raml). export const RotRutBeslutFileSchema = z.object({ diff --git a/lib/auth/scope-catalog.ts b/lib/auth/scope-catalog.ts index 92462c57..5e9cc1f6 100644 --- a/lib/auth/scope-catalog.ts +++ b/lib/auth/scope-catalog.ts @@ -413,6 +413,10 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_undo_sie_import: 'bookkeeping:write', gnubok_post_annual_depreciation: 'bookkeeping:write', gnubok_import_rot_rut_beslut: 'invoices:write', + // Rot/rut begäran history (status, beslut, settlement, refused share) + gnubok_list_rot_rut_payout_requests: 'invoices:read', + // Skatteverkets utbetalning: bank row booked against its begäran (stages) + gnubok_settle_rot_rut_payout: 'transactions:write', gnubok_list_verifikat_without_documents: 'transactions:read', gnubok_find_voucher_candidates_for_invoice: 'invoices:read', gnubok_propose_dispositioner: 'reports:read', diff --git a/lib/bookkeeping/__tests__/payment-sync.test.ts b/lib/bookkeeping/__tests__/payment-sync.test.ts index 5f6015b7..cfa6e933 100644 --- a/lib/bookkeeping/__tests__/payment-sync.test.ts +++ b/lib/bookkeeping/__tests__/payment-sync.test.ts @@ -482,3 +482,40 @@ describe('syncInvoiceStatusFromPaymentEntry', () => { expect(tablesUpdated('transactions').length).toBe(2) }) }) + +describe('syncInvoiceStatusFromPaymentEntry: reclaimed ROT/RUT share (rot_rut_reclaim)', () => { + // 25 000 invoice, 7 500 deduction, Skatteverket refused 2 500 and the + // reclaim moved it onto the customer; customer paid 17 500 + 2 500. The + // storno of the 2 500 payment must leave 2 500 open, not 0 (#2397 R1). + it('keeps the refused share in the remaining after a payment storno', async () => { + const { supabase, updatePayload } = createRecordingSupabase([ + { data: { amount: 2500 } }, // invoice_payments select amount + { + data: { + paid_amount: 20000, + total: 25000, + deduction_total: 7500, + deduction_reclaimed_total: 2500, + due_date: '2099-12-31', + }, + }, // invoices select + { data: null }, // invoices update + { data: [] }, // invoice_payments select transaction_id + { data: null }, // invoice_payments delete + { data: null }, // transactions update + ]) + + await syncInvoiceStatusFromPaymentEntry( + supabase, + 'co-1', + { id: 'entry-1', source_type: 'invoice_paid', source_id: 'invoice-1' } as JournalEntry, + ) + + expect(updatePayload('invoices')).toEqual({ + status: 'partially_paid', + paid_at: null, + paid_amount: 17500, + remaining_amount: 2500, + }) + }) +}) diff --git a/lib/bookkeeping/__tests__/propose-payment-lines.test.ts b/lib/bookkeeping/__tests__/propose-payment-lines.test.ts index 7ab5e17c..6f437ffd 100644 --- a/lib/bookkeeping/__tests__/propose-payment-lines.test.ts +++ b/lib/bookkeeping/__tests__/propose-payment-lines.test.ts @@ -710,3 +710,40 @@ describe('proposePaymentLines: ROT/RUT-avdrag (fakturamodellen)', () => { expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '12500' }) }) }) + +describe('proposePaymentLines: ROT/RUT invoice reopened by a reclaim (rot_rut_reclaim)', () => { + it('proposes the reopened remaining as a plain 1510 clearing', () => { + // 25 000 invoice, 7 500 deduction, customer paid 17 500, Skatteverket + // refused 2 500 and the reclaim voucher moved it onto 1510. + const lines = proposePaymentLines({ + invoice: { + ...makeInvoiceInput({ total: 25000 }), + deduction_total: 7500, + deduction_reclaimed_total: 2500, + paid_amount: 17500, + remaining_amount: 2500, + }, + accountingMethod: 'accrual', + entityType: 'enskild_firma', + }) + + expect(lines).toHaveLength(2) + expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '2500' }) + expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '2500' }) + }) + + it('leaves a ROT/RUT invoice without a reclaim on the legacy customer-share proposal', () => { + const lines = proposePaymentLines({ + invoice: { + ...makeInvoiceInput({ total: 25000 }), + deduction_total: 7500, + paid_amount: 0, + remaining_amount: 17500, + }, + accountingMethod: 'accrual', + entityType: 'enskild_firma', + }) + expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '17500' }) + expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '17500' }) + }) +}) diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index 41519fd6..639def62 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -34,6 +34,7 @@ import { import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill' import { syncInvoiceStatusFromPaymentEntry, isPaymentSourceType } from '@/lib/bookkeeping/payment-sync' +import { syncRotRutReclaimAfterReversal } from '@/lib/invoices/rot-rut-reclaim-reversal' import { getActor } from '@/lib/bookkeeping/actor-context' import type { AssetDisposalType, @@ -1455,6 +1456,14 @@ export async function reverseEntry( await syncInvoiceStatusFromPaymentEntry(supabase, companyId, original as JournalEntry) } + // A reversed rot_rut_reclaim voucher hands the refused share back to + // Skatteverket's side: the invoices it reopened close again, the begäran + // becomes reclaimable again (same business-state mirror as the payment + // sync above, skeptic #2397 R3). + if (original.source_type === 'rot_rut_reclaim') { + await syncRotRutReclaimAfterReversal(supabase, companyId, original.id) + } + // Fetch complete reversal entry with lines const { data: completeEntry } = await supabase .from('journal_entries') diff --git a/lib/bookkeeping/invoice-entries.ts b/lib/bookkeeping/invoice-entries.ts index f17dd06a..d939bd28 100644 --- a/lib/bookkeeping/invoice-entries.ts +++ b/lib/bookkeeping/invoice-entries.ts @@ -533,6 +533,7 @@ function invoiceOutstandingAmount(invoice: Invoice): number { remaining_amount?: number | null paid_amount?: number | null deduction_total?: number | null + deduction_reclaimed_total?: number | null } // A payment is being booked, so a stored 0 cannot mean "settled": rows // written by paths that bypass buildInvoiceWriteData (imports, sandbox seed, @@ -544,7 +545,11 @@ function invoiceOutstandingAmount(invoice: Invoice): number { } const paid = typeof inv.paid_amount === 'number' ? inv.paid_amount : 0 const deduction = typeof inv.deduction_total === 'number' ? inv.deduction_total : 0 - return roundOre(invoice.total - paid - deduction) + // A refused deduction (rot_rut_reclaim) is the customer's again: same + // formula as invoiceCustomerShare and the SQL INSERT guard. + const reclaimed = + typeof inv.deduction_reclaimed_total === 'number' ? inv.deduction_reclaimed_total : 0 + return roundOre(invoice.total - paid - deduction + reclaimed) } /** diff --git a/lib/bookkeeping/payment-sync.ts b/lib/bookkeeping/payment-sync.ts index b95a4c7e..217cd4c2 100644 --- a/lib/bookkeeping/payment-sync.ts +++ b/lib/bookkeeping/payment-sync.ts @@ -153,7 +153,7 @@ export async function syncInvoiceStatusFromPaymentEntry( const { data: customerInvoice } = await supabase .from('invoices') - .select('paid_amount, total, due_date, deduction_total') + .select('paid_amount, total, due_date, deduction_total, deduction_reclaimed_total') .eq('id', entry.source_id) .eq('company_id', companyId) .single() @@ -185,12 +185,20 @@ export async function syncInvoiceStatusFromPaymentEntry( // one shared definition (lib/invoices/customer-share.ts); the // Math.max(0, ...) mirrors the guard's GREATEST(0, ...) because this // value is persisted into the column. - const deductionTotal = - (customerInvoice as { deduction_total?: number | null }).deduction_total ?? 0 + // A refused deduction that a rot_rut_reclaim voucher moved back onto + // the customer is the customer's again: without this term a storno of + // any payment on a reopened invoice wrote a remaining short by the + // reclaimed share and no path could settle it (skeptic #2397 R1). + const { deduction_total: deductionTotal = 0, deduction_reclaimed_total: reclaimedTotal = 0 } = + customerInvoice as { deduction_total?: number | null; deduction_reclaimed_total?: number | null } const newRemaining = Math.max( 0, invoiceCustomerOutstanding( - { total: customerInvoice.total, deduction_total: deductionTotal }, + { + total: customerInvoice.total, + deduction_total: deductionTotal ?? 0, + deduction_reclaimed_total: reclaimedTotal ?? 0, + }, safePaidAmount, ), ) diff --git a/lib/bookkeeping/propose-payment-lines.ts b/lib/bookkeeping/propose-payment-lines.ts index 40875935..ff0071ed 100644 --- a/lib/bookkeeping/propose-payment-lines.ts +++ b/lib/bookkeeping/propose-payment-lines.ts @@ -39,6 +39,13 @@ export interface ProposePaymentLinesInput { * doing so is what made every ROT/RUT invoice fail the overpayment guard. */ deduction_total?: number | null + /** + * The part of the deduction Skatteverket refused and that a + * rot_rut_reclaim voucher moved back onto the customer (debit 1510). + * When set, the invoice's remaining is a plain 1510 fordran again and the + * remaining-aware proposal applies to it. + */ + deduction_reclaimed_total?: number | null /** * Dimensions PR7: the invoice's default bag. Stamped on every proposed * line: the payment dialog always submits its (editable) lines, so the @@ -145,7 +152,10 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[] * pure function does not carry; the dialog's FX path already handles it. * - On a ROT/RUT invoice the outstanding remainder is (or includes) * Skatteverket's share, which sits on 1513 and is settled by the ROT/RUT - * payout flow, not by clearing 1510 here. + * payout flow, not by clearing 1510 here. The one exception is a + * deduction Skatteverket refused: the reclaim voucher has already moved + * that share onto 1510 (deduction_reclaimed_total > 0), so the reopened + * remaining is an ordinary customer fordran and is proposed as such. * * Two shapes: * - 0 < remaining < 1 kr (the stuck öresavrundning case): a bank-less @@ -163,11 +173,15 @@ function proposeRemainingAwareLines( ): FormLine[] | null { if (accountingMethod !== 'accrual') return null if (invoice.currency !== 'SEK') return null - if ((invoice.deduction_total ?? 0) > 0) return null + const reclaimed = invoice.deduction_reclaimed_total ?? 0 + if ((invoice.deduction_total ?? 0) > 0 && !(reclaimed > 0)) return null - const total = roundOre(invoice.total) + // With a reclaimed deduction the customer's whole fordran is total minus + // the deduction plus the refused share; "partial" is measured against + // that, not the printed total. + const total = roundOre(invoice.total - (invoice.deduction_total ?? 0) + reclaimed) const remaining = roundOre( - invoice.remaining_amount ?? invoice.total - (invoice.paid_amount ?? 0), + invoice.remaining_amount ?? total - (invoice.paid_amount ?? 0), ) const hasPartial = remaining > ORE_TOLERANCE && total - remaining > ORE_TOLERANCE if (!hasPartial) return null diff --git a/lib/bookkeeping/rot-rut-entries.ts b/lib/bookkeeping/rot-rut-entries.ts index 4636b106..c8330258 100644 --- a/lib/bookkeeping/rot-rut-entries.ts +++ b/lib/bookkeeping/rot-rut-entries.ts @@ -132,3 +132,78 @@ export async function createRotRutPayoutEntry( ], }) } + +/** + * Reclaim voucher for the share of a begäran Skatteverket refused. + * + * The 1513 fordran booked at issue was never paid by Skatteverket, so it is + * the buyer's again (HUSFL 2009:194; swedish-invoice-compliance section 8: + * "SKV denies: Debit 1510, Credit 1513"): + * + * Debit 1510 Kundfordringar [refused share, per invoice] + * Credit 1513 Skattereduktion rot/rut [refused share, per invoice] + * + * One voucher per begäran (source_id = request id, guarded by the partial + * unique index journal_entries_rot_rut_reclaim_live_unique), one 1510 and one + * 1513 leg per invoice so the kundreskontra can be read per faktura. + */ +export interface RotRutReclaimLeg { + invoiceId: string + invoiceNumber: string | null + /** The refused share for this invoice (kr). */ + amount: number +} + +export async function createRotRutReclaimEntry( + supabase: SupabaseClient, + companyId: string, + userId: string, + params: { + requestId: string + requestName: string + deductionType: 'rot' | 'rut' + bookingDate: string + /** One leg per invoice with a refused share; at least one, all > 0. */ + legs: RotRutReclaimLeg[] + }, +): Promise { + const legs = params.legs + .map((leg) => ({ ...leg, amount: roundOre(leg.amount) })) + .filter((leg) => leg.amount > 0) + if (legs.length === 0) { + throw new Error('A rot/rut reclaim voucher needs at least one refused share') + } + const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, params.bookingDate) + if (!fiscalPeriodId) { + throw new Error(`No open fiscal period found for booking date ${params.bookingDate}`) + } + + const label = params.deductionType === 'rut' ? 'RUT' : 'ROT' + const description = `Nekat ${label}-avdrag från Skatteverket (${params.requestName})` + const legDescription = (leg: RotRutReclaimLeg) => + `Nekat ${label}-avdrag faktura ${leg.invoiceNumber ?? leg.invoiceId}` + + const input: CreateJournalEntryInput = { + fiscal_period_id: fiscalPeriodId, + entry_date: params.bookingDate, + description, + source_type: 'rot_rut_reclaim', + source_id: params.requestId, + lines: [ + ...legs.map((leg) => ({ + account_number: '1510', + debit_amount: leg.amount, + credit_amount: 0, + line_description: legDescription(leg), + })), + ...legs.map((leg) => ({ + account_number: '1513', + debit_amount: 0, + credit_amount: leg.amount, + line_description: legDescription(leg), + })), + ], + } + + return createJournalEntry(supabase, companyId, userId, input) +} diff --git a/lib/bookkeeping/voucher-series-resolver.ts b/lib/bookkeeping/voucher-series-resolver.ts index 414cf60e..edd642ef 100644 --- a/lib/bookkeeping/voucher-series-resolver.ts +++ b/lib/bookkeeping/voucher-series-resolver.ts @@ -153,6 +153,7 @@ export const STANDARD_VOUCHER_SERIES_MAP: Readonly = { message_sv: 'Beloppet stämmer inte med de valda utläggen. Välj de utlägg som överföringen täcker.', message_en: 'The amount does not match the selected expense claims. Pick the claims this transfer covers.', }, + // Reclaim: Skatteverkets avslag booked back onto the customer + ROT_RUT_RECLAIM_NO_BESLUT: { + httpStatus: 400, + message_sv: + 'Skatteverkets beslut är inte registrerat för begäran. Importera beslutsfilen eller registrera beslutet först.', + message_en: + "Skatteverket's decision is not recorded for this request. Import the decision file or record the decision first.", + }, + ROT_RUT_RECLAIM_NOTHING_REFUSED: { + httpStatus: 400, + message_sv: 'Skatteverket beviljade hela begäran: det finns inget nekat belopp att bokföra.', + message_en: 'Skatteverket approved the whole request: there is no refused amount to book.', + }, + ROT_RUT_RECLAIM_ALREADY_DONE: { + httpStatus: 409, + message_sv: 'Det nekade beloppet är redan bokfört för den här begäran.', + message_en: 'The refused amount has already been booked for this request.', + }, + ROT_RUT_RECLAIM_SPLIT_UNKNOWN: { + httpStatus: 400, + message_sv: + 'Beslutet är registrerat som en totalsumma för flera fakturor. Importera Skatteverkets beslutsfil så att det nekade beloppet kan fördelas per faktura.', + message_en: + "The decision was recorded as one total for several invoices. Import Skatteverket's decision file so the refused amount can be split per invoice.", + }, + ROT_RUT_RECLAIM_INVOICE_NOT_BOOKED: { + httpStatus: 400, + message_sv: + 'Fakturan har ingen verifikation, så det finns ingen fordran på konto 1513 att flytta. Bokför fakturan först.', + message_en: + 'The invoice has no voucher, so there is no receivable on account 1513 to move. Book the invoice first.', + }, + ROT_RUT_RECLAIM_INVOICE_NOT_OPEN: { + httpStatus: 400, + message_sv: 'Fakturan är makulerad eller krediterad och kan inte öppnas igen för det nekade beloppet.', + message_en: 'The invoice is cancelled or credited and cannot be reopened for the refused amount.', + }, + ROT_RUT_RECLAIM_CURRENCY: { + httpStatus: 400, + message_sv: 'Det nekade beloppet kan bara bokföras för fakturor i SEK.', + message_en: 'The refused amount can only be booked for invoices in SEK.', + }, + ROT_RUT_RECLAIM_INVOICE_REREQUESTED: { + httpStatus: 409, + message_sv: + 'Minst en faktura i begäran ingår i en senare begäran som inte är avslagen. Det nekade beloppet kan inte bokföras på kunden när Skatteverket prövar fakturan igen.', + message_en: + 'At least one invoice in this request is part of a later request that is not rejected. The refused amount cannot be booked onto the customer while Skatteverket is reviewing the invoice again.', + }, + ROT_RUT_RECLAIM_RACE: { + httpStatus: 409, + message_sv: + 'Det nekade beloppet hann redan bokföras av en annan åtgärd. Verifikationen som skapades kan inte kopplas: kontrollera bokföringen på konto 1513 och 1510.', + message_en: + 'The refused amount was already booked by another action. The voucher that was created could not be attached: check the bookkeeping on accounts 1513 and 1510.', + }, ROT_RUT_FILE_CREATE_FAILED: { httpStatus: 500, message_sv: 'Filen kunde inte skapas.', @@ -1075,6 +1131,13 @@ const INVOICE: Record = { message_sv: 'Fakturan har redan krediterats.', message_en: 'Invoice has already been credited.', }, + INVOICE_CREDIT_ROT_RUT_RECLAIMED: { + httpStatus: 400, + message_sv: + 'Fakturan har ett nekat ROT/RUT-avdrag bokfört som kundfordran. Makulera den bokningen (verifikationen med nekat avdrag) innan fakturan krediteras, annars stämmer inte kreditfakturans fördelning mellan konto 1510 och 1513.', + message_en: + 'The invoice carries a refused ROT/RUT deduction booked as a customer receivable. Reverse that voucher before crediting the invoice, otherwise the credit note splits 1510 and 1513 wrongly.', + }, INVOICE_CREDIT_NOT_SENT: { httpStatus: 400, message_sv: 'Endast skickade, betalda eller förfallna fakturor kan krediteras.', diff --git a/lib/invoices/__tests__/customer-share.test.ts b/lib/invoices/__tests__/customer-share.test.ts index 4844639e..de088a2d 100644 --- a/lib/invoices/__tests__/customer-share.test.ts +++ b/lib/invoices/__tests__/customer-share.test.ts @@ -85,3 +85,26 @@ describe('invoiceCustomerOutstanding', () => { } }) }) + +describe('invoiceCustomerShare with a reclaimed deduction (rot_rut_reclaim)', () => { + it('adds the refused share back onto the customer', () => { + // Skatteverket refused 2 500 of the 7 500: the customer owes 17 500 + 2 500. + expect(invoiceCustomerShare({ ...rot, deduction_reclaimed_total: 2500 })).toBe(20000) + // Full avslag: the whole invoice is the customer's again. + expect(invoiceCustomerShare({ ...rot, deduction_reclaimed_total: 7500 })).toBe(25000) + }) + + it('treats null, undefined and zero reclaimed as nothing reclaimed', () => { + expect(invoiceCustomerShare({ ...rot, deduction_reclaimed_total: null })).toBe(17500) + expect(invoiceCustomerShare({ ...rot, deduction_reclaimed_total: undefined })).toBe(17500) + expect(invoiceCustomerShare({ ...rot, deduction_reclaimed_total: 0 })).toBe(17500) + }) + + it('never reclaims more than the deduction (CHECK twin)', () => { + expect(invoiceCustomerShare({ ...rot, deduction_reclaimed_total: 9000 })).toBe(25000) + }) + + it('feeds the outstanding: customer paid their share, refused share is open', () => { + expect(invoiceCustomerOutstanding({ ...rot, deduction_reclaimed_total: 2500 }, 17500)).toBe(2500) + }) +}) diff --git a/lib/invoices/__tests__/invoice-register-coverage.test.ts b/lib/invoices/__tests__/invoice-register-coverage.test.ts index 2fefe9cc..7c252ed4 100644 --- a/lib/invoices/__tests__/invoice-register-coverage.test.ts +++ b/lib/invoices/__tests__/invoice-register-coverage.test.ts @@ -53,7 +53,7 @@ describe('fetchInvoiceRegisterCoverage', () => { expect(findCall('journal_entries', 'not')).toEqual([ 'source_type', 'in', - '("invoice_created","invoice_paid","invoice_cash_payment","credit_note","reminder_fee","rot_rut_payout","storno","correction")', + '("invoice_created","invoice_paid","invoice_cash_payment","credit_note","reminder_fee","rot_rut_payout","rot_rut_reclaim","storno","correction")', ]) // AR-scoped and DEBIT-only: an advance payment crediting 1510 before the // first invoice is not evidence of register-external invoices. diff --git a/lib/invoices/__tests__/rot-rut-file.test.ts b/lib/invoices/__tests__/rot-rut-file.test.ts index 47a96f13..59064654 100644 --- a/lib/invoices/__tests__/rot-rut-file.test.ts +++ b/lib/invoices/__tests__/rot-rut-file.test.ts @@ -875,3 +875,16 @@ describe('deadline + helpers', () => { expect(xml).toMatch(/TEST 1:7<\/ns2:Fastighetsbeteckning>\s*\s*/) }) }) + +describe('evaluateInvoiceForFile: reclaimed deduction (rot_rut_reclaim)', () => { + it('DEDUCTION_RECLAIMED blocks an invoice whose refused share was booked onto the customer', () => { + const result = evaluateInvoiceForFile('rot', makeRotInvoice({ deduction_reclaimed_total: 1000 })) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.blocker.code).toBe('DEDUCTION_RECLAIMED') + }) + + it('a zero or absent reclaimed total changes nothing', () => { + expect(evaluateInvoiceForFile('rot', makeRotInvoice({ deduction_reclaimed_total: 0 })).ok).toBe(true) + expect(evaluateInvoiceForFile('rot', makeRotInvoice()).ok).toBe(true) + }) +}) diff --git a/lib/invoices/__tests__/rot-rut-match-transaction.test.ts b/lib/invoices/__tests__/rot-rut-match-transaction.test.ts new file mode 100644 index 00000000..f65f46e3 --- /dev/null +++ b/lib/invoices/__tests__/rot-rut-match-transaction.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { createLogger } from '@/lib/logger' + +const mockSettle = vi.fn() +const mockSettleSet = vi.fn() +vi.mock('@/lib/invoices/rot-rut-settle', () => ({ + settleRotRutPayoutRequest: (...args: unknown[]) => mockSettle(...args), + settleRotRutPayoutRequestSet: (...args: unknown[]) => mockSettleSet(...args), +})) + +const mockResolveSettlementAccount = vi.fn() +vi.mock('@/lib/bookkeeping/settlement-account', () => ({ + resolveSettlementAccount: (...args: unknown[]) => mockResolveSettlementAccount(...args), +})) + +const mockHasLiveLink = vi.fn() +vi.mock('@/lib/transactions/link-journal-entry', () => ({ + hasLiveJournalEntryLink: (...args: unknown[]) => mockHasLiveLink(...args), +})) + +import { matchTransactionToRotRutPayout } from '../rot-rut-match-transaction' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +const supabase = mockSupabase as unknown as SupabaseClient +const log = createLogger('test') + +const TX_ID = '11111111-1111-4111-8111-111111111111' +const REQUEST_A = '22222222-2222-4222-8222-222222222222' +const REQUEST_B = '44444444-4444-4444-8444-444444444444' + +function makeTxRow(overrides: Record = {}) { + return { + id: TX_ID, + date: '2026-07-10', + amount: 3000, + currency: 'SEK', + journal_entry_id: null, + cash_account_id: 'ca-1', + transaction_voucher_links: [], + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + mockResolveSettlementAccount.mockResolvedValue('1930') + mockHasLiveLink.mockResolvedValue(false) + mockSettle.mockResolvedValue({ + ok: true, + journalEntryId: 'je-1', + amount: 3000, + fullyPaid: true, + request: { id: REQUEST_A, name: 'ROT 2026-07', status: 'paid' }, + }) + mockSettleSet.mockResolvedValue({ + ok: true, + journalEntryId: 'je-2', + amount: 5000, + requests: [ + { id: REQUEST_A, name: 'ROT 2026-07', status: 'paid' }, + { id: REQUEST_B, name: 'RUT 2026-07', status: 'paid' }, + ], + }) +}) + +describe('matchTransactionToRotRutPayout', () => { + it('refuses an empty request list before touching the database', async () => { + const outcome = await matchTransactionToRotRutPayout( + supabase, 'user-1', 'company-1', { transactionId: TX_ID, requestIds: [] }, log, + ) + expect(outcome).toEqual({ ok: false, kind: 'code', code: 'ROT_RUT_REQUEST_NOT_FOUND' }) + }) + + it('reports an unknown transaction', async () => { + enqueue({ data: null, error: { message: 'not found' } }) + const outcome = await matchTransactionToRotRutPayout( + supabase, 'user-1', 'company-1', { transactionId: TX_ID, requestIds: [REQUEST_A] }, log, + ) + expect(outcome).toMatchObject({ ok: false, code: 'TX_CATEGORIZE_TX_NOT_FOUND' }) + }) + + it('refuses an expense row and a non-SEK row', async () => { + enqueue({ data: makeTxRow({ amount: -3000 }) }) + const expense = await matchTransactionToRotRutPayout( + supabase, 'user-1', 'company-1', { transactionId: TX_ID, requestIds: [REQUEST_A] }, log, + ) + expect(expense).toMatchObject({ ok: false, code: 'ROT_RUT_MATCH_NOT_INCOME' }) + + reset() + enqueue({ data: makeTxRow({ currency: 'EUR' }) }) + const foreign = await matchTransactionToRotRutPayout( + supabase, 'user-1', 'company-1', { transactionId: TX_ID, requestIds: [REQUEST_A] }, log, + ) + expect(foreign).toMatchObject({ ok: false, code: 'ROT_RUT_MATCH_CURRENCY' }) + expect(mockSettle).not.toHaveBeenCalled() + }) + + it('refuses a row that is already booked (live pointer or bank_line junction)', async () => { + enqueue({ data: makeTxRow({ journal_entry_id: 'je-old' }) }) + mockHasLiveLink.mockResolvedValue(true) + const live = await matchTransactionToRotRutPayout( + supabase, 'user-1', 'company-1', { transactionId: TX_ID, requestIds: [REQUEST_A] }, log, + ) + expect(live).toMatchObject({ ok: false, code: 'ROT_RUT_MATCH_TX_ALREADY_LINKED' }) + + reset() + mockHasLiveLink.mockResolvedValue(false) + enqueue({ + data: makeTxRow({ transaction_voucher_links: [{ journal_entry_id: 'je-bulk', role: 'bank_line' }] }), + }) + const junction = await matchTransactionToRotRutPayout( + supabase, 'user-1', 'company-1', { transactionId: TX_ID, requestIds: [REQUEST_A] }, log, + ) + expect(junction).toMatchObject({ ok: false, code: 'ROT_RUT_MATCH_TX_ALREADY_LINKED' }) + expect(mockSettle).not.toHaveBeenCalled() + }) + + it('settles one begäran through the single writer with the row as amount, date and account', async () => { + enqueue({ data: makeTxRow({ journal_entry_id: 'je-stale' }) }) + mockResolveSettlementAccount.mockResolvedValue('1920') + const outcome = await matchTransactionToRotRutPayout( + supabase, 'user-1', 'company-1', { transactionId: TX_ID, requestIds: [REQUEST_A] }, log, + ) + expect(outcome).toMatchObject({ + ok: true, + journalEntryId: 'je-1', + amount: 3000, + request: { id: REQUEST_A }, + requests: [{ id: REQUEST_A }], + }) + expect(mockSettle).toHaveBeenCalledWith(supabase, 'user-1', 'company-1', { + requestId: REQUEST_A, + paymentDate: '2026-07-10', + amount: 3000, + bankAccount: '1920', + transactionId: TX_ID, + previousJournalEntryId: 'je-stale', + }) + expect(mockSettleSet).not.toHaveBeenCalled() + }) + + it('settles a bundle through the set writer, de-duplicating ids', async () => { + enqueue({ data: makeTxRow({ amount: 5000 }) }) + const outcome = await matchTransactionToRotRutPayout( + supabase, 'user-1', 'company-1', + { transactionId: TX_ID, requestIds: [REQUEST_A, REQUEST_B, REQUEST_A] }, + log, + ) + expect(outcome).toMatchObject({ ok: true, journalEntryId: 'je-2', amount: 5000, fullyPaid: true }) + expect((outcome as { request?: unknown }).request).toBeUndefined() + expect(mockSettleSet).toHaveBeenCalledWith(supabase, 'user-1', 'company-1', { + requestIds: [REQUEST_A, REQUEST_B], + paymentDate: '2026-07-10', + amount: 5000, + bankAccount: '1930', + transactionId: TX_ID, + previousJournalEntryId: null, + }) + expect(mockSettle).not.toHaveBeenCalled() + }) + + it('passes a writer refusal through unchanged', async () => { + enqueue({ data: makeTxRow() }) + mockSettle.mockResolvedValue({ + ok: false, + kind: 'code', + code: 'ROT_RUT_SETTLE_AMOUNT_EXCEEDS', + details: { amount: 3000, expected_amount: 2500 }, + }) + const outcome = await matchTransactionToRotRutPayout( + supabase, 'user-1', 'company-1', { transactionId: TX_ID, requestIds: [REQUEST_A] }, log, + ) + expect(outcome).toMatchObject({ ok: false, code: 'ROT_RUT_SETTLE_AMOUNT_EXCEEDS' }) + }) +}) diff --git a/lib/invoices/__tests__/rot-rut-reclaim-reversal.test.ts b/lib/invoices/__tests__/rot-rut-reclaim-reversal.test.ts new file mode 100644 index 00000000..0623db26 --- /dev/null +++ b/lib/invoices/__tests__/rot-rut-reclaim-reversal.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { syncRotRutReclaimAfterReversal } from '../rot-rut-reclaim-reversal' + +const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase() +const supabase = mockSupabase as unknown as SupabaseClient + +const REQUEST_ID = '22222222-2222-4222-8222-222222222222' +const INVOICE_A = '11111111-1111-4111-8111-111111111111' +const INVOICE_B = '33333333-3333-4333-8333-333333333333' + +beforeEach(() => { + vi.clearAllMocks() + reset() +}) + +describe('syncRotRutReclaimAfterReversal', () => { + it('does nothing when the reversed voucher is not a reclaim of any begäran', async () => { + enqueue({ data: null }) + await syncRotRutReclaimAfterReversal(supabase, 'company-1', 'je-x') + expect(mockSupabase.rpc).not.toHaveBeenCalled() + expect(findCalls('rot_rut_payout_requests', 'update')).toHaveLength(0) + }) + + it('reverts every marked leg through the RPC, then frees the begäran', async () => { + enqueue({ data: { id: REQUEST_ID } }) + enqueue({ + data: [ + { id: 'i1', invoice_id: INVOICE_A, reclaimed_amount: 2500 }, + { id: 'i2', invoice_id: INVOICE_B, reclaimed_amount: null }, // never applied: skipped + ], + }) + enqueue({ data: { reverted: true, remaining_amount: 0, status: 'paid' } }) // revert i1 + enqueue({ data: null }) // request reset + + await syncRotRutReclaimAfterReversal(supabase, 'company-1', 'je-reclaim') + + expect(mockSupabase.rpc).toHaveBeenCalledTimes(1) + expect(mockSupabase.rpc).toHaveBeenCalledWith('revert_rot_rut_reclaim_invoice', { + p_item_id: 'i1', + p_invoice_id: INVOICE_A, + p_company_id: 'company-1', + }) + const requestUpdates = findCalls('rot_rut_payout_requests', 'update') + expect(requestUpdates).toHaveLength(1) + expect(requestUpdates[0][0]).toEqual({ reclaim_journal_entry_id: null, reclaimed_at: null }) + }) + + it('keeps the request link when a leg fails, so a re-run can finish', async () => { + enqueue({ data: { id: REQUEST_ID } }) + enqueue({ + data: [ + { id: 'i1', invoice_id: INVOICE_A, reclaimed_amount: 2500 }, + { id: 'i2', invoice_id: INVOICE_B, reclaimed_amount: 1000 }, + ], + }) + enqueue({ data: null, error: { message: 'deadlock detected' } }) // revert i1 fails + + await syncRotRutReclaimAfterReversal(supabase, 'company-1', 'je-reclaim') + + expect(mockSupabase.rpc).toHaveBeenCalledTimes(1) + expect(findCalls('rot_rut_payout_requests', 'update')).toHaveLength(0) + }) +}) diff --git a/lib/invoices/__tests__/rot-rut-reclaim.test.ts b/lib/invoices/__tests__/rot-rut-reclaim.test.ts new file mode 100644 index 00000000..9d02ede4 --- /dev/null +++ b/lib/invoices/__tests__/rot-rut-reclaim.test.ts @@ -0,0 +1,390 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' +import { createQueuedMockSupabase } from '@/tests/helpers' + +const mockCreateReclaimEntry = vi.fn() +vi.mock('@/lib/bookkeeping/rot-rut-entries', () => ({ + createRotRutReclaimEntry: (...args: unknown[]) => mockCreateReclaimEntry(...args), +})) + +import { computeRefusedShares, reclaimRotRutRefusal } from '../rot-rut-reclaim' + +const { supabase: mockSupabase, enqueue, reset, findCall, findCalls } = createQueuedMockSupabase() +const supabase = mockSupabase as unknown as SupabaseClient + +const REQUEST_ID = '22222222-2222-4222-8222-222222222222' +const INVOICE_A = '11111111-1111-4111-8111-111111111111' +const INVOICE_B = '33333333-3333-4333-8333-333333333333' + +function makeRequestRow(overrides: Record = {}) { + return { + id: REQUEST_ID, + name: 'RUT 2026-08', + deduction_type: 'rut', + status: 'partially_paid', + requested_total: 5000, + decided_total: 3000, + decided_at: '2026-08-20T10:00:00Z', + reclaim_journal_entry_id: null, + ...overrides, + } +} + +function makeInvoiceRow(id: string, overrides: Record = {}) { + return { + id, + invoice_number: id === INVOICE_A ? '2026-001' : '2026-002', + status: 'paid', + currency: 'SEK', + total: 10000, + paid_amount: 5000, + deduction_total: 5000, + deduction_reclaimed_total: 0, + journal_entry_id: 'je-issue', + document_type: 'invoice', + ...overrides, + } +} + +function makeItem( + id: string, + invoiceId: string, + requested: number, + decided: number | null, + invoiceOverrides: Record = {}, +) { + return { + id, + invoice_id: invoiceId, + requested_amount: requested, + decided_amount: decided, + reclaimed_amount: null, + invoice: makeInvoiceRow(invoiceId, invoiceOverrides), + } +} + +/** Queue order: request, items, sibling-request scan (empty), then the writes. */ +function enqueueOpen(request: Record, items: unknown[], siblings: unknown[] = []) { + enqueue({ data: request }) + enqueue({ data: items }) + enqueue({ data: siblings }) +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + mockCreateReclaimEntry.mockResolvedValue({ id: 'je-reclaim' }) +}) + +describe('computeRefusedShares', () => { + it('needs a recorded beslut', () => { + expect( + computeRefusedShares({ requested_total: 5000, decided_total: null, decided_at: null }, [ + { id: 'i1', invoice_id: INVOICE_A, requested_amount: 5000, decided_amount: null }, + ]), + ).toEqual({ ok: false, code: 'ROT_RUT_RECLAIM_NO_BESLUT' }) + }) + + it('refuses every item on a full avslag, even without per-item amounts', () => { + const result = computeRefusedShares( + { requested_total: 5000, decided_total: 0, decided_at: '2026-08-20' }, + [ + { id: 'i1', invoice_id: INVOICE_A, requested_amount: 3000, decided_amount: null }, + { id: 'i2', invoice_id: INVOICE_B, requested_amount: 2000, decided_amount: null }, + ], + ) + expect(result).toEqual({ + ok: true, + total: 5000, + shares: [ + { itemId: 'i1', invoiceId: INVOICE_A, refused: 3000 }, + { itemId: 'i2', invoiceId: INVOICE_B, refused: 2000 }, + ], + }) + }) + + it('uses the request total for a single-item begäran recorded without a per-item amount', () => { + const result = computeRefusedShares( + { requested_total: 5000, decided_total: 3000, decided_at: '2026-08-20' }, + [{ id: 'i1', invoice_id: INVOICE_A, requested_amount: 5000, decided_amount: null }], + ) + expect(result).toMatchObject({ ok: true, total: 2000, shares: [{ refused: 2000 }] }) + }) + + it('refuses to guess the split of a multi-item partial beslut without per-item amounts', () => { + const result = computeRefusedShares( + { requested_total: 5000, decided_total: 3000, decided_at: '2026-08-20' }, + [ + { id: 'i1', invoice_id: INVOICE_A, requested_amount: 3000, decided_amount: null }, + { id: 'i2', invoice_id: INVOICE_B, requested_amount: 2000, decided_amount: null }, + ], + ) + expect(result).toEqual({ ok: false, code: 'ROT_RUT_RECLAIM_SPLIT_UNKNOWN' }) + }) + + it('takes per-item beslut amounts when the beslutsfil recorded them', () => { + const result = computeRefusedShares( + { requested_total: 5000, decided_total: 3000, decided_at: '2026-08-20' }, + [ + { id: 'i1', invoice_id: INVOICE_A, requested_amount: 3000, decided_amount: 3000 }, + { id: 'i2', invoice_id: INVOICE_B, requested_amount: 2000, decided_amount: 0 }, + ], + ) + expect(result).toMatchObject({ + ok: true, + total: 2000, + shares: [ + { itemId: 'i1', refused: 0 }, + { itemId: 'i2', refused: 2000 }, + ], + }) + }) + + it('refuses per-item amounts that do not reconcile with the request-level beslut', () => { + // Items say 4 000 refused, the header says 2 000: an inconsistent beslut + // must not book legs for a different sum than it displays. + const result = computeRefusedShares( + { requested_total: 5000, decided_total: 3000, decided_at: '2026-08-20' }, + [ + { id: 'i1', invoice_id: INVOICE_A, requested_amount: 3000, decided_amount: 1000 }, + { id: 'i2', invoice_id: INVOICE_B, requested_amount: 2000, decided_amount: 0 }, + ], + ) + expect(result).toEqual({ ok: false, code: 'ROT_RUT_RECLAIM_SPLIT_UNKNOWN' }) + }) + + it('reports nothing refused on a fully approved beslut', () => { + const result = computeRefusedShares( + { requested_total: 5000, decided_total: 5000, decided_at: '2026-08-20' }, + [{ id: 'i1', invoice_id: INVOICE_A, requested_amount: 5000, decided_amount: 5000 }], + ) + expect(result).toMatchObject({ ok: true, total: 0 }) + }) +}) + +describe('reclaimRotRutRefusal', () => { + const params = { requestId: REQUEST_ID, bookingDate: '2026-08-21' } + + it('returns ROT_RUT_REQUEST_NOT_FOUND for an unknown request', async () => { + enqueue({ data: null }) + const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params) + expect(outcome).toEqual({ ok: false, kind: 'code', code: 'ROT_RUT_REQUEST_NOT_FOUND' }) + expect(mockCreateReclaimEntry).not.toHaveBeenCalled() + }) + + it('refuses before booking when the beslut is not recorded', async () => { + enqueue({ data: makeRequestRow({ status: 'submitted', decided_total: null, decided_at: null }) }) + enqueue({ data: [makeItem('i1', INVOICE_A, 5000, null)] }) + const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params) + expect(outcome).toMatchObject({ ok: false, code: 'ROT_RUT_RECLAIM_NO_BESLUT' }) + expect(mockCreateReclaimEntry).not.toHaveBeenCalled() + }) + + it('refuses a request whose reclaim voucher and every leg are already applied', async () => { + enqueue({ data: makeRequestRow({ reclaim_journal_entry_id: 'je-old' }) }) + enqueue({ data: [{ ...makeItem('i1', INVOICE_A, 5000, null), reclaimed_amount: 2000 }] }) + const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params) + expect(outcome).toMatchObject({ ok: false, code: 'ROT_RUT_RECLAIM_ALREADY_DONE' }) + expect(mockCreateReclaimEntry).not.toHaveBeenCalled() + }) + + it('refuses a fully approved beslut: nothing to reclaim', async () => { + enqueue({ data: makeRequestRow({ status: 'paid', decided_total: 5000 }) }) + enqueue({ data: [makeItem('i1', INVOICE_A, 5000, 5000)] }) + const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params) + expect(outcome).toMatchObject({ ok: false, code: 'ROT_RUT_RECLAIM_NOTHING_REFUSED' }) + expect(mockCreateReclaimEntry).not.toHaveBeenCalled() + }) + + it('refuses an unknown split instead of guessing it', async () => { + enqueue({ data: makeRequestRow() }) + enqueue({ data: [makeItem('i1', INVOICE_A, 3000, null), makeItem('i2', INVOICE_B, 2000, null)] }) + const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params) + expect(outcome).toMatchObject({ ok: false, code: 'ROT_RUT_RECLAIM_SPLIT_UNKNOWN' }) + expect(mockCreateReclaimEntry).not.toHaveBeenCalled() + }) + + it('refuses while an invoice is re-requested in a later live begäran', async () => { + // Avslag on A, invoice re-requested in B (submitted): the refused share + // stays at Skatteverket until B is decided. + enqueueOpen(makeRequestRow({ status: 'rejected', decided_total: 0 }), [makeItem('i1', INVOICE_A, 5000, null)], [ + { invoice_id: INVOICE_A, request: { id: 'req-b', status: 'submitted', company_id: 'company-1' } }, + ]) + const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params) + expect(outcome).toMatchObject({ + ok: false, + code: 'ROT_RUT_RECLAIM_INVOICE_REREQUESTED', + details: { invoice_ids: [INVOICE_A] }, + }) + expect(findCall('rot_rut_payout_request_items', 'neq')).toEqual(['request_id', REQUEST_ID]) + expect(mockCreateReclaimEntry).not.toHaveBeenCalled() + }) + + it('refuses an invoice without a verifikat: no 1513 debit exists to move', async () => { + enqueueOpen(makeRequestRow(), [makeItem('i1', INVOICE_A, 5000, null, { journal_entry_id: null })]) + const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params) + expect(outcome).toMatchObject({ ok: false, code: 'ROT_RUT_RECLAIM_INVOICE_NOT_BOOKED' }) + expect(mockCreateReclaimEntry).not.toHaveBeenCalled() + }) + + it('refuses a credited or cancelled invoice and a non-SEK invoice', async () => { + enqueueOpen(makeRequestRow(), [makeItem('i1', INVOICE_A, 5000, null, { status: 'credited' })]) + const credited = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params) + expect(credited).toMatchObject({ ok: false, code: 'ROT_RUT_RECLAIM_INVOICE_NOT_OPEN' }) + + reset() + enqueueOpen(makeRequestRow(), [makeItem('i1', INVOICE_A, 5000, null, { currency: 'EUR' })]) + const foreign = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params) + expect(foreign).toMatchObject({ ok: false, code: 'ROT_RUT_RECLAIM_CURRENCY' }) + expect(mockCreateReclaimEntry).not.toHaveBeenCalled() + }) + + it('books one voucher and reopens every invoice for its refused share', async () => { + // Two invoices, beslutsfil split: A fully approved (3 000), B refused (2 000). + enqueueOpen(makeRequestRow(), [ + makeItem('i1', INVOICE_A, 3000, 3000, { total: 6000, paid_amount: 3000, deduction_total: 3000 }), + makeItem('i2', INVOICE_B, 2000, 0, { total: 4000, paid_amount: 2000, deduction_total: 2000 }), + ]) + enqueue({ data: { id: REQUEST_ID } }) // request CAS attach + enqueue({ data: { applied: true, remaining_amount: 2000, status: 'partially_paid' } }) // RPC for B + + const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params) + expect(outcome).toEqual({ + ok: true, + journalEntryId: 'je-reclaim', + reclaimedTotal: 2000, + invoices: [ + { + invoice_id: INVOICE_B, + invoice_number: '2026-002', + reclaimed_amount: 2000, + remaining_amount: 2000, + status: 'partially_paid', + }, + ], + }) + + // Only the refused invoice gets a leg. + expect(mockCreateReclaimEntry).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', { + requestId: REQUEST_ID, + requestName: 'RUT 2026-08', + deductionType: 'rut', + bookingDate: '2026-08-21', + legs: [{ invoiceId: INVOICE_B, invoiceNumber: '2026-002', amount: 2000 }], + }) + + // Request CAS on reclaim_journal_entry_id IS NULL. + const requestUpdate = findCall('rot_rut_payout_requests', 'update')?.[0] as Record + expect(requestUpdate).toMatchObject({ reclaim_journal_entry_id: 'je-reclaim' }) + expect(findCall('rot_rut_payout_requests', 'is')).toEqual(['reclaim_journal_entry_id', null]) + + // Invoice reopened through the atomic RPC: the database validates the + // amount and derives remaining/status; the caller names the share only. + expect(mockSupabase.rpc).toHaveBeenCalledTimes(1) + expect(mockSupabase.rpc).toHaveBeenCalledWith('apply_rot_rut_reclaim_invoice', { + p_item_id: 'i2', + p_invoice_id: INVOICE_B, + p_company_id: 'company-1', + p_reclaimed_amount: 2000, + }) + expect(findCalls('invoices', 'update')).toHaveLength(0) + }) + + it('resumes a reclaim whose voucher exists but whose invoice legs were not applied', async () => { + // Voucher + marker on the request landed, then the RPC failed for B. + // The second call books nothing, skips the CAS, and applies B only. + // Full avslag on both invoices: A's leg applied (marker 3 000), B's not. + enqueueOpen(makeRequestRow({ reclaim_journal_entry_id: 'je-reclaim', status: 'rejected', decided_total: 0 }), [ + { ...makeItem('i1', INVOICE_A, 3000, null, { total: 6000, paid_amount: 3000, deduction_total: 3000 }), reclaimed_amount: 3000 }, + makeItem('i2', INVOICE_B, 2000, null, { total: 4000, paid_amount: 2000, deduction_total: 2000 }), + ]) + enqueue({ data: { applied: true, remaining_amount: 2000, status: 'partially_paid' } }) // apply for B + + const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params) + expect(outcome).toMatchObject({ + ok: true, + journalEntryId: 'je-reclaim', + reclaimedTotal: 2000, + invoices: [{ invoice_id: INVOICE_B, reclaimed_amount: 2000, remaining_amount: 2000, status: 'partially_paid' }], + }) + expect(mockCreateReclaimEntry).not.toHaveBeenCalled() + expect(findCalls('rot_rut_payout_requests', 'update')).toHaveLength(0) + expect(mockSupabase.rpc).toHaveBeenCalledTimes(1) + expect(mockSupabase.rpc).toHaveBeenCalledWith('apply_rot_rut_reclaim_invoice', expect.objectContaining({ p_item_id: 'i2' })) + }) + + it('reports ROT_RUT_RECLAIM_ALREADY_DONE once every leg carries its marker', async () => { + enqueueOpen(makeRequestRow({ reclaim_journal_entry_id: 'je-reclaim' }), [ + { ...makeItem('i1', INVOICE_A, 5000, null), reclaimed_amount: 2000 }, + ]) + const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params) + expect(outcome).toMatchObject({ + ok: false, + code: 'ROT_RUT_RECLAIM_ALREADY_DONE', + details: { journal_entry_id: 'je-reclaim' }, + }) + expect(mockCreateReclaimEntry).not.toHaveBeenCalled() + expect(mockSupabase.rpc).not.toHaveBeenCalled() + }) + + it('surfaces a failed invoice leg as an update-stage error and leaves the voucher standing', async () => { + enqueueOpen(makeRequestRow(), [makeItem('i1', INVOICE_A, 5000, null)]) + enqueue({ data: { id: REQUEST_ID } }) + enqueue({ data: null, error: { message: 'deadlock detected' } }) + const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params) + expect(outcome).toMatchObject({ ok: false, kind: 'error', stage: 'update' }) + expect(mockCreateReclaimEntry).toHaveBeenCalledTimes(1) + }) + + it('reopens a full avslag on a never-paid customer share as sent', async () => { + enqueueOpen(makeRequestRow({ status: 'rejected', decided_total: 0 }), [ + makeItem('i1', INVOICE_A, 5000, null, { status: 'sent', paid_amount: 0 }), + ]) + enqueue({ data: { id: REQUEST_ID } }) + enqueue({ data: { applied: true, remaining_amount: 10000, status: 'sent' } }) + + const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params) + expect(outcome).toMatchObject({ + ok: true, + reclaimedTotal: 5000, + invoices: [{ invoice_id: INVOICE_A, reclaimed_amount: 5000, remaining_amount: 10000, status: 'sent' }], + }) + }) + + it('reports what the RPC derived for the reopened invoice, never its own arithmetic', async () => { + // The database derives remaining/status (NULL paid_amount on a paid + // invoice counts the customer share as paid there); the service relays it. + enqueueOpen(makeRequestRow(), [ + makeItem('i1', INVOICE_A, 5000, null, { status: 'paid', paid_amount: null }), + ]) + enqueue({ data: { id: REQUEST_ID } }) + enqueue({ data: { applied: true, remaining_amount: '2000.00', status: 'partially_paid' } }) + + const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params) + expect(outcome).toMatchObject({ + ok: true, + reclaimedTotal: 2000, + invoices: [{ invoice_id: INVOICE_A, reclaimed_amount: 2000, remaining_amount: 2000, status: 'partially_paid' }], + }) + }) + + it('reports a lost CAS as ROT_RUT_RECLAIM_RACE and never unbooks', async () => { + enqueueOpen(makeRequestRow(), [makeItem('i1', INVOICE_A, 5000, null)]) + enqueue({ data: null }) // CAS lost: 0 rows + const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params) + expect(outcome).toMatchObject({ + ok: false, + code: 'ROT_RUT_RECLAIM_RACE', + details: { journal_entry_id: 'je-reclaim', request_id: REQUEST_ID }, + }) + expect(findCalls('invoices', 'update')).toHaveLength(0) + }) + + it('surfaces an engine failure as a book-stage error without touching any row', async () => { + enqueueOpen(makeRequestRow(), [makeItem('i1', INVOICE_A, 5000, null)]) + mockCreateReclaimEntry.mockRejectedValue(new Error('Bokföringen är låst')) + const outcome = await reclaimRotRutRefusal(supabase, 'user-1', 'company-1', params) + expect(outcome).toMatchObject({ ok: false, kind: 'error', stage: 'book' }) + expect(findCalls('rot_rut_payout_requests', 'update')).toHaveLength(0) + }) +}) diff --git a/lib/invoices/customer-share.ts b/lib/invoices/customer-share.ts index e0d9f374..3e37fd5a 100644 --- a/lib/invoices/customer-share.ts +++ b/lib/invoices/customer-share.ts @@ -12,8 +12,14 @@ * drifted (#2248: the kontantmetod cut-off compared payments against the * gross total). It now lives here and in exactly one SQL twin: * - * invoices_remaining_amount_guard (migration 20260817191708) - * remaining_amount = GREATEST(0, ROUND(total - paid_amount - deduction_total, 2)) + * invoices_derive_remaining_amount (migrations 20260817191708, 20260907160000) + * remaining_amount = GREATEST(0, ROUND(total - paid_amount - deduction_total + * + deduction_reclaimed_total, 2)) + * + * deduction_reclaimed_total is the part of the deduction Skatteverket refused + * and that a rot_rut_reclaim voucher moved back onto the customer (debit 1510 + * / credit 1513): from then on it IS the customer's to pay, while the invoice + * document keeps the deduction it was issued with. * * Change both or neither. The guard floors at zero because it persists the * column; the functions here return the signed value and each writer applies @@ -32,10 +38,18 @@ export interface CustomerShareInvoice { * Null, undefined and 0 all mean "no deduction". */ deduction_total?: number | null + /** + * The part of the deduction Skatteverket refused and that was booked back + * onto the customer (rot_rut_reclaim). Positive magnitude, never above the + * deduction (CHECK invoices_deduction_reclaimed_total_check). Null, + * undefined and 0 all mean "nothing reclaimed". + */ + deduction_reclaimed_total?: number | null } /** - * What the customer owes on the invoice: total minus the ROT/RUT deduction. + * What the customer owes on the invoice: total minus the ROT/RUT deduction, + * plus whatever part of that deduction Skatteverket later refused. * * The deduction follows the sign of the total, so a credited ROT invoice * (total -25 000, deduction_total 7 500) owes the customer -17 500 back and @@ -47,7 +61,9 @@ export function invoiceCustomerShare(invoice: CustomerShareInvoice): number { // `!(x > 0)` also catches NaN: a non-numeric deduction reads as none rather // than poisoning every downstream amount. if (!(deduction > 0)) return invoice.total - return roundOre(invoice.total - Math.sign(invoice.total) * deduction) + const reclaimedRaw = Math.abs(invoice.deduction_reclaimed_total ?? 0) + const reclaimed = reclaimedRaw > 0 ? Math.min(reclaimedRaw, deduction) : 0 + return roundOre(invoice.total - Math.sign(invoice.total) * (deduction - reclaimed)) } /** diff --git a/lib/invoices/invoice-register-coverage.ts b/lib/invoices/invoice-register-coverage.ts index 78d454bd..2e02adec 100644 --- a/lib/invoices/invoice-register-coverage.ts +++ b/lib/invoices/invoice-register-coverage.ts @@ -67,6 +67,7 @@ export const INVOICE_ENGINE_SOURCE_TYPES = [ 'credit_note', 'reminder_fee', 'rot_rut_payout', + 'rot_rut_reclaim', 'storno', 'correction', ] as const diff --git a/lib/invoices/rot-rut-file.ts b/lib/invoices/rot-rut-file.ts index 66f77ff4..58a8c372 100644 --- a/lib/invoices/rot-rut-file.ts +++ b/lib/invoices/rot-rut-file.ts @@ -104,6 +104,7 @@ export type RotRutBlockerCode = | 'PRICE_BELOW_MINIMUM' | 'DEDUCTION_EXCEEDS_PAYMENT' | 'ZERO_DEDUCTION' + | 'DEDUCTION_RECLAIMED' export interface RotRutBlocker { invoice_id: string @@ -188,6 +189,19 @@ export function evaluateInvoiceForFile( const items = invoice.items ?? [] const typeLines = items.filter((i) => isDeductionLine(i, type)) const otherType: DeductionType = type === 'rot' ? 'rut' : 'rot' + + // Skatteverket refused (part of) this invoice's deduction and the refused + // share was booked back onto the customer (rot_rut_reclaim). The buyer now + // pays it, so a new begäran for the same kronor would claim from + // Skatteverket what the customer already owes: a double collection. The + // invoice becomes requestable again only when the reclaim voucher is + // reversed (syncRotRutReclaimAfterReversal clears the column). + if ((invoice.deduction_reclaimed_total ?? 0) > 0) { + return block( + 'DEDUCTION_RECLAIMED', + 'Skatteverket har nekat avdraget och det nekade beloppet är bokfört som kundfordran. Fakturan kan inte begäras igen så länge den bokningen står.', + ) + } const otherLines = items.filter((i) => isDeductionLine(i, otherType)) if (typeLines.length === 0) { diff --git a/lib/invoices/rot-rut-match-transaction.ts b/lib/invoices/rot-rut-match-transaction.ts new file mode 100644 index 00000000..96346dca --- /dev/null +++ b/lib/invoices/rot-rut-match-transaction.ts @@ -0,0 +1,173 @@ +/** + * Match an income bank row to the ROT/RUT begäran Skatteverket paid with it. + * + * One writer for two callers: the dashboard route + * POST /api/transactions/[id]/match-rot-rut-payout and the MCP commit + * executor for settle_rot_rut_payout (gnubok_settle_rot_rut_payout). Both + * used to need the same pre-flight (income, SEK, not already booked, the + * row's own cash account) before the settle service; keeping it here means a + * guard added for one surface is a guard on the other. + * + * Amount, date and bank account come from the bank row and the row is linked + * to the voucher in the same call, so the payout can never be booked twice + * (once by settle, once by categorising the bank row). Several begäran in + * one transfer (#2239) book ONE voucher with one 1513 credit per begäran. + */ +import type { SupabaseClient } from '@supabase/supabase-js' +import type { Logger } from '@/lib/logger' +import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account' +import { + settleRotRutPayoutRequest, + settleRotRutPayoutRequestSet, + type SettleRotRutPayoutErrorCode, + type SettledRotRutPayoutRequest, +} from '@/lib/invoices/rot-rut-settle' +import { hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry' +import { hasBankLineJunctionRow } from '@/lib/transactions/is-booked' + +export type MatchRotRutPayoutErrorCode = + | SettleRotRutPayoutErrorCode + | 'TX_CATEGORIZE_TX_NOT_FOUND' + | 'ROT_RUT_MATCH_NOT_INCOME' + | 'ROT_RUT_MATCH_CURRENCY' + | 'ROT_RUT_MATCH_TX_ALREADY_LINKED' + +export type MatchRotRutPayoutOutcome = + | { + ok: true + journalEntryId: string + amount: number + fullyPaid: boolean + /** Every begäran the voucher settled (one for the single shape). */ + requests: SettledRotRutPayoutRequest[] + /** The single begäran, when exactly one was matched. */ + request?: SettledRotRutPayoutRequest + } + | { ok: false; kind: 'code'; code: MatchRotRutPayoutErrorCode; details?: Record } + /** At stage 'update' the voucher is posted: journalEntryId names it (see the settle service). */ + | { ok: false; kind: 'error'; error: unknown; stage: 'fetch' | 'book' | 'update'; journalEntryId?: string } + +export interface MatchRotRutPayoutParams { + transactionId: string + /** 1..n begäran; duplicates collapse. */ + requestIds: string[] +} + +export async function matchTransactionToRotRutPayout( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: MatchRotRutPayoutParams, + log: Logger, +): Promise { + const payoutRequestIds = [...new Set(params.requestIds)] + if (payoutRequestIds.length === 0) { + return { ok: false, kind: 'code', code: 'ROT_RUT_REQUEST_NOT_FOUND' } + } + + // transaction_voucher_links rides along: a row bulk-booked into a + // samlingsverifikat carries journal_entry_id = NULL and must still refuse. + const { data: transactionRow, error: fetchTxError } = await supabase + .from('transactions') + .select( + 'id, date, amount, currency, journal_entry_id, cash_account_id, transaction_voucher_links(journal_entry_id, role)', + ) + .eq('id', params.transactionId) + .eq('company_id', companyId) + .single() + + if (fetchTxError || !transactionRow) { + return { ok: false, kind: 'code', code: 'TX_CATEGORIZE_TX_NOT_FOUND' } + } + const { transaction_voucher_links: junctionLinks, ...transaction } = transactionRow as { + id: string + date: string + amount: number + currency: string | null + journal_entry_id: string | null + cash_account_id: string | null + transaction_voucher_links?: Array<{ journal_entry_id: string; role?: string | null }> | null + } + + if (!(transaction.amount > 0)) { + return { + ok: false, + kind: 'code', + code: 'ROT_RUT_MATCH_NOT_INCOME', + details: { amount: transaction.amount }, + } + } + + if ((transaction.currency || 'SEK').toUpperCase() !== 'SEK') { + return { + ok: false, + kind: 'code', + code: 'ROT_RUT_MATCH_CURRENCY', + details: { currency: transaction.currency }, + } + } + + // Only a LIVE (posted) pointer or a bank_line junction row blocks: a + // pointer left behind by a storno reads as "utan koppling" in the UI and + // must stay matchable (same predicate as link-journal-entry, issue #988). + if ( + hasBankLineJunctionRow(junctionLinks) || + (await hasLiveJournalEntryLink(supabase, companyId, transaction.journal_entry_id)) + ) { + return { + ok: false, + kind: 'code', + code: 'ROT_RUT_MATCH_TX_ALREADY_LINKED', + details: { existingJournalEntryId: transaction.journal_entry_id }, + } + } + + // Debit the cash account THIS transaction belongs to, never a company-wide + // default (mirrors match-supplier-invoice). + const bankAccount = await resolveSettlementAccount( + supabase, + companyId, + transaction.cash_account_id, + log, + ) + + // Shared by both shapes: amount, date and account come from the bank row; + // the link CAS locks on the pointer read above (null for a free row, or + // the stale pointer of a reversed entry the guard let through). + const settleParams = { + paymentDate: transaction.date, + amount: transaction.amount, + bankAccount, + transactionId: params.transactionId, + previousJournalEntryId: transaction.journal_entry_id, + } + + if (payoutRequestIds.length === 1) { + const outcome = await settleRotRutPayoutRequest(supabase, userId, companyId, { + requestId: payoutRequestIds[0], + ...settleParams, + }) + if (!outcome.ok) return outcome + return { + ok: true, + journalEntryId: outcome.journalEntryId, + amount: outcome.amount, + fullyPaid: outcome.fullyPaid, + requests: [outcome.request], + request: outcome.request, + } + } + + const outcome = await settleRotRutPayoutRequestSet(supabase, userId, companyId, { + requestIds: payoutRequestIds, + ...settleParams, + }) + if (!outcome.ok) return outcome + return { + ok: true, + journalEntryId: outcome.journalEntryId, + amount: outcome.amount, + fullyPaid: true, + requests: outcome.requests, + } +} diff --git a/lib/invoices/rot-rut-reclaim-reversal.ts b/lib/invoices/rot-rut-reclaim-reversal.ts new file mode 100644 index 00000000..49a383b2 --- /dev/null +++ b/lib/invoices/rot-rut-reclaim-reversal.ts @@ -0,0 +1,105 @@ +/** + * Business-state mirror of a reversed rot_rut_reclaim voucher. + * + * The reclaim (lib/invoices/rot-rut-reclaim.ts) moves Skatteverkets refused + * share from 1513 onto the customer (1510) and reopens the invoices for it. + * When that voucher is reversed (booked on the wrong date, or Skatteverket + * granted the share on omprövning), the GL is restored by the storno but the + * invoice rows and the begäran would still say "reclaimed": the invoice open + * for a share nobody owes, the begäran unreclaimable forever + * (ROT_RUT_RECLAIM_ALREADY_DONE), the file blocker DEDUCTION_RECLAIMED stuck. + * Called from reverseEntry() next to the payment sync, and kept in its own + * module so the engine can import it without a cycle (the reclaim service + * imports the entries builder, which imports the engine). + * + * Every leg is one atomic, idempotent RPC (revert_rot_rut_reclaim_invoice): + * the item marker (reclaimed_amount) is the amount handed back and is + * cleared in the same transaction as the invoice row, so a re-run after a + * failure reverts only the legs still carrying a marker. The request's + * reclaim_journal_entry_id is cleared LAST and only when every leg + * succeeded, so a later run can still find the begäran by the reversed + * voucher and finish the job. + * + * A customer payment that already covered the reclaimed share is NOT undone + * (that voucher stands): the invoice then reads paid with an over-collected + * 1510, which is the honest state of the ledger after that sequence. + */ +import type { SupabaseClient } from '@supabase/supabase-js' +import { createLogger } from '@/lib/logger' + +const log = createLogger('invoices/rot-rut-reclaim-reversal') + +interface ReclaimedItemRow { + id: string + invoice_id: string + reclaimed_amount: number | string | null +} + +export async function syncRotRutReclaimAfterReversal( + supabase: SupabaseClient, + companyId: string, + reclaimJournalEntryId: string, +): Promise { + const { data: request, error: requestError } = await supabase + .from('rot_rut_payout_requests') + .select('id') + .eq('company_id', companyId) + .eq('reclaim_journal_entry_id', reclaimJournalEntryId) + .maybeSingle() + if (requestError) { + log.error('reclaim reversal: failed to find the begäran', requestError as Error, { + reclaimJournalEntryId, + }) + return + } + if (!request) return + + const { data: itemRows, error: itemsError } = await supabase + .from('rot_rut_payout_request_items') + .select('id, invoice_id, reclaimed_amount') + .eq('request_id', request.id) + if (itemsError) { + log.error('reclaim reversal: failed to load items', itemsError as Error, { + payoutRequestId: request.id, + }) + return + } + + for (const item of (itemRows ?? []) as ReclaimedItemRow[]) { + if (item.reclaimed_amount == null) continue + const { error: revertError } = await supabase.rpc('revert_rot_rut_reclaim_invoice', { + p_item_id: item.id, + p_invoice_id: item.invoice_id, + p_company_id: companyId, + }) + if (revertError) { + // Keep the request link: the next reversal sync (or a support re-run) + // finds the begäran by the reversed voucher and completes this leg. + log.error('reclaim reversal: invoice leg failed, request link kept for retry', revertError as Error, { + invoiceId: item.invoice_id, + itemId: item.id, + payoutRequestId: request.id, + reclaimJournalEntryId, + }) + return + } + } + + const { error: requestUpdateError } = await supabase + .from('rot_rut_payout_requests') + .update({ reclaim_journal_entry_id: null, reclaimed_at: null }) + .eq('company_id', companyId) + .eq('id', request.id) + .eq('reclaim_journal_entry_id', reclaimJournalEntryId) + if (requestUpdateError) { + log.error('reclaim reversal: request reset failed', requestUpdateError as Error, { + payoutRequestId: request.id, + }) + return + } + + log.info('rot/rut reclaim reversed: invoices closed again', { + payoutRequestId: request.id, + reclaimJournalEntryId, + }) +} diff --git a/lib/invoices/rot-rut-reclaim.ts b/lib/invoices/rot-rut-reclaim.ts new file mode 100644 index 00000000..8b4b4738 --- /dev/null +++ b/lib/invoices/rot-rut-reclaim.ts @@ -0,0 +1,472 @@ +/** + * Reclaim the share of a ROT/RUT begäran that Skatteverket refused. + * + * Under fakturamodellen the deduction is a fordran on Skatteverket (1513) + * from the day the invoice is issued. A beslut that refuses an ärende, fully + * (avslag) or in part, does not make that fordran disappear: the buyer owes + * the refused share (HUSFL 2009:194; swedish-invoice-compliance section 8: + * "SKV denies: Debit 1510, Credit 1513, re-invoice customer"). Before this + * service the request was only marked rejected / partially_paid and the + * refused kronor stayed on 1513 while the invoice read as paid. + * + * What one call does, for one begäran: + * 1. Works out the refused share per invoice from the recorded beslut: + * requested_amount minus decided_amount per item. A full avslag + * (decided_total 0) refuses every item; a single-item begäran needs no + * per-item split; a multi-item begäran whose beslut was recorded as one + * total (PATCH partially_paid) is refused with SPLIT_UNKNOWN: the split + * is Skatteverket's, never guessed (import the beslutsfil). + * 2. Books ONE voucher: debit 1510 / credit 1513 per invoice + * (createRotRutReclaimEntry, source_type rot_rut_reclaim). + * 3. CAS-attaches it to the request (reclaim_journal_entry_id IS NULL), so + * two concurrent reclaims cannot both stand; the partial unique index + * journal_entries_rot_rut_reclaim_live_unique backs this at the journal. + * 4. Reopens every invoice for its refused share: deduction_reclaimed_total + * grows, remaining_amount and status are derived INSIDE the RPC + * apply_rot_rut_reclaim_invoice from the same formula as the SQL INSERT + * guard (the database owns the accounting values; the caller only names + * the refused share, which the RPC validates against the locked item, + * request and invoice rows). Status goes back to partially_paid (the + * customer already paid their share) or sent/overdue. + * + * Refusals before any write: no beslut, nothing refused, already reclaimed, + * unknown split, an invoice without a verifikat (no 1513 debit exists to + * move: kontantmetod invoice never paid, or deferred booking), a cancelled or + * credited invoice, a non-SEK invoice (1513 is a kronor account). + * + * The invoice document is left as issued: the customer got an invoice with + * a deduction on it, and that stays true; what changes is who owes the + * refused share. A separate follow-up (påminnelse) tells the customer. + * + * The voucher IS the accounting record: engine failure blocks the whole + * operation. Everything after the voucher is best-effort-with-loud-logging, + * never an unbook (the voucher is immutable per BFL). + */ +import type { SupabaseClient } from '@supabase/supabase-js' +import { createRotRutReclaimEntry, type RotRutReclaimLeg } from '@/lib/bookkeeping/rot-rut-entries' +import { roundOre } from '@/lib/money' +import { createLogger } from '@/lib/logger' + +const log = createLogger('invoices/rot-rut-reclaim') + +export interface ReclaimRotRutRefusalParams { + requestId: string + /** Entry date of the reclaim voucher (the beslut date, or today). */ + bookingDate: string +} + +export type ReclaimRotRutErrorCode = + | 'ROT_RUT_REQUEST_NOT_FOUND' + | 'ROT_RUT_SETTLE_INVALID_STATE' + | 'ROT_RUT_RECLAIM_NO_BESLUT' + | 'ROT_RUT_RECLAIM_NOTHING_REFUSED' + | 'ROT_RUT_RECLAIM_ALREADY_DONE' + | 'ROT_RUT_RECLAIM_SPLIT_UNKNOWN' + | 'ROT_RUT_RECLAIM_INVOICE_NOT_BOOKED' + | 'ROT_RUT_RECLAIM_INVOICE_NOT_OPEN' + | 'ROT_RUT_RECLAIM_CURRENCY' + | 'ROT_RUT_RECLAIM_INVOICE_REREQUESTED' + | 'ROT_RUT_RECLAIM_RACE' + +export interface ReclaimedInvoice { + invoice_id: string + invoice_number: string | null + reclaimed_amount: number + remaining_amount: number + status: string +} + +export type ReclaimRotRutRefusalOutcome = + | { + ok: true + journalEntryId: string + reclaimedTotal: number + invoices: ReclaimedInvoice[] + } + | { ok: false; kind: 'code'; code: ReclaimRotRutErrorCode; details?: Record } + | { ok: false; kind: 'error'; error: unknown; stage: 'fetch' | 'book' | 'update' } + +/** Invoice statuses a refused share can reopen. */ +const REOPENABLE_INVOICE_STATUSES = ['paid', 'partially_paid', 'sent', 'overdue'] as const + +interface RequestRow { + id: string + name: string + deduction_type: 'rot' | 'rut' + status: string + requested_total: number | string + decided_total: number | string | null + decided_at: string | null + reclaim_journal_entry_id: string | null +} + +interface InvoiceRow { + id: string + invoice_number: string | null + status: string + currency: string | null + total: number | string + paid_amount: number | string | null + deduction_total: number | string | null + deduction_reclaimed_total: number | string | null + journal_entry_id: string | null + document_type: string | null +} + +interface ItemRow { + id: string + invoice_id: string + requested_amount: number | string + decided_amount: number | string | null + reclaimed_amount: number | string | null + invoice: InvoiceRow | null +} + +export interface RefusedShare { + itemId: string + invoiceId: string + refused: number +} + +/** + * Pure: the refused share per item from the recorded beslut, or the reason + * it cannot be known. Exported for the overview page, which shows "nekat att + * bokföra" per request without booking anything. + */ +export function computeRefusedShares( + request: Pick, + items: Array>, +): + | { ok: true; shares: RefusedShare[]; total: number } + | { ok: false; code: 'ROT_RUT_RECLAIM_NO_BESLUT' | 'ROT_RUT_RECLAIM_SPLIT_UNKNOWN' } { + if (!request.decided_at || request.decided_total == null) { + return { ok: false, code: 'ROT_RUT_RECLAIM_NO_BESLUT' } + } + const decidedTotal = roundOre(Number(request.decided_total)) + const requestedTotal = roundOre(Number(request.requested_total)) + const fullRefusal = decidedTotal <= 0 + + const shares: RefusedShare[] = [] + for (const item of items) { + const requested = roundOre(Number(item.requested_amount)) + let decided: number + if (fullRefusal) { + decided = 0 + } else if (item.decided_amount != null) { + decided = roundOre(Number(item.decided_amount)) + } else if (items.length === 1) { + // One ärende: the request total IS the item's beslut. + decided = decidedTotal + } else { + return { ok: false, code: 'ROT_RUT_RECLAIM_SPLIT_UNKNOWN' } + } + const refused = roundOre(Math.max(0, Math.min(requested, requested - decided))) + shares.push({ itemId: item.id, invoiceId: item.invoice_id, refused }) + } + // The per-item shares ARE what gets booked, so they must reconcile with the + // request-level beslut to the öre. Items decided at less than the header + // says (or more) mean the recorded beslut is inconsistent: refuse, never + // book legs that sum to something else than the refused total. + const total = roundOre(shares.reduce((sum, s) => sum + s.refused, 0)) + const refusedByHeader = roundOre(Math.max(0, requestedTotal - decidedTotal)) + if (Math.abs(total - refusedByHeader) > 0.005) { + return { ok: false, code: 'ROT_RUT_RECLAIM_SPLIT_UNKNOWN' } + } + return { ok: true, shares, total } +} + +/** + * Invoices of this begäran that sit in ANOTHER begäran Skatteverket has not + * refused or the company has not cancelled. Avslag → new file is the normal + * retry (payout-requests/[id]/route.ts): while the invoice is re-requested, + * the refused share is being reviewed again and must not be booked onto the + * customer (skeptic #2397 C2). Returns the invoice ids, or the raw error. + */ +export async function findRerequestedInvoiceIds( + supabase: SupabaseClient, + companyId: string, + requestId: string, + invoiceIds: string[], +): Promise<{ ids: string[]; error: unknown }> { + if (invoiceIds.length === 0) return { ids: [], error: null } + const { data, error } = await supabase + .from('rot_rut_payout_request_items') + .select('invoice_id, request:rot_rut_payout_requests!inner(id, status, company_id)') + .eq('request.company_id', companyId) + .neq('request_id', requestId) + .in('invoice_id', invoiceIds) + .not('request.status', 'in', '("cancelled","rejected")') + if (error) return { ids: [], error } + const ids = [...new Set(((data ?? []) as Array<{ invoice_id: string }>).map((row) => row.invoice_id))] + return { ids, error: null } +} + +export async function reclaimRotRutRefusal( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: ReclaimRotRutRefusalParams, +): Promise { + const { data: request, error: fetchError } = await supabase + .from('rot_rut_payout_requests') + .select( + 'id, name, deduction_type, status, requested_total, decided_total, decided_at, reclaim_journal_entry_id', + ) + .eq('company_id', companyId) + .eq('id', params.requestId) + .maybeSingle() + if (fetchError) return { ok: false, kind: 'error', error: fetchError, stage: 'fetch' } + if (!request) return { ok: false, kind: 'code', code: 'ROT_RUT_REQUEST_NOT_FOUND' } + const req = request as RequestRow + + if (req.status === 'cancelled') { + return { + ok: false, + kind: 'code', + code: 'ROT_RUT_SETTLE_INVALID_STATE', + details: { status: req.status, reason: 'En avbruten begäran har inget beslut att bokföra.' }, + } + } + // A begäran that already carries its reclaim voucher is not refused + // outright: a failure between the voucher and the per-invoice writes leaves + // legs whose item marker (reclaimed_amount) is still NULL, and re-running + // completes exactly those legs through the idempotent RPC (resume mode). + // Only when every leg is applied is the call ROT_RUT_RECLAIM_ALREADY_DONE. + const resumeJournalEntryId = req.reclaim_journal_entry_id + + const { data: itemRows, error: itemsError } = await supabase + .from('rot_rut_payout_request_items') + .select( + 'id, invoice_id, requested_amount, decided_amount, reclaimed_amount, invoice:invoices(id, invoice_number, status, currency, total, paid_amount, deduction_total, deduction_reclaimed_total, journal_entry_id, document_type)', + ) + .eq('request_id', req.id) + if (itemsError) return { ok: false, kind: 'error', error: itemsError, stage: 'fetch' } + const items = (itemRows ?? []) as unknown as ItemRow[] + + const computed = computeRefusedShares(req, items) + if (!computed.ok) { + return { ok: false, kind: 'code', code: computed.code, details: { request_id: req.id } } + } + const refusedShares = computed.shares.filter((share) => share.refused > 0) + if (refusedShares.length === 0 || computed.total <= 0) { + return { + ok: false, + kind: 'code', + code: 'ROT_RUT_RECLAIM_NOTHING_REFUSED', + details: { requested_total: Number(req.requested_total), decided_total: Number(req.decided_total) }, + } + } + // Resume mode: only the legs whose marker is still unset are pending. + const shares = resumeJournalEntryId + ? refusedShares.filter((share) => { + const item = items.find((row) => row.id === share.itemId) + return item != null && item.reclaimed_amount == null + }) + : refusedShares + if (resumeJournalEntryId && shares.length === 0) { + return { + ok: false, + kind: 'code', + code: 'ROT_RUT_RECLAIM_ALREADY_DONE', + details: { journal_entry_id: resumeJournalEntryId }, + } + } + + // An invoice Skatteverket is reviewing again (a later, live begäran) keeps + // its refused share on 1513 until that beslut lands. + const rerequested = await findRerequestedInvoiceIds( + supabase, + companyId, + req.id, + shares.map((share) => share.invoiceId), + ) + if (rerequested.error) { + return { ok: false, kind: 'error', error: rerequested.error, stage: 'fetch' } + } + if (rerequested.ids.length > 0) { + return { + ok: false, + kind: 'code', + code: 'ROT_RUT_RECLAIM_INVOICE_REREQUESTED', + details: { invoice_ids: rerequested.ids }, + } + } + + // Every invoice must be able to carry the fordran before anything books. + const legs: Array = [] + for (const share of shares) { + const item = items.find((row) => row.id === share.itemId)! + const invoice = item.invoice + if (!invoice) { + return { + ok: false, + kind: 'code', + code: 'ROT_RUT_RECLAIM_INVOICE_NOT_OPEN', + details: { invoice_id: share.invoiceId, reason: 'invoice row missing' }, + } + } + if ((invoice.currency || 'SEK').toUpperCase() !== 'SEK') { + return { + ok: false, + kind: 'code', + code: 'ROT_RUT_RECLAIM_CURRENCY', + details: { invoice_id: invoice.id, currency: invoice.currency }, + } + } + if (!(REOPENABLE_INVOICE_STATUSES as readonly string[]).includes(invoice.status)) { + return { + ok: false, + kind: 'code', + code: 'ROT_RUT_RECLAIM_INVOICE_NOT_OPEN', + details: { invoice_id: invoice.id, status: invoice.status }, + } + } + // No verifikat means no 1513 debit exists for this invoice: nothing to + // move (kontantmetod invoice never paid, deferred booking not yet booked). + if (!invoice.journal_entry_id) { + return { + ok: false, + kind: 'code', + code: 'ROT_RUT_RECLAIM_INVOICE_NOT_BOOKED', + details: { invoice_id: invoice.id }, + } + } + const deduction = roundOre(Number(invoice.deduction_total ?? 0)) + const alreadyReclaimed = roundOre(Number(invoice.deduction_reclaimed_total ?? 0)) + const headroom = roundOre(deduction - alreadyReclaimed) + if (share.refused > headroom + 0.005) { + // The beslut refuses more than the invoice ever carried on 1513: a data + // inconsistency, not something to book around. + return { + ok: false, + kind: 'code', + code: 'ROT_RUT_RECLAIM_NOTHING_REFUSED', + details: { + invoice_id: invoice.id, + refused: share.refused, + deduction_total: deduction, + deduction_reclaimed_total: alreadyReclaimed, + reason: 'refused share exceeds the deduction booked on 1513', + }, + } + } + legs.push({ + invoiceId: invoice.id, + invoiceNumber: invoice.invoice_number, + amount: share.refused, + item, + invoice, + }) + } + + let journalEntryId: string + if (resumeJournalEntryId) { + journalEntryId = resumeJournalEntryId + log.warn('rot/rut reclaim resuming: voucher exists, completing unapplied invoice legs', { + payoutRequestId: req.id, + journalEntryId, + pendingInvoiceIds: legs.map((leg) => leg.invoiceId), + }) + } else { + // The voucher is the accounting record: engine failure must block. + try { + const entry = await createRotRutReclaimEntry(supabase, companyId, userId, { + requestId: req.id, + requestName: req.name, + deductionType: req.deduction_type, + bookingDate: params.bookingDate, + legs: legs.map((leg) => ({ + invoiceId: leg.invoiceId, + invoiceNumber: leg.invoiceNumber, + amount: leg.amount, + })), + }) + journalEntryId = entry.id + } catch (engineError) { + return { ok: false, kind: 'error', error: engineError, stage: 'book' } + } + + // CAS on reclaim_journal_entry_id IS NULL: a concurrent reclaim must not + // reopen the invoices twice. The loser's voucher is caught by the partial + // unique index before it posts; this guard covers the request row itself. + const { data: attached, error: attachError } = await supabase + .from('rot_rut_payout_requests') + .update({ reclaim_journal_entry_id: journalEntryId, reclaimed_at: new Date().toISOString() }) + .eq('company_id', companyId) + .eq('id', req.id) + .is('reclaim_journal_entry_id', null) + .select('id') + .maybeSingle() + if (attachError) { + log.error('rot/rut reclaim entry booked but request update failed', attachError as Error, { + journalEntryId, + payoutRequestId: req.id, + }) + return { ok: false, kind: 'error', error: attachError, stage: 'update' } + } + if (!attached) { + log.error('rot/rut reclaim entry booked but request was reclaimed concurrently', undefined, { + journalEntryId, + payoutRequestId: req.id, + }) + return { + ok: false, + kind: 'code', + code: 'ROT_RUT_RECLAIM_RACE', + details: { journal_entry_id: journalEntryId, request_id: req.id }, + } + } + } + + // Reopen every invoice for its refused share. Each leg is one atomic, + // idempotent RPC (apply_rot_rut_reclaim_invoice): the database locks the + // item, request and invoice, validates the amount against all three, + // derives remaining_amount and status from the one formula, and sets the + // item marker; a set marker is a no-op. A failure here is retryable by + // calling again: the voucher stands (never unbook) and the resume path + // above completes the missing legs. + const reopened: ReclaimedInvoice[] = [] + for (const leg of legs) { + const invoice = leg.invoice + const { data: applied, error: applyError } = await supabase.rpc('apply_rot_rut_reclaim_invoice', { + p_item_id: leg.item.id, + p_invoice_id: invoice.id, + p_company_id: companyId, + p_reclaimed_amount: leg.amount, + }) + if (applyError) { + log.error('rot/rut reclaim booked but invoice reopen failed (retry the reclaim to resume)', applyError as Error, { + journalEntryId, + payoutRequestId: req.id, + invoiceId: invoice.id, + reclaimedAmount: leg.amount, + }) + return { ok: false, kind: 'error', error: applyError, stage: 'update' } + } + const result = (applied ?? {}) as { applied?: boolean; remaining_amount?: number | string; status?: string } + if (result.applied !== true) { + // Marker already set by a concurrent resume: that call reported the leg. + log.warn('rot/rut reclaim leg already applied', { itemId: leg.item.id, invoiceId: invoice.id }) + continue + } + + reopened.push({ + invoice_id: invoice.id, + invoice_number: invoice.invoice_number, + reclaimed_amount: leg.amount, + remaining_amount: roundOre(Number(result.remaining_amount ?? 0)), + status: result.status ?? invoice.status, + }) + } + + const reclaimedTotal = roundOre(legs.reduce((sum, leg) => sum + leg.amount, 0)) + log.info('rot/rut refused share reclaimed', { + userId, + payoutRequestId: req.id, + journalEntryId, + reclaimedTotal, + invoiceCount: reopened.length, + }) + + return { ok: true, journalEntryId, reclaimedTotal, invoices: reopened } +} diff --git a/lib/invoices/rot-rut-settle.ts b/lib/invoices/rot-rut-settle.ts index c1fa0c7a..cfa4139b 100644 --- a/lib/invoices/rot-rut-settle.ts +++ b/lib/invoices/rot-rut-settle.ts @@ -88,8 +88,12 @@ export type SettleRotRutPayoutOutcome = fullyPaid: boolean } | { ok: false; kind: 'code'; code: SettleRotRutPayoutErrorCode; details?: Record } - /** A raw Supabase/engine error the route maps through errorResponse(). */ - | { ok: false; kind: 'error'; error: unknown; stage: 'fetch' | 'book' | 'update' } + /** + * A raw Supabase/engine error the route maps through errorResponse(). At + * stage 'update' the voucher is already posted: journalEntryId names it so + * a caller can persist the partial state instead of losing the id. + */ + | { ok: false; kind: 'error'; error: unknown; stage: 'fetch' | 'book' | 'update'; journalEntryId?: string } const SETTLED_REQUEST_COLUMNS = 'id, name, deduction_type, status, requested_total, decided_total, decided_at, settlement_journal_entry_id' @@ -343,7 +347,7 @@ export async function settleRotRutPayoutRequest( journalEntryId, payoutRequestId: params.requestId, }) - return { ok: false, kind: 'error', error: updateError, stage: 'update' } + return { ok: false, kind: 'error', error: updateError, stage: 'update', journalEntryId } } if (!updated) { // The loser's voucher already exists (immutable per BFL): say so loudly @@ -436,7 +440,7 @@ export type SettleRotRutPayoutSetOutcome = amount: number } | { ok: false; kind: 'code'; code: SettleRotRutPayoutErrorCode; details?: Record } - | { ok: false; kind: 'error'; error: unknown; stage: 'fetch' | 'book' | 'update' } + | { ok: false; kind: 'error'; error: unknown; stage: 'fetch' | 'book' | 'update'; journalEntryId?: string } /** * Settle several begäran with ONE bank transfer: one voucher (debit 19xx for @@ -555,7 +559,7 @@ export async function settleRotRutPayoutRequestSet( payoutRequestId: leg.request.id, settledRequestIds: settled.map((request) => request.id), }) - return { ok: false, kind: 'error', error: updateError, stage: 'update' } + return { ok: false, kind: 'error', error: updateError, stage: 'update', journalEntryId } } if (!updated) { log.error('rot/rut payout set entry booked but a request was settled concurrently', undefined, { diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 418be2be..1d15a499 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -115,6 +115,7 @@ import { type BatchAllocationResult, } from '@/lib/invoices/clear-settled-batch-allocations' import { linkTransactionToJournalEntry } from '@/lib/transactions/link-journal-entry' +import { matchTransactionToRotRutPayout } from '@/lib/invoices/rot-rut-match-transaction' import { completeInboxItemsForBookedTransaction, resolveVoucherLinkedEntryIds, @@ -380,6 +381,85 @@ type ExecutorResult = { partialPostedIds?: Record } +/** + * settle_rot_rut_payout (gnubok_settle_rot_rut_payout): book Skatteverkets + * ROT/RUT utbetalning from the bank row against its begäran (one or several, + * #2239) through the same writer as the dashboard match route. The stage + * already checked income/SEK/unlinked/sum; the writer re-checks everything + * under its own CAS guards, so a race between approval and a manual match + * ends in a coded refusal, never a second 1513 credit. + */ +async function commitSettleRotRutPayout( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const transactionId = params.transaction_id as string | undefined + const requestIds = Array.isArray(params.request_ids) + ? (params.request_ids as unknown[]).filter((id): id is string => typeof id === 'string') + : [] + if (!transactionId || requestIds.length === 0) { + return { error: 'transaction_id and request_ids are required', status: 400 } + } + + const outcome = await matchTransactionToRotRutPayout( + supabase, + userId, + companyId, + { transactionId, requestIds }, + log, + ) + + if (!outcome.ok) { + if (outcome.kind === 'code') { + const entry = getErrorEntry(outcome.code) + // A race or a failed row link happens AFTER the voucher posted: the op + // lands in failed_partial with the voucher id so nobody books it twice. + const postedId = + outcome.details && typeof outcome.details.journal_entry_id === 'string' + ? (outcome.details.journal_entry_id as string) + : null + return { + error: entry?.message_en ?? outcome.code, + errorCode: outcome.code, + status: entry?.httpStatus ?? 500, + data: outcome.details as Record | undefined, + ...(postedId ? { partialPostedIds: { journal_entry_id: postedId } } : {}), + } + } + const message = outcome.error instanceof Error ? outcome.error.message : 'rot/rut payout settle failed' + // stage 'update' = the voucher posted and the request row did not absorb + // it: failed_partial with the voucher id, never a plain retryable 500 + // (a retry would hit journal_entries_rot_rut_payout_live_unique). + return { + error: `${message} (stage: ${outcome.stage})`, + status: 500, + ...(outcome.journalEntryId ? { partialPostedIds: { journal_entry_id: outcome.journalEntryId } } : {}), + } + } + + // Audit-trail entry (ids only, no amounts or counterparty PII), same shape + // as commitMatchBatchAllocate. + log.info('settle_rot_rut_payout committed', { + companyId, + operationType: 'settle_rot_rut_payout', + transactionId, + journalEntryId: outcome.journalEntryId, + requestCount: outcome.requests.length, + }) + + return { + data: { + transaction_id: transactionId, + journal_entry_id: outcome.journalEntryId, + amount: outcome.amount, + request_ids: outcome.requests.map((request) => request.id), + request_statuses: outcome.requests.map((request) => request.status), + }, + } +} + async function commitCategorizeTransaction( supabase: SupabaseClient, userId: string, @@ -5147,6 +5227,18 @@ async function commitCreditInvoice( if (!['sent', 'paid', 'overdue'].includes(original.status)) { return { error: 'Only sent, paid, or overdue invoices can be credited', status: 400 } } + // A refused ROT/RUT share booked onto the customer (rot_rut_reclaim) moved + // kronor from 1513 to 1510 after issue; the credit note reverses the + // issue-time split and would leave both accounts wrong. Reverse the reclaim + // first (same guard as the dashboard and v1 credit routes). + if (Number((original as { deduction_reclaimed_total?: number | null }).deduction_reclaimed_total ?? 0) > 0) { + const entry = getErrorEntry('INVOICE_CREDIT_ROT_RUT_RECLAIMED') + return { + error: entry?.message_en ?? 'Reverse the ROT/RUT reclaim voucher before crediting the invoice', + errorCode: 'INVOICE_CREDIT_ROT_RUT_RECLAIMED', + status: entry?.httpStatus ?? 400, + } + } const today = new Date().toISOString().split('T')[0] const creditNoteNumber = `KR-${original.invoice_number}` @@ -7355,6 +7447,9 @@ async function commitPendingOperationInner( case 'match_transaction_invoice': result = await commitMatchTransactionInvoice(supabase, userId, companyId, pendingOp.params) break + case 'settle_rot_rut_payout': + result = await commitSettleRotRutPayout(supabase, userId, companyId, pendingOp.params) + break case 'link_invoice_voucher': result = await commitLinkInvoiceVoucher(supabase, userId, companyId, pendingOp.params) break diff --git a/lib/pending-operations/risk-tiers.ts b/lib/pending-operations/risk-tiers.ts index 5ae3a42b..52549f17 100644 --- a/lib/pending-operations/risk-tiers.ts +++ b/lib/pending-operations/risk-tiers.ts @@ -64,6 +64,10 @@ export const OPERATION_RISK_TIERS: Record = { // ── Medium: reversible booking ───────────────────────────────────── categorize_transaction: 'medium', match_transaction_invoice: 'medium', + // Skatteverkets ROT/RUT utbetalning matched to its begäran: one bank row + // booked debit 19xx / credit 1513 and linked. Storno-reversible like the + // other bank-row matches, so the same tier as match_transaction_invoice. + settle_rot_rut_payout: 'medium', // Link an existing posted verifikat as payment for an invoice. Reversible by // deleting the invoice_payments row and reverting invoice status; no journal // entry is created or modified. Sits next to match_transaction_invoice diff --git a/messages/en.json b/messages/en.json index 7efc7b6d..7f54ed5b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4152,6 +4152,7 @@ "total": "Total", "in_sek": "In SEK (rate {rate})", "deduction_row": "{kind} tax reduction", + "deduction_reclaimed_row": "Refused deduction (Skatteverket)", "amount_to_pay": "Amount to pay", "deduction_line_hours": "{hours} h", "deduction_personnummer_label": "Personnummer", @@ -4389,6 +4390,70 @@ "create_failed_title": "Could not create credit note", "try_again": "Try again." }, + "rot_rut_overview": { + "title": "ROT/RUT at Skatteverket", + "description": "Payout requests, Skatteverket decisions, payouts and refused amounts in one place.", + "new_request": "New request", + "import_beslut": "Import decision file", + "import_beslut_help": "Upload the decision file (JSON) from Skatteverket e-service to record the approved amount per invoice.", + "viewer_disabled_tooltip": "You have read-only access and cannot make changes.", + "tile_at_skv": "At Skatteverket", + "tile_at_skv_help": "Requested, not yet paid out (account 1513)", + "tile_awaiting": "Awaiting decision", + "tile_awaiting_help": "Requests without a recorded decision", + "tile_refused": "Refused, to book", + "tile_refused_help": "{count, plural, =0 {No refused amount waiting} one {1 request with a refused amount} other {# requests with refused amounts}}", + "tile_ready": "Ready to request", + "tile_ready_help": "{rot} ROT · {rut} RUT", + "loading": "Loading the ROT/RUT overview", + "load_failed_title": "Could not load the ROT/RUT overview", + "empty_title": "No requests yet", + "empty_description": "Once the customer has paid a ROT or RUT invoice, create a payout request here and upload the file at Skatteverket.", + "col_name": "Request", + "col_status": "Status", + "col_created": "Created", + "col_requested": "Requested", + "col_decided": "Decided", + "col_refused": "Refused", + "col_waiting": "Waiting", + "col_actions": "", + "cases": "{count, plural, one {1 case} other {# cases}}", + "item_decided": "decided {amount}", + "item_reclaimed": "refused {amount} booked", + "status_generated": "Created", + "status_submitted": "Uploaded", + "status_paid": "Approved", + "status_partially_paid": "Partly approved", + "status_rejected": "Rejected", + "status_cancelled": "Cancelled", + "settled_link": "Payout booked", + "reclaimed_link": "Refused amount booked", + "split_unknown": "Split missing", + "split_unknown_hint": "The decision was recorded as one total. Import the decision file so the refused amount can be split per invoice.", + "rerequested": "(requested again)", + "rerequested_hint": "At least one invoice is part of a later request Skatteverket is reviewing. The refused amount is booked once that request is decided.", + "waiting_days": "{days, plural, =0 {today} one {1 day} other {# days}}", + "download": "Download file", + "cancel_request": "Cancel", + "mark_uploaded": "Mark as uploaded", + "book_refused": "Book refused amount", + "uploaded_title": "The file is marked as uploaded", + "cancelled_title": "The request is cancelled", + "update_failed_title": "Could not update the request", + "reclaim_confirm_title": "Book the refused amount?", + "reclaim_confirm_description": "{amount} refused by Skatteverket in request {name} moves from account 1513 to accounts receivable (1510) and {count, plural, one {the invoice reopens} other {the invoices reopen}} for that amount. The voucher is dated {date} (decision date). The customer needs to be invoiced or reminded for the balance.", + "reclaim_confirm_action": "Book", + "reclaim_done_title": "Refused amount booked", + "reclaim_done_description": "{amount} moved to accounts receivable on {count, plural, one {1 invoice} other {# invoices}}. The invoices now show the balance as unpaid.", + "reclaim_failed_title": "Could not book the refused amount", + "import_done_title": "Decision file imported", + "import_done_description": "{imported, plural, one {1 decision imported} other {# decisions imported}}, {already} already imported, {errors} errors.", + "import_failed_title": "Could not import the decision file", + "import_invalid_json": "The file is not a valid decision file (JSON). Download it again from the Skatteverket e-service.", + "download_failed_title": "Could not download the file", + "download_timeout": "The download took too long. Try again.", + "download_network": "The file could not be downloaded. Check the connection and try again." + }, "invoice_recurring": { "title": "Recurring invoices", "new_schedule": "New schedule", diff --git a/messages/sv.json b/messages/sv.json index b86472bc..b7c10297 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -4152,6 +4152,7 @@ "total": "Totalt", "in_sek": "I SEK (kurs {rate})", "deduction_row": "Skattereduktion {kind}", + "deduction_reclaimed_row": "Nekat avdrag (Skatteverket)", "amount_to_pay": "Att betala", "deduction_line_hours": "{hours} tim", "deduction_personnummer_label": "Personnummer", @@ -4389,6 +4390,70 @@ "create_failed_title": "Kunde inte skapa kreditfaktura", "try_again": "Försök igen." }, + "rot_rut_overview": { + "title": "ROT/RUT hos Skatteverket", + "description": "Begäran om utbetalning, Skatteverkets beslut, utbetalningar och nekade belopp på ett ställe.", + "new_request": "Ny begäran", + "import_beslut": "Importera beslutsfil", + "import_beslut_help": "Ladda upp beslutsfilen (JSON) från Skatteverkets e-tjänst så registreras godkänt belopp per faktura.", + "viewer_disabled_tooltip": "Du har läsbehörighet och kan inte göra ändringar.", + "tile_at_skv": "Hos Skatteverket", + "tile_at_skv_help": "Begärt men inte utbetalt (konto 1513)", + "tile_awaiting": "Väntar på beslut", + "tile_awaiting_help": "Begäran utan registrerat beslut", + "tile_refused": "Nekat att bokföra", + "tile_refused_help": "{count, plural, =0 {Inget nekat belopp väntar} one {1 begäran med nekat belopp} other {# begäran med nekat belopp}}", + "tile_ready": "Redo att begäras", + "tile_ready_help": "{rot} ROT · {rut} RUT", + "loading": "Laddar ROT/RUT-översikten", + "load_failed_title": "Kunde inte ladda ROT/RUT-översikten", + "empty_title": "Inga begäran ännu", + "empty_description": "När en ROT- eller RUT-faktura är betald av kunden skapar du en begäran om utbetalning här och laddar upp filen hos Skatteverket.", + "col_name": "Begäran", + "col_status": "Status", + "col_created": "Skapad", + "col_requested": "Begärt", + "col_decided": "Beslutat", + "col_refused": "Nekat", + "col_waiting": "Väntat", + "col_actions": "", + "cases": "{count, plural, one {1 ärende} other {# ärenden}}", + "item_decided": "beslutat {amount}", + "item_reclaimed": "nekat {amount} bokfört", + "status_generated": "Skapad", + "status_submitted": "Uppladdad", + "status_paid": "Beviljad", + "status_partially_paid": "Delvis beviljad", + "status_rejected": "Avslagen", + "status_cancelled": "Avbruten", + "settled_link": "Utbetalning bokförd", + "reclaimed_link": "Nekat belopp bokfört", + "split_unknown": "Fördelning saknas", + "split_unknown_hint": "Beslutet registrerades som en totalsumma. Importera beslutsfilen så kan det nekade beloppet fördelas per faktura.", + "rerequested": "(begärd igen)", + "rerequested_hint": "Minst en faktura ingår i en senare begäran som Skatteverket prövar. Det nekade beloppet bokförs först när den begäran är avgjord.", + "waiting_days": "{days, plural, =0 {i dag} one {1 dag} other {# dagar}}", + "download": "Hämta fil", + "cancel_request": "Avbryt", + "mark_uploaded": "Markera uppladdad", + "book_refused": "Bokför nekat belopp", + "uploaded_title": "Filen är markerad som uppladdad", + "cancelled_title": "Begäran är avbruten", + "update_failed_title": "Kunde inte uppdatera begäran", + "reclaim_confirm_title": "Bokför nekat belopp?", + "reclaim_confirm_description": "{amount} som Skatteverket nekade i begäran {name} flyttas från konto 1513 till kundfordran (1510) och {count, plural, one {fakturan öppnas} other {fakturorna öppnas}} igen för beloppet. Verifikationen dateras {date} (beslutsdatum). Kunden behöver faktureras eller påminnas om restbeloppet.", + "reclaim_confirm_action": "Bokför", + "reclaim_done_title": "Nekat belopp bokfört", + "reclaim_done_description": "{amount} flyttat till kundfordran på {count, plural, one {1 faktura} other {# fakturor}}. Fakturorna visar nu restbeloppet som obetalt.", + "reclaim_failed_title": "Kunde inte bokföra det nekade beloppet", + "import_done_title": "Beslutsfilen är importerad", + "import_done_description": "{imported, plural, one {1 beslut importerat} other {# beslut importerade}}, {already} redan importerade, {errors} fel.", + "import_failed_title": "Kunde inte importera beslutsfilen", + "import_invalid_json": "Filen är inte en giltig beslutsfil (JSON). Ladda ner den igen från Skatteverkets e-tjänst.", + "download_failed_title": "Kunde inte hämta filen", + "download_timeout": "Hämtningen tog för lång tid. Försök igen.", + "download_network": "Filen kunde inte hämtas. Kontrollera anslutningen och försök igen." + }, "invoice_recurring": { "title": "Återkommande fakturor", "new_schedule": "Nytt schema", diff --git a/skills/accounted-api/references/journal-entries.md b/skills/accounted-api/references/journal-entries.md index cca5527e..ff2eba12 100644 --- a/skills/accounted-api/references/journal-entries.md +++ b/skills/accounted-api/references/journal-entries.md @@ -96,7 +96,7 @@ Request body: fiscal_period_id: string, entry_date: string, description: string, - source_type?: "manual" | "bank_transaction" | "invoice_created" | "invoice_paid" | "invoice_cash_payment" | "credit_note" | "salary_payment" | "opening_balance" | "year_end" | "storno" | "correction" | "import" | "system" | "inbox_item" | "supplier_invoice_registered" | "supplier_invoice_paid" | "supplier_invoice_cash_payment" | "supplier_invoice_privately_paid" | "supplier_credit_note" | "currency_revaluation" | "reminder_fee" | "accrual" | "result_appropriation" | "rot_rut_payout" | "vat_settlement" | "stripe_payout" | "webshop_order" | "expense_claim" | "expense_payout", + source_type?: "manual" | "bank_transaction" | "invoice_created" | "invoice_paid" | "invoice_cash_payment" | "credit_note" | "salary_payment" | "opening_balance" | "year_end" | "storno" | "correction" | "import" | "system" | "inbox_item" | "supplier_invoice_registered" | "supplier_invoice_paid" | "supplier_invoice_cash_payment" | "supplier_invoice_privately_paid" | "supplier_credit_note" | "currency_revaluation" | "reminder_fee" | "accrual" | "result_appropriation" | "rot_rut_payout" | "vat_settlement" | "stripe_payout" | "webshop_order" | "expense_claim" | "expense_payout" | "rot_rut_reclaim", source_id?: string, voucher_series?: string, notes?: string, @@ -504,7 +504,7 @@ Bulk-create endpoint mirroring /invoices/bulk-create and /suppliers/bulk-create. Request body: ```ts { - journal_entries: { fiscal_period_id: string, entry_date: string, description: string, source_type?: "manual" | "bank_transaction" | "invoice_created" | "invoice_paid" | "invoice_cash_payment" | "credit_note" | "salary_payment" | "opening_balance" | "year_end" | "storno" | "correction" | "import" | "system" | "inbox_item" | "supplier_invoice_registered" | "supplier_invoice_paid" | "supplier_invoice_cash_payment" | "supplier_invoice_privately_paid" | "supplier_credit_note" | "currency_revaluation" | "reminder_fee" | "accrual" | "result_appropriation" | "rot_rut_payout" | "vat_settlement" | "stripe_payout" | "webshop_order" | "expense_claim" | "expense_payout", source_id?: string, voucher_series?: string, notes?: string, lines: { account_number: string, debit_amount?: number, credit_amount?: number, line_description?: string, currency?: string, amount_in_currency?: number, exchange_rate?: number, tax_code?: string, dimensions?: Record, cost_center?: string, project?: string }[] }[], + journal_entries: { fiscal_period_id: string, entry_date: string, description: string, source_type?: "manual" | "bank_transaction" | "invoice_created" | "invoice_paid" | "invoice_cash_payment" | "credit_note" | "salary_payment" | "opening_balance" | "year_end" | "storno" | "correction" | "import" | "system" | "inbox_item" | "supplier_invoice_registered" | "supplier_invoice_paid" | "supplier_invoice_cash_payment" | "supplier_invoice_privately_paid" | "supplier_credit_note" | "currency_revaluation" | "reminder_fee" | "accrual" | "result_appropriation" | "rot_rut_payout" | "vat_settlement" | "stripe_payout" | "webshop_order" | "expense_claim" | "expense_payout" | "rot_rut_reclaim", source_id?: string, voucher_series?: string, notes?: string, lines: { account_number: string, debit_amount?: number, credit_amount?: number, line_description?: string, currency?: string, amount_in_currency?: number, exchange_rate?: number, tax_code?: string, dimensions?: Record, cost_center?: string, project?: string }[] }[], all_or_nothing?: boolean } ``` diff --git a/supabase/migrations/20260907160000_rot_rut_reclaim.sql b/supabase/migrations/20260907160000_rot_rut_reclaim.sql new file mode 100644 index 00000000..6a50d5d8 --- /dev/null +++ b/supabase/migrations/20260907160000_rot_rut_reclaim.sql @@ -0,0 +1,142 @@ +-- ROT/RUT: Skatteverkets avslag becomes a fordran on the customer. +-- +-- Under fakturamodellen the deduction is booked as a fordran on Skatteverket +-- (1513) when the invoice is issued. When Skatteverket refuses an ärende (in +-- full or in part) that fordran is not gone: the buyer owes the refused share +-- (HUSFL 2009:194; swedish-invoice-compliance section 8: "SKV denies: Debit +-- 1510, Credit 1513, re-invoice customer"). Until now the request was only +-- marked rejected/partially_paid and the refused kronor stayed on 1513 forever +-- while the invoice read as paid. +-- +-- This migration adds the state for the reclaim voucher that moves the refused +-- share back onto the customer and reopens the invoice: +-- +-- invoices.deduction_reclaimed_total what Skatteverket refused and the +-- customer must now pay (invoice +-- currency, always SEK for ROT/RUT) +-- rot_rut_payout_requests.reclaim_journal_entry_id / reclaimed_at +-- the voucher (one per begäran) +-- rot_rut_payout_request_items.reclaimed_amount +-- per-invoice refused share +-- journal_entries.source_type 'rot_rut_reclaim' +-- +-- The invoice document itself stays as issued: deduction_total keeps the +-- deduction the customer was granted on paper, and the customer share is +-- total - deduction_total + deduction_reclaimed_total (lib/invoices/ +-- customer-share.ts; the INSERT guard below is its SQL twin and gains the +-- same term so the two definitions cannot drift). + +-- 1. invoices.deduction_reclaimed_total ------------------------------------- + +ALTER TABLE public.invoices + ADD COLUMN IF NOT EXISTS deduction_reclaimed_total NUMERIC(12,2) NOT NULL DEFAULT 0; + +COMMENT ON COLUMN public.invoices.deduction_reclaimed_total IS + 'ROT/RUT share Skatteverket refused and moved back onto the customer (debit 1510 / credit 1513, source_type rot_rut_reclaim). Customer share = total - deduction_total + deduction_reclaimed_total.'; + +ALTER TABLE public.invoices + DROP CONSTRAINT IF EXISTS invoices_deduction_reclaimed_total_check; +ALTER TABLE public.invoices + ADD CONSTRAINT invoices_deduction_reclaimed_total_check + CHECK ( + deduction_reclaimed_total >= 0 + AND deduction_reclaimed_total <= COALESCE(deduction_total, 0) + ) NOT VALID; +ALTER TABLE public.invoices + VALIDATE CONSTRAINT invoices_deduction_reclaimed_total_check; + +-- The INSERT guard from 20260817191708 is the SQL twin of +-- invoiceCustomerShare(): same formula, now with the reclaimed term. +CREATE OR REPLACE FUNCTION public.invoices_derive_remaining_amount() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF COALESCE(NEW.remaining_amount, 0) = 0 + AND NEW.credited_invoice_id IS NULL + AND COALESCE(NEW.document_type, 'invoice') = 'invoice' + AND COALESCE(NEW.total, 0) > 0 + AND COALESCE(NEW.status, 'draft') NOT IN ('paid', 'cancelled', 'credited') + THEN + NEW.remaining_amount := GREATEST( + 0, + ROUND(( + NEW.total + - COALESCE(NEW.paid_amount, 0) + - COALESCE(NEW.deduction_total, 0) + + COALESCE(NEW.deduction_reclaimed_total, 0) + )::numeric, 2) + ); + END IF; + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION public.invoices_derive_remaining_amount() IS + 'BEFORE INSERT guard: an unpaid real invoice inserted with remaining_amount NULL/0 gets total - paid_amount - deduction_total + deduction_reclaimed_total, so the NOT NULL DEFAULT 0 can never read as "settled".'; + +-- 2. Request + item reclaim state ------------------------------------------- + +ALTER TABLE public.rot_rut_payout_requests + ADD COLUMN IF NOT EXISTS reclaim_journal_entry_id uuid NULL + REFERENCES public.journal_entries(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS reclaimed_at timestamptz NULL; + +COMMENT ON COLUMN public.rot_rut_payout_requests.reclaim_journal_entry_id IS + 'Voucher that moved the refused share of this begäran from 1513 back onto the customers (1510). One per begäran; NULL until booked.'; + +ALTER TABLE public.rot_rut_payout_request_items + ADD COLUMN IF NOT EXISTS reclaimed_amount NUMERIC(12,2) NULL + CHECK (reclaimed_amount >= 0); + +COMMENT ON COLUMN public.rot_rut_payout_request_items.reclaimed_amount IS + 'The refused share (requested_amount - decided_amount) booked back onto this invoice by the reclaim voucher.'; + +-- 3. journal_entries.source_type: add 'rot_rut_reclaim' --------------------- +-- Same expansion pattern as 20260904170000: full list preserved, new value +-- appended. TS (JournalEntrySourceType) and Zod (JournalEntrySourceTypeSchema) +-- gain the value in the same change. + +ALTER TABLE public.journal_entries + DROP CONSTRAINT IF EXISTS journal_entries_source_type_check; + +ALTER TABLE public.journal_entries + ADD CONSTRAINT journal_entries_source_type_check + CHECK (source_type IN ( + 'manual', 'bank_transaction', 'invoice_created', + 'invoice_paid', 'invoice_cash_payment', 'credit_note', 'salary_payment', + 'opening_balance', 'year_end', + 'storno', 'correction', 'import', 'system', + 'inbox_item', + 'supplier_invoice_registered', 'supplier_invoice_paid', + 'supplier_invoice_cash_payment', 'supplier_credit_note', + 'currency_revaluation', + 'supplier_invoice_privately_paid', + 'reminder_fee', + 'accrual', + 'result_appropriation', + 'rot_rut_payout', + 'vat_settlement', + 'stripe_payout', + 'webshop_order', + 'expense_claim', + 'expense_payout', + 'rot_rut_reclaim' + )) NOT VALID; + +ALTER TABLE public.journal_entries + VALIDATE CONSTRAINT journal_entries_source_type_check; + +-- 4. One live reclaim voucher per begäran ----------------------------------- +-- Same race guard as journal_entries_rot_rut_payout_live_unique +-- (20260904021000): two concurrent reclaims must not both debit 1510 for the +-- same refused share. draft included so the loser fails at the draft insert. +-- pg-test: tests/pg/rot-rut-reclaim.pg.test.ts + +CREATE UNIQUE INDEX IF NOT EXISTS journal_entries_rot_rut_reclaim_live_unique + ON public.journal_entries (company_id, source_id) + WHERE source_type = 'rot_rut_reclaim' + AND source_id IS NOT NULL + AND status IN ('draft', 'posted'); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260907160100_pending_operations_add_rot_rut_settle.sql b/supabase/migrations/20260907160100_pending_operations_add_rot_rut_settle.sql new file mode 100644 index 00000000..7550a597 --- /dev/null +++ b/supabase/migrations/20260907160100_pending_operations_add_rot_rut_settle.sql @@ -0,0 +1,96 @@ +-- pending_operations.operation_type: add 'settle_rot_rut_payout'. +-- +-- gnubok_settle_rot_rut_payout stages the match of an income bank row to the +-- ROT/RUT begäran Skatteverket paid with it (one or several, #2239). Same +-- expand pattern as 20260902141000: the full list is re-stated (hand-copied, +-- so keep it in step with types/index.ts PendingOperationType) and the new +-- value appended; the VALIDATE runs in the next migration. +-- +-- pg-test: tests/pg/pending-operations-op-type-audit.pg.test.ts (collects +-- the staged op types from server.ts and OPERATION_RISK_TIERS). +ALTER TABLE public.pending_operations + DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check; + +ALTER TABLE public.pending_operations + ADD CONSTRAINT pending_operations_operation_type_check + CHECK (operation_type IN ( + 'categorize_transaction', + 'create_customer', + 'create_invoice', + 'mark_invoice_paid', + 'send_invoice', + 'mark_invoice_sent', + 'match_transaction_invoice', + 'close_period', + 'lock_period', + 'unlock_period', + 'set_opening_balances', + 'run_year_end', + 'post_kontantmetod_cutoff', + 'run_currency_revaluation', + 'import_sie', + 'explain_voucher_gap', + 'uncategorize_transaction', + 'approve_supplier_invoice', + 'credit_supplier_invoice', + 'credit_invoice', + 'convert_invoice', + 'delete_draft_invoice', + 'create_transaction', + 'attach_document_to_transaction', + 'create_voucher', + 'correct_entry', + 'reverse_entry', + 'create_supplier', + 'create_supplier_invoice_from_inbox', + 'post_annual_depreciation', + 'link_invoice_voucher', + 'undo_sie_import', + 'match_batch_allocate', + 'bulk_book_transactions', + 'create_salary_run', + 'generate_agi', + 'link_transaction_journal_entry', + 'link_supplier_invoice_voucher', + 'submit_vat_declaration', + 'submit_agi', + 'create_article', + 'update_article', + 'bulk_book_inbox_items', + 'create_dimension_value', + 'retag_line_dimensions', + 'link_document_to_voucher', + 'update_payslip_line', + 'set_run_salary', + 'update_salary_run', + 'register_absence', + 'create_employee', + 'update_employee', + 'set_employee_opening_balances', + 'vacation_year_close', + 'create_account', + 'update_account', + 'set_voucher_note', + 'book_salary_run', + 'delete_absence', + 'update_company_settings', + 'update_customer', + 'update_invoice', + 'create_recurring_schedule', + 'update_recurring_schedule', + 'log_mileage_trip', + 'book_mileage_period', + 'link_documents_to_vouchers', + 'reconciliation_match', + 'reconciliation_unmatch', + 'reconciliation_signoff', + 'reconciliation_residual', + 'book_skattekonto_row', + 'book_skattekonto_rows', + 'ignore_transaction', + 'create_sales_order', + 'transition_sales_order', + 'register_sales_order_delivery', + 'create_invoice_from_sales_order', + 'settle_rot_rut_payout' + )) NOT VALID; diff --git a/supabase/migrations/20260907160101_validate_pending_operations_rot_rut_settle.sql b/supabase/migrations/20260907160101_validate_pending_operations_rot_rut_settle.sql new file mode 100644 index 00000000..f2fc2b05 --- /dev/null +++ b/supabase/migrations/20260907160101_validate_pending_operations_rot_rut_settle.sql @@ -0,0 +1,6 @@ +-- Validate the operation type CHECK re-added in 20260907160100. +-- Separate transaction: avoids a full-table scan under the stronger lock of +-- the preceding migration (same split as 20260902141001). + +ALTER TABLE public.pending_operations + VALIDATE CONSTRAINT pending_operations_operation_type_check; diff --git a/supabase/migrations/20260907160200_voucher_series_default_rot_rut_reclaim.sql b/supabase/migrations/20260907160200_voucher_series_default_rot_rut_reclaim.sql new file mode 100644 index 00000000..0b9094af --- /dev/null +++ b/supabase/migrations/20260907160200_voucher_series_default_rot_rut_reclaim.sql @@ -0,0 +1,53 @@ +-- Migration: voucher-series column default gains rot_rut_reclaim (series C). +-- +-- 20260907160000 added 'rot_rut_reclaim' to the journal_entries.source_type +-- CHECK (Skatteverkets avslag booked back onto the customer, debit 1510 / +-- credit 1513). The column default for +-- company_settings.default_voucher_series_per_source_type must name every +-- source type the CHECK accepts (tests/pg/voucher-series-standard-default +-- .pg.test.ts asserts it against STANDARD_VOUCHER_SERIES_MAP), or a new +-- company's reclaim vouchers would fall back to 'A' through the resolver +-- while the settings picker shows them on C next to the payout vouchers. +-- +-- Same rules as 20260906210500: only the DEFAULT changes. Existing rows are +-- NOT updated (a company on A keeps A until it chooses the standard set), and +-- rows already on the standard set fall back to 'A' for the missing key +-- exactly as they did for rot_rut_payout before their own migration; the +-- settings action writes the full map when the company touches it. + +ALTER TABLE public.company_settings + ALTER COLUMN default_voucher_series_per_source_type + SET DEFAULT '{ + "manual": "A", + "bank_transaction": "A", + "invoice_created": "B", + "credit_note": "B", + "reminder_fee": "B", + "invoice_paid": "C", + "invoice_cash_payment": "C", + "rot_rut_payout": "C", + "rot_rut_reclaim": "C", + "supplier_invoice_registered": "D", + "supplier_credit_note": "D", + "supplier_invoice_privately_paid": "D", + "supplier_invoice_paid": "E", + "supplier_invoice_cash_payment": "E", + "accrual": "H", + "year_end": "I", + "result_appropriation": "I", + "salary_payment": "K", + "webshop_order": "L", + "vat_settlement": "M", + "opening_balance": "A", + "currency_revaluation": "A", + "inbox_item": "A", + "import": "A", + "system": "A", + "storno": "A", + "correction": "A", + "stripe_payout": "A", + "expense_claim": "A", + "expense_payout": "A" + }'::jsonb; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260907160300_rot_rut_reclaim_apply_rpc.sql b/supabase/migrations/20260907160300_rot_rut_reclaim_apply_rpc.sql new file mode 100644 index 00000000..dd494cfe --- /dev/null +++ b/supabase/migrations/20260907160300_rot_rut_reclaim_apply_rpc.sql @@ -0,0 +1,68 @@ +-- Rot & rut: atomic, idempotent apply of one reclaimed share to its invoice. +-- +-- lib/invoices/rot-rut-reclaim.ts books the reclaim voucher (debit 1510 / +-- credit 1513), attaches it to the begäran, and then reopens every invoice +-- for its refused share. Those last writes used to be two UPDATEs per invoice +-- (invoices, then rot_rut_payout_request_items). A failure between the +-- voucher and the invoice update left the ledger reclaimed while the invoice +-- still read as paid, and the next call was refused as already done +-- (CodeRabbit on #2397). This function makes the per-invoice step one +-- transaction with the item row as the idempotency marker: +-- +-- reclaimed_amount IS NULL on the item -> apply both writes, return true +-- already set -> touch nothing, return false +-- +-- so the service can resume after a partial failure by re-applying every +-- leg: applied legs are no-ops, missing legs complete. SECURITY INVOKER so +-- RLS still scopes the rows; the company id is checked explicitly on the +-- invoice as defense in depth (service-role callers have no RLS). + +CREATE OR REPLACE FUNCTION public.apply_rot_rut_reclaim_invoice( + p_item_id uuid, + p_invoice_id uuid, + p_company_id uuid, + p_reclaimed_amount numeric, + p_remaining_amount numeric, + p_status text +) +RETURNS boolean +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = public +AS $$ +BEGIN + IF p_reclaimed_amount IS NULL OR p_reclaimed_amount <= 0 THEN + RAISE EXCEPTION 'apply_rot_rut_reclaim_invoice: reclaimed amount must be positive'; + END IF; + + UPDATE public.rot_rut_payout_request_items + SET reclaimed_amount = p_reclaimed_amount + WHERE id = p_item_id + AND invoice_id = p_invoice_id + AND reclaimed_amount IS NULL; + + IF NOT FOUND THEN + -- Already applied (marker set) or not this invoice's item: nothing to do. + RETURN false; + END IF; + + UPDATE public.invoices + SET deduction_reclaimed_total = COALESCE(deduction_reclaimed_total, 0) + p_reclaimed_amount, + remaining_amount = p_remaining_amount, + status = p_status + WHERE id = p_invoice_id + AND company_id = p_company_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'apply_rot_rut_reclaim_invoice: invoice % not found in company %', + p_invoice_id, p_company_id; + END IF; + + RETURN true; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.apply_rot_rut_reclaim_invoice(uuid, uuid, uuid, numeric, numeric, text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.apply_rot_rut_reclaim_invoice(uuid, uuid, uuid, numeric, numeric, text) TO service_role; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260907160400_rot_rut_reclaim_rpc_derive.sql b/supabase/migrations/20260907160400_rot_rut_reclaim_rpc_derive.sql new file mode 100644 index 00000000..05c3cd07 --- /dev/null +++ b/supabase/migrations/20260907160400_rot_rut_reclaim_rpc_derive.sql @@ -0,0 +1,219 @@ +-- Rot & rut reclaim RPCs: the database owns the accounting values. +-- +-- 20260907160300 let the caller supply remaining_amount and status. Review +-- (#2397, CWE-862): any writer-role member could call the function with +-- arbitrary values for an invoice they can see. This migration replaces it +-- with a function that takes only the reclaimed amount, VALIDATES it against +-- the locked item, request and invoice rows, and DERIVES remaining_amount and +-- status from the same formula as the INSERT guard +-- (invoices_derive_remaining_amount: total - paid - deduction + reclaimed), +-- and adds the mirror used when the reclaim voucher is reversed. Both are +-- idempotent through the item marker (reclaimed_amount): a second call is a +-- no-op, so a failed batch can be re-run leg by leg. +-- +-- Returns jsonb { applied, remaining_amount, status } so the caller can +-- report what the row now says without a second read. + +DROP FUNCTION IF EXISTS public.apply_rot_rut_reclaim_invoice(uuid, uuid, uuid, numeric, numeric, text); + +CREATE OR REPLACE FUNCTION public.rot_rut_customer_outstanding( + p_total numeric, + p_paid numeric, + p_deduction numeric, + p_reclaimed numeric +) +RETURNS numeric +LANGUAGE sql +IMMUTABLE +AS $$ + SELECT GREATEST(0, ROUND((COALESCE(p_total, 0) - COALESCE(p_paid, 0) + - COALESCE(p_deduction, 0) + COALESCE(p_reclaimed, 0))::numeric, 2)); +$$; + +CREATE OR REPLACE FUNCTION public.apply_rot_rut_reclaim_invoice( + p_item_id uuid, + p_invoice_id uuid, + p_company_id uuid, + p_reclaimed_amount numeric +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = public +AS $$ +DECLARE + v_item public.rot_rut_payout_request_items%ROWTYPE; + v_request public.rot_rut_payout_requests%ROWTYPE; + v_invoice public.invoices%ROWTYPE; + v_paid numeric; + v_remaining numeric; + v_status text; + v_request_refused numeric; +BEGIN + IF p_reclaimed_amount IS NULL OR p_reclaimed_amount <= 0 THEN + RAISE EXCEPTION 'apply_rot_rut_reclaim_invoice: reclaimed amount must be positive'; + END IF; + + SELECT * INTO v_item + FROM public.rot_rut_payout_request_items + WHERE id = p_item_id AND invoice_id = p_invoice_id + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'apply_rot_rut_reclaim_invoice: item % not found for invoice %', p_item_id, p_invoice_id; + END IF; + IF v_item.reclaimed_amount IS NOT NULL THEN + -- Already applied: idempotent no-op. + RETURN jsonb_build_object('applied', false); + END IF; + + SELECT * INTO v_request + FROM public.rot_rut_payout_requests + WHERE id = v_item.request_id AND company_id = p_company_id + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'apply_rot_rut_reclaim_invoice: request for item % not found in company %', p_item_id, p_company_id; + END IF; + IF v_request.decided_at IS NULL OR v_request.decided_total IS NULL THEN + RAISE EXCEPTION 'apply_rot_rut_reclaim_invoice: no beslut recorded on request %', v_request.id; + END IF; + IF v_request.reclaim_journal_entry_id IS NULL THEN + RAISE EXCEPTION 'apply_rot_rut_reclaim_invoice: request % has no reclaim voucher', v_request.id; + END IF; + + SELECT * INTO v_invoice + FROM public.invoices + WHERE id = p_invoice_id AND company_id = p_company_id + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'apply_rot_rut_reclaim_invoice: invoice % not found in company %', p_invoice_id, p_company_id; + END IF; + + -- The refused share can never exceed what the item requested, what the + -- beslut refused for the whole request, or what the invoice still carries + -- on 1513. + v_request_refused := GREATEST(0, v_request.requested_total - v_request.decided_total); + IF p_reclaimed_amount > v_item.requested_amount + 0.005 + OR p_reclaimed_amount > v_request_refused + 0.005 + OR p_reclaimed_amount > COALESCE(v_invoice.deduction_total, 0) - COALESCE(v_invoice.deduction_reclaimed_total, 0) + 0.005 THEN + RAISE EXCEPTION 'apply_rot_rut_reclaim_invoice: reclaimed amount % exceeds the refused share for item %', p_reclaimed_amount, p_item_id; + END IF; + + -- A paid invoice with NULL paid_amount (older settlement paths) has settled + -- its customer share: that share stands in for the missing figure. + v_paid := COALESCE( + v_invoice.paid_amount, + CASE WHEN v_invoice.status = 'paid' + THEN v_invoice.total - COALESCE(v_invoice.deduction_total, 0) + ELSE 0 END + ); + v_remaining := public.rot_rut_customer_outstanding( + v_invoice.total, v_paid, v_invoice.deduction_total, + COALESCE(v_invoice.deduction_reclaimed_total, 0) + p_reclaimed_amount + ); + v_status := v_invoice.status; + IF v_remaining > 0 THEN + IF v_paid > 0 THEN + v_status := 'partially_paid'; + ELSIF v_invoice.status = 'overdue' THEN + v_status := 'overdue'; + ELSE + v_status := 'sent'; + END IF; + END IF; + + UPDATE public.rot_rut_payout_request_items + SET reclaimed_amount = p_reclaimed_amount + WHERE id = p_item_id; + + UPDATE public.invoices + SET deduction_reclaimed_total = COALESCE(deduction_reclaimed_total, 0) + p_reclaimed_amount, + remaining_amount = v_remaining, + status = v_status + WHERE id = p_invoice_id AND company_id = p_company_id; + + RETURN jsonb_build_object('applied', true, 'remaining_amount', v_remaining, 'status', v_status); +END; +$$; + +-- Mirror for a reversed reclaim voucher: the item marker is the amount to +-- hand back; a NULL marker means this leg is already reverted (no-op). +CREATE OR REPLACE FUNCTION public.revert_rot_rut_reclaim_invoice( + p_item_id uuid, + p_invoice_id uuid, + p_company_id uuid +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = public +AS $$ +DECLARE + v_item public.rot_rut_payout_request_items%ROWTYPE; + v_invoice public.invoices%ROWTYPE; + v_paid numeric; + v_reclaimed numeric; + v_remaining numeric; + v_status text; +BEGIN + SELECT * INTO v_item + FROM public.rot_rut_payout_request_items + WHERE id = p_item_id AND invoice_id = p_invoice_id + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'revert_rot_rut_reclaim_invoice: item % not found for invoice %', p_item_id, p_invoice_id; + END IF; + IF v_item.reclaimed_amount IS NULL THEN + RETURN jsonb_build_object('reverted', false); + END IF; + IF NOT EXISTS ( + SELECT 1 FROM public.rot_rut_payout_requests + WHERE id = v_item.request_id AND company_id = p_company_id + ) THEN + RAISE EXCEPTION 'revert_rot_rut_reclaim_invoice: request for item % not found in company %', p_item_id, p_company_id; + END IF; + + SELECT * INTO v_invoice + FROM public.invoices + WHERE id = p_invoice_id AND company_id = p_company_id + FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'revert_rot_rut_reclaim_invoice: invoice % not found in company %', p_invoice_id, p_company_id; + END IF; + + v_reclaimed := GREATEST(0, COALESCE(v_invoice.deduction_reclaimed_total, 0) - v_item.reclaimed_amount); + v_paid := COALESCE(v_invoice.paid_amount, 0); + v_remaining := public.rot_rut_customer_outstanding( + v_invoice.total, v_paid, v_invoice.deduction_total, v_reclaimed + ); + v_status := v_invoice.status; + IF v_invoice.status IN ('sent', 'overdue', 'partially_paid', 'paid') THEN + IF v_remaining <= 0 AND v_paid > 0 THEN + v_status := 'paid'; + ELSIF v_paid > 0 THEN + v_status := 'partially_paid'; + ELSIF v_invoice.due_date IS NOT NULL AND v_invoice.due_date < CURRENT_DATE THEN + v_status := 'overdue'; + ELSE + v_status := 'sent'; + END IF; + END IF; + + UPDATE public.rot_rut_payout_request_items + SET reclaimed_amount = NULL + WHERE id = p_item_id; + + UPDATE public.invoices + SET deduction_reclaimed_total = v_reclaimed, + remaining_amount = v_remaining, + status = v_status + WHERE id = p_invoice_id AND company_id = p_company_id; + + RETURN jsonb_build_object('reverted', true, 'remaining_amount', v_remaining, 'status', v_status); +END; +$$; + +GRANT EXECUTE ON FUNCTION public.rot_rut_customer_outstanding(numeric, numeric, numeric, numeric) TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION public.apply_rot_rut_reclaim_invoice(uuid, uuid, uuid, numeric) TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION public.revert_rot_rut_reclaim_invoice(uuid, uuid, uuid) TO authenticated, service_role; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/rot-rut-reclaim.pg.test.ts b/tests/pg/rot-rut-reclaim.pg.test.ts new file mode 100644 index 00000000..078285e3 --- /dev/null +++ b/tests/pg/rot-rut-reclaim.pg.test.ts @@ -0,0 +1,255 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { getPool } from './setup' +import { insertPostedJournalEntry, seedCompany } from './fixtures' + +/** + * Migration 20260907160000_rot_rut_reclaim: + * - journal_entries.source_type accepts 'rot_rut_reclaim' + * - one live reclaim voucher per begäran (partial unique index) + * - invoices.deduction_reclaimed_total never exceeds the deduction + * - the INSERT guard derives remaining_amount with the reclaimed term + */ + +async function insertCustomerInvoice( + companyId: string, + userId: string, + cols: { deduction_total: number; deduction_reclaimed_total: number; remaining_amount?: number | null }, +): Promise { + const customerId = randomUUID() + await getPool().query( + `INSERT INTO public.customers (id, user_id, company_id, name, customer_type) + VALUES ($1, $2, $3, 'Kund AB', 'swedish_business')`, + [customerId, userId, companyId], + ) + const id = randomUUID() + await getPool().query( + `INSERT INTO public.invoices + (id, user_id, company_id, customer_id, invoice_number, invoice_date, due_date, + currency, subtotal, vat_amount, total, vat_treatment, vat_rate, status, + paid_amount, remaining_amount, deduction_total, deduction_reclaimed_total) + VALUES ($1, $2, $3, $4, $5, '2026-01-15', '2026-02-14', 'SEK', + 20000, 5000, 25000, 'standard_25', 25, 'sent', 0, $6, $7, $8)`, + [ + id, + userId, + companyId, + customerId, + `F-${id.slice(0, 8)}`, + cols.remaining_amount ?? null, + cols.deduction_total, + cols.deduction_reclaimed_total, + ], + ) + return id +} + +describe('rot/rut reclaim (migration 20260907160000)', () => { + it('accepts source_type rot_rut_reclaim and allows exactly one live reclaim voucher per begäran', async () => { + const seeded = await seedCompany() + const requestId = randomUUID() + const common = { + userId: seeded.userId, + companyId: seeded.companyId, + fiscalPeriodId: seeded.fiscalPeriodId, + entryDate: '2026-08-21', + description: 'Nekat RUT-avdrag från Skatteverket (RUT 2026-08)', + sourceType: 'rot_rut_reclaim', + sourceId: requestId, + lines: [ + { accountNumber: '1510', debitAmount: 2000, creditAmount: 0 }, + { accountNumber: '1513', debitAmount: 0, creditAmount: 2000 }, + ], + } + + const results = await Promise.allSettled([ + insertPostedJournalEntry({ ...common, voucherNumber: 51 }), + insertPostedJournalEntry({ ...common, voucherNumber: 52 }), + ]) + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + const rejected = results.find((result) => result.status === 'rejected') + expect(String((rejected as PromiseRejectedResult).reason)).toMatch( + /journal_entries_rot_rut_reclaim_live_unique/, + ) + + // A second begäran is not blocked, nor is the payout voucher of the same begäran. + await expect( + insertPostedJournalEntry({ ...common, sourceId: randomUUID(), voucherNumber: 53 }), + ).resolves.toBeTruthy() + await expect( + insertPostedJournalEntry({ + ...common, + sourceType: 'rot_rut_payout', + voucherNumber: 54, + lines: [ + { accountNumber: '1930', debitAmount: 3000, creditAmount: 0 }, + { accountNumber: '1513', debitAmount: 0, creditAmount: 3000 }, + ], + }), + ).resolves.toBeTruthy() + }) + + it('refuses a reclaimed total above the deduction', async () => { + const seeded = await seedCompany() + await expect( + insertCustomerInvoice(seeded.companyId, seeded.userId, { + deduction_total: 7500, + deduction_reclaimed_total: 9000, + remaining_amount: 17500, + }), + ).rejects.toThrow(/invoices_deduction_reclaimed_total_check/) + await expect( + insertCustomerInvoice(seeded.companyId, seeded.userId, { + deduction_total: 7500, + deduction_reclaimed_total: 7500, + remaining_amount: 25000, + }), + ).resolves.toBeTruthy() + }) + + it('derives remaining_amount with the reclaimed term when a writer omits it', async () => { + const seeded = await seedCompany() + const id = await insertCustomerInvoice(seeded.companyId, seeded.userId, { + deduction_total: 7500, + deduction_reclaimed_total: 2500, + remaining_amount: null, + }) + const { rows } = await getPool().query<{ remaining_amount: string }>( + 'SELECT remaining_amount FROM public.invoices WHERE id = $1', + [id], + ) + // 25 000 - 0 paid - 7 500 deduction + 2 500 reclaimed + expect(Number(rows[0].remaining_amount)).toBe(20000) + }) +}) + +describe('apply / revert rot_rut_reclaim RPCs (migration 20260907160400)', () => { + async function seedReclaimCase(opts: { decidedTotal: number; itemDecided: number | null; status: string }) { + const seeded = await seedCompany() + const invoiceId = await insertCustomerInvoice(seeded.companyId, seeded.userId, { + deduction_total: 7500, + deduction_reclaimed_total: 0, + remaining_amount: 0, + }) + await getPool().query( + `UPDATE public.invoices SET status = 'paid', paid_amount = 17500 WHERE id = $1`, + [invoiceId], + ) + const requestId = randomUUID() + const reclaimEntryId = await insertPostedJournalEntry({ + userId: seeded.userId, + companyId: seeded.companyId, + fiscalPeriodId: seeded.fiscalPeriodId, + entryDate: '2026-08-21', + description: 'Nekat ROT-avdrag från Skatteverket', + sourceType: 'rot_rut_reclaim', + sourceId: requestId, + voucherNumber: 61, + lines: [ + { accountNumber: '1510', debitAmount: 2500, creditAmount: 0 }, + { accountNumber: '1513', debitAmount: 0, creditAmount: 2500 }, + ], + }) + await getPool().query( + `INSERT INTO public.rot_rut_payout_requests + (id, company_id, user_id, deduction_type, name, status, requested_total, decided_total, decided_at, file_name, reclaim_journal_entry_id) + VALUES ($1, $2, $3, 'rot', 'ROT 2026-08', $4, 7500, $5, now(), 'rot.xml', $6)`, + [requestId, seeded.companyId, seeded.userId, opts.status, opts.decidedTotal, reclaimEntryId], + ) + const itemId = randomUUID() + await getPool().query( + `INSERT INTO public.rot_rut_payout_request_items (id, request_id, invoice_id, requested_amount, decided_amount) + VALUES ($1, $2, $3, 7500, $4)`, + [itemId, requestId, invoiceId, opts.itemDecided], + ) + return { ...seeded, invoiceId, requestId, itemId } + } + + async function readInvoice(invoiceId: string) { + const { rows } = await getPool().query<{ + deduction_reclaimed_total: string + remaining_amount: string + status: string + reclaimed_amount: string | null + }>( + `SELECT i.deduction_reclaimed_total, i.remaining_amount, i.status, it.reclaimed_amount + FROM public.invoices i + JOIN public.rot_rut_payout_request_items it ON it.invoice_id = i.id + WHERE i.id = $1`, + [invoiceId], + ) + return rows[0] + } + + it('applies once (marker + invoice, derived remaining/status) and is a no-op the second time', async () => { + const c = await seedReclaimCase({ decidedTotal: 5000, itemDecided: 5000, status: 'partially_paid' }) + + const first = await getPool().query<{ r: { applied: boolean; remaining_amount: number; status: string } }>( + `SELECT public.apply_rot_rut_reclaim_invoice($1, $2, $3, 2500) AS r`, + [c.itemId, c.invoiceId, c.companyId], + ) + expect(first.rows[0].r).toMatchObject({ applied: true, remaining_amount: 2500, status: 'partially_paid' }) + + const second = await getPool().query<{ r: { applied: boolean } }>( + `SELECT public.apply_rot_rut_reclaim_invoice($1, $2, $3, 2500) AS r`, + [c.itemId, c.invoiceId, c.companyId], + ) + expect(second.rows[0].r).toEqual({ applied: false }) + + const row = await readInvoice(c.invoiceId) + // 25 000 - 17 500 paid - 7 500 deduction + 2 500 reclaimed = 2 500, once. + expect(Number(row.deduction_reclaimed_total)).toBe(2500) + expect(Number(row.remaining_amount)).toBe(2500) + expect(row.status).toBe('partially_paid') + expect(Number(row.reclaimed_amount)).toBe(2500) + }) + + it('refuses an amount above the refused share and above the 1513 headroom', async () => { + const c = await seedReclaimCase({ decidedTotal: 5000, itemDecided: 5000, status: 'partially_paid' }) + await expect( + getPool().query(`SELECT public.apply_rot_rut_reclaim_invoice($1, $2, $3, 4000)`, [ + c.itemId, c.invoiceId, c.companyId, + ]), + ).rejects.toThrow(/exceeds the refused share/) + const row = await readInvoice(c.invoiceId) + expect(row.reclaimed_amount).toBeNull() + expect(Number(row.deduction_reclaimed_total)).toBe(0) + }) + + it('refuses a foreign company without touching the marker', async () => { + const c = await seedReclaimCase({ decidedTotal: 0, itemDecided: null, status: 'rejected' }) + const other = await seedCompany() + await expect( + getPool().query(`SELECT public.apply_rot_rut_reclaim_invoice($1, $2, $3, 7500)`, [ + c.itemId, c.invoiceId, other.companyId, + ]), + ).rejects.toThrow(/not found in company/) + const row = await readInvoice(c.invoiceId) + expect(row.reclaimed_amount).toBeNull() + }) + + it('revert hands the share back, closes the invoice, and is a no-op the second time', async () => { + const c = await seedReclaimCase({ decidedTotal: 5000, itemDecided: 5000, status: 'partially_paid' }) + await getPool().query(`SELECT public.apply_rot_rut_reclaim_invoice($1, $2, $3, 2500)`, [ + c.itemId, c.invoiceId, c.companyId, + ]) + + const first = await getPool().query<{ r: { reverted: boolean; remaining_amount: number; status: string } }>( + `SELECT public.revert_rot_rut_reclaim_invoice($1, $2, $3) AS r`, + [c.itemId, c.invoiceId, c.companyId], + ) + expect(first.rows[0].r).toMatchObject({ reverted: true, remaining_amount: 0, status: 'paid' }) + + const second = await getPool().query<{ r: { reverted: boolean } }>( + `SELECT public.revert_rot_rut_reclaim_invoice($1, $2, $3) AS r`, + [c.itemId, c.invoiceId, c.companyId], + ) + expect(second.rows[0].r).toEqual({ reverted: false }) + + const row = await readInvoice(c.invoiceId) + expect(Number(row.deduction_reclaimed_total)).toBe(0) + expect(Number(row.remaining_amount)).toBe(0) + expect(row.status).toBe('paid') + expect(row.reclaimed_amount).toBeNull() + }) +}) diff --git a/tests/pg/voucher-series-standard-default.pg.test.ts b/tests/pg/voucher-series-standard-default.pg.test.ts index f9d35768..cf786125 100644 --- a/tests/pg/voucher-series-standard-default.pg.test.ts +++ b/tests/pg/voucher-series-standard-default.pg.test.ts @@ -21,9 +21,13 @@ import { STANDARD_VOUCHER_SERIES_MAP } from '@/lib/bookkeeping/voucher-series-re * Superuser pool on purpose: the object under test is the default, not RLS. */ +// The LATEST default-setting migration: every source type added since #2184 +// re-states the whole map in a new migration (20260907160200 added +// rot_rut_reclaim), and re-applying an older one here would reset the +// default to a shorter map for every test that follows. const MIGRATION_PATH = join( process.cwd(), - 'supabase/migrations/20260906210500_voucher_series_standard_default.sql', + 'supabase/migrations/20260907160200_voucher_series_default_rot_rut_reclaim.sql', ) type SeriesMap = Record diff --git a/types/index.ts b/types/index.ts index 7fbb2109..f66946ae 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1457,6 +1457,12 @@ export interface Invoice { deduction_total?: number deduction_personnummer_encrypted?: string | null deduction_personnummer_last4?: string | null + // The part of `deduction_total` Skatteverket refused and that was moved + // back onto the customer by a rot_rut_reclaim voucher (debit 1510 / credit + // 1513). The document keeps its printed deduction; the customer share is + // total - deduction_total + deduction_reclaimed_total (customer-share.ts). + // NOT NULL DEFAULT 0 in the schema; optional here for legacy fixtures. + deduction_reclaimed_total?: number // Default dimensions bag ({sie_dim_no: code}) applied to every journal line // generated from this invoice (issuance, payment, credit); item-level @@ -1836,6 +1842,10 @@ export type JournalEntrySourceType = | 'webshop_order' | 'expense_claim' | 'expense_payout' + // Skatteverket refused (part of) a ROT/RUT begäran: the refused share moves + // from 1513 back onto the customer (debit 1510 / credit 1513) and the + // invoice reopens for that amount. lib/invoices/rot-rut-reclaim.ts. + | 'rot_rut_reclaim' // Journal entry status export type JournalEntryStatus = 'draft' | 'posted' | 'reversed' | 'cancelled' @@ -2551,6 +2561,10 @@ export type PendingOperationType = // Semesterårsavslut: rolls vacation balances into the next year and may // post a 2920/2940 drift-adjustment verifikation (Phase 3). | 'vacation_year_close' + // Match an income bank row to the ROT/RUT begäran Skatteverket paid with it + // (one or several, #2239): one voucher debit 19xx / credit 1513 per begäran, + // the row linked, every begäran marked settled (gnubok_settle_rot_rut_payout). + | 'settle_rot_rut_payout' // 'failed_partial' (issue #842, DB CHECK widened in 20260722134114): terminal // state for ops whose executor posted an irreversible side-effect (voucher, // credit note) and then failed a later step. Not re-committable, not pending