diff --git a/DECISIONS.md b/DECISIONS.md index f1373518..22d8614e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -753,6 +753,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-03] CI gained a pg-upgrade job: apply the merge-base schema, seed real rows, apply ONLY the PR migrations, assert the data survived. Rationale: pg-real applies all 548 migrations to an EMPTY database, so a NOT NULL / CHECK / unique index / backfill passes against zero rows and can still break prod. Proven locally against supabase/postgres:15.8.1.060 with three bad migrations: a CHECK violating an ore-level row and a NOT NULL on a populated column both exit 0 on empty and exit 3 on seeded. Base migrations are read from the merge-base git tree, not the working tree, so a PR that edits a shipped migration still surfaces here. [2026-08-03] Issue #323 automatic excess depreciation is limited to reconciled IL 18 machinery and equipment with linear book depreciation and posts 8853/2153: buildings, intangible assets, and the 25 percent rest-value method follow separate rules, so calculation fails closed on an incomplete register or unposted planned depreciation. - [2026-08-03] Issue #314 zeroes the F-skatt avgifter basis at the calculation boundary as well as the rate: a rate-only exemption would stop the 7510/2731 charge but leave a false contribution basis in salary reports and AGI totals; the separate FK011/FK131 XML rendering defect remains scoped to issue #315. [2026-08-03] Issue #814 ships the custom inbox-domain dialog polish (i18n, role gating, load-error state) while INBOX_CUSTOM_DOMAINS_ENABLED stays off: the 2026-07-02 gate decision holds until Emil flips the flag and restores the workspace entry point, so the feature is ship-ready but dormant. +[2026-08-03] Issue #789 keeps ROT/RUT submission manual: Accounted generates, archives, and tracks Skatteverket HUS V6 XML, but the authorized user uploads and signs in the official e-service because no supported direct submission contract is available. + diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index 8d1f47db..0de10d9a 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -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 (
- {/* Page header (concept scene 15): title + Ny faktura split button */} + {/* Page header (concept scene 15): title + invoice actions */}

{t('title')}

- +
+ + +
{/* Toolbar: one status chip-picker (founder direction: the status @@ -452,6 +471,15 @@ export default function InvoicesPage() { }} /> )} + {showRotRutPayout && ( + { + if (!open) closeRotRutPayout() + }} + /> + )}
) } diff --git a/app/api/rot-rut/__tests__/routes.test.ts b/app/api/rot-rut/__tests__/routes.test.ts index f18deef1..a74b0185 100644 --- a/app/api/rot-rut/__tests__/routes.test.ts +++ b/app/api/rot-rut/__tests__/routes.test.ts @@ -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( diff --git a/components/invoices/RotRutPayoutDialog.tsx b/components/invoices/RotRutPayoutDialog.tsx new file mode 100644 index 00000000..1f216d1c --- /dev/null +++ b/components/invoices/RotRutPayoutDialog.tsx @@ -0,0 +1,582 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useLocale, useTranslations } from 'next-intl' +import { + AlertTriangle, + Ban, + CheckCircle2, + Download, + ExternalLink, + FileDown, + Loader2, +} from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { ContextPicker } from '@/components/common/ContextPicker' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Skeleton } from '@/components/ui/skeleton' +import { useToast } from '@/components/ui/use-toast' +import { downloadFile, saveBlobToDisk } from '@/lib/browser/download-file' +import { failureDescription } from '@/lib/browser/action-failure' +import { + getErrorMessage, + getResponseErrorMessage, + type ErrorLocale, +} from '@/lib/errors/get-error-message' +import { formatCurrency, formatDate } from '@/lib/utils' + +type DeductionType = 'rot' | 'rut' +type RequestStatus = + | 'generated' + | 'submitted' + | 'paid' + | 'partially_paid' + | 'rejected' + | 'cancelled' + +interface Candidate { + invoice_id: string + invoice_number: string | null + customer_name: string | null + personnummer_last4: string + betalnings_datum: string + pris_for_arbete: number + begart_belopp: number +} + +interface BlockedCandidate { + invoice_id: string + invoice_number: string | null + customer_name: string | null + code: string + message: string +} + +interface PayoutRequest { + id: string + name: string + deduction_type: DeductionType + status: RequestStatus + requested_total: number | string + decided_total: number | string | null + file_name: string + file_document_id: string | null + created_at: string + submitted_at: string | null + decided_at: string | null + items: Array<{ + id: string + invoice_id: string + requested_amount: number | string + decided_amount: number | string | null + invoice: { id: string; invoice_number: string | null } | null + }> +} + +interface RotRutPayoutDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + canWrite: boolean +} + +const MAX_CASES_PER_FILE = 100 +const SKATTEVERKET_SERVICE_URL = + 'https://www.skatteverket.se/foretag/etjansterochblanketter/allaetjanster/tjanster/rotochrutforetag.4.361dc8c15312eff6fdfca4.html' + +const STATUS_VARIANT: Record< + RequestStatus, + 'secondary' | 'outline' | 'success' | 'warning' | 'destructive' +> = { + generated: 'warning', + submitted: 'outline', + paid: 'success', + partially_paid: 'warning', + rejected: 'destructive', + cancelled: 'secondary', +} + +export default function RotRutPayoutDialog({ + open, + onOpenChange, + canWrite, +}: RotRutPayoutDialogProps) { + const t = useTranslations('invoices') + const locale = useLocale() as ErrorLocale + const { toast } = useToast() + const loadSequence = useRef(0) + const [type, setType] = useState('rot') + const [eligible, setEligible] = useState([]) + const [blocked, setBlocked] = useState([]) + const [requests, setRequests] = useState([]) + const [selectedYear, setSelectedYear] = useState('') + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [loading, setLoading] = useState(false) + const [generating, setGenerating] = useState(false) + const [updatingId, setUpdatingId] = useState(null) + const [downloadingId, setDownloadingId] = useState(null) + + const load = useCallback( + async (nextType: DeductionType) => { + const sequence = ++loadSequence.current + setLoading(true) + setSelectedIds(new Set()) + try { + const [eligibleResponse, requestsResponse] = await Promise.all([ + fetch(`/api/rot-rut/eligible?type=${nextType}`), + fetch(`/api/rot-rut/payout-requests?type=${nextType}`), + ]) + const failedResponse = !eligibleResponse.ok + ? eligibleResponse + : !requestsResponse.ok + ? requestsResponse + : null + if (failedResponse) { + const description = await getResponseErrorMessage(failedResponse, 'invoice', locale) + if (sequence !== loadSequence.current) return + setEligible([]) + setBlocked([]) + setRequests([]) + setSelectedYear('') + toast({ title: t('rot_rut_load_failed_title'), description, variant: 'destructive' }) + return + } + + const eligibleBody = (await eligibleResponse.json()) as { + data: { eligible: Candidate[]; blocked: BlockedCandidate[] } + } + const requestsBody = (await requestsResponse.json()) as { data: PayoutRequest[] } + if (sequence !== loadSequence.current) return + + const nextEligible = eligibleBody.data.eligible + const years = Array.from( + new Set(nextEligible.map((candidate) => candidate.betalnings_datum.slice(0, 4))), + ).sort((a, b) => b.localeCompare(a)) + setEligible(nextEligible) + setBlocked(eligibleBody.data.blocked) + setRequests(requestsBody.data) + setSelectedYear(years[0] ?? '') + } catch (error) { + if (sequence !== loadSequence.current) return + setEligible([]) + setBlocked([]) + setRequests([]) + setSelectedYear('') + toast({ + title: t('rot_rut_load_failed_title'), + description: getErrorMessage(error, { context: 'invoice', locale }), + variant: 'destructive', + }) + } finally { + if (sequence === loadSequence.current) setLoading(false) + } + }, + [locale, t, toast], + ) + + useEffect(() => { + if (open) void load(type) + }, [load, open, type]) + + const years = useMemo( + () => + Array.from( + new Set(eligible.map((candidate) => candidate.betalnings_datum.slice(0, 4))), + ).sort((a, b) => b.localeCompare(a)), + [eligible], + ) + const visibleCandidates = useMemo( + () => + eligible.filter((candidate) => candidate.betalnings_datum.startsWith(selectedYear)), + [eligible, selectedYear], + ) + const selectedTotal = visibleCandidates + .filter((candidate) => selectedIds.has(candidate.invoice_id)) + .reduce((sum, candidate) => sum + Number(candidate.begart_belopp), 0) + + function changeType(nextType: DeductionType) { + setType(nextType) + } + + function changeYear(year: string) { + setSelectedYear(year) + setSelectedIds(new Set()) + } + + function toggleCandidate(invoiceId: string, checked: boolean) { + setSelectedIds((current) => { + const next = new Set(current) + if (checked) { + if (next.size >= MAX_CASES_PER_FILE) return current + next.add(invoiceId) + } else { + next.delete(invoiceId) + } + return next + }) + } + + function selectAllVisible() { + const visibleIds = visibleCandidates + .slice(0, MAX_CASES_PER_FILE) + .map((candidate) => candidate.invoice_id) + const allSelected = visibleIds.every((id) => selectedIds.has(id)) + setSelectedIds(allSelected ? new Set() : new Set(visibleIds)) + } + + async function generateFile() { + if (!canWrite || generating || selectedIds.size === 0) return + setGenerating(true) + try { + const response = await fetch('/api/rot-rut/payout-file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + deduction_type: type, + invoice_ids: Array.from(selectedIds), + }), + }) + if (!response.ok) { + toast({ + title: t('rot_rut_generate_failed_title'), + description: await getResponseErrorMessage(response, 'invoice', locale), + variant: 'destructive', + }) + return + } + const body = (await response.json()) as { + data: { xml: string; file_name: string; warnings: string[] } + } + saveBlobToDisk( + new Blob([body.data.xml], { type: 'application/xml;charset=utf-8' }), + body.data.file_name, + ) + toast({ + title: t('rot_rut_generated_title'), + description: + body.data.warnings.length > 0 + ? body.data.warnings.join(' ') + : t('rot_rut_generated_description'), + }) + await load(type) + } catch (error) { + toast({ + title: t('rot_rut_generate_failed_title'), + description: getErrorMessage(error, { context: 'invoice', locale }), + variant: 'destructive', + }) + } finally { + setGenerating(false) + } + } + + async function updateRequest(requestId: string, status: 'submitted' | 'cancelled') { + if (!canWrite || updatingId) return + setUpdatingId(requestId) + try { + const response = await fetch(`/api/rot-rut/payout-requests/${requestId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }), + }) + if (!response.ok) { + toast({ + title: t('rot_rut_update_failed_title'), + description: await getResponseErrorMessage(response, 'invoice', locale), + variant: 'destructive', + }) + return + } + toast({ title: t(status === 'submitted' ? 'rot_rut_uploaded_title' : 'rot_rut_cancelled_title') }) + await load(type) + } catch (error) { + toast({ + title: t('rot_rut_update_failed_title'), + description: getErrorMessage(error, { context: 'invoice', locale }), + variant: 'destructive', + }) + } finally { + setUpdatingId(null) + } + } + + async function downloadArchivedFile(request: PayoutRequest) { + if (!request.file_document_id || downloadingId) return + setDownloadingId(request.id) + try { + const result = await downloadFile({ + url: `/api/documents/${request.file_document_id}/inline`, + filename: request.file_name, + locale, + }) + if (!result.ok) { + toast({ + title: t('rot_rut_download_failed_title'), + description: failureDescription(result, { + timeout: t('rot_rut_download_timeout'), + network: t('rot_rut_download_network'), + }), + variant: 'destructive', + }) + } + } finally { + setDownloadingId(null) + } + } + + return ( + + + + {t('rot_rut_payout_title')} + {t('rot_rut_payout_description')} + + +
+ changeType(id as DeductionType)} + triggerLabel={t(type === 'rot' ? 'rot_rut_type_rot' : 'rot_rut_type_rut')} + ariaLabel={t('rot_rut_type_aria')} + items={[ + { id: 'rot', label: t('rot_rut_type_rot') }, + { id: 'rut', label: t('rot_rut_type_rut') }, + ]} + disabled={loading || generating} + /> + {years.length > 0 && ( + ({ id: year, label: year }))} + disabled={loading || generating} + /> + )} +
+ + {loading ? ( +
+ + + +
+ ) : ( +
+
+
+
+

+ {t('rot_rut_eligible_title')} +

+

+ {t('rot_rut_selected_count', { + selected: selectedIds.size, + amount: formatCurrency(selectedTotal), + })} +

+
+ {visibleCandidates.length > 0 && ( + + )} +
+ + {visibleCandidates.length === 0 ? ( +
+

{t('rot_rut_no_eligible_title')}

+

+ {t('rot_rut_no_eligible_description')} +

+
+ ) : ( +
+ {visibleCandidates.map((candidate) => { + const checkboxId = `rot-rut-${candidate.invoice_id}` + const checked = selectedIds.has(candidate.invoice_id) + const atLimit = selectedIds.size >= MAX_CASES_PER_FILE && !checked + return ( + + ) + })} +
+ )} + + {visibleCandidates.length > MAX_CASES_PER_FILE && ( +
+ + {t('rot_rut_max_cases_help', { count: MAX_CASES_PER_FILE })} +
+ )} + +
+ +
+
+ + {blocked.length > 0 && ( +
+ + {t('rot_rut_blocked_title', { count: blocked.length })} + +
+ {blocked.map((candidate) => ( +
+ + + + {candidate.invoice_number ?? '-'} · {candidate.customer_name ?? '-'} + + {candidate.message} + +
+ ))} +
+
+ )} + +
+
+

+ {t('rot_rut_history_title')} +

+

{t('rot_rut_upload_help')}

+
+ {requests.length === 0 ? ( +

{t('rot_rut_history_empty')}

+ ) : ( +
+ {requests.map((request) => { + const isUpdating = updatingId === request.id + const isDownloading = downloadingId === request.id + return ( +
+
+
+
+ {request.name} + + {t(`rot_rut_status_${request.status}`)} + +
+

+ {t('rot_rut_history_meta', { + date: formatDate(request.created_at), + count: request.items.length, + amount: formatCurrency(Number(request.requested_total)), + })} +

+
+
+ {request.file_document_id && ( + + )} + {request.status === 'generated' && canWrite && ( + <> + + + + )} +
+
+
+ ) + })} +
+ )} + +
+
+ )} +
+
+ ) +} diff --git a/lib/invoices/__tests__/rot-rut-file.test.ts b/lib/invoices/__tests__/rot-rut-file.test.ts index 98d313f0..40d4c9b5 100644 --- a/lib/invoices/__tests__/rot-rut-file.test.ts +++ b/lib/invoices/__tests__/rot-rut-file.test.ts @@ -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', diff --git a/lib/invoices/rot-rut-file.ts b/lib/invoices/rot-rut-file.ts index 500af77a..141a3e84 100644 --- a/lib/invoices/rot-rut-file.ts +++ b/lib/invoices/rot-rut-file.ts @@ -78,8 +78,11 @@ const WORK_TYPE_ELEMENTS: Record ({ 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)) { diff --git a/lib/invoices/rot-rut-service.ts b/lib/invoices/rot-rut-service.ts index 99600682..f49a2f20 100644 --- a/lib/invoices/rot-rut-service.ts +++ b/lib/invoices/rot-rut-service.ts @@ -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 { - 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 diff --git a/messages/en.json b/messages/en.json index 4e8091e1..27b92aaf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3074,7 +3074,51 @@ "to_pay_label": "Amount to pay", "total_incl_vat_label": "Total incl. VAT", "review_customer_missing_title": "Customer details could not be loaded", - "review_customer_missing_description": "Reload the page and try again. Contact support if the problem persists." + "review_customer_missing_description": "Reload the page and try again. Contact support if the problem persists.", + "rot_rut_payout_action": "ROT/RUT file", + "rot_rut_payout_title": "Request a ROT/RUT payout", + "rot_rut_payout_description": "Select paid invoices and download an XML file for Skatteverket's e-service. The file is not submitted automatically: upload and sign it at Skatteverket.", + "rot_rut_type_aria": "Select deduction type", + "rot_rut_type_rot": "ROT", + "rot_rut_type_rut": "RUT", + "rot_rut_year_aria": "Select payment year", + "rot_rut_loading": "Loading ROT/RUT details", + "rot_rut_load_failed_title": "Could not load the ROT/RUT details", + "rot_rut_load_failed_description": "Reload the page and try again.", + "rot_rut_eligible_title": "Invoices to include", + "rot_rut_selected_count": "{selected} selected · {amount}", + "rot_rut_select_all": "Select all", + "rot_rut_clear_selection": "Clear selection", + "rot_rut_no_eligible_title": "No invoices are ready", + "rot_rut_no_eligible_description": "A paid ROT or RUT invoice appears here once its buyer details are complete.", + "rot_rut_paid_at": "Paid {date}", + "rot_rut_max_cases_help": "Skatteverket allows at most {count} cases in one file. Create multiple files if you need to request more invoices.", + "rot_rut_generate_file": "Create and download file", + "rot_rut_generating_file": "Creating file…", + "rot_rut_generated_title": "The ROT/RUT file was downloaded", + "rot_rut_generated_description": "Upload the file in Skatteverket's e-service and sign the request there.", + "rot_rut_generate_failed_title": "Could not create the ROT/RUT file", + "rot_rut_blocked_title": "Cannot be included ({count})", + "rot_rut_history_title": "Previous files", + "rot_rut_upload_help": "After uploading and signing at Skatteverket, mark the file as uploaded here.", + "rot_rut_history_empty": "No files have been created for this deduction type.", + "rot_rut_history_meta": "{date} · {count} cases · {amount}", + "rot_rut_status_generated": "Created", + "rot_rut_status_submitted": "Uploaded", + "rot_rut_status_paid": "Approved", + "rot_rut_status_partially_paid": "Partly approved", + "rot_rut_status_rejected": "Rejected", + "rot_rut_status_cancelled": "Cancelled", + "rot_rut_download_again": "Download again", + "rot_rut_cancel_request": "Cancel", + "rot_rut_mark_uploaded": "Mark as uploaded", + "rot_rut_uploaded_title": "The file is marked as uploaded", + "rot_rut_cancelled_title": "The request was cancelled", + "rot_rut_update_failed_title": "Could not update the request", + "rot_rut_download_failed_title": "Could not download the file", + "rot_rut_download_timeout": "The download took too long. Try again.", + "rot_rut_download_network": "The file could not be downloaded. Check your connection and try again.", + "rot_rut_skatteverket_link": "Open ROT and RUT at Skatteverket" }, "invoice_review": { "assigned_number_prefix": "Will be assigned invoice number", diff --git a/messages/sv.json b/messages/sv.json index 84649a5a..ce7068fc 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3074,7 +3074,51 @@ "to_pay_label": "Att betala", "total_incl_vat_label": "Totalt inkl. moms", "review_customer_missing_title": "Kunduppgifterna kunde inte laddas", - "review_customer_missing_description": "Ladda om sidan och försök igen. Kontakta support om det inte hjälper." + "review_customer_missing_description": "Ladda om sidan och försök igen. Kontakta support om det inte hjälper.", + "rot_rut_payout_action": "ROT/RUT-fil", + "rot_rut_payout_title": "Begär utbetalning för ROT/RUT", + "rot_rut_payout_description": "Välj betalda fakturor och hämta en XML-fil för Skatteverkets e-tjänst. Filen skickas inte automatiskt: du laddar upp och signerar den hos Skatteverket.", + "rot_rut_type_aria": "Välj avdragstyp", + "rot_rut_type_rot": "ROT", + "rot_rut_type_rut": "RUT", + "rot_rut_year_aria": "Välj betalningsår", + "rot_rut_loading": "Laddar ROT/RUT-underlag", + "rot_rut_load_failed_title": "Kunde inte ladda ROT/RUT-underlaget", + "rot_rut_load_failed_description": "Ladda om sidan och försök igen.", + "rot_rut_eligible_title": "Fakturor att ta med", + "rot_rut_selected_count": "{selected} valda · {amount}", + "rot_rut_select_all": "Välj alla", + "rot_rut_clear_selection": "Rensa val", + "rot_rut_no_eligible_title": "Inga fakturor är redo", + "rot_rut_no_eligible_description": "När en ROT- eller RUT-faktura är betald och har fullständiga köparuppgifter visas den här.", + "rot_rut_paid_at": "Betald {date}", + "rot_rut_max_cases_help": "Skatteverket tillåter högst {count} ärenden i samma fil. Skapa flera filer om fler fakturor ska begäras.", + "rot_rut_generate_file": "Skapa och hämta fil", + "rot_rut_generating_file": "Skapar fil…", + "rot_rut_generated_title": "ROT/RUT-filen är hämtad", + "rot_rut_generated_description": "Ladda upp filen i Skatteverkets e-tjänst och signera begäran där.", + "rot_rut_generate_failed_title": "Kunde inte skapa ROT/RUT-filen", + "rot_rut_blocked_title": "Kan inte tas med ({count})", + "rot_rut_history_title": "Tidigare filer", + "rot_rut_upload_help": "Efter uppladdning och signering hos Skatteverket markerar du filen som uppladdad här.", + "rot_rut_history_empty": "Inga filer har skapats för den här avdragstypen.", + "rot_rut_history_meta": "{date} · {count} ärenden · {amount}", + "rot_rut_status_generated": "Skapad", + "rot_rut_status_submitted": "Uppladdad", + "rot_rut_status_paid": "Beviljad", + "rot_rut_status_partially_paid": "Delvis beviljad", + "rot_rut_status_rejected": "Avslagen", + "rot_rut_status_cancelled": "Avbruten", + "rot_rut_download_again": "Hämta igen", + "rot_rut_cancel_request": "Avbryt", + "rot_rut_mark_uploaded": "Markera uppladdad", + "rot_rut_uploaded_title": "Filen är markerad som uppladdad", + "rot_rut_cancelled_title": "Begäran är avbruten", + "rot_rut_update_failed_title": "Kunde inte uppdatera begäran", + "rot_rut_download_failed_title": "Kunde inte hämta filen", + "rot_rut_download_timeout": "Hämtningen tog för lång tid. Försök igen.", + "rot_rut_download_network": "Filen kunde inte hämtas. Kontrollera anslutningen och försök igen.", + "rot_rut_skatteverket_link": "Öppna ROT och RUT hos Skatteverket" }, "invoice_review": { "assigned_number_prefix": "Tilldelas fakturanummer",