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
@@ -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',
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user