Files
accounted/lib/invoices/rot-rut-service.ts
T
MattssonandClaude Fable 5 00d4c8a49e 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>
2026-08-03 18:16:46 +02:00

211 lines
7.0 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import type { Invoice } from '@/types'
import { getSwedishLocalDate } from '@/lib/bookkeeping/engine'
import {
buildRotRutFile,
evaluateInvoiceForFile,
type BuildRotRutFileResult,
type RotRutBlocker,
} from './rot-rut-file'
import type { DeductionType } from './rot-rut-rules'
/**
* Shared service behind the rot/rut payout-file API routes and the MCP tool
* (gnubok_generate_rot_rut_file): one implementation of "which invoices can
* go into a begäran" and "record the begäran", so the two surfaces can never
* drift apart.
*/
export interface RotRutCandidateSummary {
invoice_id: string
invoice_number: string | null
customer_name: string | null
personnummer_last4: string
betalnings_datum: string
pris_for_arbete: number
begart_belopp: number
}
export interface RotRutBlockedSummary {
invoice_id: string
invoice_number: string | null
customer_name: string | null
code: string
message: string
}
type InvoiceWithCustomer = Invoice & { customer?: { name?: string | null } | null }
/**
* Paid deduction-carrying invoices not yet claimed by an active begäran,
* evaluated against the file rules. Invoices whose deduction belongs solely
* to the other type are omitted entirely (they're the other list's business).
*/
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 }
> {
const { data: invoices, error } = await supabase
.from('invoices')
.select('*, items:invoice_items(*), customer:customers(id, name)')
.eq('company_id', companyId)
.eq('document_type', 'invoice')
.eq('status', 'paid')
.gt('deduction_total', 0)
.order('paid_at', { ascending: true })
if (error) return { ok: false, dbError: error }
const { data: activeItems, error: activeError } = 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)
.not('request.status', 'in', '("cancelled","rejected")')
if (activeError) return { ok: false, dbError: activeError }
const activeInvoiceIds = new Set((activeItems ?? []).map((r) => r.invoice_id))
const eligible: RotRutCandidateSummary[] = []
const blocked: RotRutBlockedSummary[] = []
for (const invoice of (invoices ?? []) as unknown as InvoiceWithCustomer[]) {
if (activeInvoiceIds.has(invoice.id)) continue
const result = evaluateInvoiceForFile(type, invoice, { today })
if (result.ok) {
eligible.push({
invoice_id: invoice.id,
invoice_number: invoice.invoice_number ?? null,
customer_name: invoice.customer?.name ?? null,
personnummer_last4: result.value.arende.personnummer_last4,
betalnings_datum: result.value.arende.betalnings_datum,
pris_for_arbete: result.value.arende.pris_for_arbete,
begart_belopp: result.value.arende.begart_belopp,
})
} else if (result.blocker.code !== 'NO_DEDUCTION_OF_TYPE') {
blocked.push({
invoice_id: invoice.id,
invoice_number: invoice.invoice_number ?? null,
customer_name: invoice.customer?.name ?? null,
code: result.blocker.code,
message: result.blocker.message,
})
}
}
return { ok: true, eligible, blocked }
}
export type CreateRotRutRequestResult =
| { ok: true; request: Record<string, unknown>; file: BuildRotRutFileResult }
| {
ok: false
code:
| 'ROT_RUT_REQUEST_NOT_FOUND'
| 'ROT_RUT_NO_ELIGIBLE_INVOICES'
| 'ROT_RUT_INVOICES_BLOCKED'
| 'ROT_RUT_INVOICE_CONFLICT'
| 'ROT_RUT_FILE_CREATE_FAILED'
blockers?: RotRutBlocker[]
missingInvoiceIds?: string[]
}
/**
* Generate the begäran file for the given invoices and record the request +
* items. All-or-nothing: any blocked invoice rejects the whole call with the
* per-invoice blockers. The DB trigger enforce_single_active_rot_rut_request
* stays the authoritative double-request guard (surfaced as INVOICE_CONFLICT).
*
* Document archiving is deliberately NOT done here: it needs the storage
* bucket and differs per surface (the API route archives, best-effort).
*/
export async function createRotRutPayoutRequest(
supabase: SupabaseClient,
companyId: string,
userId: string,
params: {
type: DeductionType
invoiceIds: string[]
name?: string
today?: string
},
): Promise<CreateRotRutRequestResult> {
const today = params.today ?? getSwedishLocalDate()
const name = (params.name ?? `${params.type.toUpperCase()} ${today}`).slice(0, 16)
const { data: invoices, error: invoicesError } = await supabase
.from('invoices')
.select('*, items:invoice_items(*)')
.eq('company_id', companyId)
.eq('document_type', 'invoice')
.in('id', params.invoiceIds)
if (invoicesError) {
return { ok: false, code: 'ROT_RUT_FILE_CREATE_FAILED' }
}
const foundIds = new Set((invoices ?? []).map((i) => i.id))
const missing = params.invoiceIds.filter((id) => !foundIds.has(id))
if (missing.length > 0) {
return { ok: false, code: 'ROT_RUT_REQUEST_NOT_FOUND', missingInvoiceIds: missing }
}
const file = buildRotRutFile({
type: params.type,
name,
invoices: (invoices ?? []) as unknown as Invoice[],
today,
})
if (!file.xml) {
return { ok: false, code: 'ROT_RUT_NO_ELIGIBLE_INVOICES', blockers: file.blockers }
}
if (file.blockers.length > 0) {
return { ok: false, code: 'ROT_RUT_INVOICES_BLOCKED', blockers: file.blockers }
}
const { data: payoutRequest, error: insertError } = await supabase
.from('rot_rut_payout_requests')
.insert({
company_id: companyId,
user_id: userId,
deduction_type: params.type,
name,
status: 'generated',
requested_total: file.requested_total,
file_name: file.file_name,
})
.select()
.single()
if (insertError || !payoutRequest) {
return { ok: false, code: 'ROT_RUT_FILE_CREATE_FAILED' }
}
const itemRows = file.arenden.map((a) => ({
request_id: payoutRequest.id,
invoice_id: a.invoice_id,
requested_amount: a.begart_belopp,
}))
const { error: itemsError } = await supabase
.from('rot_rut_payout_request_items')
.insert(itemRows)
if (itemsError) {
// Roll back the header row: without items the request is meaningless.
await supabase.from('rot_rut_payout_requests').delete().eq('id', payoutRequest.id)
const conflict =
(itemsError as { code?: string }).code === '23505' ||
itemsError.message?.includes('active rot/rut payout request')
return { ok: false, code: conflict ? 'ROT_RUT_INVOICE_CONFLICT' : 'ROT_RUT_FILE_CREATE_FAILED' }
}
return { ok: true, request: payoutRequest, file }
}