* 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>
211 lines
7.0 KiB
TypeScript
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 }
|
|
}
|