* fix(reports): generate valid two-file NE-bilaga SRU submission (#318, #319) The NE-bilaga "Ladda ner SRU" export produced a file Skatteverket rejects: it was served as UTF-8 text/plain (å/ä/ö mojibake, #319) and was structurally invalid — a single blob with #PRODUKT KONTROLLUPPGIFTER (the KU code), no INFO.SRU/BLANKETTER.SRU split, a #SKAPAT typo, no #FIL_SLUT, and suspect field codes 7310–7350 (#318). Rewrite the generator to mirror the working INK2 generator: a two-file INFO.SRU + BLANKETTER.SRU submission, ISO 8859-1 encoded and zipped, with #PRODUKT SRU, #DATABESKRIVNING_*/#MEDIELEV_*, #BLANKETT NE-<år>P<x>, #IDENTITET <personnummer12> <date> <time>, and #FIL_SLUT. Field codes use the authoritative BAS NE_EJ_K1 coupling table (R1→7400 … R10→7505, R11→7440; period dates 7011/7012). Enskild-firma identity is the owner's 12-digit personnummer (birth-century prefix, not INK2's juridisk-person "16"). - Extract the shared ISO-8859-1 encoder to lib/reports/sru-encoding.ts (was inline in the INK2 route). - Extend the NE engine/types to carry address/postort/email for INFO.SRU. - Frontend: NE SRU download uses the INK2 blob pattern; fix a pre-existing param bug in EfDeclarationSection (fiscal_period_id → period_id, +format=sru). - Add generator tests (structure, BAS field codes, zero-omission, ISO-8859-1). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reports): address review feedback on NE-bilaga SRU generator (#318) - getZipFilename uses the income year (fiscal year END) so the filename matches the blankett type/identity for broken fiscal years. - Refuse to generate a submission when the personnummer is missing/invalid (compute + validate the 12-digit identity once in generateNESRUSubmission and throw) instead of silently emitting a placeholder #IDENTITET that Skatteverket would reject after upload. - validateBlanketterSru now asserts the mandatory räkenskapsår date fields (#UPPGIFT 7011/7012) — their absence is a level-2 rejection. - 10-digit personnummer century is inferred from adult age (≥18, <110) at the income year, fixing the e.g. 1924-born/yy=24 edge that mapped to 2024. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Download, AlertCircle } from 'lucide-react'
|
|
import { AccountNumber } from '@/components/ui/account-number'
|
|
import type { NEDeclaration } from '@/lib/reports/ne-bilaga/types'
|
|
import { formatCurrency } from '@/lib/utils'
|
|
|
|
export function NEDeclarationView({ periodId }: { periodId: string }) {
|
|
const [data, setData] = useState<NEDeclaration | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [downloading, setDownloading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const fetchDeclaration = async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const res = await fetch(`/api/reports/ne-bilaga?period_id=${periodId}`)
|
|
const result = await res.json()
|
|
if (result.error) {
|
|
setError(result.error)
|
|
} else {
|
|
setData(result.data)
|
|
}
|
|
} catch {
|
|
setError('Kunde inte hämta NE-bilaga')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const downloadSRU = async () => {
|
|
setDownloading(true)
|
|
try {
|
|
const res = await fetch(`/api/reports/ne-bilaga?period_id=${periodId}&format=sru`)
|
|
if (!res.ok) throw new Error('Download failed')
|
|
const blob = await res.blob()
|
|
const filename = res.headers.get('Content-Disposition')?.match(/filename="(.+)"/)?.[1] || 'NE_SRU.zip'
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = filename
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
} catch {
|
|
setError('Kunde inte ladda ner SRU-filer')
|
|
} finally {
|
|
setDownloading(false)
|
|
}
|
|
}
|
|
|
|
// NE ruta labels
|
|
const rutaLabels: Record<string, string> = {
|
|
R1: 'Försäljning med moms (25%)',
|
|
R2: 'Momsfria intäkter',
|
|
R3: 'Bil/bostadsförmån',
|
|
R4: 'Ränteintäkter',
|
|
R5: 'Varuinköp',
|
|
R6: 'Övriga kostnader',
|
|
R7: 'Lönekostnader',
|
|
R8: 'Räntekostnader',
|
|
R9: 'Avskrivningar fastighet',
|
|
R10: 'Avskrivningar övriga tillgångar',
|
|
R11: 'Årets resultat',
|
|
}
|
|
|
|
// Categorize rutor
|
|
const revenueRutor = ['R1', 'R2', 'R3', 'R4'] as const
|
|
const expenseRutor = ['R5', 'R6', 'R7', 'R8', 'R9', 'R10'] as const
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Info card */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">NE-bilaga (Enskild firma)</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-sm text-muted-foreground mb-4">
|
|
NE-bilagan används för att deklarera resultat från enskild näringsverksamhet.
|
|
Uppgifterna hämtas från bokföringen för valt räkenskapsår.
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button onClick={fetchDeclaration} disabled={loading}>
|
|
{loading ? 'Laddar...' : 'Hämta NE-bilaga'}
|
|
</Button>
|
|
{data && (
|
|
<Button variant="outline" onClick={downloadSRU} disabled={downloading}>
|
|
<Download className="h-4 w-4 mr-2" />
|
|
{downloading ? 'Laddar ner...' : 'Ladda ner SRU-fil'}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{error && (
|
|
<Card>
|
|
<CardContent className="p-8 text-center text-destructive">
|
|
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
|
{error}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{data && (
|
|
<>
|
|
{/* Warnings */}
|
|
{data.warnings.length > 0 && (
|
|
<Card className="border-border">
|
|
<CardContent className="py-4">
|
|
<div className="flex items-start gap-2">
|
|
<AlertCircle className="h-5 w-5 text-warning mt-0.5" />
|
|
<div>
|
|
{data.warnings.map((warning, i) => (
|
|
<p key={i} className="text-sm text-foreground">{warning}</p>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Company info */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle>
|
|
{data.companyInfo.companyName}
|
|
</CardTitle>
|
|
<Badge variant="secondary">
|
|
{data.fiscalYear.name}
|
|
</Badge>
|
|
</div>
|
|
{data.companyInfo.orgNumber && (
|
|
<p className="text-sm text-muted-foreground">
|
|
Org.nr: {data.companyInfo.orgNumber}
|
|
</p>
|
|
)}
|
|
</CardHeader>
|
|
</Card>
|
|
|
|
{/* Revenue section */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Intäkter</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<table className="w-full text-sm">
|
|
<tbody>
|
|
{revenueRutor.map((ruta) => {
|
|
const value = data.rutor[ruta]
|
|
const breakdown = data.breakdown[ruta]
|
|
return (
|
|
<NEDeclarationRow
|
|
key={ruta}
|
|
ruta={ruta}
|
|
label={rutaLabels[ruta]}
|
|
amount={value}
|
|
accounts={breakdown?.accounts || []}
|
|
/>
|
|
)
|
|
})}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr className="border-t-2 font-semibold">
|
|
<td className="py-2">Summa intäkter</td>
|
|
<td className="py-2 text-right tabular-nums">
|
|
{formatCurrency(
|
|
data.rutor.R1 + data.rutor.R2 + data.rutor.R3 + data.rutor.R4
|
|
)}
|
|
</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Expenses section */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Kostnader</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<table className="w-full text-sm">
|
|
<tbody>
|
|
{expenseRutor.map((ruta) => {
|
|
const value = data.rutor[ruta]
|
|
const breakdown = data.breakdown[ruta]
|
|
return (
|
|
<NEDeclarationRow
|
|
key={ruta}
|
|
ruta={ruta}
|
|
label={rutaLabels[ruta]}
|
|
amount={value}
|
|
accounts={breakdown?.accounts || []}
|
|
isExpense
|
|
/>
|
|
)
|
|
})}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr className="border-t-2 font-semibold">
|
|
<td className="py-2">Summa kostnader</td>
|
|
<td className="py-2 text-right tabular-nums">
|
|
-{formatCurrency(
|
|
data.rutor.R5 + data.rutor.R6 + data.rutor.R7 +
|
|
data.rutor.R8 + data.rutor.R9 + data.rutor.R10
|
|
)}
|
|
</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Result */}
|
|
<Card className="border-2">
|
|
<CardContent className="py-4">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">R11</span>
|
|
<span className="font-display text-xl">Årets resultat</span>
|
|
</div>
|
|
<span
|
|
className={`font-display text-2xl tabular-nums ${
|
|
data.rutor.R11 >= 0 ? 'text-success' : 'text-destructive'
|
|
}`}
|
|
>
|
|
{formatCurrency(data.rutor.R11)}
|
|
</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)}
|
|
|
|
{!data && !loading && !error && (
|
|
<Card>
|
|
<CardContent className="p-8 text-center text-muted-foreground">
|
|
Klicka "Hämta NE-bilaga" för att generera deklarationsunderlaget.
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function NEDeclarationRow({
|
|
ruta,
|
|
label,
|
|
amount,
|
|
accounts,
|
|
isExpense,
|
|
}: {
|
|
ruta: string
|
|
label: string
|
|
amount: number
|
|
accounts: Array<{ accountNumber: string; accountName: string; amount: number }>
|
|
isExpense?: boolean
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
|
|
// Don't show rows with zero values
|
|
if (amount === 0 && accounts.length === 0) return null
|
|
|
|
return (
|
|
<>
|
|
<tr
|
|
className="border-b cursor-pointer hover:bg-muted/50"
|
|
onClick={() => accounts.length > 0 && setExpanded(!expanded)}
|
|
>
|
|
<td className="py-2">
|
|
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">{ruta}</span>
|
|
{label}
|
|
{accounts.length > 0 && (
|
|
<span className="text-xs text-muted-foreground ml-2">
|
|
({accounts.length} konton)
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="py-2 text-right tabular-nums">
|
|
{isExpense && amount > 0 ? '-' : ''}{formatCurrency(Math.abs(amount))}
|
|
</td>
|
|
</tr>
|
|
{expanded && accounts.length > 0 && (
|
|
<tr>
|
|
<td colSpan={2} className="py-2 pl-8 bg-muted/30">
|
|
<table className="w-full text-xs">
|
|
<tbody>
|
|
{accounts.map((acc) => (
|
|
<tr key={acc.accountNumber}>
|
|
<td className="py-1"><AccountNumber number={acc.accountNumber} name={acc.accountName} size="sm" /></td>
|
|
<td className="py-1">{acc.accountName}</td>
|
|
<td className="py-1 text-right">
|
|
{isExpense && acc.amount > 0 ? '-' : ''}{formatCurrency(Math.abs(acc.amount))}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</>
|
|
)
|
|
}
|