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
+2
-1
@@ -753,6 +753,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<DeductionType>('rot')
|
||||
const [eligible, setEligible] = useState<Candidate[]>([])
|
||||
const [blocked, setBlocked] = useState<BlockedCandidate[]>([])
|
||||
const [requests, setRequests] = useState<PayoutRequest[]>([])
|
||||
const [selectedYear, setSelectedYear] = useState('')
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null)
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader className="pr-8">
|
||||
<DialogTitle>{t('rot_rut_payout_title')}</DialogTitle>
|
||||
<DialogDescription>{t('rot_rut_payout_description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ContextPicker
|
||||
value={type}
|
||||
onChange={(id) => 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 && (
|
||||
<ContextPicker
|
||||
value={selectedYear}
|
||||
onChange={changeYear}
|
||||
triggerLabel={selectedYear}
|
||||
ariaLabel={t('rot_rut_year_aria')}
|
||||
items={years.map((year) => ({ id: year, label: year }))}
|
||||
disabled={loading || generating}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3 py-2" role="status" aria-label={t('rot_rut_loading')}>
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<section className="space-y-3" aria-labelledby="rot-rut-candidates-title">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 id="rot-rut-candidates-title" className="text-sm font-medium">
|
||||
{t('rot_rut_eligible_title')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('rot_rut_selected_count', {
|
||||
selected: selectedIds.size,
|
||||
amount: formatCurrency(selectedTotal),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{visibleCandidates.length > 0 && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={selectAllVisible}>
|
||||
{visibleCandidates
|
||||
.slice(0, MAX_CASES_PER_FILE)
|
||||
.every((candidate) => selectedIds.has(candidate.invoice_id))
|
||||
? t('rot_rut_clear_selection')
|
||||
: t('rot_rut_select_all')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visibleCandidates.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-5 text-center">
|
||||
<p className="text-sm font-medium">{t('rot_rut_no_eligible_title')}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('rot_rut_no_eligible_description')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-64 divide-y overflow-y-auto rounded-lg border">
|
||||
{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 (
|
||||
<label
|
||||
key={candidate.invoice_id}
|
||||
htmlFor={checkboxId}
|
||||
className="flex min-h-14 cursor-pointer items-center gap-3 px-3 py-2.5 hover:bg-secondary/35"
|
||||
>
|
||||
<Checkbox
|
||||
id={checkboxId}
|
||||
checked={checked}
|
||||
disabled={atLimit || generating || !canWrite}
|
||||
onCheckedChange={(value) =>
|
||||
toggleCandidate(candidate.invoice_id, value === true)
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{candidate.invoice_number ?? '-'} · {candidate.customer_name ?? '-'}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('rot_rut_paid_at', { date: formatDate(candidate.betalnings_datum) })}
|
||||
{' · ****'}{candidate.personnummer_last4}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-sm tabular-nums">
|
||||
{formatCurrency(Number(candidate.begart_belopp))}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleCandidates.length > MAX_CASES_PER_FILE && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-warning/30 bg-warning/5 p-3 text-xs text-muted-foreground">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning-foreground" />
|
||||
<span>{t('rot_rut_max_cases_help', { count: MAX_CASES_PER_FILE })}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={generateFile}
|
||||
disabled={!canWrite || generating || selectedIds.size === 0}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
>
|
||||
{generating ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t(generating ? 'rot_rut_generating_file' : 'rot_rut_generate_file')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{blocked.length > 0 && (
|
||||
<details className="rounded-lg border border-dashed px-3 py-2.5">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
{t('rot_rut_blocked_title', { count: blocked.length })}
|
||||
</summary>
|
||||
<div className="mt-3 space-y-2">
|
||||
{blocked.map((candidate) => (
|
||||
<div key={candidate.invoice_id} className="flex items-start gap-2 text-xs">
|
||||
<Ban className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span>
|
||||
<span className="font-medium text-foreground">
|
||||
{candidate.invoice_number ?? '-'} · {candidate.customer_name ?? '-'}
|
||||
</span>
|
||||
<span className="block text-muted-foreground">{candidate.message}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<section className="space-y-3 border-t pt-5" aria-labelledby="rot-rut-history-title">
|
||||
<div>
|
||||
<h2 id="rot-rut-history-title" className="text-sm font-medium">
|
||||
{t('rot_rut_history_title')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{t('rot_rut_upload_help')}</p>
|
||||
</div>
|
||||
{requests.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('rot_rut_history_empty')}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{requests.map((request) => {
|
||||
const isUpdating = updatingId === request.id
|
||||
const isDownloading = downloadingId === request.id
|
||||
return (
|
||||
<div key={request.id} className="rounded-lg border p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{request.name}</span>
|
||||
<Badge variant={STATUS_VARIANT[request.status]} className="font-normal">
|
||||
{t(`rot_rut_status_${request.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('rot_rut_history_meta', {
|
||||
date: formatDate(request.created_at),
|
||||
count: request.items.length,
|
||||
amount: formatCurrency(Number(request.requested_total)),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{request.file_document_id && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={isDownloading}
|
||||
onClick={() => void downloadArchivedFile(request)}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('rot_rut_download_again')}
|
||||
</Button>
|
||||
)}
|
||||
{request.status === 'generated' && canWrite && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isUpdating}
|
||||
onClick={() => void updateRequest(request.id, 'cancelled')}
|
||||
>
|
||||
{t('rot_rut_cancel_request')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isUpdating}
|
||||
onClick={() => void updateRequest(request.id, 'submitted')}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('rot_rut_mark_uploaded')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Button asChild type="button" variant="outline" className="w-full sm:w-auto">
|
||||
<a href={SKATTEVERKET_SERVICE_URL} target="_blank" rel="noopener noreferrer">
|
||||
{t('rot_rut_skatteverket_link')}
|
||||
<ExternalLink className="ml-2 h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+45
-1
@@ -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",
|
||||
|
||||
+45
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user