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:
co-authored by
Claude Fable 5
parent
df34cae9bf
commit
00d4c8a49e
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user