feat: INK2 declaration improvements, invoice delivery date & Swedish compliance skills (#204)

* feat: add INK2 declaration improvements, invoice delivery date, and Swedish compliance skills

Expand INK2 engine with full INK2S/INK2R support and improved SRU generation.
Add delivery_date field to invoices and corresponding PDF/migration support.
Add Claude skills for Swedish asset accounting, invoice compliance, SIE import/export, SRU filing, and tax planning.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review — map BAS 4500–4899, strip CRLF in SRU, document P3

- Map BAS accounts 4500–4599 (legoarbeten), 4700–4899 (diverse
  varuinköpskostnader) to SRU 7512 so they are not silently dropped
  from INK2R declarations
- Strip \r\n in sanitizeString to prevent CRLF injection in SRU fields
- Document P3 period suffix limitation for brutet räkenskapsår

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: correct BAS 4500-4599, 4700-4899 mapping from 7512 to 7511

Per the official BAS-to-SRU mapping, these account ranges are cost of
goods (legoarbeten, inkurans, svinn) and belong under 7511 (Råvaror
och förnödenheter), not 7512 (Handelsvaror). 7512 remains 4600-4699.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-09 13:16:10 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent bf5a8d9195
commit 7a18d89c70
37 changed files with 5481 additions and 658 deletions
+117 -41
View File
@@ -7,17 +7,18 @@ import { Badge } from '@/components/ui/badge'
import { Download, AlertCircle, Info } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import { formatCurrency } from '@/lib/utils'
import type { INK2Declaration, INK2SRUCode } from '@/lib/reports/ink2/types'
import type { INK2Declaration, INK2RSRUCode } from '@/lib/reports/ink2/types'
import {
INK2_RUTA_LABELS,
INK2_ASSET_CODES,
INK2_EQUITY_LIABILITY_CODES,
INK2_INCOME_STATEMENT_CODES,
INK2R_RUTA_LABELS,
INK2R_ASSET_CODES,
INK2R_EQUITY_LIABILITY_CODES,
INK2R_INCOME_CODES,
} from '@/lib/reports/ink2/types'
export function INK2DeclarationView({ periodId }: { periodId: string }) {
const [data, setData] = useState<INK2Declaration | null>(null)
const [loading, setLoading] = useState(false)
const [downloading, setDownloading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fetchDeclaration = async () => {
@@ -38,8 +39,24 @@ export function INK2DeclarationView({ periodId }: { periodId: string }) {
}
}
const downloadSRU = () => {
window.open(`/api/reports/ink2?period_id=${periodId}&format=sru`, '_blank')
const downloadSRU = async () => {
setDownloading(true)
try {
const res = await fetch(`/api/reports/ink2?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] || 'INK2_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)
}
}
return (
@@ -52,20 +69,34 @@ export function INK2DeclarationView({ periodId }: { periodId: string }) {
<CardContent>
<div className="flex items-start gap-2 mb-4 p-3 bg-primary/10 rounded-md">
<Info className="h-4 w-4 text-primary mt-0.5 shrink-0" />
<p className="text-sm text-primary">
INK2 visar det bokföringsmässiga resultatet baserat på din bokföring.
Skattemässiga justeringar (ej avdragsgilla kostnader, periodiseringsfonder m.m.)
hanteras av din revisor/redovisningskonsult.
</p>
<div className="text-sm text-primary space-y-1">
<p>
INK2 visar det bokföringsmässiga resultatet baserat på din bokföring.
Skattemässiga justeringar (ej avdragsgilla kostnader, periodiseringsfonder m.m.)
hanteras av din revisor/redovisningskonsult.
</p>
<p>
SRU-filen laddas ner som en ZIP med INFO.SRU och BLANKETTER.SRU.
Ladda upp båda filerna till{' '}
<a
href="https://www1.skatteverket.se/fv/fv_web/start.do"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Skatteverkets filöverföringstjänst
</a>.
</p>
</div>
</div>
<div className="flex gap-2">
<Button onClick={fetchDeclaration} disabled={loading}>
{loading ? 'Laddar...' : 'Hämta INK2'}
</Button>
{data && (
<Button variant="outline" onClick={downloadSRU}>
<Button variant="outline" onClick={downloadSRU} disabled={downloading}>
<Download className="h-4 w-4 mr-2" />
Ladda ner SRU-fil
{downloading ? 'Laddar ner...' : 'Ladda ner SRU-filer'}
</Button>
)}
</div>
@@ -126,12 +157,12 @@ export function INK2DeclarationView({ periodId }: { periodId: string }) {
<CardContent>
<table className="w-full text-sm">
<tbody>
{INK2_ASSET_CODES.map((code) => (
{INK2R_ASSET_CODES.map((code) => (
<INK2DeclarationRow
key={code}
code={code}
label={INK2_RUTA_LABELS[code]}
amount={data.rutor[code]}
label={INK2R_RUTA_LABELS[code]}
amount={data.ink2r[code]}
accounts={data.breakdown[code]?.accounts || []}
/>
))}
@@ -156,12 +187,12 @@ export function INK2DeclarationView({ periodId }: { periodId: string }) {
<CardContent>
<table className="w-full text-sm">
<tbody>
{INK2_EQUITY_LIABILITY_CODES.map((code) => (
{INK2R_EQUITY_LIABILITY_CODES.map((code) => (
<INK2DeclarationRow
key={code}
code={code}
label={INK2_RUTA_LABELS[code]}
amount={data.rutor[code]}
label={INK2R_RUTA_LABELS[code]}
amount={data.ink2r[code]}
accounts={data.breakdown[code]?.accounts || []}
/>
))}
@@ -186,19 +217,15 @@ export function INK2DeclarationView({ periodId }: { periodId: string }) {
<CardContent>
<table className="w-full text-sm">
<tbody>
{INK2_INCOME_STATEMENT_CODES.map((code) => {
const isExpense = code !== '7310' && code !== '7370' && code !== '7380'
return (
<INK2DeclarationRow
key={code}
code={code}
label={INK2_RUTA_LABELS[code]}
amount={data.rutor[code]}
accounts={data.breakdown[code]?.accounts || []}
isExpense={isExpense}
/>
)
})}
{INK2R_INCOME_CODES.map((code) => (
<INK2DeclarationRow
key={code}
code={code}
label={INK2R_RUTA_LABELS[code]}
amount={data.ink2r[code]}
accounts={data.breakdown[code]?.accounts || []}
/>
))}
</tbody>
<tfoot>
<tr className="border-t font-medium">
@@ -208,7 +235,7 @@ export function INK2DeclarationView({ periodId }: { periodId: string }) {
</td>
</tr>
<tr className="border-t-2 font-semibold">
<td className="py-2">Resultat efter finansiella poster</td>
<td className="py-2">Årets resultat</td>
<td className={`py-2 text-right ${data.totals.resultAfterFinancial >= 0 ? 'text-success' : 'text-destructive'}`}>
{formatCurrency(data.totals.resultAfterFinancial)}
</td>
@@ -217,6 +244,57 @@ export function INK2DeclarationView({ periodId }: { periodId: string }) {
</table>
</CardContent>
</Card>
{/* INK2S summary */}
<Card>
<CardHeader>
<CardTitle className="text-lg">INK2S — Skattemässiga justeringar</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-start gap-2 mb-4 p-3 bg-primary/10 rounded-md">
<Info className="h-4 w-4 text-primary mt-0.5 shrink-0" />
<p className="text-sm text-primary">
Grundläggande justeringar beräknas automatiskt. Manuella justeringar
(periodiseringsfonder, koncernbidrag m.m.) hanteras av din redovisningskonsult.
</p>
</div>
<table className="w-full text-sm">
<tbody>
<tr className="border-b">
<td className="py-2">
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">4.1</span>
Årets resultat (vinst)
</td>
<td className="py-2 text-right">{formatCurrency(data.ink2s['7650'])}</td>
</tr>
<tr className="border-b">
<td className="py-2">
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">4.2</span>
Årets resultat (förlust)
</td>
<td className="py-2 text-right">{formatCurrency(data.ink2s['7750'])}</td>
</tr>
<tr className="border-b">
<td className="py-2">
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">4.3a</span>
Skatt på årets resultat (ej avdragsgill)
</td>
<td className="py-2 text-right">{formatCurrency(data.ink2s['7651'])}</td>
</tr>
</tbody>
<tfoot>
<tr className="border-t-2 font-semibold">
<td className="py-2">
{data.ink2s['8020'] > 0 ? 'Överskott (punkt 1.1)' : 'Underskott (punkt 1.2)'}
</td>
<td className={`py-2 text-right ${data.ink2s['8020'] > 0 ? 'text-success' : 'text-destructive'}`}>
{formatCurrency(data.ink2s['8020'] > 0 ? data.ink2s['8020'] : data.ink2s['8021'])}
</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
</>
)}
@@ -236,13 +314,11 @@ function INK2DeclarationRow({
label,
amount,
accounts,
isExpense,
}: {
code: INK2SRUCode
code: INK2RSRUCode
label: string
amount: number
accounts: Array<{ accountNumber: string; accountName: string; amount: number }>
isExpense?: boolean
}) {
const [expanded, setExpanded] = useState(false)
@@ -263,8 +339,8 @@ function INK2DeclarationRow({
</span>
)}
</td>
<td className="py-2 text-right">
{isExpense && amount > 0 ? '-' : ''}{formatCurrency(Math.abs(amount))}
<td className="py-2 text-right tabular-nums">
{formatCurrency(amount)}
</td>
</tr>
{expanded && accounts.length > 0 && (
@@ -278,8 +354,8 @@ function INK2DeclarationRow({
<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 className="py-1 text-right tabular-nums">
{formatCurrency(acc.amount)}
</td>
</tr>
))}