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
+36 -8
View File
@@ -23,7 +23,7 @@ import { formatCurrency, formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
import { invoiceDisplayNumber } from '@/lib/invoices/display'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { Plus, Search, ReceiptText, Repeat, FileInput } from 'lucide-react'
import { Plus, Search, ReceiptText, Repeat, FileInput, FileDown } from 'lucide-react'
import { EmptyInvoices } from '@/components/ui/empty-state'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
@@ -50,6 +50,10 @@ const NewInvoiceDialog = dynamic(
{ loading: NewInvoiceDialogLoading },
)
const RotRutPayoutDialog = dynamic(
() => import('@/components/invoices/RotRutPayoutDialog'),
)
const INITIAL_VISIBLE_ROWS = 100
const CREATE_MODES = ['faktura', 'aterkommande', 'sjalvfaktura'] as const
@@ -116,9 +120,12 @@ export default function InvoicesPage() {
const copyFromId = searchParams.get('copy')
const showNewInvoice = searchParams.has('new') || copyFromId !== null
const openSelfBilled = searchParams.has('self')
const showRotRutPayout = searchParams.has('rot-rut')
const closeNewInvoice = () => router.replace('/invoices', { scroll: false })
const openNewInvoice = () => router.push('/invoices?new=1', { scroll: false })
const openNewSelfBilled = () => router.push('/invoices?new=1&self=1', { scroll: false })
const closeRotRutPayout = () => router.replace('/invoices', { scroll: false })
const openRotRutPayout = () => router.push('/invoices?rot-rut=1', { scroll: false })
async function fetchInvoices() {
if (!company) return
@@ -251,15 +258,27 @@ export default function InvoicesPage() {
return (
<div className="space-y-8">
{/* Page header (concept scene 15): title + Ny faktura split button */}
{/* Page header (concept scene 15): title + invoice actions */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="font-display text-2xl leading-8 tracking-tight">{t('title')}</h1>
<SplitButton
key={uiStateLoaded ? 'loaded' : 'initial'}
persistKey="invoices"
initialModeKey={resolveInitialMode(uiState, 'invoices', CREATE_MODES, 'faktura')}
options={createOptions}
/>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
onClick={openRotRutPayout}
disabled={!canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
<FileDown className="mr-2 h-4 w-4" />
{t('rot_rut_payout_action')}
</Button>
<SplitButton
key={uiStateLoaded ? 'loaded' : 'initial'}
persistKey="invoices"
initialModeKey={resolveInitialMode(uiState, 'invoices', CREATE_MODES, 'faktura')}
options={createOptions}
/>
</div>
</div>
{/* Toolbar: one status chip-picker (founder direction: the status
@@ -452,6 +471,15 @@ export default function InvoicesPage() {
}}
/>
)}
{showRotRutPayout && (
<RotRutPayoutDialog
open
canWrite={canWrite}
onOpenChange={(open) => {
if (!open) closeRotRutPayout()
}}
/>
)}
</div>
)
}
+30
View File
@@ -232,6 +232,36 @@ describe('POST /api/rot-rut/payout-file', () => {
expect(body.error.code).toBe('ROT_RUT_INVOICES_BLOCKED')
})
it('rejects a file that mixes payment years', async () => {
const otherInvoiceId = '33333333-3333-4333-8333-333333333333'
enqueue({
data: [
makePaidRotInvoice(),
makePaidRotInvoice({
id: otherInvoiceId,
invoice_number: 'F-2025',
paid_at: '2025-12-30T10:00:00Z',
}),
],
})
const response = await payoutFilePOST(
createMockRequest('/api/rot-rut/payout-file', {
method: 'POST',
body: { deduction_type: 'rot', invoice_ids: [INVOICE_ID, otherInvoiceId] },
}),
)
const { status, body } = await parseJsonResponse<{
error: { code: string; details?: { blockers: Array<{ code: string }> } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('ROT_RUT_INVOICES_BLOCKED')
expect(body.error.details?.blockers).toEqual([
expect.objectContaining({ invoice_id: otherInvoiceId, code: 'MIXED_PAYMENT_YEARS' }),
])
})
it('returns 404 when an invoice id does not belong to the company', async () => {
enqueue({ data: [] })
const response = await payoutFilePOST(