feat(invoices): ROT/RUT payout file dialog and file guards (#1380)

* feat(invoices): ROT/RUT payout file dialog and file guards

Rebuild the UI for the existing headless HUS V6 payout-file flow
(demanded via #789): a dialog on the invoices page to pick eligible
paid ROT/RUT invoices, generate the XML, download it and track
request status. Adds file-level guards from the Skatteverket spec:
future payment dates blocked, one file per payment year, max 100
cases per file, with per-invoice blocker messages.

Submission stays manual (upload + sign in the SKV e-service);
no direct submission API exists.

Fixes #789

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

* fix(invoices): compute rot-rut gating date in Europe/Stockholm

The candidate and begäran date defaults used the UTC calendar day,
which near midnight Swedish time could wrongly block or admit an
invoice via FUTURE_PAYMENT_DATE and shift the 31 January deadline
warning. Use getSwedishLocalDate() like the bookkeeping engine.
Raised by the Swedish compliance review on PR #1380.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-03 18:16:46 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent df34cae9bf
commit 00d4c8a49e
9 changed files with 830 additions and 14 deletions
@@ -395,6 +395,16 @@ describe('eligibility blockers', () => {
if (!result.ok) expect(result.blocker.code).toBe('MISSING_PAYMENT_DATE')
})
it('FUTURE_PAYMENT_DATE when the recorded payment is after today', () => {
const result = evaluateInvoiceForFile(
'rot',
makeRotInvoice({ paid_at: '2026-07-03T10:00:00Z' }),
{ today: TODAY },
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.blocker.code).toBe('FUTURE_PAYMENT_DATE')
})
it('NO_DEDUCTION_OF_TYPE when the invoice has no lines of the requested type', () => {
const result = evaluateInvoiceForFile('rut', makeRotInvoice())
expect(result.ok).toBe(false)
@@ -550,6 +560,49 @@ describe('eligibility blockers', () => {
expect(result.xml).not.toBeNull()
})
it('MIXED_PAYMENT_YEARS when one file spans more than one payment year', () => {
const result = buildRotRutFile({
type: 'rot',
name: 'Två år',
invoices: [
makeRotInvoice({ id: 'invoice-2026', invoice_number: 'F-2026' }),
makeRotInvoice({
id: 'invoice-2025',
invoice_number: 'F-2025',
paid_at: '2025-12-30T10:00:00Z',
}),
],
today: TODAY,
})
expect(result.arenden).toHaveLength(1)
expect(result.blockers).toEqual([
expect.objectContaining({ invoice_id: 'invoice-2025', code: 'MIXED_PAYMENT_YEARS' }),
])
})
it('TOO_MANY_CASES when a file contains more than 100 cases', () => {
// Reuse one encrypted synthetic personnummer. Creating 101 independent
// ciphertexts would benchmark the KDF rather than the file-size rule.
const baseInvoice = makeRotInvoice()
const invoices = Array.from({ length: 101 }, (_, index) => ({
...baseInvoice,
id: `invoice-${index + 1}`,
invoice_number: `F-${index + 1}`,
}))
const result = buildRotRutFile({
type: 'rot',
name: 'För många',
invoices,
today: TODAY,
})
expect(result.arenden).toHaveLength(100)
expect(result.blockers).toEqual([
expect.objectContaining({ invoice_id: 'invoice-101', code: 'TOO_MANY_CASES' }),
])
}, 30_000)
it('returns xml: null when nothing is eligible', () => {
const result = buildRotRutFile({
type: 'rot',
+31 -1
View File
@@ -78,8 +78,11 @@ const WORK_TYPE_ELEMENTS: Record<DeductionType, ReadonlyArray<{
export type RotRutBlockerCode =
| 'NOT_PAID'
| 'MISSING_PAYMENT_DATE'
| 'FUTURE_PAYMENT_DATE'
| 'NO_DEDUCTION_OF_TYPE'
| 'MIXED_DEDUCTION_TYPES'
| 'MIXED_PAYMENT_YEARS'
| 'TOO_MANY_CASES'
| 'MISSING_PERSONNUMMER'
| 'PERSONNUMMER_UNREADABLE'
| 'MISSING_EXCHANGE_RATE'
@@ -167,6 +170,7 @@ export function normalizeBrfOrgNr(raw: string): string | null {
export function evaluateInvoiceForFile(
type: DeductionType,
invoice: Invoice,
options: { today?: string } = {},
): { ok: true; value: EvaluatedArende } | { ok: false; blocker: RotRutBlocker } {
const block = (code: RotRutBlockerCode, message: string): { ok: false; blocker: RotRutBlocker } => ({
ok: false,
@@ -198,6 +202,12 @@ export function evaluateInvoiceForFile(
if (!paidDate) {
return block('MISSING_PAYMENT_DATE', 'Fakturan saknar betalningsdatum.')
}
if (options.today && paidDate > options.today) {
return block(
'FUTURE_PAYMENT_DATE',
`Fakturans betalningsdatum (${paidDate}) ligger i framtiden och kan inte skickas till Skatteverket ännu.`,
)
}
if (!invoice.deduction_personnummer_encrypted) {
return block('MISSING_PERSONNUMMER', 'Fakturan saknar köparens personnummer.')
@@ -386,11 +396,31 @@ export function buildRotRutFile(params: {
const warnings: string[] = []
for (const invoice of invoices) {
const result = evaluateInvoiceForFile(type, invoice)
const result = evaluateInvoiceForFile(type, invoice, { today })
if (!result.ok) {
blockers.push(result.blocker)
continue
}
const paymentYear = result.value.arende.betalnings_datum.slice(0, 4)
const filePaymentYear = evaluated[0]?.arende.betalnings_datum.slice(0, 4)
if (filePaymentYear && paymentYear !== filePaymentYear) {
blockers.push({
invoice_id: invoice.id,
invoice_number: invoice.invoice_number ?? null,
code: 'MIXED_PAYMENT_YEARS',
message: `Fakturan betalades ${paymentYear}, men filen innehåller redan betalningar från ${filePaymentYear}. Skatteverket kräver en separat fil per betalningsår.`,
})
continue
}
if (evaluated.length >= 100) {
blockers.push({
invoice_id: invoice.id,
invoice_number: invoice.invoice_number ?? null,
code: 'TOO_MANY_CASES',
message: 'Skatteverket tillåter högst 100 ärenden per fil. Skapa ytterligare en fil för resten.',
})
continue
}
evaluated.push(result.value)
arenden.push(result.value.arende)
if (isPastRequestDeadline(result.value.arende.betalnings_datum, today)) {
+6 -2
View File
@@ -1,5 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Invoice } from '@/types'
import { getSwedishLocalDate } from '@/lib/bookkeeping/engine'
import {
buildRotRutFile,
evaluateInvoiceForFile,
@@ -44,6 +45,9 @@ export async function listRotRutCandidates(
supabase: SupabaseClient,
companyId: string,
type: DeductionType,
// Europe/Stockholm, not UTC: this date gates FUTURE_PAYMENT_DATE and the
// 31 January begäran deadline, both defined by Swedish calendar days.
today = getSwedishLocalDate(),
): Promise<
| { ok: true; eligible: RotRutCandidateSummary[]; blocked: RotRutBlockedSummary[] }
| { ok: false; dbError: unknown }
@@ -74,7 +78,7 @@ export async function listRotRutCandidates(
for (const invoice of (invoices ?? []) as unknown as InvoiceWithCustomer[]) {
if (activeInvoiceIds.has(invoice.id)) continue
const result = evaluateInvoiceForFile(type, invoice)
const result = evaluateInvoiceForFile(type, invoice, { today })
if (result.ok) {
eligible.push({
invoice_id: invoice.id,
@@ -133,7 +137,7 @@ export async function createRotRutPayoutRequest(
today?: string
},
): Promise<CreateRotRutRequestResult> {
const today = params.today ?? new Date().toISOString().slice(0, 10)
const today = params.today ?? getSwedishLocalDate()
const name = (params.name ?? `${params.type.toUpperCase()} ${today}`).slice(0, 16)
const { data: invoices, error: invoicesError } = await supabase