Add/bokslut (#718)

* feat(arcim-migration): Briox provider with SIE-over-API import

- Briox auth via account ID + application token (no app-level
  credentials); both tokens rotate on refresh and are persisted
- New sie-fetcher pulls the general ledger as SIE through the
  provider API for Fortnox, Briox and Bjorn Lunden
- Wizard stops on a failed SIE import and surfaces the real errors
  instead of proceeding to the misleading migrate-guard message
- PROVIDER_SIE_ONLY_FORTNOX renamed to PROVIDER_SIE_NOT_SUPPORTED;
  new PROVIDER_TOKEN_INVALID for rejected provider credentials

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): per-line accruals (periodisering) on invoices and supplier invoices

Defer revenue/costs per invoice line to 29xx/17xx interim accounts with
automatic monthly dissolution (nightly cron + catch-up at registration),
schedule cancellation on credit, year-end auto-detect exclusion for
already-scheduled invoices, invoice-inbox service-period extraction for
prefill, and an MCP tool to list schedules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bokslut): iXBRL arsredovisning generation and Bolagsverket digital filing

Generate the annual report as iXBRL from a generated taxonomy registry
(K2 element lists, taxonomy:generate/check scripts + CI guard), expose it
via the fiscal-period API, and add the bolagsverket extension for digital
submission to eget utrymme with webhook-driven status tracking
(submissions table + pg tests, lifecycle events, year-end wizard UI).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mcp): raise origin-guard test timeout to 20s

The dynamic import pulls in the full server module; the parse alone
flirts with the 5s default under full-suite parallel load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add new scripts and documentation for K2 AB taxonomy generation and validation

- Introduced `generate-taxonomy-registry.ts` to automate the generation of the iXBRL taxonomy concept registry from official element lists and tuple models.
- Added `validate-ixbrl.mjs` for validating generated iXBRL reports against the official taxonomy package using Arelle.
- Included new documentation files:
  - `k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx`
  - `tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx`
  - `taxonomi-paket-2024-09-12_rev20250312.zip`

* Add tests for bookkeeping accruals dissolution and supplier invoices

- Implement tests for the POST /api/bookkeeping/accruals/[id]/dissolve route, covering success and error scenarios.
- Add tests for the DELETE /api/supplier-invoices/[id] route, including authentication checks and validation of invoice deletion conditions.
- Introduce tests for the Arcim migration provider client, ensuring token handling and error classification.
- Create tests for the Bolagsverket extension, validating submission role enforcement and environment settings.
- Add Zod schemas for Bolagsverket response payloads to ensure proper validation.
- Implement tests for MCP server's list accrual schedules, confirming registration and scope mapping.
- Add consistency tests for IXBRL document generation, ensuring duplicate facts and XML escaping are handled correctly.
- Introduce typed domain errors for accrual schedules to improve error handling in the service.
- Add tests for resolving consent with Briox token refresh concurrency, ensuring proper token management and error handling.

* fix(tests): update payload size guard comments to reflect recent changes in tool descriptions and ceiling adjustments

* fix(gitattributes): mark generated JSON files in bokslut taxonomy as linguist-generated

* feat(migrations): add backfill for invoices.journal_entry_id and fallback for next_voucher_number user_id

* feat(bokslut): enhance compliance and financial processing features with new submission details and security measures

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-12 16:35:30 +02:00
committed by GitHub
parent 8e8b63a200
commit db8983ba9e
131 changed files with 33796 additions and 340 deletions
+44
View File
@@ -321,3 +321,47 @@ processing_activities:
- service_role_only_writes
- error_message_truncated_500_chars
- no_tool_args_or_results_persisted
- id: arsredovisning.bolagsverket.submit
name: Digital inlämning av årsredovisning till Bolagsverket
purpose: >-
Lämna in årsredovisningen (iXBRL) digitalt till Bolagsverkets eget
utrymme (ÅRL 8 kap.). Avsändarens och undertecknarens personnummer
krävs av Bolagsverkets API (skapa-inlamningtoken, lamna-in) och
används transient i anropen; i databasen sparas endast
företagssaltade SHA-256-hashar. Fastställelseintyget signeras med
e-legitimation hos Bolagsverket, aldrig i appen.
lawful_basis: art_6_1_c # legal obligation (ÅRL filing duty)
special_category_basis: null
controller: gnubok-tenant
processor: anthropic-na # software supplier; Bolagsverket is recipient, not processor
data_subjects:
- business_owner # avsändare/undertecknare (styrelseledamot, VD)
data_categories:
- user.government_id # personnummer (transient; at rest only salted SHA-256 hash)
- user.name # undertecknarens namn
- user.contact # undertecknarens/kvittens e-post
- user.financial # årsredovisningens finansiella innehåll
recipients:
- name: Bolagsverket
country: SE
role: legal_recipient
international_transfers:
applicable: false
mechanism: null
note: Sweden-to-Sweden flow to a public authority; no third-country transfer.
retention:
duration: 7y
basis: bfl_7_kap # filed .xhtml archived as räkenskapsinformation
stored_in:
- arsredovisning_submissions # status, idnummer, checksums, PNR hashes only
- document_attachments # the exact filed iXBRL bytes (WORM)
security_measures:
- mtls_client_certificate_to_bolagsverket
- mtls_key_env_only_injected_from_secret_manager # never in settings/DB; see .env.example custody note
- pinned_endpoint_allowlist_per_environment # HOSTS map in client.ts; not env-configurable
- personnummer_never_persisted_plaintext_salted_sha256_only
- personnummer_never_logged
- webhook_secret_constant_time_validation
- rls_company_scoped
- immutable_status_machine_trigger
+21
View File
@@ -25,3 +25,24 @@ CRON_SECRET=generate-a-random-secret
# FORTNOX_CLIENT_ID=
# FORTNOX_CLIENT_SECRET=
# FORTNOX_REDIRECT_URI=
# Björn Lundén app credentials (OAuth2 client credentials; per-company
# User-Key is entered by the user in the migration wizard)
# BJORN_LUNDEN_CLIENT_ID=
# BJORN_LUNDEN_CLIENT_SECRET=
# Bolagsverket — digital inlämning av årsredovisning (bolagsverket extension).
# BOLAGSVERKET_ENV is test | accept | prod (default test) and also caps which
# environment a company may select in settings (test < accept < prod).
# Certificate material is read from env ONLY (PEM or base64-wrapped PEM) —
# never from extension settings or the database.
#
# SECRET CUSTODY (prod): never keep the real mTLS private key in a plaintext
# .env file. Inject these at runtime from a secrets manager (Vercel encrypted
# env vars, AWS Secrets Manager, Vault, Doppler, …), restrict read access to
# the deploy pipeline, and rotate the client certificate/key on the cadence
# agreed with Bolagsverket (and immediately on suspected exposure). Outbound
# hosts are pinned per environment in extensions/general/bolagsverket/lib/
# client.ts (HOSTS) — the endpoint is not configurable via env.
# BOLAGSVERKET_ENV=
# BOLAGSVERKET_CLIENT_CERT=
# BOLAGSVERKET_CLIENT_KEY=
# BOLAGSVERKET_CA=
+5
View File
@@ -6,3 +6,8 @@ Dockerfile text eol=lf
docker-entrypoint.sh text eol=lf
docker/Caddyfile text eol=lf
docker/crontab.* text eol=lf
# Generated taxonomy registry is compared byte-for-byte by `npm run
# taxonomy:check` (CI); keep LF on Windows checkouts so the local check
# matches what the generator writes.
lib/bokslut/ixbrl/taxonomy/generated/*.json text eol=lf linguist-generated=true
+6
View File
@@ -16,6 +16,12 @@ jobs:
# seed migration (npm run skills:generate). Keeps prod skill content from
# silently drifting out of sync. No DB needed — reads files + manifest.
run: npm run skills:check
- name: Verify taxonomy registry is in sync with the element lists
# Fails if dev_docs/bokslut/taxonomi/** changed without regenerating
# lib/bokslut/ixbrl/taxonomy/generated/ (npm run taxonomy:generate).
# The iXBRL generator emits facts strictly from the generated registry,
# so drift here means filings tagged against a stale concept set.
run: npm run taxonomy:check
- name: Reset extensions config
run: echo '{"extensions":[]}' > extensions.config.json
- run: npm run setup:extensions
+20 -2
View File
@@ -60,8 +60,26 @@ supabase/.temp/
# scripts/inject-public-branding.mjs — runs via predev/prebuild)
/public/sw.js
# dev docs (internal reference, not published)
/dev_docs
# dev docs (internal reference, not published) — EXCEPT the iXBRL taxonomy
# sources and the official Bolagsverket example, which CI needs: the committed
# lib/bokslut/ixbrl/taxonomy/generated/ registry is regenerated from them by
# `npm run taxonomy:check` (core-build.yml) and the golden test pins against
# the example document.
/dev_docs/*
!/dev_docs/bokslut/
/dev_docs/bokslut/*
!/dev_docs/bokslut/taxonomi/
/dev_docs/bokslut/taxonomi/*
!/dev_docs/bokslut/taxonomi/taxonomi-paket-2024-09-12_rev20250312.zip
!/dev_docs/bokslut/taxonomi/dokumentation/
/dev_docs/bokslut/taxonomi/dokumentation/*
!/dev_docs/bokslut/taxonomi/dokumentation/k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx
!/dev_docs/bokslut/taxonomi/dokumentation/tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx
!/dev_docs/bokslut/exempel/
/dev_docs/bokslut/exempel/*
!/dev_docs/bokslut/exempel/k2/
/dev_docs/bokslut/exempel/k2/*
!/dev_docs/bokslut/exempel/k2/faststalld-arsredovisning-exempel-1-rev20240214.xhtml
# Extension registry (auto-generated but defaults are committed)
# Run `npm run setup:extensions` to regenerate after changing extensions.config.json
@@ -0,0 +1,420 @@
'use client'
// Periodiseringar — löpande accrual schedules (förutbetalda kostnader 17xx /
// förutbetalda intäkter 29xx) skapade från fakturarader. Djupt regulatorisk
// bokföringsyta → svenska i båda locales, i linje med bokslutsguiden.
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { AlertCircle, CalendarClock, ChevronDown, Loader2 } from 'lucide-react'
import { PageHeader } from '@/components/ui/page-header'
import { Card, CardContent } from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { EmptyState } from '@/components/ui/empty-state'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import type {
AccrualSchedule,
AccrualScheduleInstallment,
AccrualScheduleStatus,
} from '@/types'
type ScheduleWithInstallments = AccrualSchedule & {
installments: AccrualScheduleInstallment[]
}
type StatusFilter = 'active' | 'completed' | 'all'
const SCHEDULE_BADGE: Record<
AccrualScheduleStatus,
{ label: string; variant: 'secondary' | 'success' | 'outline' }
> = {
active: { label: 'Aktiv', variant: 'secondary' },
completed: { label: 'Avslutad', variant: 'success' },
cancelled: { label: 'Makulerad', variant: 'outline' },
}
function monthLabel(periodMonth: string): string {
return periodMonth.slice(0, 7)
}
function sumPosted(installments: AccrualScheduleInstallment[]): number {
return (
Math.round(
installments
.filter((i) => i.status === 'posted')
.reduce((sum, i) => sum + i.amount, 0) * 100,
) / 100
)
}
export default function AccrualSchedulesPage() {
const { toast } = useToast()
const { canWrite } = useCanWrite()
const [statusFilter, setStatusFilter] = useState<StatusFilter>('active')
const [schedules, setSchedules] = useState<ScheduleWithInstallments[]>([])
const [dueCount, setDueCount] = useState(0)
const [isLoading, setIsLoading] = useState(true)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [isPosting, setIsPosting] = useState(false)
const [dissolveTarget, setDissolveTarget] = useState<ScheduleWithInstallments | null>(null)
const [isDissolving, setIsDissolving] = useState(false)
const fetchSchedules = useCallback(async (filter: StatusFilter) => {
setIsLoading(true)
try {
const res = await fetch(`/api/bookkeeping/accruals?status=${filter}`)
const json = await res.json()
if (!res.ok) throw new Error(getErrorMessage(json, { context: 'journal_entry' }))
setSchedules(json.data ?? [])
setDueCount(json.due_count ?? 0)
} catch (error) {
toast({
title: 'Kunde inte ladda periodiseringar',
description: getErrorMessage(error, { context: 'journal_entry' }),
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}, [toast])
useEffect(() => {
fetchSchedules(statusFilter)
}, [statusFilter, fetchSchedules])
function toggleExpanded(id: string) {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
async function handlePostDue() {
setIsPosting(true)
try {
const res = await fetch('/api/bookkeeping/accruals/post-due', { method: 'POST' })
const json = await res.json()
if (!res.ok) throw new Error(getErrorMessage(json, { context: 'journal_entry' }))
const result = json.data as { posted: number; failed: number }
toast({
title:
result.failed > 0
? 'Periodiseringar bokförda med fel'
: 'Periodiseringar bokförda',
description:
result.failed > 0
? `${result.posted} verifikat bokfördes, ${result.failed} misslyckades — se felmeddelandet på respektive månad.`
: `${result.posted} verifikat bokfördes.`,
variant: result.failed > 0 ? 'destructive' : undefined,
})
await fetchSchedules(statusFilter)
} catch (error) {
toast({
title: 'Bokföringen misslyckades',
description: getErrorMessage(error, { context: 'journal_entry' }),
variant: 'destructive',
})
} finally {
setIsPosting(false)
}
}
async function handleDissolve() {
if (!dissolveTarget) return
setIsDissolving(true)
try {
const res = await fetch(`/api/bookkeeping/accruals/${dissolveTarget.id}/dissolve`, {
method: 'POST',
})
const json = await res.json()
if (!res.ok) throw new Error(getErrorMessage(json, { context: 'journal_entry' }))
toast({
title: 'Periodiseringen upplöst',
description: `Återstående ${formatCurrency(json.data.amount)} bokfördes i ett verifikat.`,
})
setDissolveTarget(null)
await fetchSchedules(statusFilter)
} catch (error) {
toast({
title: 'Upplösningen misslyckades',
description: getErrorMessage(error, { context: 'journal_entry' }),
variant: 'destructive',
})
} finally {
setIsDissolving(false)
}
}
const blockedInstallments = useMemo(
() =>
schedules.reduce(
(count, schedule) =>
count +
schedule.installments.filter((i) => i.status === 'pending' && i.last_error).length,
0,
),
[schedules],
)
return (
<div className="space-y-8">
<PageHeader title="Periodiseringar" />
{(dueCount > 0 || blockedInstallments > 0) && (
<div
role="status"
className="flex flex-col gap-3 rounded-lg border border-border bg-secondary/40 p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex items-start gap-3">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
<div className="text-sm">
<p className="font-medium">
{dueCount > 0
? `${dueCount} ${dueCount === 1 ? 'månad väntar' : 'månader väntar'} på att bokföras`
: 'Periodiseringar med fel'}
</p>
<p className="text-muted-foreground">
{blockedInstallments > 0
? `${blockedInstallments} ${blockedInstallments === 1 ? 'månad kunde' : 'månader kunde'} inte bokföras automatiskt — öppna raden för felmeddelandet.`
: 'Förfallna månader bokförs automatiskt varje natt, eller direkt här.'}
</p>
</div>
</div>
{canWrite && dueCount > 0 && (
<Button onClick={handlePostDue} disabled={isPosting} className="shrink-0">
{isPosting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Bokför
</>
) : (
'Bokför förfallna'
)}
</Button>
)}
</div>
)}
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
<TabsList>
<TabsTrigger value="active">Aktiva</TabsTrigger>
<TabsTrigger value="completed">Avslutade</TabsTrigger>
<TabsTrigger value="all">Alla</TabsTrigger>
</TabsList>
</Tabs>
{isLoading ? (
<Card>
<CardContent className="space-y-3 p-6">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-2/3" />
</CardContent>
</Card>
) : schedules.length === 0 ? (
<EmptyState
icon={CalendarClock}
title="Inga periodiseringar"
description="Periodisera en fakturarad när du registrerar en leverantörsfaktura eller skapar en kundfaktura, så fördelas beloppet automatiskt över månaderna här."
/>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" />
<TableHead>Beskrivning</TableHead>
<TableHead>Konto</TableHead>
<TableHead>Period</TableHead>
<TableHead className="text-right">Totalt</TableHead>
<TableHead className="text-right">Kvar</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-28" />
</TableRow>
</TableHeader>
<TableBody>
{schedules.map((schedule) => {
const dissolved = sumPosted(schedule.installments)
const remaining =
schedule.status === 'cancelled'
? 0
: roundOre(schedule.total_amount - dissolved)
const isOpen = expanded.has(schedule.id)
const badge = SCHEDULE_BADGE[schedule.status]
const sourceHref = schedule.supplier_invoice_id
? `/supplier-invoices/${schedule.supplier_invoice_id}`
: schedule.invoice_id
? `/invoices/${schedule.invoice_id}`
: null
return (
<Fragment key={schedule.id}>
<TableRow
className="cursor-pointer"
onClick={() => toggleExpanded(schedule.id)}
>
<TableCell className="pr-0">
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform duration-150',
isOpen && 'rotate-180',
)}
aria-hidden="true"
/>
</TableCell>
<TableCell className="max-w-[320px]">
<span className="block truncate" title={schedule.description ?? ''}>
{schedule.description || '—'}
</span>
{sourceHref && (
<Link
href={sourceHref}
onClick={(e) => e.stopPropagation()}
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
>
{schedule.supplier_invoice_id ? 'Leverantörsfaktura' : 'Kundfaktura'}
</Link>
)}
</TableCell>
<TableCell className="tabular-nums text-muted-foreground">
{schedule.balance_account} {schedule.target_account}
</TableCell>
<TableCell className="tabular-nums">
{formatDate(schedule.period_start)} {formatDate(schedule.period_end)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCurrency(schedule.total_amount)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCurrency(remaining)}
</TableCell>
<TableCell>
<Badge variant={badge.variant}>{badge.label}</Badge>
</TableCell>
<TableCell className="text-right">
{canWrite && schedule.status === 'active' && remaining > 0 && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation()
setDissolveTarget(schedule)
}}
>
Lös upp nu
</Button>
)}
</TableCell>
</TableRow>
{isOpen && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={8} className="bg-muted/30 p-0">
<div className="px-6 py-4">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<th className="pb-2">Månad</th>
<th className="pb-2 text-right">Belopp</th>
<th className="pb-2 pl-6">Status</th>
<th className="pb-2 pl-6">Verifikat</th>
</tr>
</thead>
<tbody>
{schedule.installments.map((installment) => (
<tr key={installment.id} className="border-t border-border/60">
<td className="py-1.5 tabular-nums">
{monthLabel(installment.period_month)}
</td>
<td className="py-1.5 text-right tabular-nums">
{formatCurrency(installment.amount)}
</td>
<td className="py-1.5 pl-6">
{installment.status === 'posted' ? (
<Badge variant="success">Bokförd</Badge>
) : installment.status === 'cancelled' ? (
<Badge variant="outline">Makulerad</Badge>
) : installment.last_error ? (
<span className="inline-flex items-center gap-1.5">
<Badge variant="destructive">Fel</Badge>
<span className="text-xs text-muted-foreground">
{installment.last_error}
</span>
</span>
) : (
<Badge variant="outline">Väntar</Badge>
)}
</td>
<td className="py-1.5 pl-6">
{installment.journal_entry_id ? (
<Link
href={`/bookkeeping/${installment.journal_entry_id}`}
className="text-xs underline-offset-2 hover:underline"
>
Öppna verifikat
</Link>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</TableCell>
</TableRow>
)}
</Fragment>
)
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{dissolveTarget && (
<ConfirmationDialog
open={!!dissolveTarget}
onOpenChange={(open) => !open && setDissolveTarget(null)}
onConfirm={handleDissolve}
isSubmitting={isDissolving}
title="Lös upp periodiseringen nu?"
warningText={`Återstående ${formatCurrency(
Math.round(
(dissolveTarget.total_amount - sumPosted(dissolveTarget.installments)) * 100,
) / 100,
)} bokförs i ett verifikat daterat idag, och periodiseringen avslutas.`}
confirmLabel="Lös upp nu"
>
<div className="space-y-1 text-sm">
<p className="font-medium">{dissolveTarget.description || 'Periodisering'}</p>
<p className="tabular-nums text-muted-foreground">
{dissolveTarget.target_account} {dissolveTarget.balance_account} ·{' '}
{formatDate(dissolveTarget.period_start)} {formatDate(dissolveTarget.period_end)}
</p>
</div>
</ConfirmationDialog>
)}
</div>
)
}
@@ -14,6 +14,7 @@ import { PageHeader } from '@/components/ui/page-header'
import { ArrowLeft, FileDown, Plus, ExternalLink, Loader2, Save, CheckCircle2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import { DigitalInlamning, INLAMNING_COMING_SOON } from '@/components/bokslut/DigitalInlamning'
import type { ArsredovisningData } from '@/lib/bokslut/arsredovisning/types'
import type { SignatureRequest } from '@/lib/bokslut/arsredovisning/signature-service'
@@ -676,16 +677,37 @@ export default function ArsredovisningPage() {
<FileDown className="mr-2 h-4 w-4" /> Ladda ner PDF (utkast)
</Link>
</Button>
<Button variant="outline" asChild>
<Link
href="https://www.bolagsverket.se/foretag/aktiebolag/arsredovisning/lamna-in-arsredovisning"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="mr-2 h-4 w-4" /> Bolagsverket Mina Sidor
</Link>
</Button>
{/* Bolagsverket-delarna blurras tills integrationen är godkänd —
rubriken, instruktionstexten och PDF-knappen förblir skarpa. */}
<span
inert={INLAMNING_COMING_SOON}
aria-hidden={INLAMNING_COMING_SOON}
className={
INLAMNING_COMING_SOON
? 'pointer-events-none select-none blur-[3px] opacity-60'
: undefined
}
>
<Button variant="outline" asChild>
<Link
href="https://www.bolagsverket.se/foretag/aktiebolag/arsredovisning/lamna-in-arsredovisning"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="mr-2 h-4 w-4" /> Bolagsverket Mina Sidor
</Link>
</Button>
</span>
</div>
<div
inert={INLAMNING_COMING_SOON}
aria-hidden={INLAMNING_COMING_SOON}
className={
INLAMNING_COMING_SOON
? 'pointer-events-none select-none blur-[3px] opacity-60 space-y-4'
: 'space-y-4'
}
>
{data.warnings.length > 0 && (
<div className="rounded-md border border-warning/40 bg-warning/5 p-3 text-xs text-warning-foreground space-y-1">
<p className="font-medium">Innan inlämning till Bolagsverket:</p>
@@ -697,14 +719,19 @@ export default function ArsredovisningPage() {
</div>
)}
<div className="rounded-md border border-warning/40 bg-warning/5 p-3 text-xs text-warning-foreground">
<strong>Notis om digital inlämning:</strong> Bolagsverket har föreslagit att
digital inlämning (iXBRL) av årsredovisning för aktiebolag ska bli
obligatorisk beslut och ikraftträdande är ännu inte fastställda. Idag är
PDF-inlämning fortfarande godkänd. Accounted stödjer för närvarande endast
PDF-utkast; iXBRL-generering är planerad till en kommande version.
<strong>Notis om digital inlämning:</strong> Digital inlämning (iXBRL) av
årsredovisning föreslås bli obligatorisk för K2/K3-aktiebolag för
räkenskapsår som inleds efter 2025-12-31. Använd avsnittet{' '}
<strong>Digital inlämning</strong> nedan för att granska, validera och lämna
in årsredovisningen som iXBRL PDF:en ovan är ett läsexemplar.
</div>
</div>
</CardContent>
</Card>
{data.accounting_framework === 'k2' && periodId && (
<DigitalInlamning periodId={periodId} />
)}
</div>
)
}
+42 -1
View File
@@ -30,6 +30,7 @@ import {
MessageSquare,
Trash2,
Lock,
CalendarClock,
} from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import PaymentBookingDialog from '@/components/invoices/PaymentBookingDialog'
@@ -55,6 +56,13 @@ const statusVariantMap: Record<InvoiceStatus, 'default' | 'secondary' | 'success
credited: 'secondary',
}
// A line is periodiserad when both period dates are set — the revenue was
// parked on the 29xx interim account and dissolves monthly via accrual_schedules.
const itemHasAccrual = (item: InvoiceItem): boolean =>
!!(item.accrual_period_start && item.accrual_period_end)
const accrualMonth = (date: string): string => date.slice(0, 7)
interface InvoiceWithRelations extends Invoice {
customer: Customer
items: InvoiceItem[]
@@ -511,6 +519,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
// Self-billing invoices we received: the document is the counterparty's, so
// there is no own PDF to render and no send step — it arrives already booked.
const isSelfBilled = !!invoice.is_self_billed
const hasAccruedItems = invoice.items.some(itemHasAccrual)
return (
<div className="space-y-8">
{/* Header */}
@@ -534,6 +543,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<Badge variant={displayStatusVariant as 'default' | 'secondary' | 'destructive' | 'outline'}>
{displayStatusLabel}
</Badge>
{hasAccruedItems && (
<Badge variant="outline" className="gap-1">
<CalendarClock className="h-3 w-3" />
{t('badge_accrued')}
</Badge>
)}
</div>
<p className="text-muted-foreground">
{t('created_at', { date: formatDate(invoice.created_at) })}
@@ -690,7 +705,21 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</div>
) : (
<div key={item.id} className="grid grid-cols-12 gap-4 text-sm">
<div className="col-span-5">{item.description}</div>
<div className="col-span-5">
{item.description}
{itemHasAccrual(item) && (
<p className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="tabular-nums">
{t('accrual_line_info', {
from: accrualMonth(item.accrual_period_start!),
to: accrualMonth(item.accrual_period_end!),
})}
{item.accrual_balance_account && ` · ${item.accrual_balance_account}`}
</span>
</p>
)}
</div>
<div className="col-span-2 text-right">{item.quantity}</div>
<div className="col-span-1 text-center">{item.unit}</div>
<div className="col-span-2 text-right">
@@ -712,6 +741,18 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
) : (
<div key={item.id} className="border rounded-lg p-3 text-sm space-y-1.5">
<p className="font-medium">{item.description}</p>
{itemHasAccrual(item) && (
<p className="flex items-center gap-1 text-xs text-muted-foreground">
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="tabular-nums">
{t('accrual_line_info', {
from: accrualMonth(item.accrual_period_start!),
to: accrualMonth(item.accrual_period_end!),
})}
{item.accrual_balance_account && ` · ${item.accrual_balance_account}`}
</span>
</p>
)}
<div className="flex items-center justify-between text-muted-foreground">
<span>{item.quantity} {item.unit} × {formatCurrency(item.unit_price, invoice.currency)}</span>
</div>
+116 -7
View File
@@ -23,7 +23,7 @@ import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical } from 'lucide-react'
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
@@ -52,6 +52,9 @@ import {
RUT_MAX,
computeDeduction,
} from '@/lib/invoices/rot-rut-rules'
import AccrualPeriodControl from '@/components/bookkeeping/AccrualPeriodControl'
import { DEFAULT_DEFERRED_REVENUE_ACCOUNT } from '@/lib/bookkeeping/accruals/account-suggestions'
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
import type { Customer, Currency, CreateInvoiceInput, CreateCustomerInput, InvoiceDocumentType, Article } from '@/types'
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
@@ -75,6 +78,7 @@ export default function NewInvoicePage() {
const supabase = createClient()
const t = useTranslations('invoice_editor')
const ts = useTranslations('self_billing')
const ta = useTranslations('accruals')
// Toggle between a normal customer invoice (default) and registering a
// self-billing invoice we received (mottagen självfaktura, ML 17 kap 15§).
const [mode, setMode] = useState<'invoice' | 'self_billed'>('invoice')
@@ -99,7 +103,31 @@ export default function NewInvoicePage() {
work_type: z.string().nullable().optional(),
housing_designation: z.string().nullable().optional(),
apartment_number: z.string().nullable().optional(),
// Periodisering (förutbetald intäkt). Active when balance account is
// non-null; both period dates are then required (refine below).
accrual_period_start: z.string().nullable().optional(),
accrual_period_end: z.string().nullable().optional(),
accrual_balance_account: z.string().nullable().optional(),
}).superRefine((item, ctx) => {
if (item.accrual_balance_account != null) {
const start = item.accrual_period_start
const end = item.accrual_period_end
let invalid = !start || !end || end < start
if (!invalid) {
try {
invalid = countCalendarMonths(start as string, end as string) < 2
} catch {
invalid = true
}
}
if (invalid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['accrual_period_end'],
message: ta('validation_period'),
})
}
}
if (item.line_type === 'text') return
if (item.description.trim().length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['description'], message: t('validation_description_required') })
@@ -135,7 +163,7 @@ export default function NewInvoicePage() {
deduction_housing_designation: z.string().optional(),
items: z.array(itemSchema).min(1, t('validation_min_one_row')),
})
}, [t])
}, [t, ta])
type FormData = z.infer<typeof schema>
@@ -202,6 +230,9 @@ export default function NewInvoicePage() {
work_type: null,
housing_designation: null,
apartment_number: null,
accrual_period_start: null,
accrual_period_end: null,
accrual_balance_account: null,
}],
},
})
@@ -537,6 +568,30 @@ export default function NewInvoicePage() {
const hasAnyRotLine = isInvoiceDoc && watchItems.some((i) => i.deduction_type === 'rot')
const toPay = Math.round((total - deductionTotal) * 100) / 100
// Periodisering per rad: kräver faktureringsmetoden och en riktig faktura.
// EU-/exportkunder bokas på 3308/3305 (omvänd skattskyldighet/export) och
// kan inte periodiseras — ruta 39/40 ska spegla hela försäljningen.
const customerBlocksAccrual =
selectedCustomer?.customer_type === 'eu_business' ||
selectedCustomer?.customer_type === 'non_eu_business'
const canUseAccrual = isInvoiceDoc && accountingMethod === 'accrual' && !customerBlocksAccrual
function toggleAccrual(index: number) {
if (watchItems[index]?.accrual_balance_account != null) {
setValue(`items.${index}.accrual_period_start`, null, { shouldDirty: true })
setValue(`items.${index}.accrual_period_end`, null, { shouldDirty: true })
setValue(`items.${index}.accrual_balance_account`, null, { shouldDirty: true })
} else {
setValue(`items.${index}.accrual_period_start`, watch('invoice_date') || '', { shouldDirty: true })
setValue(`items.${index}.accrual_period_end`, '', { shouldDirty: true })
setValue(
`items.${index}.accrual_balance_account`,
DEFAULT_DEFERRED_REVENUE_ACCOUNT,
{ shouldDirty: true },
)
}
}
// Self-billing path: no review dialog, no PDF, no send — it arrives already
// booked. POST straight to the dedicated endpoint and open the verifikat.
async function handleSelfBilledSubmit(data: FormData) {
@@ -1046,7 +1101,7 @@ export default function NewInvoicePage() {
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align="end" className="min-w-56">
<DropdownMenuLabel>{t('deduction_menu_label')}</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={watchItems[index]?.deduction_type ?? 'none'}
@@ -1058,16 +1113,33 @@ export default function NewInvoicePage() {
setValue(`items.${index}.labor_hours`, null)
setValue(`items.${index}.housing_designation`, null)
setValue(`items.${index}.apartment_number`, null)
} else if (watchItems[index]?.accrual_balance_account != null) {
// ROT/RUT och periodisering kombineras aldrig
// på samma rad — avdraget vinner.
setValue(`items.${index}.accrual_period_start`, null)
setValue(`items.${index}.accrual_period_end`, null)
setValue(`items.${index}.accrual_balance_account`, null)
}
}}
>
<DropdownMenuRadioItem value="none">{t('deduction_none')}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="rot">{t('deduction_rot')}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="rut">{t('deduction_rut')}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="none" className="py-2">{t('deduction_none')}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="rot" className="py-2">{t('deduction_rot')}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="rut" className="py-2">{t('deduction_rut')}</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
{canUseAccrual && !watchItems[index]?.deduction_type && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => toggleAccrual(index)} className="py-2">
<CalendarClock className="h-4 w-4" />
{watchItems[index]?.accrual_balance_account != null
? ta('row_menu_remove')
: ta('row_menu_add')}
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
className="py-2 text-destructive focus:text-destructive"
disabled={fields.length === 1}
onSelect={() => remove(index)}
>
@@ -1325,6 +1397,37 @@ export default function NewInvoicePage() {
</div>
)}
{/* Periodisering (förutbetald intäkt) — activated via the
row's ⋮ menu. Intäkten krediteras 29xx vid bokning och
löses upp månadsvis över perioden; momsen påverkas inte. */}
{canUseAccrual && watchItems[index]?.accrual_balance_account != null && (
<div className="md:col-span-12 mt-2 md:mt-3">
<AccrualPeriodControl
direction="revenue"
amount={lineTotal}
idPrefix={`accrual-invoice-${index}`}
value={{
start: watchItems[index]?.accrual_period_start ?? '',
end: watchItems[index]?.accrual_period_end ?? '',
balanceAccount:
watchItems[index]?.accrual_balance_account ||
DEFAULT_DEFERRED_REVENUE_ACCOUNT,
}}
onChange={(next) => {
setValue(`items.${index}.accrual_period_start`, next.start, { shouldDirty: true })
setValue(`items.${index}.accrual_period_end`, next.end, { shouldDirty: true })
setValue(`items.${index}.accrual_balance_account`, next.balanceAccount, { shouldDirty: true })
}}
onRemove={() => toggleAccrual(index)}
/>
{errors.items?.[index]?.accrual_period_end && (
<p className="mt-1 text-sm text-destructive">
{errors.items[index].accrual_period_end?.message}
</p>
)}
</div>
)}
{/* Mobile summary row */}
<div className="flex justify-between text-sm pt-1 border-t border-border/40 md:hidden">
<span className="text-muted-foreground">{t('row_label', { index: index + 1 })}</span>
@@ -1356,6 +1459,9 @@ export default function NewInvoicePage() {
work_type: null,
housing_designation: null,
apartment_number: null,
accrual_period_start: null,
accrual_period_end: null,
accrual_balance_account: null,
})
}
>
@@ -1388,6 +1494,9 @@ export default function NewInvoicePage() {
work_type: null,
housing_designation: null,
apartment_number: null,
accrual_period_start: null,
accrual_period_end: null,
accrual_balance_account: null,
})
}
>
@@ -13,7 +13,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info, Pencil, Plus } from 'lucide-react'
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info, Pencil, Plus, CalendarClock } from 'lucide-react'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import LinkVoucherPicker from '@/components/invoices/LinkVoucherPicker'
import { useCanWrite } from '@/lib/hooks/use-can-write'
@@ -59,6 +59,13 @@ function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
// A line is periodiserad when both period dates are set — the cost was parked
// on the 17xx interim account and dissolves monthly via accrual_schedules.
const itemHasAccrual = (item: SupplierInvoiceItem): boolean =>
!!(item.accrual_period_start && item.accrual_period_end)
const accrualMonth = (date: string): string => date.slice(0, 7)
const statusVariants: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = {
registered: 'secondary',
approved: 'default',
@@ -431,6 +438,12 @@ export default function SupplierInvoiceDetailPage() {
<Badge variant={statusVariants[invoice.status] || 'secondary'}>
{statusLabels[invoice.status] || invoice.status}
</Badge>
{items.some(itemHasAccrual) && (
<Badge variant="outline" className="gap-1">
<CalendarClock className="h-3 w-3" />
{t('badge_accrued')}
</Badge>
)}
</div>
<p className="text-muted-foreground text-sm sm:text-base truncate">
{t('header_subtitle', {
@@ -646,7 +659,21 @@ export default function SupplierInvoiceDetailPage() {
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-b last:border-0">
<td className="py-2">{item.description}</td>
<td className="py-2">
{item.description}
{itemHasAccrual(item) && (
<p className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="tabular-nums">
{t('accrual_line_info', {
from: accrualMonth(item.accrual_period_start!),
to: accrualMonth(item.accrual_period_end!),
})}
{item.accrual_balance_account && ` · ${item.accrual_balance_account}`}
</span>
</p>
)}
</td>
<td className="py-2 text-right">{item.quantity}</td>
<td className="py-2">{item.unit}</td>
<td className="py-2 text-right font-mono">{formatAmount(item.unit_price)}</td>
@@ -664,6 +691,18 @@ export default function SupplierInvoiceDetailPage() {
{items.map((item) => (
<div key={item.id} className="border rounded-lg p-3 space-y-1.5">
<div className="font-medium text-sm">{item.description}</div>
{itemHasAccrual(item) && (
<p className="flex items-center gap-1 text-xs text-muted-foreground">
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="tabular-nums">
{t('accrual_line_info', {
from: accrualMonth(item.accrual_period_start!),
to: accrualMonth(item.accrual_period_end!),
})}
{item.accrual_balance_account && ` · ${item.accrual_balance_account}`}
</span>
</p>
)}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{item.quantity} {item.unit} × {formatAmount(item.unit_price)}</span>
<span className="font-mono">{formatAmount(item.line_total)} kr</span>
+210 -23
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { Fragment, useState, useEffect, useRef } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { useForm, Controller, useFieldArray } from 'react-hook-form'
@@ -24,7 +24,10 @@ import { cn, formatCurrency } from '@/lib/utils'
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import BankTransactionPicker from '@/components/transactions/BankTransactionPicker'
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, MessageCircle, Link2 } from 'lucide-react'
import AccrualPeriodControl from '@/components/bookkeeping/AccrualPeriodControl'
import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions'
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock, AlertCircle, MessageCircle, Link2, CalendarClock } from 'lucide-react'
import type { Supplier, BASAccount, VatTreatment, EntityType, InvoiceExtractionResult, FiscalPeriod } from '@/types'
interface LineItem {
@@ -35,6 +38,11 @@ interface LineItem {
// Self-assessed VAT rate for omvänd skattskyldighet (0.25/0.12/0.06). Only
// meaningful when reverse_charge is on; the line's vat_rate is then 0.
reverse_charge_rate?: number
// Periodisering (förutbetald kostnad): both dates + 17xx interim account.
// Present only while the row's periodisering panel is active.
accrual_period_start?: string
accrual_period_end?: string
accrual_balance_account?: string
}
// The existing invoice surfaced on a duplicate-number conflict, used to drive
@@ -248,6 +256,7 @@ export default function NewSupplierInvoicePage() {
const { canWrite } = useCanWrite()
const { toast } = useToast()
const t = useTranslations('supplier_invoice_editor')
const ta = useTranslations('accruals')
// When opened from an invoice-inbox item, every redirect should land the
// user back in the inbox so they can pick the next document. Outside the
@@ -437,18 +446,48 @@ export default function NewSupplierInvoicePage() {
}
// Line items: keep the single empty default if AI returned nothing,
// otherwise replace it with the extracted lines.
// otherwise replace it with the extracted lines. When the document
// states a service window of 2+ calendar months (insurance period,
// license term), pre-fill periodisering on every positive line — the
// user sees the panel and can remove it before booking.
if (extracted.lineItems && extracted.lineItems.length > 0) {
// AI-extracted values are untrusted input — only accept strict
// ISO-8601 dates before they reach form state (and later the API).
const isIsoDate = (v: unknown): v is string =>
typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v)
const spsRaw = extracted.invoice?.servicePeriodStart
const speRaw = extracted.invoice?.servicePeriodEnd
const sps = isIsoDate(spsRaw) ? spsRaw : null
const spe = isIsoDate(speRaw) ? speRaw : null
let prefillAccrual = false
if (sps && spe && spe >= sps) {
try {
prefillAccrual = countCalendarMonths(sps, spe) >= 2
} catch {
prefillAccrual = false
}
}
replace(
extracted.lineItems.map((li) => ({
description: li.description || '',
amount: typeof li.lineTotal === 'number' ? li.lineTotal : 0,
// Extraction never suggests accounts (forcibly nulled at parse
// time) and a silent default misbooks — leave empty so the user
// (or the supplier default) makes the call.
account_number: '',
vat_rate: vatRateFromAi(li.vatRate),
})),
extracted.lineItems.map((li) => {
const amount = typeof li.lineTotal === 'number' ? li.lineTotal : 0
const withAccrual = prefillAccrual && amount > 0
return {
description: li.description || '',
amount,
// Extraction never suggests accounts (forcibly nulled at parse
// time) and a silent default misbooks — leave empty so the user
// (or the supplier default) makes the call.
account_number: '',
vat_rate: vatRateFromAi(li.vatRate),
accrual_period_start: withAccrual ? (sps as string) : undefined,
accrual_period_end: withAccrual ? (spe as string) : undefined,
// No account yet → generic 1790; toggleAccrual re-suggests the
// same way once the user picks one.
accrual_balance_account: withAccrual
? suggestBalanceAccount('expense', '')
: undefined,
}
}),
)
}
@@ -612,6 +651,77 @@ export default function NewSupplierInvoicePage() {
}
}
// Periodisering per rad: kräver faktureringsmetoden; eget utlägg bokar
// kostnaden direkt mot ägarkontot och kan inte periodiseras. Omvänd
// skattskyldighet kan inte heller periodiseras — kostnadsraden utgör
// momsunderlaget (ruta 2032) och får inte flyttas till ett interimskonto.
const canUseAccrual =
accountingMethod === 'accrual' && !watchedPaidPrivately && !watchedReverseCharge
// When reverse charge is switched on, clear any per-line periodisering so a
// stale AI prefill (or fields set before the toggle) can never reach the
// API, which rejects the combination with SI_CREATE_ACCRUAL_REVERSE_CHARGE.
useEffect(() => {
if (!watchedReverseCharge) return
const items = getValues('items') ?? []
items.forEach((item, index) => {
if (
item.accrual_period_start !== undefined ||
item.accrual_period_end !== undefined ||
item.accrual_balance_account !== undefined
) {
setValue(`items.${index}.accrual_period_start`, undefined, { shouldDirty: true })
setValue(`items.${index}.accrual_period_end`, undefined, { shouldDirty: true })
setValue(`items.${index}.accrual_balance_account`, undefined, { shouldDirty: true })
}
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [watchedReverseCharge])
function isAccrualOpen(index: number): boolean {
return watchedItems?.[index]?.accrual_balance_account != null
}
function toggleAccrual(index: number) {
if (isAccrualOpen(index)) {
setValue(`items.${index}.accrual_period_start`, undefined, { shouldDirty: true })
setValue(`items.${index}.accrual_period_end`, undefined, { shouldDirty: true })
setValue(`items.${index}.accrual_balance_account`, undefined, { shouldDirty: true })
} else {
const account = watch(`items.${index}.account_number`) || ''
setValue(`items.${index}.accrual_period_start`, watch('invoice_date') || '', { shouldDirty: true })
setValue(`items.${index}.accrual_period_end`, '', { shouldDirty: true })
setValue(
`items.${index}.accrual_balance_account`,
suggestBalanceAccount('expense', account),
{ shouldDirty: true },
)
}
}
function renderAccrualPanel(index: number, idPrefix: string) {
const item = watchedItems?.[index]
if (!item || item.accrual_balance_account == null) return null
return (
<AccrualPeriodControl
direction="expense"
amount={item.amount || 0}
idPrefix={idPrefix}
value={{
start: item.accrual_period_start ?? '',
end: item.accrual_period_end ?? '',
balanceAccount: item.accrual_balance_account || '1790',
}}
onChange={(next) => {
setValue(`items.${index}.accrual_period_start`, next.start, { shouldDirty: true })
setValue(`items.${index}.accrual_period_end`, next.end, { shouldDirty: true })
setValue(`items.${index}.accrual_balance_account`, next.balanceAccount, { shouldDirty: true })
}}
onRemove={() => toggleAccrual(index)}
/>
)
}
const itemTotals = (watchedItems || []).map((item) => {
const lineTotal = Math.round((item.amount || 0) * 100) / 100
// Reverse charge: VAT is self-assessed at reverse_charge_rate (25% default),
@@ -722,6 +832,16 @@ export default function NewSupplierInvoicePage() {
// the self-assessed rate travels on reverse_charge_rate (25% default).
vat_rate: data.reverse_charge ? 0 : item.vat_rate,
reverse_charge_rate: data.reverse_charge ? (item.reverse_charge_rate ?? 0.25) : undefined,
// Periodisering: only sent when the row has a complete period AND the
// flow supports it (kontantmetod/eget utlägg would be rejected by the
// API — an AI prefill must never block those submits).
...(canUseAccrual && item.accrual_period_start && item.accrual_period_end
? {
accrual_period_start: item.accrual_period_start,
accrual_period_end: item.accrual_period_end,
accrual_balance_account: item.accrual_balance_account || undefined,
}
: {}),
})),
}
}
@@ -820,6 +940,22 @@ export default function NewSupplierInvoicePage() {
})
return
}
// A row with an open periodisering panel must carry a complete period of
// at least two calendar months before the invoice can be booked.
const invalidAccrual = canUseAccrual && data.items.some((item) => {
if (item.accrual_balance_account == null) return false
if (!item.accrual_period_start || !item.accrual_period_end) return true
if (item.accrual_period_end < item.accrual_period_start) return true
return countCalendarMonths(item.accrual_period_start, item.accrual_period_end) < 2
})
if (invalidAccrual) {
toast({
title: ta('incomplete_toast_title'),
description: ta('incomplete_toast_description'),
variant: 'destructive',
})
return
}
if (submitModeRef.current === 'register_and_match') {
// Open the bank-transaction picker; actual create happens on pick.
@@ -1378,7 +1514,8 @@ export default function NewSupplierInvoicePage() {
</thead>
<tbody>
{fields.map((field, index) => (
<tr key={field.id} className="border-b last:border-0 align-top">
<Fragment key={field.id}>
<tr className={cn('align-top', canUseAccrual && isAccrualOpen(index) ? 'border-0' : 'border-b last:border-0')}>
<td className="py-2 pr-2">
<Controller
name={`items.${index}.account_number`}
@@ -1447,13 +1584,41 @@ export default function NewSupplierInvoicePage() {
{formatAmount(itemTotals[index]?.vatAmount ?? 0)}
</td>
<td className="py-2 pt-3">
{fields.length > 1 && (
<Button type="button" variant="ghost" size="icon" onClick={() => remove(index)} aria-label={t('remove_row_aria', { index: index + 1 })}>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
)}
<div className="flex items-center">
{canUseAccrual && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => toggleAccrual(index)}
aria-label={ta('row_toggle_aria', { index: index + 1 })}
aria-pressed={isAccrualOpen(index)}
title={ta('row_toggle')}
>
<CalendarClock
className={cn(
'h-4 w-4',
isAccrualOpen(index) ? 'text-foreground' : 'text-muted-foreground',
)}
/>
</Button>
)}
{fields.length > 1 && (
<Button type="button" variant="ghost" size="icon" onClick={() => remove(index)} aria-label={t('remove_row_aria', { index: index + 1 })}>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
)}
</div>
</td>
</tr>
{canUseAccrual && isAccrualOpen(index) && (
<tr className="border-b last:border-0">
<td colSpan={6} className="pb-3">
{renderAccrualPanel(index, `accrual-desktop-${index}`)}
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
@@ -1465,11 +1630,31 @@ export default function NewSupplierInvoicePage() {
<div key={field.id} className="border rounded-lg p-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">{t('row_label', { index: index + 1 })}</span>
{fields.length > 1 && (
<Button type="button" variant="ghost" size="icon" onClick={() => remove(index)} aria-label={t('remove_row_aria', { index: index + 1 })}>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
)}
<div className="flex items-center">
{canUseAccrual && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => toggleAccrual(index)}
aria-label={ta('row_toggle_aria', { index: index + 1 })}
aria-pressed={isAccrualOpen(index)}
title={ta('row_toggle')}
>
<CalendarClock
className={cn(
'h-4 w-4',
isAccrualOpen(index) ? 'text-foreground' : 'text-muted-foreground',
)}
/>
</Button>
)}
{fields.length > 1 && (
<Button type="button" variant="ghost" size="icon" onClick={() => remove(index)} aria-label={t('remove_row_aria', { index: index + 1 })}>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
)}
</div>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{t('col_account')}</Label>
@@ -1543,6 +1728,8 @@ export default function NewSupplierInvoicePage() {
{formatAmount(itemTotals[index]?.vatAmount ?? 0)}
</span>
</div>
{canUseAccrual && isAccrualOpen(index) &&
renderAccrualPanel(index, `accrual-mobile-${index}`)}
</div>
))}
</div>
@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
} from '@/tests/helpers'
import {
AccrualNothingToDissolveError,
AccrualScheduleNotActiveError,
AccrualScheduleNotFoundError,
} from '@/lib/bookkeeping/accruals/errors'
const { supabase: mockSupabase, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockDissolveScheduleNow = vi.fn()
vi.mock('@/lib/bookkeeping/accruals/service', () => ({
dissolveScheduleNow: (...args: unknown[]) => mockDissolveScheduleNow(...args),
}))
import { POST } from '../route'
describe('POST /api/bookkeeping/accruals/[id]/dissolve', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
function dissolveRequest() {
return POST(
createMockRequest('/api/bookkeeping/accruals/sched-1/dissolve', { method: 'POST' }),
createMockRouteParams({ id: 'sched-1' }),
)
}
it('returns the dissolution result on success', async () => {
mockDissolveScheduleNow.mockResolvedValue({ journalEntryId: 'je-1', amount: 2000 })
const { status, body } = await parseJsonResponse<{
data: { journalEntryId: string; amount: number }
}>(await dissolveRequest())
expect(status).toBe(200)
expect(body.data).toEqual({ journalEntryId: 'je-1', amount: 2000 })
})
it('maps the typed not-found error to 404 ACCRUAL_NOT_FOUND', async () => {
mockDissolveScheduleNow.mockRejectedValue(new AccrualScheduleNotFoundError())
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await dissolveRequest(),
)
expect(status).toBe(404)
expect(body.error.code).toBe('ACCRUAL_NOT_FOUND')
})
it('maps the typed not-active error to 400 ACCRUAL_NOT_ACTIVE', async () => {
mockDissolveScheduleNow.mockRejectedValue(new AccrualScheduleNotActiveError('cancelled'))
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { currentStatus: string } }
}>(await dissolveRequest())
expect(status).toBe(400)
expect(body.error.code).toBe('ACCRUAL_NOT_ACTIVE')
expect(body.error.details.currentStatus).toBe('cancelled')
})
it('maps the typed nothing-to-dissolve error to 400 ACCRUAL_NOTHING_TO_DISSOLVE', async () => {
mockDissolveScheduleNow.mockRejectedValue(new AccrualNothingToDissolveError())
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await dissolveRequest(),
)
expect(status).toBe(400)
expect(body.error.code).toBe('ACCRUAL_NOTHING_TO_DISSOLVE')
})
it('falls back to ACCRUAL_DISSOLVE_FAILED for untyped errors', async () => {
mockDissolveScheduleNow.mockRejectedValue(new Error('Ingen öppen räkenskapsperiod för 2026-01-01'))
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { reason: string } }
}>(await dissolveRequest())
expect(status).toBe(400)
expect(body.error.code).toBe('ACCRUAL_DISSOLVE_FAILED')
expect(body.error.details.reason).toMatch(/Ingen öppen räkenskapsperiod/)
})
})
@@ -0,0 +1,65 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { dissolveScheduleNow } from '@/lib/bookkeeping/accruals/service'
import {
ACCRUAL_NOTHING_TO_DISSOLVE,
ACCRUAL_SCHEDULE_NOT_ACTIVE,
ACCRUAL_SCHEDULE_NOT_FOUND,
isAccrualError,
} from '@/lib/bookkeeping/accruals/errors'
ensureInitialized()
/**
* POST /api/bookkeeping/accruals/[id]/dissolve
*
* "Lös upp nu": books the schedule's remaining months in ONE verifikat dated
* today (clamped by lock date) and completes the schedule. Used when the
* underlying service ends early or the user wants the rest expensed now.
* Cancelling-with-storno only happens via the credit flows — a standalone
* cancel would strand the interim-account balance.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'accruals.dissolve',
async (_request, ctx, { params }) => {
const { id } = await params
const { user, supabase, companyId, log, requestId } = ctx
try {
const result = await dissolveScheduleNow(supabase, companyId!, user.id, id)
// Manual financial write — log the acting user for auditability.
log.info('accrual schedule dissolved', {
userId: user.id,
companyId,
scheduleId: id,
amount: result.amount,
journalEntryId: result.journalEntryId,
})
return NextResponse.json({ data: result })
} catch (err) {
const reason = err instanceof Error ? err.message : 'unknown'
// Typed domain errors carry a stable code — never match Swedish prose.
if (isAccrualError(err)) {
switch (err.code) {
case ACCRUAL_SCHEDULE_NOT_FOUND:
return errorResponseFromCode('ACCRUAL_NOT_FOUND', log, { requestId })
case ACCRUAL_SCHEDULE_NOT_ACTIVE:
return errorResponseFromCode('ACCRUAL_NOT_ACTIVE', log, {
requestId,
details: { currentStatus: err.currentStatus },
})
case ACCRUAL_NOTHING_TO_DISSOLVE:
return errorResponseFromCode('ACCRUAL_NOTHING_TO_DISSOLVE', log, { requestId })
}
}
log.error('accrual dissolve failed', err as Error, { entityId: id })
return errorResponseFromCode('ACCRUAL_DISSOLVE_FAILED', log, {
requestId,
details: { reason },
})
}
},
{ requireWrite: true },
)
@@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/auth/cron', () => ({
verifyCronSecret: vi.fn(() => null),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
let installmentsResult: {
data: Array<{ company_id: string }> | null
error: { message: string } | null
} = { data: [], error: null }
// The route loads company ids through fetchAllRows, which appends
// .range(from, to) per page — the mock slices the fixture so pagination
// (>1000 rows) is exercised for real.
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn(() => ({
from: vi.fn(() => {
const chain: Record<string, unknown> = {}
let from = 0
let to = Number.MAX_SAFE_INTEGER
chain.select = vi.fn(() => chain)
chain.eq = vi.fn(() => chain)
chain.lte = vi.fn(() => chain)
chain.order = vi.fn(() => chain)
chain.range = vi.fn((f: number, t: number) => {
from = f
to = t
return chain
})
chain.then = (resolve: (v: unknown) => unknown) => {
const result = installmentsResult.error
? { data: null, error: installmentsResult.error }
: { data: (installmentsResult.data ?? []).slice(from, to + 1), error: null }
return Promise.resolve(result).then(resolve)
}
return chain
}),
})),
}))
const mockPostDueInstallments = vi.fn()
vi.mock('@/lib/bookkeeping/accruals/service', () => ({
postDueInstallments: (...args: unknown[]) => mockPostDueInstallments(...args),
}))
import { GET } from '../route'
function cronRequest(): Request {
return new Request('http://localhost:3000/api/bookkeeping/accruals/post-due/cron')
}
beforeEach(() => {
vi.clearAllMocks()
installmentsResult = { data: [], error: null }
})
describe('GET /api/bookkeeping/accruals/post-due/cron', () => {
it('runs once per distinct company and aggregates results', async () => {
installmentsResult = {
data: [
{ company_id: 'company-1' },
{ company_id: 'company-1' },
{ company_id: 'company-2' },
],
error: null,
}
mockPostDueInstallments
.mockResolvedValueOnce({ posted: 2, failed: 0, skipped: 0, errors: [] })
.mockResolvedValueOnce({ posted: 1, failed: 0, skipped: 0, errors: [] })
const response = await GET(cronRequest())
const json = await response.json()
expect(mockPostDueInstallments).toHaveBeenCalledTimes(2)
expect(mockPostDueInstallments.mock.calls.map((c) => c[1])).toEqual([
'company-1',
'company-2',
])
expect(json.success).toBe(true)
expect(json.total).toBe(2)
expect(json.succeeded).toBe(2)
expect(json.results).toEqual([
{ companyId: 'company-1', posted: 2, failed: 0, skipped: 0 },
{ companyId: 'company-2', posted: 1, failed: 0, skipped: 0 },
])
})
it('paginates past the 1000-row PostgREST cap so no company is starved', async () => {
// 1000 rows for company-1 fill the first page exactly; company-2's single
// row only exists on page 2 and would be dropped by an unpaginated select.
installmentsResult = {
data: [
...Array.from({ length: 1000 }, () => ({ company_id: 'company-1' })),
{ company_id: 'company-2' },
],
error: null,
}
mockPostDueInstallments.mockResolvedValue({ posted: 1, failed: 0, skipped: 0, errors: [] })
const response = await GET(cronRequest())
const json = await response.json()
expect(json.success).toBe(true)
expect(mockPostDueInstallments).toHaveBeenCalledTimes(2)
expect(mockPostDueInstallments.mock.calls.map((c) => c[1])).toEqual([
'company-1',
'company-2',
])
})
it('isolates a failing company so the rest still run', async () => {
installmentsResult = {
data: [{ company_id: 'company-1' }, { company_id: 'company-2' }],
error: null,
}
mockPostDueInstallments
.mockRejectedValueOnce(new Error('database exploded'))
.mockResolvedValueOnce({ posted: 1, failed: 0, skipped: 0, errors: [] })
const response = await GET(cronRequest())
const json = await response.json()
expect(json.success).toBe(true)
expect(json.succeeded).toBe(1)
expect(json.failed).toBe(1)
expect(json.failures).toEqual([{ index: 0, error: 'database exploded' }])
expect(mockPostDueInstallments).toHaveBeenCalledTimes(2)
})
it('returns 500 when the due query fails', async () => {
installmentsResult = { data: null, error: { message: 'boom' } }
const response = await GET(cronRequest())
expect(response.status).toBe(500)
expect(mockPostDueInstallments).not.toHaveBeenCalled()
})
it('rejects unauthorized callers', async () => {
const { verifyCronSecret } = await import('@/lib/auth/cron')
const { NextResponse } = await import('next/server')
vi.mocked(verifyCronSecret).mockReturnValueOnce(
NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
)
const response = await GET(cronRequest())
expect(response.status).toBe(401)
expect(mockPostDueInstallments).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,97 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withCronContext } from '@/lib/api/with-cron-context'
import { createServiceClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { postDueInstallments } from '@/lib/bookkeeping/accruals/service'
import { firstOfMonth } from '@/lib/bookkeeping/accruals/compute'
ensureInitialized()
/**
* GET /api/bookkeeping/accruals/post-due/cron — daily 05:15 UTC.
*
* Posts the monthly periodiseringsverifikat (source_type 'accrual') for every
* pending installment whose calendar month has begun. Companies run in
* isolated try/catch; one company's failure never blocks the rest. Per-
* installment failures are recorded on the row (last_error) by the service
* and retried on the next run — the periodiseringar page surfaces them.
*
* Idempotency: posting flips the installment pending→posted with a CAS
* claim, so a cron retry (or a concurrent manual "Bokför förfallna") can
* never double-book a month.
*/
export const GET = withCronContext('cron.accrual_postings', async (_request, ctx) => {
const supabase = createServiceClient()
const todayIso = new Date().toISOString().slice(0, 10)
// fetchAllRows pages past PostgREST's 1000-row cap — a single unpaginated
// select would silently drop companies once total due installments exceed
// the cap, permanently starving the ones sorted last.
let rows: Array<{ company_id: string }>
try {
rows = await fetchAllRows<{ company_id: string }>(({ from, to }) =>
supabase
.from('accrual_schedule_installments')
.select('company_id')
.eq('status', 'pending')
.lte('period_month', firstOfMonth(todayIso))
.order('id', { ascending: true })
.range(from, to),
)
} catch (error) {
ctx.log.error('failed to load due accrual installments', error as Error)
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : 'unknown' },
{ status: 500 },
)
}
const companyIds = Array.from(new Set(rows.map((row) => row.company_id)))
ctx.log.info('accrual posting cron starting', {
companyCount: companyIds.length,
todayIso,
})
const results: Array<{
companyId: string
posted: number
failed: number
skipped: number
}> = []
const summary = await ctx.forEach('company', companyIds, async (companyId, itemCtx) => {
const result = await postDueInstallments(supabase, companyId)
results.push({
companyId,
posted: result.posted,
failed: result.failed,
skipped: result.skipped,
})
if (result.failed > 0) {
itemCtx.log.warn('some accrual installments failed to post', {
companyId,
failed: result.failed,
})
}
})
ctx.log.info('accrual posting cron summary', {
total: summary.total,
succeeded: summary.succeeded,
failed: summary.failed,
posted: results.reduce((sum, r) => sum + r.posted, 0),
})
return NextResponse.json({
success: true,
total: summary.total,
succeeded: summary.succeeded,
failed: summary.failed,
failures: summary.failures,
results,
})
})
export const POST = GET
@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { postDueInstallments } from '@/lib/bookkeeping/accruals/service'
ensureInitialized()
/**
* POST /api/bookkeeping/accruals/post-due
*
* Manual "Bokför förfallna periodiseringar" for the active company —
* complements the daily cron (same service, same CAS idempotency), so the
* user never has to wait for the nightly run after creating a schedule with
* elapsed months or after fixing a blocked installment.
*/
export const POST = withRouteContext(
'accruals.post_due',
async (_request, ctx) => {
const { user, supabase, companyId } = ctx
const result = await postDueInstallments(supabase, companyId!, {
userId: user.id,
})
return NextResponse.json({ data: result })
},
{ requireWrite: true },
)
+59
View File
@@ -0,0 +1,59 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse } from '@/lib/errors/get-structured-error'
import { firstOfMonth } from '@/lib/bookkeeping/accruals/compute'
import type { AccrualSchedule, AccrualScheduleInstallment } from '@/types'
ensureInitialized()
/**
* GET /api/bookkeeping/accruals?status=active|completed|cancelled|all
*
* Schedules with their installments for the periodiseringar page, plus a
* `due_count` of pending installments whose month has begun (drives the
* "Bokför förfallna" banner).
*/
export const GET = withRouteContext(
'accruals.list',
async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const { searchParams } = new URL(request.url)
const status = searchParams.get('status') || 'active'
let query = supabase
.from('accrual_schedules')
.select('*, installments:accrual_schedule_installments(*)')
.eq('company_id', companyId)
.order('created_at', { ascending: false })
if (status !== 'all') {
query = query.eq('status', status)
}
const { data, error } = await query
if (error) {
log.error('failed to list accrual schedules', error)
return errorResponse(error, log, { requestId })
}
const todayMonth = firstOfMonth(new Date().toISOString().slice(0, 10))
let dueCount = 0
const schedules = ((data ?? []) as Array<
AccrualSchedule & { installments: AccrualScheduleInstallment[] }
>).map((schedule) => {
const installments = [...(schedule.installments ?? [])].sort((a, b) =>
a.period_month.localeCompare(b.period_month),
)
if (schedule.status === 'active') {
dueCount += installments.filter(
(i) => i.status === 'pending' && i.period_month <= todayMonth,
).length
}
return { ...schedule, installments }
})
return NextResponse.json({ data: schedules, due_count: dueCount })
},
)
@@ -0,0 +1,162 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/bokslut/ixbrl/build-input', () => ({
buildIxbrlInput: vi.fn(),
}))
import { createClient } from '@/lib/supabase/server'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { buildIxbrlInput } from '@/lib/bokslut/ixbrl/build-input'
import { makeInput } from '@/lib/bokslut/ixbrl/__tests__/fixtures'
import { GET } from '../route'
import { GET as GET_VALIDATE } from '../validate/route'
function mkReq(query = '') {
return new Request(
`http://localhost/api/bookkeeping/fiscal-periods/period-1/arsredovisning/ixbrl${query}`,
)
}
function mkParams(id = 'period-1') {
return { params: Promise.resolve({ id }) }
}
function authedSupabase() {
const { supabase } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: { id: 'user-1' } } })
vi.mocked(createClient).mockResolvedValue(supabase as never)
return supabase
}
describe('GET /api/bookkeeping/fiscal-periods/[id]/arsredovisning/ixbrl', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 401 when unauthenticated', async () => {
const { supabase } = createQueuedMockSupabase()
supabase.auth.getUser.mockResolvedValue({ data: { user: null } })
vi.mocked(createClient).mockResolvedValue(supabase as never)
const res = await GET(mkReq(), mkParams())
expect(res.status).toBe(401)
})
it('returns 404 when the period is missing', async () => {
authedSupabase()
vi.mocked(buildIxbrlInput).mockRejectedValue(new Error('Fiscal period not found'))
const res = await GET(mkReq(), mkParams())
expect(res.status).toBe(404)
})
it('returns the generated XHTML inline for iframe preview', async () => {
authedSupabase()
vi.mocked(buildIxbrlInput).mockResolvedValue(makeInput())
const res = await GET(mkReq(), mkParams())
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toContain('application/xhtml+xml')
expect(res.headers.get('Content-Disposition')).toContain('inline')
expect(res.headers.get('Cache-Control')).toContain('no-store')
const body = await res.text()
expect(body).toContain('<?xml version="1.0" encoding="utf-8"?>')
expect(body).toContain('se-k2-ab-risbs-2024-09-12.xsd')
expect(body).toContain('ID_DATUM_UNDERTECKNANDE_FASTSTALLELSEINTYG')
})
it('serves as attachment with ?download=1 and forwards utdelning', async () => {
authedSupabase()
vi.mocked(buildIxbrlInput).mockResolvedValue(makeInput())
const res = await GET(mkReq('?download=1&utdelning=50000'), mkParams())
expect(res.status).toBe(200)
expect(res.headers.get('Content-Disposition')).toContain('attachment')
expect(res.headers.get('Content-Disposition')).toContain('arsredovisning-2025-12-31.xhtml')
expect(vi.mocked(buildIxbrlInput)).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'period-1',
{ proposedDividend: 50000 },
)
})
it('returns 500 envelope when generation explodes', async () => {
authedSupabase()
const broken = makeInput()
broken.entryPointId = 'okant-entry-point'
vi.mocked(buildIxbrlInput).mockResolvedValue(broken)
const res = await GET(mkReq(), mkParams())
expect(res.status).toBe(500)
})
})
describe('GET /api/bookkeeping/fiscal-periods/[id]/arsredovisning/ixbrl/validate', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns ok with no errors for the happy-path fixture', async () => {
authedSupabase()
const input = makeInput()
// Keep date rules deterministic: the fixture period ends 2025-12-31 and
// AGM is 2026-03-15, both in the past relative to the suite's clock.
vi.mocked(buildIxbrlInput).mockResolvedValue(input)
const res = await GET_VALIDATE(mkReq('/validate'), mkParams())
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.ok).toBe(true)
expect(body.data.error_count).toBe(0)
expect(body.data.generated_bytes).toBeGreaterThan(10_000)
expect(body.data.entry_point).toBe('k2-ab-risbs-2024-09-12')
})
it('reports rule violations as issues without failing the request', async () => {
authedSupabase()
const input = makeInput()
input.underskrifter.signers = []
input.totals.tillgangar = { current: 1, previous: 1 }
vi.mocked(buildIxbrlInput).mockResolvedValue(input)
const res = await GET_VALIDATE(mkReq('/validate'), mkParams())
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.ok).toBe(false)
const issueCodes = body.data.issues.map((issue: { code: string }) => issue.code)
expect(issueCodes).toContain('1107')
expect(issueCodes).toContain('3005')
})
it('surfaces generation failures as ACC-GEN issues', async () => {
authedSupabase()
const broken = makeInput()
broken.rr = {} as never
broken.totals = { ...broken.totals }
// Force a generation error by pointing at a non-existent entry point.
broken.entryPointId = 'okant-entry-point'
vi.mocked(buildIxbrlInput).mockResolvedValue(broken)
const res = await GET_VALIDATE(mkReq('/validate'), mkParams())
expect(res.status).toBe(200)
const body = await res.json()
const issueCodes = body.data.issues.map((issue: { code: string }) => issue.code)
expect(issueCodes).toContain('ACC-GEN')
expect(body.data.ok).toBe(false)
})
it('returns 404 when the period is missing', async () => {
authedSupabase()
vi.mocked(buildIxbrlInput).mockRejectedValue(new Error('Fiscal period not found'))
const res = await GET_VALIDATE(mkReq('/validate'), mkParams())
expect(res.status).toBe(404)
})
})
@@ -0,0 +1,56 @@
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { buildIxbrlInput } from '@/lib/bokslut/ixbrl/build-input'
import { generateK2IxbrlDocument } from '@/lib/bokslut/ixbrl/document/k2-document'
/**
* GET /api/bookkeeping/fiscal-periods/:id/arsredovisning/ixbrl
*
* Generates the iXBRL (XHTML) årsredovisning for the period. The document IS
* the presentation (per TILLAMPNINGSANVISNING) — the wizard renders it in an
* iframe as the authoritative preview, and `?download=1` hands the same bytes
* to the user for manual filing at bolagsverket.se (the self-hosted path).
*
* Query params:
* - download=1 → Content-Disposition: attachment
* - utdelning=N → proposed dividend in whole SEK for the resultatdisposition
*/
export const GET = withRouteContext(
'period.arsredovisning_ixbrl',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
try {
const url = new URL(request.url)
const download = url.searchParams.get('download') === '1'
const utdelningRaw = url.searchParams.get('utdelning')
const proposedDividend = utdelningRaw ? Number(utdelningRaw) : 0
const input = await buildIxbrlInput(supabase, companyId, id, {
proposedDividend: Number.isFinite(proposedDividend) ? proposedDividend : 0,
})
const { xhtml, warnings } = generateK2IxbrlDocument(input)
const safePeriodEnd = input.period.end.replace(/[^\w.-]/g, '_')
const filename = `arsredovisning-${safePeriodEnd}.xhtml`
return new Response(xhtml, {
headers: {
// Served as XHTML so iframe preview renders the inline XBRL
// document exactly as Bolagsverket will present it.
'Content-Type': 'application/xhtml+xml; charset=utf-8',
'Content-Disposition': `${download ? 'attachment' : 'inline'}; filename="${filename}"`,
'Cache-Control': 'private, no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
// Generation warnings surfaced without disturbing the body.
'X-Ixbrl-Warning-Count': String(warnings.length),
},
})
} catch (err) {
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
}
return errorResponse(err, log, { requestId })
}
},
)
@@ -0,0 +1,74 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { buildIxbrlInput } from '@/lib/bokslut/ixbrl/build-input'
import { generateK2IxbrlDocument } from '@/lib/bokslut/ixbrl/document/k2-document'
import { runPreflightChecks, type PreflightIssue } from '@/lib/bokslut/ixbrl/validate/rules'
/**
* GET /api/bookkeeping/fiscal-periods/:id/arsredovisning/ixbrl/validate
*
* Layer-1 validation (local mirror of Bolagsverket kontrollera, GUIDE
* Appendix E) + a generation dry-run so taxonomy-level problems (unknown
* concept, context mismatch) surface as issues instead of a 500 in the
* preview. Layer 3 (the real kontrollera call) lives in the bolagsverket
* extension and runs in the Skicka in step.
*/
export const GET = withRouteContext(
'period.arsredovisning_ixbrl_validate',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
try {
const url = new URL(request.url)
const utdelningRaw = url.searchParams.get('utdelning')
const proposedDividend = utdelningRaw ? Number(utdelningRaw) : 0
const input = await buildIxbrlInput(supabase, companyId, id, {
proposedDividend: Number.isFinite(proposedDividend) ? proposedDividend : 0,
})
const result = runPreflightChecks(input)
// Generation dry-run: a document that cannot even be generated must
// block, with the reason in the issue list rather than a raw error.
const issues: PreflightIssue[] = [...result.issues]
let generatedBytes = 0
try {
const { xhtml } = generateK2IxbrlDocument(input)
generatedBytes = Buffer.byteLength(xhtml, 'utf8')
if (generatedBytes >= 5 * 1024 * 1024) {
issues.push({
code: '5006',
severity: 'error',
message: 'Dokumentet överstiger Bolagsverkets maxstorlek 5 MB.',
})
}
} catch (genErr) {
issues.push({
code: 'ACC-GEN',
severity: 'error',
message: `iXBRL-dokumentet kunde inte genereras: ${genErr instanceof Error ? genErr.message : 'okänt fel'}`,
})
}
const errors = issues.filter((issue) => issue.severity === 'error')
return NextResponse.json({
data: {
ok: errors.length === 0,
issues,
error_count: errors.length,
warning_count: issues.length - errors.length,
generated_bytes: generatedBytes,
entry_point: input.entryPointId,
period: input.period,
},
})
} catch (err) {
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
}
return errorResponse(err, log, { requestId })
}
},
)
+20
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { ensureInitialized } from '@/lib/init'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
@@ -105,6 +106,25 @@ export async function POST(
)
if (journalEntry) {
journalEntryId = journalEntry.id
// Periodiserade lines: create schedules + catch-up dissolutions now
// that the revenue entry exists. Failures are logged, never fatal —
// the verifikat is committed.
const accrual = await createSchedulesForCustomerInvoice(
supabase,
companyId,
user.id,
invoice as Invoice,
(invoice.items as InvoiceItem[] | null) ?? [],
journalEntry.id,
(settings?.entity_type as EntityType) || 'enskild_firma',
)
if (accrual.failed > 0) {
log.error('accrual schedule creation failed on mark-sent', {
failed: accrual.failed,
})
}
const { error: linkError } = await supabase
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
+20
View File
@@ -11,6 +11,7 @@ import {
generateInvoiceEmailSubject,
} from '@/lib/email/invoice-templates'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { withRouteContext } from '@/lib/api/with-route-context'
@@ -226,6 +227,25 @@ export const POST = withRouteContext(
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
.eq('id', id)
// Periodiserade lines: create their schedules + catch-up
// dissolutions now that the revenue entry exists. Failures degrade
// to PARTIAL — the entry is committed and must not be rolled back.
const accrual = await createSchedulesForCustomerInvoice(
supabase,
companyId!,
user.id,
invoice as Invoice,
items,
journalEntry.id,
(company as CompanySettings).entity_type,
)
if (accrual.failed > 0) {
partialFailures.push({
step: 'accrual_schedules',
reason: `${accrual.failed} periodisering(ar) kunde inte skapas`,
})
}
}
} catch (err) {
opLog.error('failed to create invoice journal entry on send', err as Error)
+103 -2
View File
@@ -7,6 +7,8 @@ import type { EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocument
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { cancelSchedulesForSource } from '@/lib/bookkeeping/accruals/service'
import { DEFAULT_DEFERRED_REVENUE_ACCOUNT } from '@/lib/bookkeeping/accruals/account-suggestions'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import {
computeDeduction,
@@ -139,6 +141,39 @@ export const POST = withRouteContext(
for (const item of invoiceInput.items) item.vat_rate = 0
}
// Periodisering guards. The line schema already validates the period
// shape; here we gate the flows where deferral has no meaning: cash
// method (recognition at payment), reverse charge/export (3308/3305 must
// reflect the full sale for ruta 39/40), and non-invoice document types.
const hasAccrualItems = invoiceInput.items.some(
(item) => item.accrual_period_start && item.accrual_period_end,
)
if (hasAccrualItems) {
if (documentType !== 'invoice') {
return errorResponseFromCode('INVOICE_CREATE_ACCRUAL_INVALID', log, {
requestId,
details: { reason: 'document_type', documentType },
})
}
if (vatRules.treatment === 'reverse_charge' || vatRules.treatment === 'export') {
return errorResponseFromCode('INVOICE_CREATE_ACCRUAL_INVALID', log, {
requestId,
details: { reason: 'vat_treatment', vatTreatment: vatRules.treatment },
})
}
const { data: methodSettings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('company_id', companyId!)
.maybeSingle()
if ((methodSettings?.accounting_method || 'accrual') !== 'accrual') {
return errorResponseFromCode('INVOICE_CREATE_ACCRUAL_INVALID', log, {
requestId,
details: { reason: 'accounting_method' },
})
}
}
// Free-text rows carry no amounts and are excluded from totals + VAT.
const subtotal = invoiceInput.items.reduce(
(sum, item) => (item.line_type === 'text' ? sum : sum + item.quantity * item.unit_price),
@@ -353,6 +388,9 @@ export const POST = withRouteContext(
work_type: null,
housing_designation: null,
apartment_number: null,
accrual_period_start: null,
accrual_period_end: null,
accrual_balance_account: null,
}
}
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
@@ -392,6 +430,22 @@ export const POST = withRouteContext(
work_type: documentType === 'invoice' ? (item.work_type ?? null) : null,
housing_designation: documentType === 'invoice' ? (item.housing_designation ?? null) : null,
apartment_number: documentType === 'invoice' ? (item.apartment_number ?? null) : null,
// Periodisering (förutbetald intäkt): frozen onto the line. The
// schedule itself is created when the invoice is sent/booked. ROT/RUT
// lines never defer (schema-enforced); the guard above already
// restricted this to real invoices under faktureringsmetoden.
accrual_period_start:
documentType === 'invoice' && !deductionType
? (item.accrual_period_start ?? null)
: null,
accrual_period_end:
documentType === 'invoice' && !deductionType
? (item.accrual_period_end ?? null)
: null,
accrual_balance_account:
documentType === 'invoice' && !deductionType && item.accrual_period_start && item.accrual_period_end
? (item.accrual_balance_account ?? DEFAULT_DEFERRED_REVENUE_ACCOUNT)
: null,
}
})
@@ -490,6 +544,10 @@ async function createCreditNote(
log: Logger,
requestId: string,
) {
// Non-blocking issues (e.g. partial accrual cancellation) surfaced to the
// caller alongside the created credit note.
const warnings: Array<{ code: string; message: string }> = []
const { data: originalInvoice, error: originalError } = await supabase
.from('invoices')
.select('*, items:invoice_items(*)')
@@ -561,7 +619,7 @@ async function createCreditNote(
})
}
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null }) => ({
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; line_type?: 'product' | 'text'; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number; revenue_account?: string | null; article_id?: string | null; accrual_period_start?: string | null; accrual_period_end?: string | null; accrual_balance_account?: string | null }) => ({
invoice_id: creditNote.id,
sort_order: item.sort_order,
line_type: item.line_type ?? 'product',
@@ -578,6 +636,14 @@ async function createCreditNote(
// balance. article_id is preserved for the usage history.
revenue_account: item.revenue_account ?? null,
article_id: item.article_id ?? null,
// Same reasoning for periodiserade lines: the credit-note verifikat must
// reverse against the 29xx interim account the original credited, not the
// revenue account. generatePerRateLines reads these fields to substitute.
// No schedule is ever created for a credit note (only send/mark-sent
// create schedules); the original's schedule is cancelled below.
accrual_period_start: item.accrual_period_start ?? null,
accrual_period_end: item.accrual_period_end ?? null,
accrual_balance_account: item.accrual_balance_account ?? null,
}))
const { error: itemsError } = await supabase.from('invoice_items').insert(creditNoteItems)
@@ -638,11 +704,46 @@ async function createCreditNote(
// Non-blocking — credit note still exists.
}
// Periodisering interplay: cancel remaining months and storno posted
// dissolutions so origin + dissolutions + stornos + credit net to zero on
// both 29xx and 3xxx. Best-effort — never blocks the credit itself, but
// partial reversals are surfaced as a response warning so the user knows
// the schedule stayed active.
try {
const cancelResult = await cancelSchedulesForSource(
supabase,
companyId,
userId,
{ invoiceId: input.credited_invoice_id },
{ reversalDate: creditNote.invoice_date },
)
if (cancelResult.failedReversals > 0) {
warnings.push({
code: 'ACCRUAL_CANCEL_PARTIAL',
message:
'Fakturan krediterades, men en eller flera periodiseringsverifikat ' +
'kunde inte vändas. Periodiseringen är fortfarande aktiv — ' +
'kontrollera under Bokföring → Periodiseringar.',
})
}
} catch (err) {
log.warn('failed to cancel accrual schedules for credited invoice', err as Error)
warnings.push({
code: 'ACCRUAL_CANCEL_PARTIAL',
message:
'Fakturan krediterades, men periodiseringarna kunde inte avslutas. ' +
'Kontrollera under Bokföring → Periodiseringar.',
})
}
await eventBus.emit({
type: 'credit_note.created',
payload: { creditNote: completeCreditNote as CreditNote, companyId, userId },
})
}
return NextResponse.json({ data: completeCreditNote })
return NextResponse.json({
data: completeCreditNote,
...(warnings.length > 0 ? { warnings } : {}),
})
}
@@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { DELETE } from '../route'
describe('DELETE /api/supplier-invoices/[id]', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
function deleteRequest() {
return DELETE(
createMockRequest('/api/supplier-invoices/si-1', { method: 'DELETE' }),
createMockRouteParams({ id: 'si-1' }),
)
}
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const response = await deleteRequest()
expect(response.status).toBe(401)
})
it('returns 404 when the invoice does not exist', async () => {
enqueue({ data: null, error: null })
const response = await deleteRequest()
expect(response.status).toBe(404)
})
it('blocks deletion of credit notes', async () => {
enqueue({
data: { status: 'registered', registration_journal_entry_id: null, is_credit_note: true },
})
const { status } = await parseJsonResponse(await deleteRequest())
expect(status).toBe(400)
})
it('blocks deletion when a registration journal entry exists', async () => {
enqueue({
data: {
status: 'registered',
registration_journal_entry_id: 'je-1',
is_credit_note: false,
},
})
const response = await deleteRequest()
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { reason: string } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_DELETE_HAS_BOOKING')
expect(body.error.details.reason).toBe('registration_journal_entry')
// Items must NOT have been deleted (only the existence fetch ran).
expect(mockSupabase.from).toHaveBeenCalledTimes(1)
})
it('blocks deletion when an accrual schedule references the invoice', async () => {
enqueue({
data: {
status: 'registered',
registration_journal_entry_id: null,
is_credit_note: false,
},
})
// accrual_schedules lookup finds a linked schedule (ON DELETE RESTRICT
// would otherwise fail AFTER the items were already deleted).
enqueue({ data: { id: 'sched-1' } })
const response = await deleteRequest()
const { status, body } = await parseJsonResponse<{
error: { code: string; details: { reason: string; scheduleId: string } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_DELETE_HAS_BOOKING')
expect(body.error.details.reason).toBe('accrual_schedule')
expect(body.error.details.scheduleId).toBe('sched-1')
// Only the existence fetch + schedule lookup ran — no item deletion.
expect(mockSupabase.from).toHaveBeenCalledTimes(2)
})
it('deletes an unbooked registered invoice', async () => {
enqueue({
data: {
status: 'registered',
registration_journal_entry_id: null,
is_credit_note: false,
},
})
enqueue({ data: null }) // accrual_schedules lookup: none
enqueue({ data: null }) // items delete
enqueue({ data: null }) // invoice delete
const response = await deleteRequest()
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
})
})
+42 -1
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { cancelSchedulesForSource } from '@/lib/bookkeeping/accruals/service'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
@@ -102,10 +103,14 @@ export const POST = withRouteContext(
let journalEntryId: string | null = null
if (accountingMethod === 'accrual') {
try {
// Pass the ORIGINAL items: deferred lines carry their periodisering
// fields there, so the credit entry reverses against the same 17xx
// interim account the registration booked to. The copied credit-note
// items intentionally have no accrual fields.
const journalEntry = await createSupplierCreditNoteEntry(
supabase, companyId!, user.id,
creditNote as SupplierInvoice,
creditItems as SupplierInvoiceItem[],
(original.items || []) as SupplierInvoiceItem[],
original.supplier?.supplier_type || 'swedish_business',
original.supplier?.name,
)
@@ -135,6 +140,41 @@ export const POST = withRouteContext(
}
}
// Periodisering interplay: cancel remaining months and storno the
// already-posted dissolutions so origin + dissolutions + stornos +
// credit-note net to zero on both the interim and cost accounts.
// Best-effort: a reversal hiccup (e.g. locked period) must not block the
// credit itself — the schedule stays active and visible for follow-up,
// and the response carries a PARTIAL-style warning (same pattern as the
// supplier-create route's ACCRUAL_SCHEDULE_FAILED warning).
const warnings: Array<{ code: string; message: string }> = []
try {
const cancelResult = await cancelSchedulesForSource(
supabase,
companyId!,
user.id,
{ supplierInvoiceId: id },
{ reversalDate: creditNote.invoice_date },
)
if (cancelResult.failedReversals > 0) {
warnings.push({
code: 'ACCRUAL_CANCEL_PARTIAL',
message:
'Fakturan krediterades, men en eller flera periodiseringsverifikat ' +
'kunde inte vändas. Periodiseringen är fortfarande aktiv — ' +
'kontrollera under Bokföring → Periodiseringar.',
})
}
} catch (err) {
opLog.warn('failed to cancel accrual schedules for credited supplier invoice', err as Error)
warnings.push({
code: 'ACCRUAL_CANCEL_PARTIAL',
message:
'Fakturan krediterades, men periodiseringarna kunde inte avslutas. ' +
'Kontrollera under Bokföring → Periodiseringar.',
})
}
const newRemaining = Math.max(0, original.remaining_amount - original.total)
const newStatus = newRemaining <= 0 ? 'credited' : original.status
@@ -163,6 +203,7 @@ export const POST = withRouteContext(
return NextResponse.json({
data: creditNote,
journal_entry_id: journalEntryId,
...(warnings.length > 0 ? { warnings } : {}),
})
},
{ requireWrite: true },
+31
View File
@@ -4,6 +4,10 @@ import { validateBody } from '@/lib/api/validate'
import { UpdateSupplierInvoiceSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createLogger } from '@/lib/logger'
const log = createLogger('api.supplier_invoices.id')
export async function GET(
_request: Request,
@@ -143,6 +147,33 @@ export async function DELETE(
)
}
// Booked invoices must go through the credit flow (mirrors the credit-note
// guard above). Two independent blockers:
// (a) a posted registration verifikat — deleting the row would orphan it
// and silently understate 2440/2641 for the momsdeklaration;
// (b) an accrual schedule — accrual_schedules.supplier_invoice_id is
// ON DELETE RESTRICT, so the invoice DELETE below would fail AFTER the
// items were already deleted, leaving a broken invoice with zero rows.
if (existing.registration_journal_entry_id) {
return errorResponseFromCode('SI_DELETE_HAS_BOOKING', log, {
details: { reason: 'registration_journal_entry' },
})
}
const { data: linkedSchedule } = await supabase
.from('accrual_schedules')
.select('id')
.eq('company_id', companyId)
.eq('supplier_invoice_id', id)
.limit(1)
.maybeSingle()
if (linkedSchedule) {
return errorResponseFromCode('SI_DELETE_HAS_BOOKING', log, {
details: { reason: 'accrual_schedule', scheduleId: linkedSchedule.id },
})
}
// Delete items first, then invoice
await supabase.from('supplier_invoice_items').delete().eq('supplier_invoice_id', id)
@@ -687,6 +687,37 @@ describe('POST /api/supplier-invoices', () => {
expect(items[0].vat_amount).toBe(2500)
})
it('rejects periodisering combined with reverse_charge', async () => {
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-RC-ACC',
invoice_date: '2026-01-01',
due_date: '2026-02-01',
reverse_charge: true,
items: [
{
description: 'Licens 12 mån',
amount: 12000,
account_number: '6540',
vat_rate: 0,
accrual_period_start: '2026-01-01',
accrual_period_end: '2026-12-31',
},
],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_CREATE_ACCRUAL_REVERSE_CHARGE')
// The guard must fire before anything is persisted or booked.
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
expect(mockCreateSupplierInvoicePrivatelyPaidEntry).not.toHaveBeenCalled()
})
it('rejects paid_with_private_funds combined with reverse_charge', async () => {
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
+79 -1
View File
@@ -4,6 +4,8 @@ import {
createSupplierInvoiceRegistrationEntry,
createSupplierInvoicePrivatelyPaidEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
@@ -69,6 +71,40 @@ export const POST = withRouteContext(
})
}
const hasAccrualItems = body.items.some(
(item) => item.accrual_period_start && item.accrual_period_end,
)
if (hasAccrualItems && body.reverse_charge) {
// Omvänd skattskyldighet: the expense line IS the VAT base for rutor
// 2032 — deferring the net to a 17xx interim account would corrupt the
// momsdeklaration. Mirrors the customer-side reverse-charge guard.
return errorResponseFromCode('SI_CREATE_ACCRUAL_REVERSE_CHARGE', log, { requestId })
}
if (hasAccrualItems && paidPrivately) {
// Eget utlägg books the expense in one verifikat at registration —
// there is no interim-account flow to defer. UI hides the combination.
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
requestId,
details: { reason: 'periodisering is not supported with paid_with_private_funds' },
})
}
if (hasAccrualItems) {
// Kontantmetoden recognises the cost at payment; periodisering only
// exists under faktureringsmetoden. Reject loudly instead of silently
// dropping the periods.
const { data: methodSettings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('company_id', companyId)
.single()
if ((methodSettings?.accounting_method || 'accrual') !== 'accrual') {
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', log, {
requestId,
details: { reason: 'periodisering requires faktureringsmetoden (accrual)' },
})
}
}
const { data: supplier, error: supplierError } = await supabase
.from('suppliers')
.select('*')
@@ -121,6 +157,7 @@ export const POST = withRouteContext(
const vatAmount = item.vat_amount != null
? Math.round(item.vat_amount * 100) / 100
: Math.round(lineTotal * vatRate * 100) / 100
const hasAccrual = Boolean(item.accrual_period_start && item.accrual_period_end)
return {
sort_order: index,
description: item.description,
@@ -136,6 +173,15 @@ export const POST = withRouteContext(
// supplier charges no VAT (vat_rate stays 0); the engine self-assesses
// at this rate, defaulting to 25% huvudregeln when null.
reverse_charge_rate: body.reverse_charge ? (item.reverse_charge_rate ?? null) : null,
// Periodisering: frozen onto the line at create time. The balance
// account defaults from the cost account's BAS convention when the
// client leaves it blank.
accrual_period_start: hasAccrual ? item.accrual_period_start : null,
accrual_period_end: hasAccrual ? item.accrual_period_end : null,
accrual_balance_account: hasAccrual
? (item.accrual_balance_account ??
suggestBalanceAccount('expense', item.account_number))
: null,
}
})
@@ -265,9 +311,10 @@ export const POST = withRouteContext(
...item,
}))
const { error: itemsError } = await supabase
const { data: insertedItems, error: itemsError } = await supabase
.from('supplier_invoice_items')
.insert(itemInserts)
.select('id, sort_order')
if (itemsError) {
// Roll back the parent on items failure to avoid orphan rows.
@@ -378,6 +425,37 @@ export const POST = withRouteContext(
.from('supplier_invoices')
.update({ registration_journal_entry_id: journalEntry.id })
.eq('id', invoice.id)
if (hasAccrualItems) {
// The registration entry is committed (immutable) — a schedule
// failure must not roll the invoice back. Surface a warning and
// let the user retry from the periodiseringar page instead.
const idBySortOrder = new Map(
((insertedItems ?? []) as Array<{ id: string; sort_order: number }>).map(
(row) => [row.sort_order, row.id],
),
)
const itemsWithIds = items.map((item) => ({
...item,
id: idBySortOrder.get(item.sort_order) ?? null,
}))
const scheduleResult = await createSchedulesForSupplierInvoice(
supabase,
companyId!,
user.id,
invoice as SupplierInvoice,
itemsWithIds as unknown as SupplierInvoiceItem[],
journalEntry.id,
)
if (scheduleResult.failed > 0) {
warnings.push({
code: 'ACCRUAL_SCHEDULE_FAILED',
message:
'Fakturan bokfördes, men en eller flera periodiseringar kunde inte ' +
'skapas. Kontrollera under Bokföring → Periodiseringar.',
})
}
}
} else {
// createSupplierInvoiceRegistrationEntry returns null ONLY when no
// fiscal period covers invoice_date (every other failure throws and
+698
View File
@@ -0,0 +1,698 @@
'use client'
/**
* Digital inlämning av årsredovisning (iXBRL → Bolagsverket).
*
* Three steps below the year-end ÅR editors:
* 1. Granska — the generated iXBRL rendered in an iframe (the XHTML *is*
* the filed presentation) + pre-flight validation results + download
* for manual filing (the self-hosted/no-extension path).
* 2. Skicka in — only when the bolagsverket extension responds: avtalstext
* acceptance → kontrollera-utfall → upload till eget utrymme → kvittens
* with "signera hos Bolagsverket"-link. The fastställelseintyg is signed
* with e-legitimation at Bolagsverket, never here.
* 3. Status — submission history driven by webhooks + polling fallback.
*
* Year-end surface: copy stays Swedish in both locales (see i18n rules).
*/
import { useCallback, useEffect, 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 { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { formatDate } from '@/lib/utils'
import {
ExternalLink,
FileDown,
Loader2,
RefreshCcw,
SearchCheck,
Send,
ShieldCheck,
} from 'lucide-react'
/** Inlämningen till Bolagsverket väntar på avtal + organisationscertifikat
* (M0). Tills dess visas hela digital inlämning-sektionen blurrad med en
* "Kommer snart"-skylt — endast PDF-nedladdningen på ÅR-sidan är användbar.
* Flippa till false när integrationen är godkänd. Importeras också av
* ÅR-sidan som blurrar sina Bolagsverket-delar med samma flagga. */
export const INLAMNING_COMING_SOON = true
interface PreflightIssue {
code: string
severity: 'error' | 'warn'
message: string
}
interface ValidateResponse {
ok: boolean
issues: PreflightIssue[]
error_count: number
warning_count: number
generated_bytes: number
entry_point: string
}
interface KontrolleraUtfall {
kod: string
text: string
typ: string
}
interface SubmissionRow {
id: string
status: string
environment: string
idnummer: string | null
kontrollsumma: string | null
sha256_checksumma: string | null
bolagsverket_url: string | null
undertecknare_namn: string | null
kontrollera_utfall: KontrolleraUtfall[] | null
error_message: string | null
uploaded_at: string | null
registered_at: string | null
created_at: string
}
type SubmitOutcome =
| { outcome: 'avtal_required'; avtalstext: string; avtalstextAndrad: string }
| { outcome: 'preflight_failed'; issues: PreflightIssue[] }
| { outcome: 'kontrollera_stopped'; submissionId: string; utfall: KontrolleraUtfall[] }
| { outcome: 'uploaded'; submissionId: string; idnummer: string; url: string; utfall: KontrolleraUtfall[] }
/**
* Normalize a Swedish personnummer to the 12-digit ÅÅÅÅMMDDNNNN form the
* Bolagsverket token API requires. 10-digit input gets its century inferred:
* a 2-digit year greater than the current year's last two digits → 19xx,
* otherwise 20xx; the '+' separator (person 100+ years) shifts one more
* century back. Returns null when the input is neither 10 nor 12 digits.
*/
function normalizePnr(raw: string): string | null {
const trimmed = raw.trim()
const digits = trimmed.replace(/\D/g, '')
if (digits.length === 12) return digits
if (digits.length !== 10) return null
const now = new Date()
const currentCentury = Math.floor(now.getFullYear() / 100)
const currentYy = now.getFullYear() % 100
const yy = Number(digits.slice(0, 2))
let century = yy > currentYy ? currentCentury - 1 : currentCentury
if (trimmed.includes('+')) century -= 1
return `${century}${digits}`
}
const STATUS_BADGES: Record<string, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline' }> = {
draft: { label: 'Utkast', variant: 'outline' },
kontrollerad: { label: 'Kontrollerad', variant: 'secondary' },
uploaded: { label: 'Uppladdad — väntar på signering', variant: 'warning' },
inkommen: { label: 'Inkommen till Bolagsverket', variant: 'secondary' },
forelagd: { label: 'Föreläggande — åtgärd krävs', variant: 'destructive' },
komplettering: { label: 'Komplettering inlämnad', variant: 'secondary' },
registrerad: { label: 'Registrerad', variant: 'success' },
avslutad: { label: 'Avslutad utan registrering', variant: 'destructive' },
error: { label: 'Fel', variant: 'destructive' },
}
export function DigitalInlamning({ periodId }: { periodId: string }) {
const { toast } = useToast()
const ixbrlUrl = `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/ixbrl`
const [showPreview, setShowPreview] = useState(false)
const [validating, setValidating] = useState(false)
const [validation, setValidation] = useState<ValidateResponse | null>(null)
// Extension availability: probe the status route; 404 = not enabled.
const [extensionActive, setExtensionActive] = useState<boolean | null>(null)
const [environment, setEnvironment] = useState<string>('test')
// Submission form
const [avsandarePnr, setAvsandarePnr] = useState('')
const [pnr, setPnr] = useState('')
const [fornamn, setFornamn] = useState('')
const [efternamn, setEfternamn] = useState('')
const [roll, setRoll] = useState('Styrelseledamot')
const [epost, setEpost] = useState('')
const [submitting, setSubmitting] = useState(false)
const [avtal, setAvtal] = useState<{ text: string; andrad: string; accepted: boolean } | null>(null)
const [utfall, setUtfall] = useState<KontrolleraUtfall[] | null>(null)
const [kvittens, setKvittens] = useState<{ idnummer: string; url: string } | null>(null)
// Proposed dividend (utdelning) for the resultatdisposition. There is no
// persisted dividend proposal in the year-end flow yet, so the value is
// entered here and forwarded to the preview, the download and the
// submission so all three render the same disposition.
const [utdelning, setUtdelning] = useState('')
const parsedUtdelning = Math.round(Number(utdelning.replace(/\s/g, '').replace(',', '.')))
const utdelningValue = Number.isFinite(parsedUtdelning) && parsedUtdelning > 0 ? parsedUtdelning : 0
const previewUrl = utdelningValue > 0 ? `${ixbrlUrl}?utdelning=${utdelningValue}` : ixbrlUrl
const downloadUrl =
utdelningValue > 0 ? `${ixbrlUrl}?download=1&utdelning=${utdelningValue}` : `${ixbrlUrl}?download=1`
const [submissions, setSubmissions] = useState<SubmissionRow[]>([])
const [loadingSubmissions, setLoadingSubmissions] = useState(false)
const [submissionsError, setSubmissionsError] = useState<string | null>(null)
const loadSubmissions = useCallback(async () => {
setLoadingSubmissions(true)
try {
const res = await fetch(
`/api/extensions/ext/bolagsverket/submissions?fiscal_period_id=${periodId}`,
)
if (res.ok) {
const body = await res.json()
setSubmissions((body.data ?? []) as SubmissionRow[])
setSubmissionsError(null)
} else {
setSubmissionsError('Kunde inte hämta inlämningshistoriken — försök igen.')
}
} catch {
// Non-blocking: the call sites fire-and-forget (`void loadSubmissions()`),
// so a network failure must surface here instead of as an unhandled
// rejection.
setSubmissionsError('Kunde inte hämta inlämningshistoriken — försök igen.')
} finally {
setLoadingSubmissions(false)
}
}, [periodId])
useEffect(() => {
let cancelled = false
fetch('/api/extensions/ext/bolagsverket/status')
.then(async (res) => {
if (cancelled) return
if (!res.ok) {
setExtensionActive(false)
return
}
const body = await res.json()
setExtensionActive(true)
setEnvironment(body.data?.environment ?? 'test')
void loadSubmissions()
})
.catch(() => {
if (!cancelled) setExtensionActive(false)
})
return () => {
cancelled = true
}
}, [loadSubmissions])
const handleValidate = async () => {
setValidating(true)
try {
const res = await fetch(
`${ixbrlUrl}/validate${utdelningValue > 0 ? `?utdelning=${utdelningValue}` : ''}`,
)
const body = await res.json()
if (body?.error) {
toast({ title: 'Kunde inte validera', description: body.error.message, variant: 'destructive' })
return
}
setValidation(body.data as ValidateResponse)
} catch {
toast({ title: 'Kunde inte validera', variant: 'destructive' })
} finally {
setValidating(false)
}
}
const handleSubmit = async (opts: { ignoreWarnings?: boolean } = {}) => {
// The Bolagsverket token API needs 12 digits (ÅÅÅÅMMDDNNNN); 10-digit
// input is normalized client-side with a century pivot.
const normalizedAvsandare = normalizePnr(avsandarePnr)
const normalizedPnr = normalizePnr(pnr)
if (!normalizedAvsandare || !normalizedPnr) {
toast({ title: 'Ange personnummer med 10 eller 12 siffror', variant: 'destructive' })
return
}
if (!fornamn.trim() || !efternamn.trim() || !epost.trim()) {
toast({ title: 'Fyll i undertecknarens namn och e-post', variant: 'destructive' })
return
}
setSubmitting(true)
setUtfall(null)
try {
const res = await fetch('/api/extensions/ext/bolagsverket/submissions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fiscal_period_id: periodId,
avsandare_pnr: normalizedAvsandare,
undertecknare: {
pnr: normalizedPnr,
fornamn: fornamn.trim(),
efternamn: efternamn.trim(),
roll,
epost: epost.trim(),
},
...(utdelningValue > 0 ? { utdelning: utdelningValue } : {}),
...(avtal?.accepted ? { accepted_avtalstext_andrad: avtal.andrad } : {}),
...(opts.ignoreWarnings ? { ignore_warnings: true } : {}),
}),
})
const body = await res.json()
if (body?.error) {
toast({ title: 'Inlämningen misslyckades', description: body.error.message, variant: 'destructive' })
return
}
const result = body.data as SubmitOutcome
if (result.outcome === 'avtal_required') {
setAvtal({ text: result.avtalstext, andrad: result.avtalstextAndrad, accepted: false })
return
}
if (result.outcome === 'preflight_failed') {
setValidation({
ok: false,
issues: result.issues,
error_count: result.issues.filter((issue) => issue.severity === 'error').length,
warning_count: result.issues.filter((issue) => issue.severity === 'warn').length,
generated_bytes: 0,
entry_point: '',
})
toast({
title: 'Årsredovisningen är inte komplett',
description: 'Åtgärda punkterna under Granska & validera och försök igen.',
variant: 'destructive',
})
return
}
if (result.outcome === 'kontrollera_stopped') {
setUtfall(result.utfall)
void loadSubmissions()
return
}
setKvittens({ idnummer: result.idnummer, url: result.url })
setUtfall(result.utfall.length > 0 ? result.utfall : null)
setAvtal(null)
void loadSubmissions()
toast({ title: 'Uppladdad till Bolagsverkets eget utrymme' })
} catch {
toast({ title: 'Inlämningen misslyckades', variant: 'destructive' })
} finally {
setSubmitting(false)
}
}
const handlePollEvents = async () => {
try {
const res = await fetch('/api/extensions/ext/bolagsverket/poll-events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const body = await res.json()
if (body?.error) {
toast({ title: 'Kunde inte hämta händelser', description: body.error.message, variant: 'destructive' })
return
}
void loadSubmissions()
toast({ title: 'Status uppdaterad från Bolagsverket' })
} catch {
toast({ title: 'Kunde inte hämta händelser', variant: 'destructive' })
}
}
const blockingErrors = validation !== null && validation.error_count > 0
const utfallHasErrors = (utfall ?? []).some((item) => item.typ?.toLowerCase() === 'error')
return (
<div className="relative">
<div
inert={INLAMNING_COMING_SOON}
aria-hidden={INLAMNING_COMING_SOON}
className={
INLAMNING_COMING_SOON
? 'pointer-events-none select-none blur-[3px] opacity-60 space-y-8'
: 'space-y-8'
}
>
{/* Steg: Granska & validera */}
<Card>
<CardHeader>
<CardTitle className="text-base">Digital inlämning granska &amp; validera (iXBRL)</CardTitle>
<p className="text-sm text-muted-foreground">
Bolagsverket tar emot årsredovisningen som iXBRL (XHTML). Dokumentet nedan är
exakt det som lämnas in granska det som den slutliga presentationen.
</p>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div className="space-y-1.5 max-w-xs">
<Label htmlFor="di-utdelning">Föreslagen utdelning (kr)</Label>
<Input
id="di-utdelning"
inputMode="numeric"
placeholder="0"
value={utdelning}
onChange={(event) => setUtdelning(event.target.value)}
/>
<p className="text-xs text-muted-foreground">
Ingår i resultatdispositionen i dokumentet 0 betyder att allt
balanseras i ny räkning. Beloppet följer med förhandsgranskning,
nedladdning och inlämning.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button variant="outline" onClick={() => setShowPreview((value) => !value)}>
{showPreview ? 'Dölj förhandsgranskning' : 'Förhandsgranska iXBRL'}
</Button>
<Button variant="outline" asChild>
<a href={downloadUrl}>
<FileDown className="mr-2 h-4 w-4" /> Ladda ner iXBRL (.xhtml)
</a>
</Button>
<Button onClick={() => void handleValidate()} disabled={validating}>
{validating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<SearchCheck className="mr-2 h-4 w-4" />
)}
Validera
</Button>
</div>
{showPreview && (
<iframe
src={previewUrl}
title="Förhandsgranskning av årsredovisning (iXBRL)"
className="w-full h-[640px] rounded-lg border border-border bg-white"
/>
)}
{validation && (
<div className="space-y-2">
<div className="flex items-center gap-2">
{validation.ok ? (
<Badge variant="success">Klar för inlämning</Badge>
) : (
<Badge variant="destructive">{validation.error_count} fel</Badge>
)}
{validation.warning_count > 0 && (
<Badge variant="warning">{validation.warning_count} varningar</Badge>
)}
</div>
{validation.issues.length > 0 && (
<ul className="space-y-1.5">
{validation.issues.map((issue, index) => (
<li key={`${issue.code}-${index}`} className="flex gap-2 items-start">
<Badge
variant={issue.severity === 'error' ? 'destructive' : 'warning'}
className="mt-0.5 shrink-0"
>
{issue.code}
</Badge>
<span className="text-muted-foreground">{issue.message}</span>
</li>
))}
</ul>
)}
</div>
)}
</CardContent>
</Card>
{/* Steg: Skicka in */}
<Card>
<CardHeader>
<CardTitle className="text-base">Skicka in till Bolagsverket</CardTitle>
<p className="text-sm text-muted-foreground">
Årsredovisningen laddas upp till företagets eget utrymme hos Bolagsverket.
Undertecknaren får ett e-postmeddelande och signerar fastställelseintyget med
e-legitimation hos Bolagsverket först är årsredovisningen inlämnad.
</p>
</CardHeader>
<CardContent className="space-y-4 text-sm">
{extensionActive === null && (
<p className="text-muted-foreground">
<Loader2 className="inline h-4 w-4 animate-spin mr-2" />
Kontrollerar anslutningen till Bolagsverket
</p>
)}
{extensionActive === false && (
<div className="space-y-3">
<p className="text-muted-foreground">
Bolagsverket-integrationen är inte aktiverad i den här installationen.
Ladda ner iXBRL-filen ovan och lämna in den manuellt via Bolagsverkets
e-tjänst, eller aktivera integrationen (kräver avtal med Bolagsverket och
organisationscertifikat).
</p>
<Button variant="outline" asChild>
<a
href="https://www.bolagsverket.se/foretag/aktiebolag/arsredovisning/lamna-in-arsredovisning"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="mr-2 h-4 w-4" /> Lämna in hos Bolagsverket
</a>
</Button>
</div>
)}
{extensionActive === true && (
<>
{environment !== 'prod' && (
<Badge variant="warning">
{environment === 'test' ? 'Testmiljö (statiskt testdata)' : 'Acceptansmiljö'}
</Badge>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="di-avsandare-pnr">Ditt personnummer (avsändare)</Label>
<Input
id="di-avsandare-pnr"
inputMode="numeric"
placeholder="ÅÅÅÅMMDDNNNN eller ÅÅMMDD-NNNN"
value={avsandarePnr}
onChange={(event) => setAvsandarePnr(event.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="di-pnr">Undertecknarens personnummer</Label>
<Input
id="di-pnr"
inputMode="numeric"
placeholder="ÅÅÅÅMMDDNNNN eller ÅÅMMDD-NNNN"
value={pnr}
onChange={(event) => setPnr(event.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="di-fornamn">Undertecknarens förnamn</Label>
<Input id="di-fornamn" value={fornamn} onChange={(event) => setFornamn(event.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="di-efternamn">Undertecknarens efternamn</Label>
<Input id="di-efternamn" value={efternamn} onChange={(event) => setEfternamn(event.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="di-roll">Roll</Label>
<select
id="di-roll"
className="border border-border rounded-md h-9 text-sm px-2 bg-background w-full"
value={roll}
onChange={(event) => setRoll(event.target.value)}
>
<option>Styrelseledamot</option>
<option>Styrelseordförande</option>
<option>Verkställande direktör</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="di-epost">Undertecknarens e-post</Label>
<Input
id="di-epost"
type="email"
placeholder="namn@foretag.se"
value={epost}
onChange={(event) => setEpost(event.target.value)}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Personnumren skickas till Bolagsverket för att skapa eget utrymme och bjuda
in undertecknaren. De sparas inte i Accounted endast en teknisk
referens (hash) lagras.
</p>
{avtal && (
<div className="rounded-lg border border-border p-4 space-y-3">
<p className="font-medium">Villkor för eget utrymme hos Bolagsverket</p>
<p className="text-muted-foreground whitespace-pre-wrap text-xs max-h-48 overflow-y-auto">
{avtal.text}
</p>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
className="mt-0.5"
checked={avtal.accepted}
onChange={(event) =>
setAvtal({ ...avtal, accepted: event.target.checked })
}
/>
<span>
Jag har tagit del av villkoren och är behörig att företräda företaget.
</span>
</label>
</div>
)}
{utfall && utfall.length > 0 && (
<div className="space-y-2">
<p className="font-medium">Bolagsverkets kontroll hittade följande:</p>
<ul className="space-y-1.5">
{utfall.map((item, index) => (
<li key={`${item.kod}-${index}`} className="flex gap-2 items-start">
<Badge
variant={item.typ?.toLowerCase() === 'error' ? 'destructive' : 'warning'}
className="mt-0.5 shrink-0"
>
{item.kod}
</Badge>
<span className="text-muted-foreground">{item.text}</span>
</li>
))}
</ul>
{!utfallHasErrors && (
<p className="text-xs text-muted-foreground">
Varningarna hindrar inte inlämning, men minskar risken för
föreläggande om de åtgärdas.
</p>
)}
</div>
)}
<div className="flex flex-wrap gap-3">
<Button
onClick={() => void handleSubmit()}
disabled={submitting || blockingErrors || (avtal !== null && !avtal.accepted)}
>
{submitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
{avtal ? 'Godkänn villkoren och skicka in' : 'Kontrollera och skicka in'}
</Button>
{utfall && utfall.length > 0 && !utfallHasErrors && (
<Button
variant="outline"
onClick={() => void handleSubmit({ ignoreWarnings: true })}
disabled={submitting}
>
Skicka in trots varningar
</Button>
)}
</div>
{kvittens && (
<div className="rounded-lg border border-border p-4 space-y-2">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4" />
<p className="font-medium">Uppladdad till eget utrymme</p>
</div>
<p className="text-muted-foreground text-xs">
Dokument-id: <span className="tabular-nums">{kvittens.idnummer}</span>.
Undertecknaren har fått e-post från Bolagsverket och signerar
fastställelseintyget där. Ärendet startar först efter signering.
</p>
<Button variant="outline" size="sm" asChild>
<a href={kvittens.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" /> Signera hos Bolagsverket
</a>
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
{/* Steg: Status */}
{extensionActive === true && (
<Card>
<CardHeader>
<CardTitle className="text-base">Inlämningsstatus</CardTitle>
<p className="text-sm text-muted-foreground">
Status uppdateras automatiskt via händelseaviseringar från Bolagsverket.
</p>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => void handlePollEvents()}>
<RefreshCcw className="mr-2 h-4 w-4" /> Uppdatera status
</Button>
</div>
{submissionsError && <p className="text-xs text-destructive">{submissionsError}</p>}
{loadingSubmissions && submissions.length === 0 && (
<p className="text-muted-foreground">
<Loader2 className="inline h-4 w-4 animate-spin mr-2" /> Hämtar
</p>
)}
{!loadingSubmissions && submissions.length === 0 && !submissionsError && (
<p className="text-muted-foreground italic">Inga inlämningar ännu.</p>
)}
{submissions.map((submission) => {
const badge = STATUS_BADGES[submission.status] ?? {
label: submission.status,
variant: 'outline' as const,
}
return (
<div
key={submission.id}
className="flex items-start justify-between gap-4 border-b border-border last:border-b-0 pb-3 last:pb-0"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Badge variant={badge.variant}>{badge.label}</Badge>
{submission.environment !== 'prod' && (
<Badge variant="outline">{submission.environment}</Badge>
)}
</div>
<p className="text-xs text-muted-foreground tabular-nums">
{formatDate(submission.created_at)}
{submission.idnummer ? ` · id ${submission.idnummer}` : ''}
{submission.undertecknare_namn ? ` · ${submission.undertecknare_namn}` : ''}
</p>
{submission.status === 'forelagd' && (
<p className="text-xs text-destructive">
Bolagsverket har skickat ett föreläggande läs brevet, åtgärda
bristerna och lämna in en komplettering (ny inlämning ovan).
</p>
)}
{submission.error_message && (
<p className="text-xs text-destructive">{submission.error_message}</p>
)}
</div>
{submission.bolagsverket_url && submission.status === 'uploaded' && (
<Button variant="outline" size="sm" asChild>
<a href={submission.bolagsverket_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-1 h-3.5 w-3.5" /> Signera
</a>
</Button>
)}
</div>
)
})}
</CardContent>
</Card>
)}
</div>
{INLAMNING_COMING_SOON && (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<div className="rounded-lg border border-border bg-background px-8 py-6 text-center">
<p className="font-display text-2xl">Kommer snart</p>
<p className="mt-2 max-w-xs text-sm text-muted-foreground">
Digital inlämning till Bolagsverket öppnar snart integrationen
är godkänd. Tills dess: ladda ner PDF-utkastet ovan.
</p>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,170 @@
'use client'
import { useTranslations } from 'next-intl'
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { formatCurrency } from '@/lib/utils'
import {
computeInstallmentAmounts,
countCalendarMonths,
} from '@/lib/bookkeeping/accruals/compute'
import type { AccrualDirection } from '@/types'
export interface AccrualFormValue {
start: string
end: string
balanceAccount: string
}
// The statutory BAS interim accounts per direction — a fixed list reads
// better than a full account combobox and mirrors the DB CHECK (17xx/29xx).
const BALANCE_ACCOUNT_OPTIONS: Record<AccrualDirection, Array<{ value: string; label: string }>> = {
expense: [
{ value: '1710', label: '1710 Förutbetalda hyreskostnader' },
{ value: '1720', label: '1720 Förutbetalda leasingavgifter' },
{ value: '1730', label: '1730 Förutbetalda försäkringspremier' },
{ value: '1740', label: '1740 Förutbetalda räntekostnader' },
{ value: '1790', label: '1790 Övriga förutbetalda kostnader' },
],
revenue: [
{ value: '2970', label: '2970 Förutbetalda intäkter' },
{ value: '2971', label: '2971 Förutbetalda hyresintäkter' },
{ value: '2972', label: '2972 Förutbetalda medlemsavgifter' },
{ value: '2979', label: '2979 Övriga förutbetalda intäkter' },
],
}
/**
* Per-line periodisering panel for the invoice editors: service period +
* interim balance account + a live "N månader × X kr" preview. The parent
* owns the toggle; this renders only while periodisering is active on the
* line. VAT is never affected — only the net amount is deferred.
*/
export default function AccrualPeriodControl({
direction,
amount,
value,
onChange,
onRemove,
idPrefix,
}: {
direction: AccrualDirection
/** Net line amount (ex VAT) — drives the preview and the K2 hint. */
amount: number
value: AccrualFormValue
onChange: (next: AccrualFormValue) => void
onRemove: () => void
idPrefix: string
}) {
const t = useTranslations('accruals')
let preview: string | null = null
let previewInvalid: string | null = null
if (value.start && value.end) {
if (value.end < value.start) {
previewInvalid = t('preview_invalid_period')
} else {
try {
const months = countCalendarMonths(value.start, value.end)
if (months < 2) {
previewInvalid = t('preview_min_months')
} else if (amount > 0) {
const amounts = computeInstallmentAmounts(amount, months)
preview = t('preview', {
months,
amount: formatCurrency(amounts[0]),
})
}
} catch {
previewInvalid = t('preview_invalid_period')
}
}
}
const showK2Hint = amount > 0 && amount < 5000
return (
<div className="rounded-md border bg-muted/30 p-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t('panel_title')}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onRemove}
aria-label={t('remove_aria')}
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label htmlFor={`${idPrefix}-start`} className="text-xs">
{t('start_label')}
</Label>
<Input
id={`${idPrefix}-start`}
type="date"
className="h-9"
value={value.start}
onChange={(e) => onChange({ ...value, start: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`${idPrefix}-end`} className="text-xs">
{t('end_label')}
</Label>
<Input
id={`${idPrefix}-end`}
type="date"
className="h-9"
value={value.end}
onChange={(e) => onChange({ ...value, end: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t('account_label')}</Label>
<Select
value={value.balanceAccount}
onValueChange={(account) => onChange({ ...value, balanceAccount: account })}
>
<SelectTrigger className="h-9" aria-label={t('account_label')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BALANCE_ACCOUNT_OPTIONS[direction].map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{(preview || previewInvalid) && (
<p
className={
previewInvalid ? 'text-xs text-destructive' : 'text-xs text-muted-foreground tabular-nums'
}
>
{previewInvalid ?? preview}
</p>
)}
{showK2Hint && (
<p className="text-xs text-muted-foreground">{t('k2_hint')}</p>
)}
</div>
)
}
@@ -64,14 +64,17 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
const data = body.data as DispositionsProposal
setProposal(data)
const selections: UiState['selections'] = {}
for (const p of data.proposals) {
const key = proposalKey(p)
data.proposals.forEach((p, index) => {
// Key must match the render loop and buildPostItems, which both pass
// the array index — omitting it here defaulted every non-ateforing
// key to ":0", so only the first proposal card ever rendered.
const key = proposalKey(p, index)
selections[key] = {
accept: true,
overrideAmount: p.amount,
lockedSkip: Boolean(p.required),
}
}
})
setUi({ selections })
} catch {
setFetchError('Kunde inte ladda dispositioner')
@@ -40,14 +40,34 @@ import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registr
type ArcimProvider = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden'
const ARCIM_PROVIDERS: { id: ArcimProvider; name: string; authType: 'oauth' | 'token' }[] = [
{ id: 'fortnox', name: 'Fortnox', authType: 'oauth' },
{ id: 'visma', name: 'Visma', authType: 'oauth' },
{ id: 'bokio', name: 'Bokio', authType: 'token' },
{ id: 'bjornlunden', name: 'Björn Lundén', authType: 'token' },
{ id: 'briox', name: 'Briox', authType: 'token' },
// `sieViaApi`: the provider serves its general ledger as SIE over the API —
// no manual SIE upload needed. Deliberately duplicated from
// extensions/general/arcim-migration/types.ts (core code must not import from
// @/extensions/ — CI enforces it). Keep both lists in sync.
const ARCIM_PROVIDERS: { id: ArcimProvider; name: string; authType: 'oauth' | 'token'; sieViaApi: boolean }[] = [
{ id: 'fortnox', name: 'Fortnox', authType: 'oauth', sieViaApi: true },
{ id: 'visma', name: 'Visma', authType: 'oauth', sieViaApi: false },
{ id: 'bokio', name: 'Bokio', authType: 'token', sieViaApi: false },
{ id: 'bjornlunden', name: 'Björn Lundén', authType: 'token', sieViaApi: true },
{ id: 'briox', name: 'Briox', authType: 'token', sieViaApi: true },
]
/**
* Extract a human-readable message from an API error body. Routes answer in
* two shapes: legacy `{ error: 'text' }` and the structured envelope
* `{ error: { code, message } }` — naively rendering the latter shows
* "[object Object]".
*/
function apiErrorMessage(data: unknown, fallback: string): string {
const err = (data as { error?: unknown } | null)?.error
if (typeof err === 'string' && err) return err
if (err && typeof err === 'object') {
const message = (err as { message?: unknown }).message
if (typeof message === 'string' && message) return message
}
return fallback
}
interface SkipReasons {
duplicate?: number
inactive?: number
@@ -158,6 +178,9 @@ interface SIEData {
allImported: boolean
newFileCount: number
replacedFileCount?: number
// Fiscal years whose provider export failed. Importing the remaining years
// anyway leaves an IB/UB gap — the options step warns before proceeding.
failedYears?: { year: number; error: string }[]
basAccounts: BASAccount[]
}
@@ -190,7 +213,7 @@ interface ConnectionStatus {
}
}
const COMING_SOON_PROVIDERS = new Set<ArcimProvider>(['bjornlunden', 'briox'])
const COMING_SOON_PROVIDERS = new Set<ArcimProvider>([])
const PROVIDER_LOGOS: Record<ArcimProvider, string> = {
fortnox: '/logos/fortnox.svg',
@@ -215,19 +238,20 @@ function ProviderStep({
}) {
const activeConsents = connectionStatus?.consents.filter(c => c.status === 1) ?? []
const hasSieImport = (connectionStatus?.sieImports.filter(i => i.status === 'completed').length ?? 0) > 0
const allFortnox = activeConsents.length > 0 && activeConsents.every(c => c.provider === 'fortnox')
const showSieRequiredBanner = !isLoadingStatus && !hasSieImport && !allFortnox
const sieViaApi = (id: ArcimProvider) => ARCIM_PROVIDERS.find(p => p.id === id)?.sieViaApi === true
const allSieViaApi = activeConsents.length > 0 && activeConsents.every(c => sieViaApi(c.provider))
const showSieRequiredBanner = !isLoadingStatus && !hasSieImport && !allSieViaApi
return (
<div className="space-y-4">
{/* SIE-required banner (not relevant for Fortnox — it fetches SIE itself) */}
{/* SIE-required banner (not relevant for Fortnox/Briox — they fetch SIE via API) */}
{showSieRequiredBanner && (
<div className="flex gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 p-4">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-500" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">SIE-import krävs först</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Bokio, Visma, Björn Lundén och Briox hämtar endast kunder, leverantörer och fakturor via API:et. Bokföringsdata (kontoplan, verifikationer och balanser) måste importeras via SIE-fil först. Gäller inte Fortnox där hämtar vi SIE direkt via API:et.
Bokio och Visma hämtar endast kunder, leverantörer och fakturor via API:et. Bokföringsdata (kontoplan, verifikationer och balanser) måste importeras via SIE-fil först. Gäller inte Fortnox, Briox och Björn Lundén där hämtar vi SIE direkt via API:et.
</p>
<Link
href="/import?mode=sie"
@@ -349,13 +373,13 @@ function ProviderStep({
{ARCIM_PROVIDERS.map((provider) => {
const comingSoon = COMING_SOON_PROVIDERS.has(provider.id)
const alreadyConnected = activeConsents.some(c => c.provider === provider.id)
// Non-Fortnox providers only expose entity data (customers,
// suppliers, invoices) via API — the ledger must arrive via SIE
// first. Gate the connection entry until a completed SIE import
// exists so users don't authenticate into a flow that can't
// import anything yet. The /migrate route enforces this
// Providers without SIE-over-API only expose entity data
// (customers, suppliers, invoices) — the ledger must arrive via
// SIE upload first. Gate the connection entry until a completed
// SIE import exists so users don't authenticate into a flow that
// can't import anything yet. The /migrate route enforces this
// server-side regardless; this is just the matching UX.
const needsSieFirst = !hasSieImport && provider.id !== 'fortnox'
const needsSieFirst = !hasSieImport && !provider.sieViaApi
const isDisabled = comingSoon || alreadyConnected || needsSieFirst
return (
<button
@@ -399,7 +423,9 @@ function ProviderStep({
? 'Importera SIE-fil först'
: provider.authType === 'oauth'
? 'Anslut via inloggning'
: 'Anslut med API-nyckel'}
: provider.id === 'bjornlunden'
? 'Anslut med företagsnyckel'
: 'Anslut med API-nyckel'}
</p>
</div>
</button>
@@ -441,17 +467,27 @@ function ConnectStep({
// BL uses server-side client credentials — only needs company ID, no API key
const isClientCredentials = provider === 'bjornlunden'
const needsApiToken = !isClientCredentials
const needsCompanyId = provider === 'bokio' || provider === 'bjornlunden'
// Briox: the account ID is the `clientid` half of the token exchange
const needsCompanyId = provider === 'bokio' || provider === 'bjornlunden' || provider === 'briox'
const companyIdLabel = provider === 'briox'
? 'Konto-ID'
: provider === 'bjornlunden'
? 'Företagsnyckel (User-Key)'
: 'Företags-ID'
const tokenDescription = isClientCredentials
? `Ange ditt företags-ID (GUID) från Björn Lundén. ${branding.appName.toLowerCase()} ansluter automatiskt via sin integrationspartner-åtkomst.`
: `Ange din API-nyckel från ${providerName} för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata.`
? `Ange din företagsnyckel (User-Key) från Björn Lundén. ${branding.appName.toLowerCase()} ansluter automatiskt via sin integrationspartner-åtkomst.`
: provider === 'briox'
? `Ange ditt konto-ID och din applikationstoken från Briox för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata.`
: `Ange din API-nyckel från ${providerName} för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata.`
const tokenHelpText = isClientCredentials
? `Hittas i Björn Lundén under Inställningar \u2192 Företagsinformation (GUID-format).`
? `Företagsnyckeln (User-Key) är ett GUID som du hittar i Lundify under Integrationer → kugghjulet vid integrationen, eller i aktiveringsmejlet från Björn Lundén.`
: provider === 'bokio'
? `Du hittar din API-nyckel i ${providerName} under Inställningar \u2192 Integrationer \u2192 API. Ditt företags-ID är det GUID som syns i URL:en när du är inloggad, t.ex. https://app.bokio.se/ditt-företags-id/settings-r/private-integrations.`
: `Du hittar din applikationstoken i ${providerName} under Administration \u2192 Integrationer.`
: provider === 'briox'
? `Skapa din applikationstoken i Briox under Admin \u2192 Anv\u00e4ndare \u2192 kugghjulet vid din anv\u00e4ndare \u2192 Applikationstoken. Ditt konto-ID \u00e4r det l\u00e5nga numret inom parentes bredvid f\u00f6retagsnamnet under "Ditt konto" i menyn till h\u00f6ger.`
: `Du hittar din applikationstoken i ${providerName} under Administration \u2192 Integrationer.`
const canSubmit = isClientCredentials
? !!companyId
@@ -548,13 +584,19 @@ function ConnectStep({
{needsCompanyId && (
<div>
<label htmlFor="companyId" className="text-sm font-medium">
Företags-ID
{companyIdLabel}
</label>
<Input
id="companyId"
name="companyId_nocomplete"
autoComplete="new-password"
placeholder={isClientCredentials ? 'GUID från företagsinställningar' : 'GUID från URL:en, t.ex. 14ccad83-67f6-49bd-...'}
placeholder={
isClientCredentials
? 'Företagsnyckel, t.ex. 1f0e2d3c-4b5a-...'
: provider === 'briox'
? 'Det långa numret inom parentes, t.ex. 35649125'
: 'GUID från URL:en, t.ex. 14ccad83-67f6-49bd-...'
}
value={companyId}
onChange={(e) => setCompanyId(e.target.value)}
/>
@@ -712,6 +754,7 @@ function MappingStep({
sieData,
isLoading,
error,
errorDetails,
onMappingChange,
onContinue,
onBack,
@@ -719,6 +762,7 @@ function MappingStep({
sieData: SIEData | null
isLoading: boolean
error: string | null
errorDetails: string[] | null
onMappingChange: (sourceAccount: string, targetAccount: string, targetName: string) => void
onContinue: () => void
onBack: () => void
@@ -743,9 +787,19 @@ function MappingStep({
<CardContent className="pt-6">
<div className="flex gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div>
<div className="min-w-0">
<p className="font-medium text-destructive">Kunde inte ladda SIE-data</p>
<p className="text-sm text-muted-foreground">{error}</p>
{errorDetails && errorDetails.length > 0 && (
<ul className="mt-2 list-disc space-y-1 pl-4 text-sm text-muted-foreground">
{errorDetails.slice(0, 8).map((detail, i) => (
<li key={i} className="break-words">{detail}</li>
))}
{errorDetails.length > 8 && (
<li> och {errorDetails.length - 8} fel till</li>
)}
</ul>
)}
</div>
</div>
</CardContent>
@@ -807,6 +861,7 @@ function OptionsStep({
const yearsToReplace = fileStatuses
.filter(fs => fs.previousImport)
.map(fs => fs.fiscalYear)
const failedYears = sieData?.failedYears ?? []
const selectedItems: string[] = []
if (options.importCompanyInfo) selectedItems.push('Företagsinformation')
@@ -836,6 +891,29 @@ function OptionsStep({
{sieAvailable && (
<>
{/* Years whose provider export failed — must be visible before
the user proceeds, otherwise an IB/UB gap slips through. */}
{failedYears.length > 0 && (
<div className="rounded-md border border-amber-500/30 bg-amber-50/50 p-3 dark:bg-amber-950/20">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<div className="space-y-1">
<p className="text-sm font-medium">
{failedYears.length === 1
? `Räkenskapsår ${failedYears[0].year} kunde inte hämtas`
: `Räkenskapsår ${failedYears.map(f => f.year).join(', ')} kunde inte hämtas`}
</p>
<p className="text-xs text-muted-foreground">
Exporten från källsystemet misslyckades för{' '}
{failedYears.length === 1 ? 'det här räkenskapsåret' : 'dessa räkenskapsår'}.
Om du fortsätter importeras övriga år, men ingående och utgående balanser
kan sakna kontinuitet mellan åren. Försök igen senare eller ladda upp en
SIE-fil för {failedYears.length === 1 ? 'det saknade året' : 'de saknade åren'} manuellt.
</p>
</div>
</div>
</div>
)}
<OptionRow
icon={<Database className="h-4 w-4" />}
label="Bokföringsdata (SIE)"
@@ -1291,7 +1369,7 @@ function ResultStep({
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div>
<p className="text-base font-medium text-destructive">Migreringen misslyckades</p>
<p className="mt-1 text-sm text-muted-foreground">{error}</p>
<p className="mt-1 whitespace-pre-line text-sm text-muted-foreground">{error}</p>
</div>
</div>
</CardContent>
@@ -1560,6 +1638,10 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
const [isLoading, setIsLoading] = useState(false)
const [isLoadingStatus, setIsLoadingStatus] = useState(true)
const [error, setError] = useState<string | null>(null)
// Per-item details behind `error` — e.g. the SIE validation errors from
// /sie-data, which would otherwise be swallowed (the envelope's `error`
// field is just the string "validation").
const [errorDetails, setErrorDetails] = useState<string[] | null>(null)
// Connection status (existing connections + import history)
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus | null>(null)
@@ -1630,7 +1712,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
const res = await fetch(`/api/extensions/ext/arcim-migration/preview?consentId=${cId}`)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || `HTTP ${res.status}`)
throw new Error(apiErrorMessage(data, `HTTP ${res.status}`))
}
const data = await res.json()
@@ -1663,7 +1745,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || `HTTP ${res.status}`)
throw new Error(apiErrorMessage(data, `HTTP ${res.status}`))
}
const data = await res.json()
@@ -1708,7 +1790,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || 'Kunde inte koppla från')
throw new Error(apiErrorMessage(data, 'Kunde inte koppla från'))
}
toast({ title: 'Frånkopplad', description: 'Anslutningen har tagits bort.' })
await fetchStatus()
@@ -1738,7 +1820,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || `HTTP ${res.status}`)
throw new Error(apiErrorMessage(data, `HTTP ${res.status}`))
}
// Token stored — consent is now accepted, proceed to preview
@@ -1813,12 +1895,23 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
setStep('mapping')
setIsLoading(true)
setError(null)
setErrorDetails(null)
try {
const res = await fetch(`/api/extensions/ext/arcim-migration/sie-data?consentId=${consentId}`)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || `HTTP ${res.status}`)
const data = await res.json().catch(() => ({})) as {
error?: unknown
validation?: { errors?: unknown }
}
const validationErrors = data?.error === 'validation' ? data.validation?.errors : undefined
if (Array.isArray(validationErrors)) {
setErrorDetails(validationErrors.filter((e): e is string => typeof e === 'string'))
throw new Error(
'Bokföringsdatan hos leverantören klarade inte valideringen. Felen nedan måste rättas i källsystemet innan importen kan fortsätta.'
)
}
throw new Error(apiErrorMessage(data, `HTTP ${res.status}`))
}
const data = await res.json()
@@ -1916,11 +2009,21 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || `SIE import HTTP ${res.status}`)
throw new Error(apiErrorMessage(data, `SIE import HTTP ${res.status}`))
}
const result = await res.json() as ImportResult
setSieImportResults(prev => [...prev, result])
// The endpoint returns HTTP 200 with success:false when the import
// itself failed (e.g. räkenskapsår mismatch). Stop here — continuing
// to /migrate would hit its SIE-guard, whose "SIE måste importeras
// först" message masks the real error.
if (!result.success) {
throw new Error(result.errors.length > 0
? result.errors.join('\n')
: 'SIE-importen misslyckades utan felmeddelande.')
}
}
}
@@ -1950,7 +2053,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || `HTTP ${res.status}`)
throw new Error(apiErrorMessage(data, `HTTP ${res.status}`))
}
const data = await res.json()
@@ -2070,6 +2173,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
sieData={sieData}
isLoading={isLoading}
error={error}
errorDetails={errorDetails}
onMappingChange={handleMappingChange}
onContinue={() => setStep('options')}
onBack={() => setStep('preview')}
+34 -1
View File
@@ -1,10 +1,12 @@
'use client'
import { useTranslations } from 'next-intl'
import { CalendarClock } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { formatCurrency } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions'
import type { Customer, Currency } from '@/types'
interface ReviewItem {
@@ -15,8 +17,15 @@ interface ReviewItem {
vat_rate?: number
/** 'text' rows are free-text/blank lines — description only, no amounts. */
line_type?: 'product' | 'text'
// Periodisering: when both dates are set, the revenue books to the 29xx
// interim account and dissolves monthly over the period.
accrual_period_start?: string | null
accrual_period_end?: string | null
accrual_balance_account?: string | null
}
const accrualMonth = (date: string): string => date.slice(0, 7)
interface InvoiceReviewContentProps {
customer: Customer
invoiceDate: string
@@ -130,7 +139,20 @@ export function InvoiceReviewContent({
</tr>
) : (
<tr key={index} className="border-b last:border-0">
<td className="py-2">{item.description}</td>
<td className="py-2">
{item.description}
{itemHasAccrual(item) && (
<p className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="tabular-nums">
{t('accrual_line_info', {
from: accrualMonth(item.accrual_period_start!),
to: accrualMonth(item.accrual_period_end!),
})}
</span>
</p>
)}
</td>
<td className="py-2 text-right">{item.quantity}</td>
<td className="py-2 text-center">{item.unit}</td>
<td className="py-2 text-right">{formatCurrency(item.unit_price, currency)}</td>
@@ -153,6 +175,17 @@ export function InvoiceReviewContent({
) : (
<div key={index} className="border rounded-lg p-3 text-sm space-y-1.5">
<p className="font-medium">{item.description}</p>
{itemHasAccrual(item) && (
<p className="flex items-center gap-1 text-xs text-muted-foreground">
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="tabular-nums">
{t('accrual_line_info', {
from: accrualMonth(item.accrual_period_start!),
to: accrualMonth(item.accrual_period_end!),
})}
</span>
</p>
)}
<div className="flex items-center justify-between text-muted-foreground">
<span>{item.quantity} {item.unit} × {formatCurrency(item.unit_price, currency)}</span>
{showVatColumn && <span className="text-xs">{t('mobile_vat_suffix', { rate: item.vat_rate ?? 0 })}</span>}
@@ -1,6 +1,7 @@
'use client'
import { useTranslations } from 'next-intl'
import { CalendarClock } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { AccountNumber } from '@/components/ui/account-number'
@@ -10,6 +11,7 @@ import {
isReverseChargeBasisAccount,
generateReverseChargeBasisLines,
} from '@/lib/bookkeeping/vat-entries'
import { resolveBookingAccount, itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions'
import type { Supplier } from '@/types'
interface ReviewLineItem {
@@ -23,8 +25,16 @@ interface ReviewLineItem {
// Self-assessed VAT rate for omvänd skattskyldighet (0.06/0.12/0.25). The
// supplier charges no VAT (vat_rate = 0); this drives the fiktiv-moms preview.
reverse_charge_rate?: number
// Periodisering: when both dates are set, the registration entry books the
// net to the 17xx interim account instead of account_number (mirrored via
// resolveBookingAccount so this preview matches the saved verifikat).
accrual_period_start?: string | null
accrual_period_end?: string | null
accrual_balance_account?: string | null
}
const accrualMonth = (date: string): string => date.slice(0, 7)
interface SupplierInvoiceReviewContentProps {
supplier: Supplier
invoiceNumber: string
@@ -74,11 +84,15 @@ function buildJournalPreview(
const lines: JournalPreviewLine[] = []
const toSek = (n: number) => Math.round(n * fxRate * 100) / 100
// Aggregate expense amounts by account number (in SEK)
// Aggregate expense amounts by booking account (in SEK). Periodiserade
// lines book their net to the 17xx interim account instead of the cost
// account — same resolveBookingAccount the entry generator uses, so the
// preview matches the saved verifikat.
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
expenseByAccount.set(item.account_number, current + toSek(item.amount))
const bookingAccount = resolveBookingAccount('expense', item, item.account_number)
const current = expenseByAccount.get(bookingAccount) || 0
expenseByAccount.set(bookingAccount, current + toSek(item.amount))
}
// Debit: Expense accounts
@@ -228,6 +242,11 @@ export function SupplierInvoiceReviewContent({
'2614': t('account_2614'),
'2624': t('account_2624'),
'2634': t('account_2634'),
'1710': t('account_1710'),
'1720': t('account_1720'),
'1730': t('account_1730'),
'1740': t('account_1740'),
'1790': t('account_1790'),
}
return (
@@ -300,7 +319,20 @@ export function SupplierInvoiceReviewContent({
<td className="py-2">
<AccountNumber number={item.account_number} size="sm" />
</td>
<td className="py-2">{item.description}</td>
<td className="py-2">
{item.description}
{itemHasAccrual(item) && (
<p className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="tabular-nums">
{t('review_accrual_line_info', {
from: accrualMonth(item.accrual_period_start!),
to: accrualMonth(item.accrual_period_end!),
})}
</span>
</p>
)}
</td>
<td className="py-2 text-right font-mono">{formatAmount(item.amount)}</td>
<td className="py-2 text-right">{Math.round(displayRate * 100)}%</td>
<td className="py-2 text-right font-mono">{formatAmount(vatAmount)}</td>
@@ -324,6 +356,17 @@ export function SupplierInvoiceReviewContent({
<p className="font-medium">{item.description}</p>
<AccountNumber number={item.account_number} size="sm" />
</div>
{itemHasAccrual(item) && (
<p className="flex items-center gap-1 text-xs text-muted-foreground">
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="tabular-nums">
{t('review_accrual_line_info', {
from: accrualMonth(item.accrual_period_start!),
to: accrualMonth(item.accrual_period_end!),
})}
</span>
</p>
)}
<div className="flex items-center justify-between text-muted-foreground">
<span>{formatAmount(item.amount)} kr</span>
<span className="text-xs">{t('review_vat_inline', { rate: Math.round(displayRate * 100), amount: formatAmount(vatAmount) })}</span>
+1
View File
@@ -6,3 +6,4 @@
30 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/pending-operations/expire/cron
0 3 * * 0 curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron
15 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/bookkeeping/accruals/post-due/cron
+1
View File
@@ -6,3 +6,4 @@
30 2 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/pending-operations/expire/cron
0 3 * * 0 curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/documents/verify/cron
0 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/sandbox/cleanup/cron
15 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/bookkeeping/accruals/post-due/cron
+85 -94
View File
@@ -12,7 +12,10 @@ import {
deleteConsent,
resolveConsent,
fetchCompanyInfoDirect,
ProviderTokenInvalidError,
ConsentNotFoundError,
} from './lib/provider-client'
import { providerSupportsSie, fetchProviderSieFiles, getAllowedFiscalYears } from './lib/sie-fetcher'
import { mapCompanyInfo } from './lib/entity-mapper'
import { executeMigration } from './lib/migration-orchestrator'
import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers'
@@ -22,7 +25,6 @@ import { parseSIEFile, validateSIEFile } from '@/lib/import/sie-parser'
import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/account-mapper'
import { loadMappings, generateImportPreview, executeSIEImport, saveMappings } from '@/lib/import/sie-import'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
import { FortnoxClient } from '@/lib/providers/fortnox/client'
import type { ProviderName } from '@/lib/providers/types'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { classifyProviderError } from '@/lib/providers/with-provider-call'
@@ -30,11 +32,6 @@ import { createLogger } from '@/lib/logger'
const moduleLog = createLogger('extensions/arcim-migration')
/** Fiscal years we support importing — older data is not needed */
const ALLOWED_FISCAL_YEARS = new Set([2024, 2025, 2026])
const fortnoxClient = new FortnoxClient()
/**
* Map known OAuth error codes from providers (Fortnox, Visma) to actionable
* Swedish guidance. Falls back to the raw provider message so we never hide
@@ -306,7 +303,12 @@ export const arcimMigrationExtension: Extension = {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { consentId, provider, apiToken, companyId } = await request.json() as {
// The caller's tenant — NOT the provider-side company id below.
const ownerCompanyId = ctx?.companyId ?? user.id
// `companyId` in the body is the PROVIDER-side company identifier
// (BL User-Key / Briox account ID / Bokio company GUID).
const { consentId, provider, apiToken, companyId: providerCompanyId } = await request.json() as {
consentId: string
provider: ArcimProvider
apiToken: string
@@ -326,17 +328,38 @@ export const arcimMigrationExtension: Extension = {
})
}
if ((provider === 'bokio' || provider === 'bjornlunden') && !companyId) {
// Briox needs the account ID (the /token clientid param) alongside
// the application token; Bokio/BL need their company GUID.
if ((provider === 'bokio' || provider === 'bjornlunden' || provider === 'briox') && !providerCompanyId) {
return errorResponseFromCode('PROVIDER_COMPANY_ID_REQUIRED', moduleLog, {
details: { provider },
})
}
try {
await submitProviderToken(consentId, provider, apiToken || 'client_credentials', companyId)
await submitProviderToken(
consentId,
provider,
apiToken || 'client_credentials',
providerCompanyId,
ownerCompanyId,
)
return NextResponse.json({ success: true, consentId })
} catch (error) {
log.error('arcim submit-token failed', error as Error, { provider })
// Consent missing or owned by another company — same 404 either way.
if (error instanceof ConsentNotFoundError) {
return errorResponseFromCode('PROVIDER_CONSENT_NOT_FOUND', moduleLog, {
details: { consentId },
})
}
// Wrong credentials (provider actively rejected them) — tell the
// user to re-check the pasted values instead of a generic 500.
if (error instanceof ProviderTokenInvalidError) {
return errorResponseFromCode('PROVIDER_TOKEN_INVALID', moduleLog, {
details: { provider, reason: error.message },
})
}
return errorResponseFromCode('PROVIDER_TOKEN_SUBMIT_FAILED', moduleLog, {
details: { reason: error instanceof Error ? error.message : 'unknown' },
})
@@ -513,46 +536,27 @@ export const arcimMigrationExtension: Extension = {
log.info('Company info fetch failed:', err instanceof Error ? err.message : String(err))
}
// Try to fetch SIE data (Fortnox has native SIE export)
// Try to fetch SIE data (Fortnox and Briox serve SIE over the API)
let sieAvailable = false
let sieStats: { accountCount: number; transactionCount: number; fiscalYears: number[] } | null = null
if (provider === 'fortnox') {
if (providerSupportsSie(provider)) {
try {
log.info(`Fetching SIE export from Fortnox for consent ${consentId}...`)
// Fortnox SIE export endpoint: /3/sie/{type}?financialyear={id}
// First get financial years
const fyResponse = await fortnoxClient.get<Record<string, unknown>>(
log.info(`Fetching SIE export from ${provider} for consent ${consentId}...`)
// Fetch SIE type 4 for the most recent allowed year to get stats
const { files, availableYears } = await fetchProviderSieFiles(
provider,
resolved.accessToken,
'/financialyears'
resolved.providerCompanyId,
{ latestOnly: true },
)
const years = (fyResponse['FinancialYears'] as Record<string, unknown>[] | undefined) ?? []
const allowedYears = years
.map(fy => ({
id: fy['Id'] as number,
fromDate: fy['FromDate'] as string,
toDate: fy['ToDate'] as string,
}))
.filter(fy => {
const year = new Date(fy.fromDate).getFullYear()
return ALLOWED_FISCAL_YEARS.has(year)
})
if (allowedYears.length > 0) {
// Fetch SIE type 4 for the most recent allowed year to get stats
const latestYear = allowedYears[allowedYears.length - 1]
const sieContent = await fortnoxClient.getText(
resolved.accessToken,
`/sie/4?financialyear=${latestYear.id}`
)
if (sieContent) {
const parsed = parseSIEFile(sieContent)
sieAvailable = true
sieStats = {
accountCount: parsed.accounts.length,
transactionCount: parsed.vouchers.length,
fiscalYears: allowedYears.map(fy => new Date(fy.fromDate).getFullYear()),
}
if (files.length > 0) {
const parsed = parseSIEFile(files[files.length - 1].rawContent)
sieAvailable = true
sieStats = {
accountCount: parsed.accounts.length,
transactionCount: parsed.vouchers.length,
fiscalYears: availableYears,
}
}
} catch (err) {
@@ -621,54 +625,30 @@ export const arcimMigrationExtension: Extension = {
const resolved = await resolveConsent(companyId, consentId)
const provider = resolved.consent.provider as ProviderName
if (provider !== 'fortnox') {
return errorResponseFromCode('PROVIDER_SIE_ONLY_FORTNOX', moduleLog, {
if (!providerSupportsSie(provider)) {
return errorResponseFromCode('PROVIDER_SIE_NOT_SUPPORTED', moduleLog, {
details: { provider },
})
}
// Fetch financial years from Fortnox
const fyResponse = await fortnoxClient.get<Record<string, unknown>>(
// Fetch SIE type 4 for each allowed fiscal year
const { files: sieFiles, failedYears } = await fetchProviderSieFiles(
provider,
resolved.accessToken,
'/financialyears'
resolved.providerCompanyId,
)
const years = (fyResponse['FinancialYears'] as Record<string, unknown>[] | undefined) ?? []
const allowedYears = years
.map(fy => ({
id: fy['Id'] as number,
fromDate: fy['FromDate'] as string,
toDate: fy['ToDate'] as string,
}))
.filter(fy => {
const year = new Date(fy.fromDate).getFullYear()
return ALLOWED_FISCAL_YEARS.has(year)
})
if (allowedYears.length === 0) {
return errorResponseFromCode('PROVIDER_SIE_NO_YEARS', moduleLog)
}
// Fetch SIE type 4 for each allowed year
const sieFiles: { fiscalYear: number; rawContent: string }[] = []
for (const fy of allowedYears) {
try {
const sieContent = await fortnoxClient.getText(
resolved.accessToken,
`/sie/4?financialyear=${fy.id}`
)
if (sieContent) {
sieFiles.push({
fiscalYear: new Date(fy.fromDate).getFullYear(),
rawContent: sieContent,
})
}
} catch (err) {
log.info(`Failed to fetch SIE for year ${fy.id}:`, err instanceof Error ? err.message : String(err))
}
}
if (sieFiles.length === 0) {
return errorResponseFromCode('PROVIDER_SIE_NO_YEARS', moduleLog)
// The allowed window is rolling (current year and the two before
// it) — interpolate the actual range instead of the static
// registry message so the text never goes stale.
const allowedYears = [...getAllowedFiscalYears()].sort((a, b) => a - b)
const range = `${allowedYears[0]}${allowedYears[allowedYears.length - 1]}`
return errorResponseFromCode('PROVIDER_SIE_NO_YEARS', moduleLog, {
messageSv: `Inga räkenskapsår ${range} hittades hos leverantören.`,
messageEn: `No fiscal years available for ${range}.`,
...(failedYears.length > 0 ? { details: { failedYears } } : {}),
})
}
// Parse most recent file for preview/validation
@@ -677,6 +657,10 @@ export const arcimMigrationExtension: Extension = {
const validation = validateSIEFile(parsed)
if (!validation.valid) {
log.warn(
`arcim sie-data validation failed for ${provider} fiscal year ${sieFile.fiscalYear}: ` +
`${validation.errors.length} error(s) — ${validation.errors.slice(0, 3).join(' | ')}`,
)
return NextResponse.json({
error: 'validation',
message: 'SIE file validation failed',
@@ -725,7 +709,7 @@ export const arcimMigrationExtension: Extension = {
const preview = generateImportPreview(parsed, mappings)
// Detect prior imports by *fiscal period overlap*, not file hash.
// Fortnox embeds the export-time #GEN date in every SIE export so
// Providers embed the export-time #GEN date in every SIE export so
// the hash always changes between syncs; only the period stays
// stable. A re-sync replaces the prior import for the same period.
const fileStatuses: {
@@ -794,6 +778,9 @@ export const arcimMigrationExtension: Extension = {
allImported: false,
newFileCount: fileStatuses.length - replacedFileCount,
replacedFileCount,
// Allowed years whose provider export failed — the wizard warns
// the user before proceeding so an IB/UB gap cannot slip through.
failedYears,
basAccounts: BAS_REFERENCE,
})
} catch (error) {
@@ -871,7 +858,7 @@ export const arcimMigrationExtension: Extension = {
// Default ON: re-syncs keep account names current with the source
// system (idempotent — equal names are a no-op in the rename pass).
updateAccountNames: options.updateAccountNames ?? true,
// Fortnox re-sync semantics: a prior completed import for the
// Provider re-sync semantics: a prior completed import for the
// same fiscal year is automatically replaced (its imported
// entries are cancelled) so the user can pull updated data
// without manual cleanup. Manual SIE upload keeps default
@@ -946,15 +933,19 @@ export const arcimMigrationExtension: Extension = {
}
// ── Guard: a completed SIE import is required before entity import ──
// Every provider except Fortnox exposes ONLY entity data (customers,
// suppliers, invoices) via API — never the general ledger. Fortnox pulls
// the GL itself via SIE-over-API. Importing entities without the
// SIE-derived ledger (kontoplan, ingående balanser, verifikationer)
// would leave an incomplete bokföring under BFL: a subledger with no
// chart of accounts and no opening balances, so every subsequent posting
// and balance is wrong. The wizard surfaces this as an advisory banner,
// but it must be enforced here so the rule cannot be bypassed by a direct
// API call, a skipped wizard step, or a stale client.
// Most providers expose ONLY entity data (customers, suppliers,
// invoices) via API — never the general ledger. Fortnox pulls the GL
// itself via SIE-over-API and is exempt. Briox and Björn Lundén also
// serve SIE over the API, but the wizard runs /import-sie before
// /migrate, so this guard stays satisfied — and keeps protecting
// against a skipped SIE step. Importing entities without the
// SIE-derived ledger (kontoplan,
// ingående balanser, verifikationer) would leave an incomplete
// bokföring under BFL: a subledger with no chart of accounts and no
// opening balances, so every subsequent posting and balance is wrong.
// The wizard surfaces this as an advisory banner, but it must be
// enforced here so the rule cannot be bypassed by a direct API call,
// a skipped wizard step, or a stale client.
if (consent.provider !== 'fortnox') {
const { count: completedSieImports } = await supabase
.from('sie_imports')
@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const { mockBlGet } = vi.hoisted(() => ({ mockBlGet: vi.fn() }))
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn(),
createClient: vi.fn(),
}))
vi.mock('@/lib/providers/bjornlunden/oauth', () => ({
refreshBjornLundenToken: vi.fn().mockResolvedValue({
access_token: 'bl-app-token',
token_type: 'Bearer',
expires_in: 3600,
}),
}))
// Keep the real BjornLundenApiError (instanceof checks in provider-client)
// but replace the client so the /details probe is controllable per test.
vi.mock('@/lib/providers/bjornlunden/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/providers/bjornlunden/client')>()
return {
...actual,
// Must be a `function` (not an arrow) so `new BjornLundenClient()` works.
BjornLundenClient: vi.fn().mockImplementation(function mockClient() {
return { get: mockBlGet }
}),
}
})
import { createServiceClient } from '@/lib/supabase/server'
import { BjornLundenApiError } from '@/lib/providers/bjornlunden/client'
import {
submitProviderToken,
ProviderTokenInvalidError,
ConsentNotFoundError,
} from '../provider-client'
describe('submitProviderToken', () => {
let mock: ReturnType<typeof createQueuedMockSupabase>
beforeEach(() => {
vi.clearAllMocks()
mock = createQueuedMockSupabase()
vi.mocked(createServiceClient).mockReturnValue(mock.supabase as never)
})
const tablesTouched = () => vi.mocked(mock.supabase.from).mock.calls.map((c) => c[0])
// ── Consent ownership (IDOR guard) ────────────────────────────────
it('throws ConsentNotFoundError and writes NOTHING when the consent belongs to another company', async () => {
// Ownership check finds no row for (consentId, ownerCompanyId) — the same
// result whether the consent does not exist or belongs to another tenant.
mock.enqueue({ data: [] })
await expect(
submitProviderToken('consent-other-tenant', 'bokio', 'tok', 'bokio-guid', 'company-A'),
).rejects.toBeInstanceOf(ConsentNotFoundError)
// Only the ownership read happened — no token upsert, no consent update.
expect(tablesTouched()).toEqual(['provider_consents'])
})
it('stores tokens when the consent belongs to the caller company', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
mock.enqueue({ data: null }) // token upsert
const result = await submitProviderToken('consent-1', 'bokio', 'tok', 'bokio-guid', 'company-A')
expect(result).toEqual({ success: true, consentId: 'consent-1' })
expect(tablesTouched()).toEqual(['provider_consents', 'provider_consent_tokens'])
})
// ── BL /details probe error classification ────────────────────────
it('does NOT map a 429 from the BL probe to ProviderTokenInvalidError', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError('Björn Lunden API error: 429', 429))
const err: unknown = await submitProviderToken(
'consent-1',
'bjornlunden',
'client_credentials',
'user-key-guid',
'company-A',
).catch((e: unknown) => e)
expect(err).toBeInstanceOf(BjornLundenApiError)
expect(err).not.toBeInstanceOf(ProviderTokenInvalidError)
// The transient failure must not store the unverified key either.
expect(tablesTouched()).not.toContain('provider_consent_tokens')
})
it('does NOT map gateway-style 5xx (503) from the BL probe to ProviderTokenInvalidError', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] })
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError('Björn Lunden API error: 503', 503))
const err: unknown = await submitProviderToken(
'consent-1',
'bjornlunden',
'client_credentials',
'user-key-guid',
'company-A',
).catch((e: unknown) => e)
expect(err).toBeInstanceOf(BjornLundenApiError)
expect(err).not.toBeInstanceOf(ProviderTokenInvalidError)
})
it('maps 500 from the BL probe to invalid credentials (sandbox-verified bad-key signal) and disables probe retries', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] })
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError('Björn Lunden API error: 500', 500))
await expect(
submitProviderToken('consent-1', 'bjornlunden', 'client_credentials', 'user-key-guid', 'company-A'),
).rejects.toBeInstanceOf(ProviderTokenInvalidError)
// The probe must fail fast: a typo'd key answers 500, which the client's
// retry policy treats as retryable — retry is disabled per call.
expect(mockBlGet).toHaveBeenCalledTimes(1)
expect(mockBlGet).toHaveBeenCalledWith('bl-app-token', 'user-key-guid', '/details', {
retry: false,
})
})
it('maps 404 from the BL probe to invalid credentials', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] })
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError('Björn Lunden API error: 404', 404))
await expect(
submitProviderToken('consent-1', 'bjornlunden', 'client_credentials', 'user-key-guid', 'company-A'),
).rejects.toBeInstanceOf(ProviderTokenInvalidError)
})
it('stores BL tokens (and labels the consent) when the probe succeeds', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
mock.enqueue({ data: null }) // consent company_name update
mock.enqueue({ data: null }) // token upsert
mockBlGet.mockResolvedValueOnce({ name: 'Testbolaget AB' })
const result = await submitProviderToken(
'consent-1',
'bjornlunden',
'client_credentials',
'user-key-guid',
'company-A',
)
expect(result).toEqual({ success: true, consentId: 'consent-1' })
expect(tablesTouched()).toEqual([
'provider_consents',
'provider_consents',
'provider_consent_tokens',
])
})
})
@@ -0,0 +1,300 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
providerSupportsSie,
fetchProviderSieFiles,
getAllowedFiscalYears,
} from '../sie-fetcher'
// The allowed window is rolling (current year and the two before it) — derive
// fixture years from the clock so these tests never go stale at new year.
const CY = new Date().getFullYear()
/**
* Routes mocked fetch responses by URL substring. The fetcher goes through
* the real provider clients (rate limiter in in-memory mode, retry on), so
* these tests exercise the full request path including auth headers and the
* encoding-detection decode of binary SIE payloads.
*/
function routeFetch(
fetchSpy: ReturnType<typeof vi.spyOn>,
routes: { match: string; respond: () => Response }[],
) {
fetchSpy.mockImplementation(((input: RequestInfo | URL) => {
const url = String(input)
const route = routes.find((r) => url.includes(r.match))
if (!route) {
return Promise.resolve(new Response(`no mock for ${url}`, { status: 404 }))
}
return Promise.resolve(route.respond())
}) as typeof fetch)
}
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
/** Build a CP437-encoded SIE snippet containing "Företagskonto" (ö = 0x94). */
function cp437SieBytes(label: string): Uint8Array {
const prefix = `#FLAGGA 0\n#KONTO 1930 "F`
const suffix = `retagskonto ${label}"\n`
const bytes: number[] = []
for (const c of prefix) bytes.push(c.charCodeAt(0))
bytes.push(0x94) // 'ö' in CP437
for (const c of suffix) bytes.push(c.charCodeAt(0))
return new Uint8Array(bytes)
}
function octetResponse(bytes: Uint8Array): Response {
return new Response(bytes.buffer as ArrayBuffer, {
status: 200,
headers: { 'Content-Type': 'application/octet-stream' },
})
}
describe('getAllowedFiscalYears', () => {
it('is a rolling three-year window ending at the current year', () => {
const years = getAllowedFiscalYears(new Date('2031-06-15'))
expect([...years].sort((a, b) => a - b)).toEqual([2029, 2030, 2031])
})
it('defaults to the real current year', () => {
const years = getAllowedFiscalYears()
expect(years.has(CY)).toBe(true)
expect(years.has(CY - 2)).toBe(true)
expect(years.has(CY - 3)).toBe(false)
})
})
describe('providerSupportsSie', () => {
it('is true for providers with SIE-over-API, false otherwise', () => {
expect(providerSupportsSie('fortnox')).toBe(true)
expect(providerSupportsSie('briox')).toBe(true)
expect(providerSupportsSie('bjornlunden')).toBe(true)
expect(providerSupportsSie('visma')).toBe(false)
expect(providerSupportsSie('bokio')).toBe(false)
})
})
describe('fetchProviderSieFiles', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch')
})
afterEach(() => {
fetchSpy.mockRestore()
})
it('rejects providers without SIE support', async () => {
await expect(fetchProviderSieFiles('visma', 't', undefined)).rejects.toThrow(
/does not support SIE over API/,
)
})
describe('briox', () => {
const yearRoutes = {
match: '/financialyear',
respond: () =>
jsonResponse({
data: {
financialyears: [
{ id: '8', fromdate: `${CY - 3}-01-01`, todate: `${CY - 3}-12-31` },
{ id: '9', fromdate: `${CY - 2}-01-01`, todate: `${CY - 2}-12-31` },
{ id: '10', fromdate: `${CY - 1}-01-01`, todate: `${CY - 1}-12-31` },
],
},
}),
}
it('fetches one decoded SIE file per allowed fiscal year', async () => {
routeFetch(fetchSpy, [
yearRoutes,
{ match: '/sie/9/4', respond: () => octetResponse(cp437SieBytes(String(CY - 2))) },
{ match: '/sie/10/4', respond: () => octetResponse(cp437SieBytes(String(CY - 1))) },
])
const result = await fetchProviderSieFiles('briox', 'token', undefined)
// CY-3 is outside the allowed window and must not be fetched
expect(getAllowedFiscalYears().has(CY - 3)).toBe(false)
expect(result.availableYears).toEqual([CY - 2, CY - 1])
expect(result.files.map((f) => f.fiscalYear)).toEqual([CY - 2, CY - 1])
expect(result.failedYears).toEqual([])
// CP437 bytes decoded into proper Swedish characters
expect(result.files[0].rawContent).toContain(`Företagskonto ${CY - 2}`)
expect(result.files[1].rawContent).toContain(`Företagskonto ${CY - 1}`)
expect(fetchSpy.mock.calls.map((c: unknown[]) => String(c[0]))).not.toContainEqual(
expect.stringContaining('/sie/8/4'),
)
})
it('latestOnly fetches just the most recent year but reports all years', async () => {
routeFetch(fetchSpy, [
yearRoutes,
{ match: '/sie/10/4', respond: () => octetResponse(cp437SieBytes(String(CY - 1))) },
])
const result = await fetchProviderSieFiles('briox', 'token', undefined, { latestOnly: true })
expect(result.files).toHaveLength(1)
expect(result.files[0].fiscalYear).toBe(CY - 1)
expect(result.availableYears).toEqual([CY - 2, CY - 1])
const sieCalls = fetchSpy.mock.calls.filter((c: unknown[]) => String(c[0]).includes('/sie/'))
expect(sieCalls).toHaveLength(1)
})
it('reports a year whose export fails in failedYears and keeps the rest', async () => {
routeFetch(fetchSpy, [
yearRoutes,
// 404 is non-retryable, so the bad year fails fast
{ match: '/sie/9/4', respond: () => new Response('gone', { status: 404 }) },
{ match: '/sie/10/4', respond: () => octetResponse(cp437SieBytes(String(CY - 1))) },
])
const result = await fetchProviderSieFiles('briox', 'token', undefined)
expect(result.files.map((f) => f.fiscalYear)).toEqual([CY - 1])
expect(result.availableYears).toEqual([CY - 2, CY - 1])
// The failed year must NOT be dropped silently — importing only the
// surviving years would break IB/UB continuity unnoticed.
expect(result.failedYears).toEqual([
{ year: CY - 2, error: expect.stringContaining('404') },
])
})
})
describe('fortnox', () => {
const yearRoutes = {
match: '/financialyears',
respond: () =>
jsonResponse({
FinancialYears: [
{ Id: 5, FromDate: `${CY - 2}-01-01`, ToDate: `${CY - 2}-12-31` },
{ Id: 6, FromDate: `${CY - 1}-01-01`, ToDate: `${CY - 1}-12-31` },
],
}),
}
it('fetches SIE via /sie/4?financialyear={id} and decodes UTF-8 bodies', async () => {
routeFetch(fetchSpy, [
yearRoutes,
{
match: '/sie/4?financialyear=',
respond: () => new Response('#FLAGGA 0\n#KONTO 1930 "Företagskonto"\n', { status: 200 }),
},
])
const result = await fetchProviderSieFiles('fortnox', 'token', undefined)
expect(result.availableYears).toEqual([CY - 2, CY - 1])
expect(result.files).toHaveLength(2)
expect(result.failedYears).toEqual([])
expect(result.files[0].rawContent).toContain('#KONTO 1930 "Företagskonto"')
const sieUrls = fetchSpy.mock.calls
.map((c: unknown[]) => String(c[0]))
.filter((u: string) => u.includes('/sie/4'))
expect(sieUrls[0]).toContain('financialyear=5')
expect(sieUrls[1]).toContain('financialyear=6')
})
it('decodes CP437 bytes from the Fortnox SIE endpoint (no blind UTF-8 text())', async () => {
// Some Fortnox endpoint variants serve the SIE body in CP437 (the SIE
// spec encoding). A blind response.text() would turn å/ä/ö into U+FFFD
// irrecoverably — the byte-level path must detect-decode like Briox/BL.
routeFetch(fetchSpy, [
yearRoutes,
{
match: '/sie/4?financialyear=',
respond: () => octetResponse(cp437SieBytes(String(CY - 1))),
},
])
const result = await fetchProviderSieFiles('fortnox', 'token', undefined)
expect(result.files).toHaveLength(2)
expect(result.files[1].rawContent).toContain(`Företagskonto ${CY - 1}`)
expect(result.files[1].rawContent).not.toContain('')
})
})
describe('bjornlunden', () => {
it('requires the company User-Key (providerCompanyId)', async () => {
await expect(fetchProviderSieFiles('bjornlunden', 't', undefined)).rejects.toThrow(/User-Key/)
})
it('fetches date-ranged exports with the User-Key header', async () => {
routeFetch(fetchSpy, [
{
match: `/sie/export/${CY - 1}-01-01/${CY - 1}-12-31`,
respond: () => octetResponse(cp437SieBytes(String(CY - 1))),
},
{
match: '/financialyear',
respond: () =>
jsonResponse([
{
entityId: 1,
id: `${CY - 1}01`,
fromDate: `${CY - 1}-01-01`,
toDate: `${CY - 1}-12-31`,
open: true,
},
]),
},
])
const result = await fetchProviderSieFiles('bjornlunden', 'token', 'user-key-guid')
expect(result.files).toHaveLength(1)
expect(result.files[0].fiscalYear).toBe(CY - 1)
expect(result.files[0].rawContent).toContain(`Företagskonto ${CY - 1}`)
const exportCall = fetchSpy.mock.calls.find((c: unknown[]) => String(c[0]).includes('/sie/export/'))
expect(exportCall).toBeDefined()
expect((exportCall![1] as RequestInit).headers).toMatchObject({
'User-Key': 'user-key-guid',
Authorization: 'Bearer token',
})
})
it('also decodes the swagger-declared base64 body shape', async () => {
// BL's swagger declares the export as a base64 string; the live API
// sends raw bytes. Guard the contingency path: a JSON-quoted base64
// payload of CP437 SIE bytes must decode to the same content.
const sieBytes = cp437SieBytes(String(CY - 1))
const base64 = Buffer.from(sieBytes).toString('base64')
routeFetch(fetchSpy, [
{
match: `/sie/export/${CY - 1}-01-01/${CY - 1}-12-31`,
respond: () =>
new Response(JSON.stringify(base64), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
},
{
match: '/financialyear',
respond: () =>
jsonResponse([
{
entityId: 1,
id: `${CY - 1}01`,
fromDate: `${CY - 1}-01-01`,
toDate: `${CY - 1}-12-31`,
open: true,
},
]),
},
])
const result = await fetchProviderSieFiles('bjornlunden', 'token', 'user-key-guid')
expect(result.files).toHaveLength(1)
expect(result.files[0].rawContent).toContain(`Företagskonto ${CY - 1}`)
})
})
})
@@ -13,8 +13,40 @@ import { buildFortnoxAuthUrl } from '@/lib/providers/fortnox/oauth'
import { exchangeFortnoxCode } from '@/lib/providers/fortnox/oauth'
import { buildVismaAuthUrl, exchangeVismaCode } from '@/lib/providers/visma/oauth'
import { refreshBjornLundenToken } from '@/lib/providers/bjornlunden/oauth'
import { BjornLundenClient, BjornLundenApiError } from '@/lib/providers/bjornlunden/client'
import { exchangeBrioxCode } from '@/lib/providers/briox/oauth'
import { BrioxApiError } from '@/lib/providers/briox/client'
import type { ConsentRecord, OtcResponse } from '../types'
// Singleton (holds the rate limiter) — used to validate BL User-Keys at submit
const bjornLundenClient = new BjornLundenClient()
/**
* Thrown by submitProviderToken when the provider actively rejects the
* submitted credentials (as opposed to a transient failure). The route maps
* this to the PROVIDER_TOKEN_INVALID structured error so the wizard can tell
* the user to re-check what they pasted.
*/
export class ProviderTokenInvalidError extends Error {
constructor(message: string) {
super(message)
this.name = 'ProviderTokenInvalidError'
}
}
/**
* Thrown when a consent does not exist OR does not belong to the caller's
* company. The two cases are deliberately indistinguishable so a caller
* cannot probe whether other tenants' consent IDs exist. The route maps this
* to PROVIDER_CONSENT_NOT_FOUND (404).
*/
export class ConsentNotFoundError extends Error {
constructor() {
super('Consent not found')
this.name = 'ConsentNotFoundError'
}
}
// Re-export data fetching functions from the provider layer
export { resolveConsent } from '@/lib/providers/resolve-consent'
export {
@@ -226,18 +258,103 @@ export async function submitProviderToken(
consentId: string,
provider: ProviderName,
apiToken: string,
companyId?: string,
providerCompanyId: string | undefined,
ownerCompanyId: string,
): Promise<{ success: boolean; consentId: string }> {
const supabase = createServiceClient()
// Ownership guard (IDOR): the consent must belong to the caller's company
// before ANY write — this module runs on the service client, which bypasses
// RLS, so this check is the only tenant boundary. Mirrors resolveConsent()
// in lib/providers/resolve-consent.ts. A consent that exists but belongs to
// another company throws the same not-found error as a nonexistent one.
const { data: ownedRows } = await supabase
.from('provider_consents')
.select('id')
.eq('id', consentId)
.eq('company_id', ownerCompanyId)
.limit(1)
if (!ownedRows || ownedRows.length === 0) {
throw new ConsentNotFoundError()
}
let accessToken = apiToken
let refreshToken: string | null = null
let tokenExpiresAt: string | null = null
// BL uses client credentials — get a real token
// BL uses app-level client credentials — get a real token, then prove the
// pasted User-Key actually opens a company before storing anything.
if (provider === 'bjornlunden') {
if (!providerCompanyId) {
throw new ProviderTokenInvalidError('Björn Lundén requires a company key (User-Key)')
}
const tokenResponse = await refreshBjornLundenToken()
accessToken = tokenResponse.access_token
tokenExpiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
// Sandbox-verified: an unknown User-Key makes /details answer 500 (BL
// fails to bind the company database), not 401/403. Without this probe a
// typo'd GUID is stored silently and only surfaces as a confusing failure
// at preview. retry:false makes a bad key fail fast instead of burning
// the client's full retry budget on the "retryable" 500.
try {
const details = await bjornLundenClient.get<Record<string, unknown>>(
accessToken,
providerCompanyId,
'/details',
{ retry: false },
)
// Bonus from the probe: label the consent with the company name so the
// wizard's connection list shows which BL company was linked.
const blCompanyName = typeof details?.['name'] === 'string' ? (details['name'] as string).trim() : ''
if (blCompanyName) {
await supabase
.from('provider_consents')
.update({ company_name: blCompanyName })
.eq('id', consentId)
}
} catch (error) {
if (error instanceof BjornLundenApiError) {
// 429 and gateway-style 5xx (502/503/504) are transient provider
// failures, not a verdict on the key — rethrow so the route reports a
// generic submit failure instead of "your key is wrong". 500 stays
// mapped to invalid credentials: per the sandbox finding above, 500
// IS the bad-key signal at BL. Tradeoff: a genuine BL 500 outage also
// reads as a rejected key.
if (error.statusCode === 429 || error.statusCode >= 501) {
throw error
}
throw new ProviderTokenInvalidError(
`Björn Lundén rejected the company key (HTTP ${error.statusCode})`,
)
}
throw error
}
}
// Briox: the user pastes an application token + account ID, which we
// exchange ONCE for an access/refresh token pair. Storing the raw
// application token would fail on every data call.
if (provider === 'briox') {
if (!providerCompanyId) {
throw new ProviderTokenInvalidError('Briox requires an account ID (clientid)')
}
try {
const tokenResponse = await exchangeBrioxCode(providerCompanyId, apiToken)
accessToken = tokenResponse.access_token
refreshToken = tokenResponse.refresh_token
tokenExpiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
} catch (error) {
// /token answers 400/401/404 for a wrong account ID or application
// token — surface as invalid credentials, not a server error.
if (error instanceof BrioxApiError && error.statusCode < 500 && error.statusCode !== 429) {
throw new ProviderTokenInvalidError(
`Briox rejected the credentials (HTTP ${error.statusCode})`,
)
}
throw error
}
}
// Store tokens — consent stays at status 0 until migration/SIE import completes
@@ -247,9 +364,9 @@ export async function submitProviderToken(
consent_id: consentId,
provider,
access_token: accessToken,
refresh_token: null,
refresh_token: refreshToken,
token_expires_at: tokenExpiresAt,
provider_company_id: companyId,
provider_company_id: providerCompanyId,
})
return { success: true, consentId }
@@ -0,0 +1,234 @@
/**
* Per-provider SIE-over-API fetcher.
*
* Providers that expose their general ledger as a SIE export over the API get
* the "Fortnox-grade" migration experience: the wizard pulls the GL itself
* instead of requiring a manual SIE upload. Everything downstream (parsing,
* validation, account mapping, replace-mode import) is provider-agnostic —
* this module's only job is to produce raw SIE file contents per fiscal year.
*
* Shared by /preview (latest year, for stats) and /sie-data (all years).
*/
import { FortnoxClient } from '@/lib/providers/fortnox/client'
import { BrioxClient } from '@/lib/providers/briox/client'
import { BjornLundenClient } from '@/lib/providers/bjornlunden/client'
import type { ProviderName } from '@/lib/providers/types'
import { detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
import { createLogger } from '@/lib/logger'
const log = createLogger('extensions/arcim-migration/sie-fetcher')
/**
* Fiscal years we support importing — the current year and the two before it.
* Derived at call time (not a module constant) so the window rolls forward
* automatically at new year without a code change.
*/
export function getAllowedFiscalYears(now: Date = new Date()): Set<number> {
const currentYear = now.getFullYear()
return new Set([currentYear - 2, currentYear - 1, currentYear])
}
export interface ProviderSieFile {
fiscalYear: number
rawContent: string
}
export interface ProviderSieFetchResult {
files: ProviderSieFile[]
/**
* Every fiscal year available at the provider within the allowed window —
* also populated when latestOnly fetched just one file, so /preview can show
* the full year list without a second round-trip.
*/
availableYears: number[]
/**
* Allowed years whose export failed (or came back empty). Callers MUST
* surface these to the user: silently importing e.g. 2024+2026 without 2025
* breaks IB/UB continuity between the years without anyone noticing.
*/
failedYears: { year: number; error: string }[]
}
// Singleton clients (they hold rate limiters)
const fortnoxClient = new FortnoxClient()
const brioxClient = new BrioxClient()
const bjornLundenClient = new BjornLundenClient()
/** True when the provider's API can serve the GL as SIE (no manual upload). */
export function providerSupportsSie(provider: ProviderName): boolean {
return provider === 'fortnox' || provider === 'briox' || provider === 'bjornlunden'
}
interface FiscalYearRef {
id: string | number
year: number
/** Period bounds — required by BL, whose export URL is date-ranged. */
fromDate?: string
toDate?: string
}
/**
* Fetch SIE type-4 exports from the provider, one file per allowed fiscal
* year (oldest first). Years whose export fails do not block the rest of the
* migration, but they are reported in `failedYears` so the caller can warn
* the user before importing a gap (IB/UB continuity).
*/
export async function fetchProviderSieFiles(
provider: ProviderName,
accessToken: string,
providerCompanyId: string | undefined,
opts?: { latestOnly?: boolean },
): Promise<ProviderSieFetchResult> {
const fetcher = getSieFetcher(provider, providerCompanyId)
if (!fetcher) {
throw new Error(`Provider ${provider} does not support SIE over API`)
}
const allowedFiscalYears = getAllowedFiscalYears()
const allYears = await fetcher.listYears(accessToken)
const allowedYears = allYears
.filter((fy) => allowedFiscalYears.has(fy.year))
.sort((a, b) => a.year - b.year)
const availableYears = allowedYears.map((fy) => fy.year)
const toFetch = opts?.latestOnly ? allowedYears.slice(-1) : allowedYears
const files: ProviderSieFile[] = []
const failedYears: { year: number; error: string }[] = []
for (const fy of toFetch) {
try {
const rawContent = await fetcher.fetchSie(accessToken, fy)
if (rawContent) {
files.push({ fiscalYear: fy.year, rawContent })
} else {
failedYears.push({ year: fy.year, error: 'Provider returned an empty SIE export' })
}
} catch (err) {
const reason = err instanceof Error ? err.message : String(err)
log.warn(`Failed to fetch SIE for ${provider} fiscal year ${fy.year} (id ${fy.id})`, {
reason,
})
failedYears.push({ year: fy.year, error: reason })
}
}
return { files, availableYears, failedYears }
}
interface SieFetcher {
listYears(accessToken: string): Promise<FiscalYearRef[]>
fetchSie(accessToken: string, fy: FiscalYearRef): Promise<string>
}
function getSieFetcher(
provider: ProviderName,
providerCompanyId: string | undefined,
): SieFetcher | null {
if (provider === 'fortnox') {
return {
async listYears(accessToken) {
const fyResponse = await fortnoxClient.get<Record<string, unknown>>(
accessToken,
'/financialyears',
)
const years = (fyResponse['FinancialYears'] as Record<string, unknown>[] | undefined) ?? []
return years.map((fy) => ({
id: fy['Id'] as number,
year: new Date(fy['FromDate'] as string).getFullYear(),
}))
},
async fetchSie(accessToken, fy) {
// Fortnox normally serves the SIE body as UTF-8, but endpoint variants
// have been seen answering CP437 (the SIE spec encoding) — a blind
// response.text() would turn å/ä/ö into U+FFFD irrecoverably. Fetch
// raw bytes and detect-decode like the Briox/BL paths.
const buffer = await fortnoxClient.getBytes(accessToken, `/sie/4?financialyear=${fy.id}`)
return decodeBuffer(buffer, detectEncoding(buffer))
},
}
}
if (provider === 'briox') {
return {
async listYears(accessToken) {
const years = await brioxClient.listFinancialYears(accessToken)
return years.map((fy) => ({
id: fy.id,
year: new Date(fy.fromdate).getFullYear(),
}))
},
async fetchSie(accessToken, fy) {
// Briox serves SIE as an octet-stream whose encoding varies
// (CP437/Windows-1252/UTF-8) — fetch bytes and detect-decode.
const buffer = await brioxClient.getBytes(accessToken, `/sie/${fy.id}/4`)
return decodeBuffer(buffer, detectEncoding(buffer))
},
}
}
if (provider === 'bjornlunden') {
// providerCompanyId carries the per-company User-Key header value.
const userKey = providerCompanyId
if (!userKey) {
throw new Error('Björn Lundén requires a company User-Key — reconnect the provider')
}
return {
async listYears(accessToken) {
const years = await bjornLundenClient.listFinancialYears(accessToken, userKey)
return years.map((fy) => ({
id: fy.id ?? fy.entityId,
year: new Date(fy.fromDate).getFullYear(),
fromDate: fy.fromDate,
toDate: fy.toDate,
}))
},
async fetchSie(accessToken, fy) {
// BL's export is date-ranged rather than year-id based. Sandbox-
// verified: the body is RAW SIE bytes (CP437, Content-Type
// text/vnd.sie-gruppen.si) even though the swagger declares a base64
// string — decodeSieBytes handles both shapes.
const buffer = await bjornLundenClient.getBytes(
accessToken,
userKey,
`/sie/export/${fy.fromDate}/${fy.toDate}`,
)
return decodeSieBytes(buffer)
},
}
}
return null
}
/**
* Decode a SIE payload that may arrive either as raw SIE bytes or as a
* base64 string (optionally JSON-quoted). BL's swagger declares base64 but
* the live API sends raw CP437 — handle both so a future API change doesn't
* silently break the import.
*/
function decodeSieBytes(buffer: ArrayBuffer): string {
const direct = decodeBuffer(buffer, detectEncoding(buffer))
if (looksLikeSie(direct)) return direct
const candidate = direct.trim().replace(/^"|"$/g, '')
if (/^[A-Za-z0-9+/=\s]+$/.test(candidate)) {
try {
const bytes = Buffer.from(candidate, 'base64')
const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
const decoded = decodeBuffer(ab, detectEncoding(ab))
if (looksLikeSie(decoded)) return decoded
} catch {
// fall through to returning the direct decode
}
}
// Neither shape matched — return the direct decode and let the SIE parser
// produce its own diagnostics instead of failing silently here.
return direct
}
/** SIE files start with a #-record (#FLAGGA per spec; be lenient about order). */
function looksLikeSie(text: string): boolean {
return text.trimStart().startsWith('#')
}
@@ -12,7 +12,6 @@
"VISMA_CLIENT_ID",
"VISMA_CLIENT_SECRET",
"VISMA_REDIRECT_URI",
"BRIOX_CLIENT_ID",
"BJORN_LUNDEN_CLIENT_ID",
"BJORN_LUNDEN_CLIENT_SECRET",
"UPSTASH_REDIS_REST_URL",
+10 -6
View File
@@ -34,12 +34,16 @@ export type { CustomerType as ArcimCustomerType } from '@/lib/providers/dto'
export type ArcimProvider = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden'
export const ARCIM_PROVIDERS: { id: ArcimProvider; name: string; authType: 'oauth' | 'token' }[] = [
{ id: 'fortnox', name: 'Fortnox', authType: 'oauth' },
{ id: 'visma', name: 'Visma eEkonomi', authType: 'oauth' },
{ id: 'bokio', name: 'Bokio', authType: 'token' },
{ id: 'bjornlunden', name: 'Björn Lundén', authType: 'token' },
{ id: 'briox', name: 'Briox', authType: 'token' },
// `sieViaApi`: the provider serves its general ledger as SIE over the API, so
// the wizard imports bookkeeping automatically — no manual SIE upload needed.
// Mirrored in ArcimMigrationWorkspace.tsx (deliberate duplication: core code
// must not import from @/extensions/ — CI enforces it). Keep both in sync.
export const ARCIM_PROVIDERS: { id: ArcimProvider; name: string; authType: 'oauth' | 'token'; sieViaApi: boolean }[] = [
{ id: 'fortnox', name: 'Fortnox', authType: 'oauth', sieViaApi: true },
{ id: 'visma', name: 'Visma eEkonomi', authType: 'oauth', sieViaApi: false },
{ id: 'bokio', name: 'Bokio', authType: 'token', sieViaApi: false },
{ id: 'bjornlunden', name: 'Björn Lundén', authType: 'token', sieViaApi: true },
{ id: 'briox', name: 'Briox', authType: 'token', sieViaApi: true },
]
// ── Migration state ─────────────────────────────────────────────────
@@ -0,0 +1,100 @@
import { describe, it, expect, afterEach } from 'vitest'
import {
BolagsverketClient,
BolagsverketApiError,
BOLAGSVERKET_ERROR_MESSAGES,
configFromEnv,
extractFelkod,
isBolagsverketEnvironment,
} from '../lib/client'
describe('configFromEnv', () => {
const originalEnv = process.env.BOLAGSVERKET_ENV
afterEach(() => {
if (originalEnv === undefined) delete process.env.BOLAGSVERKET_ENV
else process.env.BOLAGSVERKET_ENV = originalEnv
})
it('defaults to the test environment', () => {
delete process.env.BOLAGSVERKET_ENV
const config = configFromEnv()
expect(config.environment).toBe('test')
})
it('rejects an invalid environment override with a clear error', () => {
expect(() => configFromEnv({ environment: 'banana' as never })).toThrow(
/Ogiltig Bolagsverket-miljö/,
)
})
it('rejects a garbage BOLAGSVERKET_ENV env var with a clear error', () => {
process.env.BOLAGSVERKET_ENV = 'production' // common typo for 'prod'
expect(() => configFromEnv()).toThrow(/Ogiltig Bolagsverket-miljö/)
})
it('isBolagsverketEnvironment validates the three known environments', () => {
expect(isBolagsverketEnvironment('test')).toBe(true)
expect(isBolagsverketEnvironment('accept')).toBe(true)
expect(isBolagsverketEnvironment('prod')).toBe(true)
expect(isBolagsverketEnvironment('production')).toBe(false)
expect(isBolagsverketEnvironment(undefined)).toBe(false)
expect(isBolagsverketEnvironment(null)).toBe(false)
})
it('decodes base64-wrapped PEM material', () => {
const pem = '-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----'
const config = configFromEnv({
environment: 'accept',
clientCertPem: pem,
clientKeyPem: pem,
})
expect(config.clientCertPem).toContain('-----BEGIN CERTIFICATE-----')
})
})
describe('BolagsverketClient', () => {
it('refuses accept/prod calls without an organisationscertifikat', async () => {
const client = new BolagsverketClient({ environment: 'accept' })
await expect(client.getGrunduppgifter('5560001111')).rejects.toThrow(
/Organisationscertifikat saknas/,
)
})
it('exposes the configured environment', () => {
expect(new BolagsverketClient({ environment: 'test' }).environment).toBe('test')
expect(new BolagsverketClient({ environment: 'prod' }).environment).toBe('prod')
})
})
describe('extractFelkod (error-body anchoring)', () => {
it('extracts the felkod from JSON bodies', () => {
expect(extractFelkod('{"felkod":"4001","text":"fel"}')).toBe('4001')
expect(extractFelkod('{"kod":7003}')).toBe('7003')
})
it('extracts the felkod from "NNNN=text" bodies', () => {
expect(extractFelkod('4008=Filen innehåller tekniska fel')).toBe('4008')
expect(extractFelkod(' \n 5006 = Dokumentet är för stort')).toBe('5006')
})
it('does not false-positive on years or stray four-digit numbers in prose', () => {
expect(extractFelkod('Räkenskapsåret 2026 kunde inte hanteras just nu')).toBeNull()
expect(extractFelkod('{"message":"taxonomin för 2024 hittades inte (id 5008)"}')).toBeNull()
expect(extractFelkod('Internt fel 9003 uppstod')).toBeNull()
})
})
describe('felkodskarta (GUIDE Appendix A §6.2)', () => {
it('covers the documented API error codes with Swedish messages', () => {
for (const code of ['4001', '4003', '4005', '4008', '4010', '5006', '7003', '7006', '9003']) {
expect(BOLAGSVERKET_ERROR_MESSAGES[code]).toBeTruthy()
}
})
it('BolagsverketApiError carries status + truncated body for logging', () => {
const err = new BolagsverketApiError('Testfel', 400, 'body')
expect(err.name).toBe('BolagsverketApiError')
expect(err.status).toBe(400)
expect(err.body).toBe('body')
})
})
@@ -0,0 +1,219 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
vi.mock('../lib/submission-service', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/submission-service')>()
return {
...actual,
submitArsredovisning: vi.fn(),
}
})
import { bolagsverketExtension } from '../index'
import { BolagsverketSubmissionError, submitArsredovisning } from '../lib/submission-service'
function route(method: string, path: string) {
const found = bolagsverketExtension.apiRoutes?.find(
(r) => r.method === method && r.path === path,
)
if (!found) throw new Error(`route ${method} ${path} not found`)
return found
}
function makeCtx(supabase: unknown): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'bolagsverket',
requestId: 'req-1',
supabase,
emit: vi.fn(),
settings: { get: vi.fn(), set: vi.fn(), clear: vi.fn() },
storage: {},
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
services: {},
} as unknown as ExtensionContext
}
const validBody = {
fiscal_period_id: '123e4567-e89b-12d3-a456-426614174000',
avsandare_pnr: '198001019876',
undertecknare: {
pnr: '198001019876',
fornamn: 'Anna',
efternamn: 'Svensson',
roll: 'VD',
epost: 'anna@example.com',
},
}
function makePost(path: string, body: unknown = validBody): Request {
return new Request(`http://localhost/api/extensions/ext/bolagsverket${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
}
interface ErrorEnvelope {
error: { code: string; message: string; message_en?: string }
}
const ORIGINAL_APP_URL = process.env.NEXT_PUBLIC_APP_URL
const ORIGINAL_BV_ENV = process.env.BOLAGSVERKET_ENV
beforeEach(() => {
vi.clearAllMocks()
process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000'
delete process.env.BOLAGSVERKET_ENV
})
afterEach(() => {
if (ORIGINAL_APP_URL === undefined) delete process.env.NEXT_PUBLIC_APP_URL
else process.env.NEXT_PUBLIC_APP_URL = ORIGINAL_APP_URL
if (ORIGINAL_BV_ENV === undefined) delete process.env.BOLAGSVERKET_ENV
else process.env.BOLAGSVERKET_ENV = ORIGINAL_BV_ENV
})
describe('POST /submissions — write-role enforcement', () => {
it('rejects viewer members with 403 BOLAGSVERKET_FORBIDDEN', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'viewer' }, error: null }) // company_members
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(403)
expect(body.error.code).toBe('BOLAGSVERKET_FORBIDDEN')
})
it('rejects non-members (no role row) with 403', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // no membership
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(403)
expect(body.error.code).toBe('BOLAGSVERKET_FORBIDDEN')
})
it('rejects viewer members on POST /poll-events too', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'viewer' }, error: null })
const res = await route('POST', '/poll-events').handler(makePost('/poll-events', {}), makeCtx(supabase))
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(403)
expect(body.error.code).toBe('BOLAGSVERKET_FORBIDDEN')
})
})
describe('POST /submissions — environment validation + ceiling', () => {
it('rejects an invalid environment setting with 400 BOLAGSVERKET_INVALID_ENVIRONMENT', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'member' }, error: null }) // company_members
enqueue({ data: { value: { environment: 'banana' } }, error: null }) // settings blob
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(400)
expect(body.error.code).toBe('BOLAGSVERKET_INVALID_ENVIRONMENT')
})
it('rejects an environment above the BOLAGSVERKET_ENV ceiling (unset → test)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'member' }, error: null })
enqueue({ data: { value: { environment: 'prod' } }, error: null })
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(403)
expect(body.error.code).toBe('BOLAGSVERKET_ENV_NOT_ALLOWED')
})
it('allows an environment at or below the ceiling', async () => {
process.env.BOLAGSVERKET_ENV = 'accept'
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'member' }, error: null })
enqueue({ data: { value: { environment: 'test' } }, error: null })
vi.mocked(submitArsredovisning).mockResolvedValue({
outcome: 'uploaded',
submissionId: 'sub-1',
idnummer: '49679',
sha256: 'sha',
url: 'https://ext.bolagsverket.se/eu/49679',
utfall: [],
})
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<{ data: { outcome: string } }>(res as Response)
expect(status).toBe(200)
expect(body.data.outcome).toBe('uploaded')
// The service got a client pinned to the validated environment.
const deps = vi.mocked(submitArsredovisning).mock.calls[0][0]
expect(deps.client.environment).toBe('test')
expect(deps.appUrl).toBe('http://localhost:3000')
})
})
describe('POST /submissions — config + error mapping', () => {
it('fails fast with 503 BOLAGSVERKET_CONFIG_MISSING when NEXT_PUBLIC_APP_URL is unset', async () => {
delete process.env.NEXT_PUBLIC_APP_URL
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'owner' }, error: null })
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(503)
expect(body.error.code).toBe('BOLAGSVERKET_CONFIG_MISSING')
})
it('maps BolagsverketSubmissionError to its structured code (409 double submission)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'member' }, error: null })
enqueue({ data: { value: { environment: 'test' } }, error: null })
vi.mocked(submitArsredovisning).mockRejectedValue(
new BolagsverketSubmissionError('BOLAGSVERKET_SUBMISSION_EXISTS', 'already active', {
submission_id: 'sub-0',
}),
)
const res = await route('POST', '/submissions').handler(makePost('/submissions'), makeCtx(supabase))
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(409)
expect(body.error.code).toBe('BOLAGSVERKET_SUBMISSION_EXISTS')
})
it('returns 400 VALIDATION_ERROR for a malformed body', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { role: 'member' }, error: null })
const res = await route('POST', '/submissions').handler(
makePost('/submissions', { fiscal_period_id: 'not-a-uuid' }),
makeCtx(supabase),
)
const { status, body } = await parseJsonResponse<ErrorEnvelope>(res as Response)
expect(status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
})
})
describe('GET /status', () => {
it('reports resolved environment, ceiling, and env-derived certificate presence', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { value: { environment: 'test' } }, error: null })
const res = await route('GET', '/status').handler(
new Request('http://localhost/api/extensions/ext/bolagsverket/status'),
makeCtx(supabase),
)
const { status, body } = await parseJsonResponse<{
data: { environment: string; environment_ceiling: string; has_certificate: boolean }
}>(res as Response)
expect(status).toBe(200)
expect(body.data.environment).toBe('test')
expect(body.data.environment_ceiling).toBe('test')
// No BOLAGSVERKET_CLIENT_CERT/_KEY in the test env.
expect(body.data.has_certificate).toBe(false)
})
})
@@ -0,0 +1,415 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { createQueuedMockSupabase } from '@/tests/helpers'
vi.mock('@/lib/bokslut/ixbrl/build-input', () => ({
buildIxbrlInput: vi.fn(async () => ({
entryPointId: 'k2-ab-risbs-2024-09-12',
period: { start: '2025-01-01', end: '2025-12-31' },
})),
}))
vi.mock('@/lib/bokslut/ixbrl/document/k2-document', () => ({
generateK2IxbrlDocument: vi.fn(() => ({ xhtml: '<?xml version="1.0"?><html></html>' })),
embedKontrollsumma: vi.fn((xhtml: string) => xhtml),
}))
vi.mock('@/lib/bokslut/ixbrl/validate/rules', () => ({
runPreflightChecks: vi.fn(() => ({ ok: true, issues: [] })),
}))
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: vi.fn(async () => ({ id: 'doc-1' })),
}))
vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: vi.fn(() => {
throw new Error('service client not expected in these tests')
}),
}))
import { uploadDocument } from '@/lib/core/documents/document-service'
import {
applyHandelse,
BolagsverketSubmissionError,
handleWebhook,
hashPnr,
normalizeOrgnr,
submitArsredovisning,
} from '../lib/submission-service'
import type { HandelseMeddelande } from '../types'
function makeLog() {
return { info: vi.fn(), warn: vi.fn(), error: vi.fn() }
}
/**
* Recording mock: chainable builder consuming queued results per terminal
* (single/maybeSingle/await), capturing update payloads per table so tests
* can assert what was written.
*/
function makeRecordingSupabase(results: Array<{ data?: unknown; error?: unknown }>) {
let idx = 0
const updates: Array<{ table: string; payload: Record<string, unknown> }> = []
const next = () => results[idx++] ?? { data: null, error: null }
const makeBuilder = (table: string) => {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'order', 'limit', 'insert']) {
b[m] = () => b
}
b.update = (payload: Record<string, unknown>) => {
updates.push({ table, payload })
return b
}
b.single = async () => next()
b.maybeSingle = async () => next()
b.then = (resolve: (v: unknown) => void) => resolve(next())
return b
}
return { supabase: { from: (table: string) => makeBuilder(table) } as never, updates }
}
function makeClientMock(overrides: Record<string, unknown> = {}) {
return {
environment: 'test',
createInlamningToken: vi.fn(async () => ({
token: 'tok-1',
avtalstext: 'Avtalstext',
avtalstextAndrad: '2017-12-06',
})),
createChecksumToken: vi.fn(async () => ({ token: 'tok-2', avtalstext: '', avtalstextAndrad: '' })),
createChecksum: vi.fn(async () => ({ kontrollsumma: 'ksum', algoritm: 'SHA-256' })),
kontrollera: vi.fn(async () => ({ orgnr: '5560001111', utfall: [] })),
lamnaIn: vi.fn(async () => ({
orgnr: '5560001111',
avsandare: 'avs',
undertecknare: 'und',
handlingsinfo: {
typ: 'arsredovisning_komplett',
dokumentlangd: 1,
idnummer: '49679',
sha256checksumma: 'sha256',
},
url: 'https://ext.bolagsverket.se/eu/49679',
})),
...overrides,
} as never
}
const submitParams = {
companyId: 'company-1',
userId: 'user-1',
fiscalPeriodId: 'period-1',
avsandarePnr: '198001019876',
undertecknare: {
pnr: '198001019876',
fornamn: 'Anna',
efternamn: 'Svensson',
roll: 'VD',
epost: 'anna@example.com',
},
}
function message(overrides: Partial<HandelseMeddelande> = {}): HandelseMeddelande {
return {
typ: 'AR-v2',
id: '5560001111',
nr: 3,
tid: '2026-06-01T10:00:00.000+02:00',
data: {
version: '2.0',
handlingsinfo: [{ handling: 'arsredovisning', idnummer: '49679' }],
status: 'arsred_inkommen',
},
...overrides,
}
}
describe('normalizeOrgnr / hashPnr', () => {
it('normalizes 12-digit and dashed org numbers to the 10-digit API form', () => {
expect(normalizeOrgnr('556000-1111')).toBe('5560001111')
expect(normalizeOrgnr('165560001111')).toBe('5560001111')
expect(normalizeOrgnr('5560001111')).toBe('5560001111')
})
it('hashes personnummer with company salt — never the raw value', () => {
const hash = hashPnr('company-1', '19830101-9876')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
expect(hash).not.toContain('9876')
expect(hashPnr('company-2', '198301019876')).not.toBe(hash)
// Same pnr + company → stable.
expect(hashPnr('company-1', '198301019876')).toBe(hash)
})
})
describe('handleWebhook', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
it('rejects messages without a matching auth header (401)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ company_id: 'company-1', auth_secret: 'right-secret' }], error: null })
const result = await handleWebhook(supabase as never, message(), 'wrong-secret')
expect(result.status).toBe(401)
})
it('rejects messages for unknown orgnr (401)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [], error: null })
const result = await handleWebhook(supabase as never, message(), 'any')
expect(result.status).toBe(401)
})
it('acks the subscription test message without touching submissions', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ company_id: 'company-1', auth_secret: 's3cret' }], error: null })
const result = await handleWebhook(
supabase as never,
message({ nr: -1, data: { version: '2.0', status: 'test' } }),
's3cret',
)
expect(result.status).toBe(200)
expect(result.body.ok).toBe(true)
})
it('applies a real status event to the matching submission and emits events', async () => {
const emitted: string[] = []
eventBus.on('arsredovisning.status_changed', (payload) => {
emitted.push(`changed:${payload.status}`)
})
eventBus.on('arsredovisning.registered', () => {
emitted.push('registered')
})
const { supabase, enqueue } = createQueuedMockSupabase()
// 1) subscription lookup
enqueue({ data: [{ company_id: 'company-1', auth_secret: 's3cret' }], error: null })
// 2) submission lookup by idnummer
enqueue({
data: [
{
id: 'sub-1',
status: 'uploaded',
fiscal_period_id: 'period-1',
user_id: 'user-1',
company_id: 'company-1',
},
],
error: null,
})
// 3) update
enqueue({ data: null, error: null })
const result = await handleWebhook(
supabase as never,
message({ data: { version: '2.0', handlingsinfo: [{ handling: 'arsredovisning', idnummer: '49679' }], status: 'arsred_registrerad' } }),
's3cret',
)
expect(result.status).toBe(200)
expect(emitted).toContain('changed:registrerad')
expect(emitted).toContain('registered')
})
it('rejects malformed payloads (400)', async () => {
const { supabase } = createQueuedMockSupabase()
const result = await handleWebhook(
supabase as never,
{} as never,
's3cret',
)
expect(result.status).toBe(400)
})
})
describe('applyHandelse', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
it('emits forelagd event on föreläggande and skips unknown statuses', async () => {
const emitted: string[] = []
eventBus.on('arsredovisning.forelagd', () => {
emitted.push('forelagd')
})
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [
{
id: 'sub-1',
status: 'inkommen',
fiscal_period_id: 'period-1',
user_id: 'user-1',
company_id: 'company-1',
},
],
error: null,
})
enqueue({ data: null, error: null })
await applyHandelse(
supabase as never,
message({ data: { version: '2.0', handlingsinfo: [{ handling: 'arsredovisning', idnummer: '49679' }], status: 'arsred_forelaggande_skickat' } }),
['company-1'],
)
expect(emitted).toEqual(['forelagd'])
// Unknown status: nothing should be queried or emitted.
await applyHandelse(
supabase as never,
message({ data: { version: '2.0', status: 'test' } }),
['company-1'],
)
expect(emitted).toEqual(['forelagd'])
})
it('does not emit when the stored status already matches', async () => {
const emitted: string[] = []
eventBus.on('arsredovisning.status_changed', () => {
emitted.push('changed')
})
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [
{
id: 'sub-1',
status: 'inkommen',
fiscal_period_id: 'period-1',
user_id: 'user-1',
company_id: 'company-1',
},
],
error: null,
})
await applyHandelse(supabase as never, message(), ['company-1'])
expect(emitted).toEqual([])
})
it('logs rejected transitions instead of silently continuing', async () => {
const emitted: string[] = []
eventBus.on('arsredovisning.status_changed', () => {
emitted.push('changed')
})
const log = makeLog()
const { supabase, enqueue } = createQueuedMockSupabase()
// Stored status differs from the incoming one, but the DB trigger
// rejects the transition.
enqueue({
data: [
{
id: 'sub-1',
status: 'registrerad',
fiscal_period_id: 'period-1',
user_id: 'user-1',
company_id: 'company-1',
},
],
error: null,
})
enqueue({ data: null, error: { message: 'Ogiltig statusövergång: registrerad → inkommen' } })
await applyHandelse(supabase as never, message(), ['company-1'], log)
expect(log.warn).toHaveBeenCalledTimes(1)
expect(log.warn.mock.calls[0][0]).toMatch(/rejected/)
expect(emitted).toEqual([])
})
})
describe('submitArsredovisning', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
vi.mocked(uploadDocument).mockResolvedValue({ id: 'doc-1' } as never)
})
it('refuses when an active submission already exists for the fiscal period', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { org_number: '556000-1111' }, error: null }) // company_settings
enqueue({ data: [{ id: 'sub-0', status: 'uploaded' }], error: null }) // active submissions
await expect(
submitArsredovisning(
{ supabase: supabase as never, client: makeClientMock(), appUrl: 'https://app.test', log: makeLog() },
submitParams,
),
).rejects.toMatchObject({
name: 'BolagsverketSubmissionError',
code: 'BOLAGSVERKET_SUBMISSION_EXISTS',
})
})
it('marks the submission row as error and rethrows when inlämning fails', async () => {
const { supabase, updates } = makeRecordingSupabase([
{ data: { org_number: '5560001111' } }, // getOrgnr
{ data: [] }, // no active submission
{ data: { id: 'acc-1' } }, // avtal acceptance exists
{ data: { id: 'sub-1' } }, // insert submission row
{}, // update → kontrollerad
{}, // markSubmissionError update
])
const client = makeClientMock({
lamnaIn: vi.fn(async () => {
throw new Error('inlamning exploded')
}),
})
const log = makeLog()
await expect(
submitArsredovisning({ supabase, client, appUrl: 'https://app.test', log }, submitParams),
).rejects.toThrow('inlamning exploded')
const errorUpdate = updates.find((u) => u.payload.status === 'error')
expect(errorUpdate).toBeDefined()
expect(errorUpdate!.table).toBe('arsredovisning_submissions')
expect(errorUpdate!.payload.error_message).toContain('inlamning exploded')
})
it('logs and persists a document-archive failure without blocking the filing', async () => {
vi.mocked(uploadDocument).mockRejectedValueOnce(new Error('magic bytes rejected'))
const { supabase, updates } = makeRecordingSupabase([
{ data: { org_number: '5560001111' } }, // getOrgnr
{ data: [] }, // no active submission
{ data: { id: 'acc-1' } }, // avtal acceptance exists
{ data: { id: 'sub-1' } }, // insert submission row
{}, // update → kontrollerad
{}, // error_message update (doc failure)
{}, // update → uploaded
// ensureSubscription throws on the empty appUrl before any query.
])
const log = makeLog()
const result = await submitArsredovisning(
{ supabase, client: makeClientMock(), appUrl: '', log },
submitParams,
)
expect(result.outcome).toBe('uploaded')
// The failure is logged AND visible on the row.
expect(log.error).toHaveBeenCalledTimes(1)
expect(log.error.mock.calls[0][0]).toMatch(/archive/)
const docFailureUpdate = updates.find(
(u) => typeof u.payload.error_message === 'string' && !u.payload.status,
)
expect(docFailureUpdate).toBeDefined()
expect(docFailureUpdate!.payload.error_message).toContain('magic bytes rejected')
// The filing itself still went through with dokument_id null.
const uploadedUpdate = updates.find((u) => u.payload.status === 'uploaded')
expect(uploadedUpdate).toBeDefined()
expect(uploadedUpdate!.payload.dokument_id).toBeNull()
// Subscription failure (invalid appUrl) is logged, not swallowed.
expect(log.warn.mock.calls.some(([msg]) => /prenumeration/.test(String(msg)))).toBe(true)
})
it('exports BolagsverketSubmissionError with a stable code', () => {
const err = new BolagsverketSubmissionError('BOLAGSVERKET_SUBMISSION_EXISTS', 'exists', {
submission_id: 'sub-1',
})
expect(err.code).toBe('BOLAGSVERKET_SUBMISSION_EXISTS')
expect(err.details).toEqual({ submission_id: 'sub-1' })
})
})
+415
View File
@@ -0,0 +1,415 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createLogger } from '@/lib/logger'
import {
BolagsverketClient,
BolagsverketApiError,
configFromEnv,
isBolagsverketEnvironment,
} from './lib/client'
import {
applyHandelse,
BolagsverketSubmissionError,
handleWebhook,
normalizeOrgnr,
submitArsredovisning,
} from './lib/submission-service'
import type { BolagsverketEnvironment, HandelseMeddelande } from './types'
/**
* Bolagsverket integration — digital inlämning av årsredovisning.
*
* Generates iXBRL in core (lib/bokslut/ixbrl — works without this extension),
* and adds the Bolagsverket leg: grunduppgifter prefill, kontrollera,
* inlämning till eget utrymme, händelseprenumerationer + webhook receiver.
*
* Requires an avtal with Bolagsverket and an Expisoft/Steria
* organisationscertifikat for acceptans/produktion (ANSLUTNINGSANVISNING
* §56). The static test environment (BOLAGSVERKET_ENV=test) runs without a
* certificate but needs a firewall opening (orgnr 1234567890/1234567891).
*
* Environment variables (certificate material is ENV-ONLY — see clientFor):
* - BOLAGSVERKET_ENV test | accept | prod (default test). Also acts
* as the CEILING for the per-company `environment`
* setting: members may select an environment at or
* below it (test < accept < prod). Unset → ceiling
* is 'test', so settings alone can never reach the
* platform certificate's prod access.
* - BOLAGSVERKET_CLIENT_CERT PEM (or base64-PEM) organisationscertifikat
* - BOLAGSVERKET_CLIENT_KEY PEM (or base64-PEM) private key
* - BOLAGSVERKET_CA optional extra CA chain
*
* Self-hosted installs without a certificate can skip this extension entirely
* and file manually with the downloaded .xhtml.
*/
/** Roles allowed to file/poll — the dispatcher itself only authenticates. */
const WRITE_ROLES = new Set(['owner', 'admin', 'member'])
const ENV_ORDER: Record<BolagsverketEnvironment, number> = { test: 0, accept: 1, prod: 2 }
/** Logger for the unauthenticated webhook path (no ExtensionContext there). */
const webhookLog = createLogger('ext:bolagsverket')
/**
* Platform ceiling for the per-company environment setting. Operator-set
* BOLAGSVERKET_ENV caps what tenants may select; unset/invalid → 'test'.
*/
function environmentCeiling(): BolagsverketEnvironment {
const raw = process.env.BOLAGSVERKET_ENV
return isBolagsverketEnvironment(raw) ? raw : 'test'
}
/**
* Resolve the effective Bolagsverket environment for a company.
*
* The generic extension settings endpoint
* (app/api/extensions/[sector]/[slug]/settings) PATCHes ONE JSON blob into
* extension_data under extension_id 'general/bolagsverket', key 'settings' —
* not per-key rows under this extension's dispatcher id — so read that row
* directly rather than via ctx.settings.
*
* Validation: the value must be one of test|accept|prod and must not exceed
* the BOLAGSVERKET_ENV ceiling (a member with settings access must not be
* able to point a hosted tenant at prod and ride the platform certificate).
*/
async function resolveEnvironment(ctx: ExtensionContext): Promise<BolagsverketEnvironment> {
const { data } = await ctx.supabase
.from('extension_data')
.select('value')
.eq('company_id', ctx.companyId)
.eq('extension_id', 'general/bolagsverket')
.eq('key', 'settings')
.maybeSingle()
const configured = (data?.value as { environment?: unknown } | null)?.environment
const ceiling = environmentCeiling()
if (configured === undefined || configured === null || configured === '') {
return ceiling
}
if (!isBolagsverketEnvironment(configured)) {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_INVALID_ENVIRONMENT',
`Invalid environment setting '${String(configured)}'.`,
{ configured: String(configured), allowed: ['test', 'accept', 'prod'] },
)
}
if (ENV_ORDER[configured] > ENV_ORDER[ceiling]) {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_ENV_NOT_ALLOWED',
`Environment setting '${configured}' exceeds the BOLAGSVERKET_ENV ceiling '${ceiling}'.`,
{ configured, ceiling },
)
}
return configured
}
/**
* Build a client for the company's resolved environment.
*
* SECURITY: certificate material is ENV-ONLY (BOLAGSVERKET_CLIENT_CERT/_KEY/
* _CA). It must NEVER be read from extension settings — extension_data rows
* are readable by every company member through the extension_data SELECT RLS
* policy, which would hand the mTLS private key to any viewer.
*/
async function clientFor(ctx: ExtensionContext): Promise<BolagsverketClient> {
const environment = await resolveEnvironment(ctx)
return new BolagsverketClient(configFromEnv({ environment }))
}
async function companyOrgnr(ctx: ExtensionContext): Promise<string> {
const { data } = await ctx.supabase
.from('company_settings')
.select('org_number')
.eq('company_id', ctx.companyId)
.maybeSingle()
const orgNumber = (data as { org_number?: string } | null)?.org_number
if (!orgNumber) throw new Error('Organisationsnummer saknas i företagsinställningarna.')
return normalizeOrgnr(orgNumber)
}
/**
* Defense-in-depth RBAC for write endpoints. The extension dispatcher only
* authenticates and resolves a company; it does NOT check the member's role.
* Filing an årsredovisning is a write operation — viewer members are blocked.
* Mirrors requireAgiWriteRole in the skatteverket extension.
*
* Returns null on success, a 403/500 NextResponse on failure.
*/
async function requireWriteRole(ctx: ExtensionContext): Promise<NextResponse | null> {
const { data, error } = await ctx.supabase
.from('company_members')
.select('role')
.eq('company_id', ctx.companyId)
.eq('user_id', ctx.userId)
.maybeSingle()
if (error) {
return errorResponseFromCode('INTERNAL_ERROR', ctx.log, {
requestId: ctx.requestId,
reason: `company_members role lookup failed: ${error.message}`,
})
}
if (!data?.role || !WRITE_ROLES.has(data.role as string)) {
return errorResponseFromCode('BOLAGSVERKET_FORBIDDEN', ctx.log, {
requestId: ctx.requestId,
})
}
return null
}
function apiErrorResponse(err: unknown, ctx: ExtensionContext): NextResponse {
if (err instanceof BolagsverketSubmissionError) {
return errorResponseFromCode(err.code, ctx.log, {
requestId: ctx.requestId,
reason: err.message,
details: err.details,
})
}
if (err instanceof BolagsverketApiError) {
return errorResponseFromCode('BOLAGSVERKET_API_ERROR', ctx.log, {
requestId: ctx.requestId,
reason: err.message,
status: err.status >= 400 && err.status < 600 ? err.status : 502,
details: { upstream_message: err.message, upstream_status: err.status },
})
}
const message = err instanceof Error ? err.message : 'Okänt fel'
return errorResponseFromCode('INTERNAL_ERROR', ctx.log, {
requestId: ctx.requestId,
reason: message,
details: { message },
})
}
const noContextResponse = () =>
NextResponse.json({ error: { code: 'NO_CONTEXT', message: 'Saknar kontext' } }, { status: 500 })
const SubmitSchema = z.object({
fiscal_period_id: z.string().uuid(),
avsandare_pnr: z.string().regex(/^\d{10,12}$/, 'Personnummer anges med 1012 siffror'),
undertecknare: z.object({
pnr: z.string().regex(/^\d{10,12}$/, 'Personnummer anges med 1012 siffror'),
fornamn: z.string().min(1).max(100),
efternamn: z.string().min(1).max(100),
roll: z.string().min(1).max(100),
epost: z.string().email(),
}),
kvittens_epost: z.array(z.string().email()).max(5).optional(),
utdelning: z.number().min(0).optional(),
accepted_avtalstext_andrad: z.string().optional(),
ignore_warnings: z.boolean().optional(),
})
const PollSchema = z.object({
fromtidpunkt: z.string().optional(),
})
export const bolagsverketExtension: Extension = {
id: 'bolagsverket',
name: 'Bolagsverket — digital årsredovisning',
version: '1.0.0',
settingsPanel: { label: 'Bolagsverket', path: '/settings/extensions' },
apiRoutes: [
{
method: 'GET',
path: '/status',
handler: async (_request, ctx) => {
if (!ctx) return noContextResponse()
try {
const environment = await resolveEnvironment(ctx)
const config = configFromEnv()
return NextResponse.json({
data: {
environment,
environment_ceiling: environmentCeiling(),
// Certificate material is env-only; settings can never carry it.
has_certificate: Boolean(config.clientCertPem && config.clientKeyPem),
},
})
} catch (err) {
return apiErrorResponse(err, ctx)
}
},
},
{
method: 'GET',
path: '/grunduppgifter',
handler: async (_request, ctx) => {
if (!ctx) return noContextResponse()
try {
const client = await clientFor(ctx)
const orgnr = await companyOrgnr(ctx)
const data = await client.getGrunduppgifter(orgnr)
return NextResponse.json({ data })
} catch (err) {
return apiErrorResponse(err, ctx)
}
},
},
{
method: 'GET',
path: '/arendestatus',
handler: async (_request, ctx) => {
if (!ctx) return noContextResponse()
try {
const client = await clientFor(ctx)
const orgnr = await companyOrgnr(ctx)
const data = await client.getArendestatus(orgnr)
return NextResponse.json({ data })
} catch (err) {
return apiErrorResponse(err, ctx)
}
},
},
{
method: 'GET',
path: '/submissions',
handler: async (request, ctx) => {
if (!ctx) return noContextResponse()
const url = new URL(request.url)
const fiscalPeriodId = url.searchParams.get('fiscal_period_id')
let query = ctx.supabase
.from('arsredovisning_submissions')
.select(
'id, fiscal_period_id, handling_typ, taxonomy_version, entry_point, environment, status, undertecknare_namn, undertecknare_epost, idnummer, sha256_checksumma, kontrollsumma, bolagsverket_url, kontrollera_utfall, error_message, uploaded_at, registered_at, created_at, updated_at',
)
.eq('company_id', ctx.companyId)
.order('created_at', { ascending: false })
.limit(50)
if (fiscalPeriodId) query = query.eq('fiscal_period_id', fiscalPeriodId)
const { data, error } = await query
if (error) {
return errorResponseFromCode('INTERNAL_ERROR', ctx.log, {
requestId: ctx.requestId,
reason: `submissions list failed: ${error.message}`,
})
}
return NextResponse.json({ data })
},
},
{
method: 'POST',
path: '/submissions',
handler: async (request, ctx) => {
if (!ctx) return noContextResponse()
const forbidden = await requireWriteRole(ctx)
if (forbidden) return forbidden
let parsed: z.infer<typeof SubmitSchema>
try {
parsed = SubmitSchema.parse(await request.json())
} catch (err) {
const message =
err instanceof z.ZodError ? err.issues.map((issue) => issue.message).join('; ') : 'Ogiltig begäran'
return errorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
reason: message,
details: { message },
})
}
// The webhook subscription registers this URL with Bolagsverket — a
// missing/relative base would register a broken endpoint externally.
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? ''
if (!/^https?:\/\//.test(appUrl)) {
return errorResponseFromCode('BOLAGSVERKET_CONFIG_MISSING', ctx.log, {
requestId: ctx.requestId,
reason: 'NEXT_PUBLIC_APP_URL is missing or not an absolute URL',
details: { missing: 'NEXT_PUBLIC_APP_URL' },
})
}
try {
const client = await clientFor(ctx)
const result = await submitArsredovisning(
{ supabase: ctx.supabase, client, appUrl, log: ctx.log },
{
companyId: ctx.companyId,
userId: ctx.userId,
fiscalPeriodId: parsed.fiscal_period_id,
avsandarePnr: parsed.avsandare_pnr,
undertecknare: parsed.undertecknare,
kvittensEpost: parsed.kvittens_epost,
proposedDividend: parsed.utdelning,
acceptedAvtalstextAndrad: parsed.accepted_avtalstext_andrad,
ignoreWarnings: parsed.ignore_warnings,
},
)
return NextResponse.json({ data: result })
} catch (err) {
ctx.log.error('bolagsverket submission failed', err)
return apiErrorResponse(err, ctx)
}
},
},
{
// Webhook receiver for händelsemeddelanden (GUIDE §5.4.5 + Appendix D).
// skipAuth: Bolagsverket authenticates with the `auth` header we set at
// subscription time; validated against bolagsverket_subscriptions.
method: 'POST',
path: '/webhook',
skipAuth: true,
handler: async (request) => {
let message: HandelseMeddelande
try {
message = (await request.json()) as HandelseMeddelande
} catch {
return NextResponse.json({ ok: false, reason: 'invalid json' }, { status: 400 })
}
const serviceClient = createServiceClientNoCookies()
const result = await handleWebhook(
serviceClient,
message,
request.headers.get('auth'),
webhookLog,
)
return NextResponse.json(result.body, { status: result.status })
},
},
{
// Polling fallback: fetch händelser kept by Bolagsverket (~1 year) in
// case webhook deliveries were missed (GUIDE §5.4.4).
method: 'POST',
path: '/poll-events',
handler: async (request, ctx) => {
if (!ctx) return noContextResponse()
const forbidden = await requireWriteRole(ctx)
if (forbidden) return forbidden
let parsed: z.infer<typeof PollSchema>
try {
parsed = PollSchema.parse(await request.json().catch(() => ({})))
} catch {
parsed = {}
}
try {
const client = await clientFor(ctx)
const orgnr = await companyOrgnr(ctx)
const { data: sub } = await ctx.supabase
.from('bolagsverket_subscriptions')
.select('url')
.eq('company_id', ctx.companyId)
.eq('orgnr', orgnr)
.eq('environment', client.environment)
.maybeSingle()
if (!sub) {
return errorResponseFromCode('BOLAGSVERKET_NO_SUBSCRIPTION', ctx.log, {
requestId: ctx.requestId,
})
}
const svar = await client.hamtaHandelser({
url: (sub as { url: string }).url,
orgnr: [orgnr],
...(parsed.fromtidpunkt ? { fromtidpunkt: parsed.fromtidpunkt } : {}),
})
for (const message of svar.meddelanden) {
await applyHandelse(ctx.supabase, message, [ctx.companyId], ctx.log)
}
return NextResponse.json({ data: { applied: svar.meddelanden.length } })
} catch (err) {
return apiErrorResponse(err, ctx)
}
},
},
],
}
export default bolagsverketExtension
@@ -0,0 +1,415 @@
/**
* Typed HTTP client for Bolagsverket's digital-inlämning REST services.
*
* Environments + endpoints per Anslutningsanvisning v1.7 §3:
* - test https://api-accept2.bolagsverket.se/testapi/… (no client cert;
* static data, orgnr 1234567890/1234567891; requires firewall
* opening ordered via api@bolagsverket.se)
* - accept https://api-accept2.bolagsverket.se/… (mTLS)
* - prod https://api.bolagsverket.se/… (mTLS)
*
* mTLS uses an Expisoft/Steria organisationscertifikat whose SERIALNUMBER is
* `16` + the supplier's 10-digit orgnr (ANSLUTNINGSANVISNING §5.3). Cert/key
* come from env vars ONLY (BOLAGSVERKET_CLIENT_CERT/_KEY/_CA) never from
* extension settings; extension_data is readable by every company member, so
* private-key material must not be stored there. Implemented with node:https
* (undici fetch has no portable client-cert support inside Next.js route
* handlers).
*/
import { request as httpsRequest, type RequestOptions } from 'node:https'
import { URL } from 'node:url'
import type { ZodType } from 'zod'
import {
InlamningSvarSchema,
InlamningTokenSvarSchema,
KontrolleraSvarSchema,
KontrollsummaSvarSchema,
} from './schemas'
import type {
ArendestatusSvar,
BolagsverketEnvironment,
GrunduppgifterSvar,
HamtaHandelserSvar,
HandlingTyp,
InlamningSvar,
InlamningTokenSvar,
KontrolleraSvar,
KontrollsummaSvar,
} from '../types'
export const BOLAGSVERKET_ENVIRONMENTS = ['test', 'accept', 'prod'] as const
export function isBolagsverketEnvironment(value: unknown): value is BolagsverketEnvironment {
return (
typeof value === 'string' &&
(BOLAGSVERKET_ENVIRONMENTS as readonly string[]).includes(value)
)
}
const HOSTS: Record<BolagsverketEnvironment, { base: string; prefix: string }> = {
test: { base: 'https://api-accept2.bolagsverket.se', prefix: '/testapi' },
accept: { base: 'https://api-accept2.bolagsverket.se', prefix: '' },
prod: { base: 'https://api.bolagsverket.se', prefix: '' },
}
/** API felkoder (GUIDE Appendix A §6.2) → user-facing Swedish messages. */
export const BOLAGSVERKET_ERROR_MESSAGES: Record<string, string> = {
'4001': 'Dokumentet är inte en giltig iXBRL-fil.',
'4002': 'Programvaruversionen stöds inte längre av Bolagsverkets tjänst.',
'4003': 'Ogiltigt organisationsnummer.',
'4004': 'Organisationsnumret avser inte ett aktiebolag.',
'4005': 'Ingen träff på organisationsnumret hos Bolagsverket.',
'4007': 'Ogiltigt personnummer.',
'4008': 'Filen innehåller ett eller flera tekniska fel.',
'4010': 'Årsredovisningen är upprättad i en taxonomiversion som Bolagsverket inte längre stödjer.',
'4011': 'Tjänsten stödjer inte den här företagsformen.',
'5001': 'Dokumentet saknar eller har tom title-tagg.',
'5002': 'Dokumentet är inte en iXBRL-fil.',
'5006': 'Dokumentet överstiger tillåten maxstorlek (5 MB).',
'5008': 'Dokumentet är inte kodat i UTF-8.',
'5009': 'Dokumentet saknar taggning av programvara och/eller programversion.',
'7003': 'Felaktig token — skapa en ny inlämningstoken och försök igen.',
'7004': 'Dokumentet innehåller skadlig kod.',
'7006': 'Årsredovisningen kan inte skickas in eftersom företaget är avvecklat.',
'7007': 'Tjänsten stödjer inte digital inlämning för den här företagsformen.',
'9003': 'Icke godkänd användare av tjänsten — kontrollera certifikat och avtal med Bolagsverket.',
}
export class BolagsverketApiError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly body: string,
) {
super(message)
this.name = 'BolagsverketApiError'
}
}
export interface BolagsverketClientConfig {
environment: BolagsverketEnvironment
/** PEM strings — required for accept/prod, ignored for test. */
clientCertPem?: string | null
clientKeyPem?: string | null
/** Extra CA chain (TeliaSonera root is normally in the system store). */
caPem?: string | null
/** Socket inactivity timeout. */
timeoutMs?: number
/** Overall per-request deadline (covers slow trickling bodies too). */
deadlineMs?: number
}
/**
* Resolve config from env vars; the extension may override the environment
* per install (validated + capped against BOLAGSVERKET_ENV in index.ts).
* Certificate material is ENV-ONLY never read from extension settings.
*/
export function configFromEnv(
overrides: Partial<BolagsverketClientConfig> = {},
): BolagsverketClientConfig {
const rawEnvironment = overrides.environment ?? process.env.BOLAGSVERKET_ENV ?? 'test'
if (!isBolagsverketEnvironment(rawEnvironment)) {
// Fail fast with a clear message instead of HOSTS[env] → opaque TypeError.
throw new BolagsverketApiError(
`Ogiltig Bolagsverket-miljö '${String(rawEnvironment)}' — tillåtna värden: ${BOLAGSVERKET_ENVIRONMENTS.join(', ')}.`,
0,
'',
)
}
const environment = rawEnvironment
const decode = (value: string | undefined | null): string | null => {
if (!value) return null
// Allow base64-wrapped PEM in env vars (newline-hostile platforms).
return value.includes('-----BEGIN')
? value
: Buffer.from(value, 'base64').toString('utf8')
}
return {
environment,
clientCertPem: overrides.clientCertPem ?? decode(process.env.BOLAGSVERKET_CLIENT_CERT),
clientKeyPem: overrides.clientKeyPem ?? decode(process.env.BOLAGSVERKET_CLIENT_KEY),
caPem: overrides.caPem ?? decode(process.env.BOLAGSVERKET_CA),
timeoutMs: overrides.timeoutMs ?? 30_000,
deadlineMs: overrides.deadlineMs ?? 90_000,
}
}
interface HttpResponse {
status: number
body: string
}
function rawRequest(
config: BolagsverketClientConfig,
method: 'GET' | 'POST' | 'DELETE',
url: string,
jsonBody?: unknown,
): Promise<HttpResponse> {
return new Promise((resolvePromise, rejectPromise) => {
const parsed = new URL(url)
const payload = jsonBody === undefined ? null : JSON.stringify(jsonBody)
const options: RequestOptions = {
method,
hostname: parsed.hostname,
path: parsed.pathname + parsed.search,
headers: {
Accept: 'application/json',
...(payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {}),
},
timeout: config.timeoutMs ?? 30_000,
}
// mTLS for accept/prod (the test env is plain TLS behind a firewall).
if (config.environment !== 'test') {
if (!config.clientCertPem || !config.clientKeyPem) {
rejectPromise(
new BolagsverketApiError(
'Organisationscertifikat saknas — miljövariablerna BOLAGSVERKET_CLIENT_CERT/BOLAGSVERKET_CLIENT_KEY krävs för acceptans- och produktionsmiljön.',
0,
'',
),
)
return
}
options.cert = config.clientCertPem
options.key = config.clientKeyPem
if (config.caPem) options.ca = config.caPem
}
let settled = false
const settle = (fn: () => void) => {
if (settled) return
settled = true
clearTimeout(deadline)
fn()
}
// Overall deadline: the socket-inactivity `timeout` above never fires for
// a server that trickles bytes forever — destroy the request outright.
const deadline = setTimeout(() => {
req.destroy(new Error('tidsgränsen för hela anropet överskreds'))
}, config.deadlineMs ?? 90_000)
const req = httpsRequest(options, (res) => {
const chunks: Buffer[] = []
res.on('data', (chunk: Buffer) => chunks.push(chunk))
res.on('end', () => {
settle(() =>
resolvePromise({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }),
)
})
// Mid-body connection failure: without this the promise never settles
// (the request-level 'error' handler does not fire once headers landed).
res.on('error', (err: Error) => {
settle(() =>
rejectPromise(
new BolagsverketApiError(
`Anslutningen till Bolagsverket bröts mitt i svaret (${err.message}). Försök igen.`,
0,
'',
),
),
)
})
})
req.on('timeout', () => {
req.destroy(new Error('timeout'))
})
req.on('error', (err) => {
settle(() =>
rejectPromise(
new BolagsverketApiError(
`Kunde inte nå Bolagsverket (${err.message}). Kontrollera brandväggsöppning och certifikat.`,
0,
'',
),
),
)
})
if (payload) req.write(payload)
req.end()
})
}
/**
* Pull a felkod out of an error body. Error bodies carry "NNNN=text" lines or
* {"felkod":"NNNN", ...}-style JSON (GUIDE Appendix A §6.2). Anchored to those
* two shapes a bare \bNNNN\b match would false-positive on years and other
* four-digit numbers inside prose.
*/
export function extractFelkod(body: string): string | null {
try {
const parsed = JSON.parse(body) as Record<string, unknown>
if (parsed && typeof parsed === 'object') {
for (const field of ['felkod', 'kod']) {
const value = parsed[field]
const asString = typeof value === 'number' ? String(value) : value
if (typeof asString === 'string' && /^[4579]\d{3}$/.test(asString)) return asString
}
}
} catch {
// not JSON — fall through to the "NNNN=text" shape
}
const match = body.match(/(?:^|[\r\n])\s*([4579]\d{3})\s*=/)
return match ? match[1] : null
}
function mapError(status: number, body: string): BolagsverketApiError {
const felkod = extractFelkod(body)
const known = felkod ? BOLAGSVERKET_ERROR_MESSAGES[felkod] : null
const fallback =
status === 404
? 'Ingen träff hos Bolagsverket (404).'
: status === 503 || status === 504
? 'Bolagsverkets tjänst är tillfälligt otillgänglig — försök igen om en stund.'
: `Bolagsverket svarade med fel (HTTP ${status}).`
return new BolagsverketApiError(known ?? fallback, status, body.slice(0, 2_000))
}
interface RequestJsonOptions {
/**
* Validate the response shape at the boundary (Zod). Schemas are loose
* (passthrough) and only pin the fields downstream code dereferences, so
* the parsed value is returned as the caller's declared DTO type.
*/
schema?: ZodType<unknown>
/** Human-readable endpoint name for error messages. */
endpoint?: string
}
async function requestJson<T>(
config: BolagsverketClientConfig,
method: 'GET' | 'POST' | 'DELETE',
url: string,
jsonBody?: unknown,
options: RequestJsonOptions = {},
): Promise<T> {
let res = await rawRequest(config, method, url, jsonBody)
// One retry for idempotent GETs when the service is momentarily unavailable.
if (method === 'GET' && (res.status === 503 || res.status === 504)) {
await new Promise((resolve) => setTimeout(resolve, 300))
res = await rawRequest(config, method, url, jsonBody)
}
if (res.status < 200 || res.status >= 300) throw mapError(res.status, res.body)
if (res.body.trim().length === 0) return undefined as T
let parsed: unknown
try {
parsed = JSON.parse(res.body)
} catch {
throw new BolagsverketApiError('Oväntat svar från Bolagsverket (inte JSON).', res.status, res.body.slice(0, 500))
}
if (options.schema) {
const result = options.schema.safeParse(parsed)
if (!result.success) {
const issues = result.error.issues
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
.join('; ')
throw new BolagsverketApiError(
`Oväntat svarsformat från Bolagsverket (${options.endpoint ?? url}): ${issues}`,
res.status,
res.body.slice(0, 500),
)
}
return result.data as T
}
return parsed as T
}
export class BolagsverketClient {
constructor(private readonly config: BolagsverketClientConfig) {}
get environment(): BolagsverketEnvironment {
return this.config.environment
}
private url(service: string, path: string): string {
const { base, prefix } = HOSTS[this.config.environment]
return `${base}${prefix}/${service}${path}`
}
// ---- informationstjänster (GUIDE §5.2) ----------------------------------
getGrunduppgifter(orgnr: string): Promise<GrunduppgifterSvar> {
return requestJson(this.config, 'GET', this.url('hamta-arsredovisningsinformation/v1.4', `/grunduppgifter/${encodeURIComponent(orgnr)}`))
}
getArendestatus(orgnr: string): Promise<ArendestatusSvar> {
return requestJson(this.config, 'GET', this.url('hamta-arsredovisningsinformation/v1.4', `/arendestatus/${encodeURIComponent(orgnr)}`))
}
/** Token for skapa-kontrollsumma (information service, v1.1). */
createChecksumToken(pnr: string, orgnr: string): Promise<InlamningTokenSvar> {
return requestJson(this.config, 'POST', this.url('hamta-arsredovisningsinformation/v1.1', '/skapa-inlamningtoken/'), { pnr, orgnr }, {
schema: InlamningTokenSvarSchema,
endpoint: 'skapa-inlamningtoken (kontrollsumma)',
})
}
createChecksum(token: string, fileBase64: string): Promise<KontrollsummaSvar> {
return requestJson(this.config, 'POST', this.url('hamta-arsredovisningsinformation/v1.1', `/skapa-kontrollsumma/${encodeURIComponent(token)}`), { fil: fileBase64 }, {
schema: KontrollsummaSvarSchema,
endpoint: 'skapa-kontrollsumma',
})
}
// ---- inlämning (GUIDE §5.3) ----------------------------------------------
createInlamningToken(pnr: string, orgnr: string): Promise<InlamningTokenSvar> {
return requestJson(this.config, 'POST', this.url('lamna-in-arsredovisning/v2.1', '/skapa-inlamningtoken/'), { pnr, orgnr }, {
schema: InlamningTokenSvarSchema,
endpoint: 'skapa-inlamningtoken',
})
}
kontrollera(token: string, fileBase64: string, typ: HandlingTyp): Promise<KontrolleraSvar> {
return requestJson(this.config, 'POST', this.url('lamna-in-arsredovisning/v2.1', `/kontrollera/${encodeURIComponent(token)}`), {
handling: { fil: fileBase64, typ },
}, {
schema: KontrolleraSvarSchema,
endpoint: 'kontrollera',
})
}
lamnaIn(
token: string,
body: {
undertecknare: string
epostadresser: string[]
kvittensepostadresser?: string[]
notifieringEpostadresser?: string[]
fileBase64: string
typ: HandlingTyp
},
): Promise<InlamningSvar> {
return requestJson(this.config, 'POST', this.url('lamna-in-arsredovisning/v2.1', `/inlamning/${encodeURIComponent(token)}`), {
undertecknare: body.undertecknare,
epostadresser: body.epostadresser,
...(body.kvittensepostadresser?.length ? { kvittensepostadresser: body.kvittensepostadresser } : {}),
...(body.notifieringEpostadresser?.length ? { notifieringEpostadresser: body.notifieringEpostadresser } : {}),
handling: { fil: body.fileBase64, typ: body.typ },
}, {
schema: InlamningSvarSchema,
endpoint: 'inlamning',
})
}
// ---- händelser (GUIDE §5.4) ----------------------------------------------
/** Idempotent: existing (url, orgnr) pair gets its TTL extended 6 months. */
async createSubscription(url: string, orgnr: string, auth: string): Promise<void> {
await requestJson(this.config, 'POST', this.url('hantera-arsredovisningsprenumerationer/v2.0', '/handelseprenumeration/'), {
prenumerationer: [{ url, orgnr, auth }],
})
}
async deleteSubscription(url: string, orgnr: string): Promise<void> {
await requestJson(this.config, 'DELETE', this.url('hantera-arsredovisningsprenumerationer/v2.0', '/handelseprenumeration/'), { url, orgnr })
}
/** Polling fallback for missed webhooks (events kept ~1 year). */
hamtaHandelser(body: {
url: string
orgnr: string[]
fromtidpunkt?: string
tomtidpunkt?: string
}): Promise<HamtaHandelserSvar> {
return requestJson(this.config, 'POST', this.url('hamta-arsredovisningshandelser/v2.0', '/handelser/'), body)
}
}
@@ -0,0 +1,62 @@
/**
* Zod schemas for the Bolagsverket response payloads we actually dereference.
* Parsed at the HTTP-client boundary (lib/client.ts) so a contract drift on
* Bolagsverket's side surfaces as a clear "unexpected response shape from
* <endpoint>" error instead of an opaque TypeError deep inside the
* submission flow.
*
* Schemas are deliberately loose (`.passthrough()`): we only pin the fields
* we read see types.ts for the full hand-written DTOs.
*/
import { z } from 'zod'
// skapa-inlamningtoken (v2.1 + v1.1) — token + avtalstext gate fields.
export const InlamningTokenSvarSchema = z
.object({
token: z.string().min(1),
avtalstext: z.string(),
avtalstextAndrad: z.string(),
})
.passthrough()
// kontrollera (v2.1) — utfall drives the warn/error gate.
export const KontrolleraSvarSchema = z
.object({
orgnr: z.string().optional(),
utfall: z
.array(
z
.object({
kod: z.string().optional(),
text: z.string().optional(),
typ: z.string().optional(),
})
.passthrough(),
)
.nullable()
.optional(),
})
.passthrough()
// inlamning (v2.1) — handlingsinfo.idnummer correlates webhooks; sha256 and
// url are persisted on the submission row.
export const InlamningSvarSchema = z
.object({
handlingsinfo: z
.object({
idnummer: z.string().min(1),
sha256checksumma: z.string(),
})
.passthrough(),
url: z.string(),
})
.passthrough()
// skapa-kontrollsumma (v1.1).
export const KontrollsummaSvarSchema = z
.object({
kontrollsumma: z.string().min(1),
algoritm: z.string(),
})
.passthrough()
@@ -0,0 +1,621 @@
/**
* Submission orchestration for digital inlämning av årsredovisning.
*
* Flow (GUIDE §4.2/§5.3): skapa-inlamningtoken [avtalstext gate]
* kontrollera inlamning till eget utrymme handelseprenumeration. The
* undertecknare then signs the fastställelseintyg with e-legitimation AT
* Bolagsverket (never in our app); webhooks/polling drive the status from
* there: uploaded inkommen (förelagd komplettering)*
* registrerad | avslutad.
*
* Personnummer are transient: used for the API calls, persisted only as
* company-salted SHA-256 hashes.
*/
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events/bus'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { buildIxbrlInput } from '@/lib/bokslut/ixbrl/build-input'
import { generateK2IxbrlDocument, embedKontrollsumma } from '@/lib/bokslut/ixbrl/document/k2-document'
import { runPreflightChecks } from '@/lib/bokslut/ixbrl/validate/rules'
import { BolagsverketClient } from './client'
import type { ExtensionLogger } from '@/lib/extensions/types'
import type {
ArsredovisningSubmission,
HandelseMeddelande,
KontrolleraUtfall,
SubmissionStatus,
} from '../types'
/**
* Domain error carrying a structured-error registry code
* (lib/errors/structured-errors.ts). index.ts maps it through
* errorResponseFromCode for the canonical envelope.
*/
export class BolagsverketSubmissionError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly details?: Record<string, unknown>,
) {
super(message)
this.name = 'BolagsverketSubmissionError'
}
}
/** Statuses that mean "Bolagsverket currently holds an open filing for this period". */
export const ACTIVE_SUBMISSION_STATUSES = [
'uploaded',
'inkommen',
'forelagd',
'komplettering',
] as const
export function hashPnr(companyId: string, pnr: string): string {
return createHash('sha256').update(`${companyId}:${pnr.replace(/\D/g, '')}`).digest('hex')
}
/** Normalize to the 10-digit orgnr the API expects (no dash, no century). */
export function normalizeOrgnr(orgNumber: string): string {
const digits = orgNumber.replace(/\D/g, '')
return digits.length === 12 ? digits.slice(2) : digits
}
/**
* SECURITY: `avsandarePnr` and `undertecknare.pnr` are plaintext personnummer,
* needed only for the Bolagsverket API calls. They must NEVER reach a log sink
* log structured fields (companyId, fiscalPeriodId, submissionId) and never
* the params object itself. At rest only company-salted SHA-256 hashes are
* stored (see hashPnr).
*/
export interface SubmitParams {
companyId: string
userId: string
fiscalPeriodId: string
/** Avsändarens personnummer (12 siffror) — required by skapa-inlamningtoken. */
avsandarePnr: string
/** Undertecknare of fastställelseintyget. */
undertecknare: {
pnr: string
fornamn: string
efternamn: string
roll: string
epost: string
}
kvittensEpost?: string[]
proposedDividend?: number
/** User accepted the current avtalstext (avtalstextAndrad value). */
acceptedAvtalstextAndrad?: string
/** Upload even when kontrollera returns warn-level utfall (GUIDE §4.2.2). */
ignoreWarnings?: boolean
}
export type SubmitResult =
| { outcome: 'avtal_required'; avtalstext: string; avtalstextAndrad: string }
| { outcome: 'preflight_failed'; issues: ReturnType<typeof runPreflightChecks>['issues'] }
| {
outcome: 'kontrollera_stopped'
submissionId: string
utfall: KontrolleraUtfall[]
}
| {
outcome: 'uploaded'
submissionId: string
idnummer: string
sha256: string
url: string
utfall: KontrolleraUtfall[]
}
interface ServiceDeps {
supabase: SupabaseClient
client: BolagsverketClient
/** Absolute base URL of this install, for the webhook subscription. */
appUrl: string
/** Extension logger — non-fatal failures must be visible, never swallowed. */
log: ExtensionLogger
}
/** Best-effort: persist a failure on the submission row so it is visible. */
async function markSubmissionError(
supabase: SupabaseClient,
log: ExtensionLogger,
submissionId: string,
err: unknown,
): Promise<void> {
const message = err instanceof Error ? err.message : String(err)
const { error } = await supabase
.from('arsredovisning_submissions')
.update({ status: 'error', error_message: message.slice(0, 2_000) })
.eq('id', submissionId)
if (error) {
log.error('could not mark submission as error', { submissionId, dbError: error.message })
}
}
async function getOrgnr(supabase: SupabaseClient, companyId: string): Promise<string> {
const { data } = await supabase
.from('company_settings')
.select('org_number')
.eq('company_id', companyId)
.maybeSingle()
const orgNumber = (data as { org_number?: string } | null)?.org_number
if (!orgNumber) throw new Error('Organisationsnummer saknas i företagsinställningarna.')
return normalizeOrgnr(orgNumber)
}
export async function submitArsredovisning(
deps: ServiceDeps,
params: SubmitParams,
): Promise<SubmitResult> {
const { supabase, client, log } = deps
const orgnr = await getOrgnr(supabase, params.companyId)
// 0. Double-submission guard: once an upload reached Bolagsverket, a retry
// would file a second handling (and store a second audit document).
// Refuse while a submission for this period is still open with the
// authority. Rows in draft/kontrollerad/error/registrerad/avslutad do
// not block — retries after failure create a fresh row.
const { data: activeRows } = await supabase
.from('arsredovisning_submissions')
.select('id, status')
.eq('company_id', params.companyId)
.eq('fiscal_period_id', params.fiscalPeriodId)
.in('status', [...ACTIVE_SUBMISSION_STATUSES])
.limit(1)
const active = (activeRows as Array<{ id: string; status: string }> | null)?.[0]
if (active) {
throw new BolagsverketSubmissionError(
'BOLAGSVERKET_SUBMISSION_EXISTS',
`An active submission (${active.status}) already exists for this fiscal period.`,
{ submission_id: active.id, status: active.status },
)
}
// 1. Token (also carries the avtalstext we must gate on).
const token = await client.createInlamningToken(params.avsandarePnr, orgnr)
// 2. Avtalstext gate (GUIDE §4.2): the user must have accepted THIS version
// of the text for THIS company before kontrollera/inlämning may run.
const { data: acceptance } = await supabase
.from('bolagsverket_avtal_acceptances')
.select('id')
.eq('company_id', params.companyId)
.eq('user_id', params.userId)
.eq('avtalstext_andrad', token.avtalstextAndrad)
.maybeSingle()
const acceptedNow = params.acceptedAvtalstextAndrad === token.avtalstextAndrad
if (!acceptance && !acceptedNow) {
return {
outcome: 'avtal_required',
avtalstext: token.avtalstext,
avtalstextAndrad: token.avtalstextAndrad,
}
}
if (!acceptance && acceptedNow) {
await supabase.from('bolagsverket_avtal_acceptances').insert({
company_id: params.companyId,
user_id: params.userId,
avtalstext_andrad: token.avtalstextAndrad,
})
}
// 3. Generate the iXBRL + local pre-flight (layer 1) — cheaper than a
// kontrollera round-trip and catches data problems with better messages.
const input = await buildIxbrlInput(supabase, params.companyId, params.fiscalPeriodId, {
proposedDividend: params.proposedDividend,
undertecknare: {
firstName: params.undertecknare.fornamn,
lastName: params.undertecknare.efternamn,
role: params.undertecknare.roll,
},
})
const preflight = runPreflightChecks(input)
if (!preflight.ok) {
return { outcome: 'preflight_failed', issues: preflight.issues }
}
let { xhtml } = generateK2IxbrlDocument(input)
// 4. Kontrollsumma (TA §4.5, recommended): tag the checksum into <head> so
// the kvittens email carries a verifiable hash. Non-fatal on failure.
let kontrollsumma: string | null = null
try {
const checksumToken = await client.createChecksumToken(params.avsandarePnr, orgnr)
const checksum = await client.createChecksum(
checksumToken.token,
Buffer.from(xhtml, 'utf8').toString('base64'),
)
kontrollsumma = checksum.kontrollsumma
xhtml = embedKontrollsumma(xhtml, checksum.kontrollsumma, checksum.algoritm)
} catch (err) {
kontrollsumma = null
log.warn('kontrollsumma generation failed — continuing without embedded checksum', {
companyId: params.companyId,
fiscalPeriodId: params.fiscalPeriodId,
error: err instanceof Error ? err.message : String(err),
})
}
const fileBase64 = Buffer.from(xhtml, 'utf8').toString('base64')
// 5. Create the submission row (draft) before talking to Bolagsverket so
// every attempt is traceable.
const { data: submissionRow, error: insertError } = await supabase
.from('arsredovisning_submissions')
.insert({
company_id: params.companyId,
user_id: params.userId,
fiscal_period_id: params.fiscalPeriodId,
handling_typ: 'arsredovisning_komplett',
taxonomy_version: '2024-09-12',
entry_point: input.entryPointId,
environment: client.environment,
status: 'draft',
undertecknare_namn: `${params.undertecknare.fornamn} ${params.undertecknare.efternamn}`,
undertecknare_epost: params.undertecknare.epost,
undertecknare_pnr_hash: hashPnr(params.companyId, params.undertecknare.pnr),
avsandare_pnr_hash: hashPnr(params.companyId, params.avsandarePnr),
kontrollsumma,
})
.select('id')
.single()
if (insertError || !submissionRow) {
throw new Error(`Kunde inte spara inlämningsförsöket: ${insertError?.message ?? 'okänt fel'}`)
}
const submissionId = (submissionRow as { id: string }).id
// Steps 68 talk to Bolagsverket with a persisted row in play. Any failure
// here must flip the row to status='error' with the message — otherwise it
// sits in draft/kontrollerad forever and the failed attempt is invisible.
let svar: Awaited<ReturnType<BolagsverketClient['lamnaIn']>>
let utfall: KontrolleraUtfall[]
try {
// 6. Kontrollera (layer 3) — always run; surface utfall to the user.
const kontrollSvar = await client.kontrollera(token.token, fileBase64, 'arsredovisning_komplett')
utfall = kontrollSvar.utfall ?? []
await supabase
.from('arsredovisning_submissions')
.update({ status: 'kontrollerad', kontrollera_utfall: utfall })
.eq('id', submissionId)
const hasBlocking = utfall.some((item) => item.typ?.toLowerCase() === 'error')
if (utfall.length > 0 && (hasBlocking || !params.ignoreWarnings)) {
return { outcome: 'kontrollera_stopped', submissionId, utfall }
}
// 7. Store the exact uploaded bytes as räkenskapsinformation (7-year
// retention, Accounting Guard Rail #7) BEFORE upload.
let dokumentId: string | null = null
try {
const buffer = Buffer.from(xhtml, 'utf8')
const doc = await uploadDocument(
supabase,
params.userId,
params.companyId,
{
name: `arsredovisning-${input.period.end}-inlamnad.xhtml`,
buffer: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer,
type: 'application/xhtml+xml',
},
{ upload_source: 'system' },
)
dokumentId = doc.id
} catch (err) {
// Storage failure must not block the filing, but it MUST be visible:
// without the stored bytes the legally-filed document is not
// reproducible from our archive (Guard Rail #7).
dokumentId = null
const message = err instanceof Error ? err.message : String(err)
log.error('failed to archive the filed .xhtml as räkenskapsinformation', {
submissionId,
companyId: params.companyId,
error: message,
})
await supabase
.from('arsredovisning_submissions')
.update({ error_message: `Dokumentarkivering misslyckades: ${message}`.slice(0, 2_000) })
.eq('id', submissionId)
}
// 8. Lämna in till eget utrymme.
svar = await client.lamnaIn(token.token, {
undertecknare: params.undertecknare.pnr,
epostadresser: [params.undertecknare.epost],
kvittensepostadresser: params.kvittensEpost,
fileBase64,
typ: 'arsredovisning_komplett',
})
const { error: uploadUpdateError } = await supabase
.from('arsredovisning_submissions')
.update({
status: 'uploaded',
idnummer: svar.handlingsinfo.idnummer,
sha256_checksumma: svar.handlingsinfo.sha256checksumma,
bolagsverket_url: svar.url,
dokument_id: dokumentId,
uploaded_at: new Date().toISOString(),
})
.eq('id', submissionId)
if (uploadUpdateError) {
log.error('failed to persist uploaded state after successful inlämning', {
submissionId,
idnummer: svar.handlingsinfo.idnummer,
dbError: uploadUpdateError.message,
})
}
} catch (err) {
await markSubmissionError(supabase, log, submissionId, err)
throw err // preserved for the route's error mapping (5xx / upstream status)
}
await eventBus.emit({
type: 'arsredovisning.uploaded',
payload: {
submissionId,
fiscalPeriodId: params.fiscalPeriodId,
idnummer: svar.handlingsinfo.idnummer,
environment: client.environment,
userId: params.userId,
companyId: params.companyId,
},
})
// 9. Subscribe to händelser (idempotent; extends TTL 6 months — GUIDE §4.3).
try {
await ensureSubscription(deps, params.companyId, params.userId, orgnr)
} catch (err) {
// Non-fatal: polling fallback (hamta-handelser) covers missed webhooks.
log.warn('handelseprenumeration could not be created/renewed — relying on polling fallback', {
submissionId,
companyId: params.companyId,
error: err instanceof Error ? err.message : String(err),
})
}
return {
outcome: 'uploaded',
submissionId,
idnummer: svar.handlingsinfo.idnummer,
sha256: svar.handlingsinfo.sha256checksumma,
url: svar.url,
utfall,
}
}
export async function ensureSubscription(
deps: ServiceDeps,
companyId: string,
userId: string,
orgnr: string,
): Promise<void> {
const { supabase, client, appUrl } = deps
if (!/^https?:\/\//.test(appUrl)) {
// A relative/empty base URL would register a broken webhook endpoint at
// Bolagsverket. Fail fast — the caller logs this as a subscription failure.
throw new Error(
'NEXT_PUBLIC_APP_URL saknas eller är inte en absolut URL — kan inte registrera webhook hos Bolagsverket.',
)
}
const url = `${appUrl.replace(/\/$/, '')}/api/extensions/ext/bolagsverket/webhook`
const { data: existing } = await supabase
.from('bolagsverket_subscriptions')
.select('id, auth_secret')
.eq('company_id', companyId)
.eq('orgnr', orgnr)
.eq('url', url)
.eq('environment', client.environment)
.maybeSingle()
// Org-number reuse is allowed in this product, and Bolagsverket dedupes
// subscriptions per (url, orgnr): registering a NEW secret here would
// overwrite the delivery auth another company sharing this orgnr already
// depends on, 401-ing their webhooks. Reuse any existing secret for the
// same (orgnr, url, environment) across ALL companies (service client —
// RLS would hide other tenants' rows) so everyone sharing the orgnr
// authenticates the same deliveries.
let sharedSecret: string | null = null
if (!existing) {
const serviceClient = createServiceClientNoCookies()
const { data: shared } = await serviceClient
.from('bolagsverket_subscriptions')
.select('auth_secret')
.eq('orgnr', orgnr)
.eq('url', url)
.eq('environment', client.environment)
.limit(1)
.maybeSingle()
sharedSecret = (shared as { auth_secret?: string } | null)?.auth_secret ?? null
}
const secret =
(existing as { auth_secret?: string } | null)?.auth_secret ??
sharedSecret ??
randomBytes(24).toString('base64url')
await client.createSubscription(url, orgnr, secret)
const expires = new Date()
expires.setMonth(expires.getMonth() + 6)
if (existing) {
await supabase
.from('bolagsverket_subscriptions')
.update({ subscribed_at: new Date().toISOString(), expires_at: expires.toISOString() })
.eq('id', (existing as { id: string }).id)
} else {
await supabase.from('bolagsverket_subscriptions').insert({
company_id: companyId,
user_id: userId,
orgnr,
url,
auth_secret: secret,
environment: client.environment,
expires_at: expires.toISOString(),
})
}
}
/** Bolagsverket ärendestatus → our submission status. */
const STATUS_MAP: Record<string, SubmissionStatus> = {
arsred_inkommen: 'inkommen',
arsred_forelaggande_skickat: 'forelagd',
arsred_komplettering_inkommen: 'komplettering',
arsred_registrerad: 'registrerad',
arsred_avslutad_ej_registrerad: 'avslutad',
}
export interface WebhookHandlingResult {
status: number
body: { ok: boolean; reason?: string }
}
/**
* Apply one händelsemeddelande to the submission rows. Used by both the
* webhook receiver and the polling fallback. `serviceClient` is the
* cookieless service-role client all queries still filter by company.
*/
export async function applyHandelse(
serviceClient: SupabaseClient,
message: HandelseMeddelande,
matchedCompanyIds: string[],
log?: Pick<ExtensionLogger, 'warn' | 'error'>,
): Promise<void> {
const mapped = STATUS_MAP[message.data.status]
if (!mapped) return // 'test' or future statuses — nothing to apply
const idnummerList = (message.data.handlingsinfo ?? [])
.filter((info) => info.handling === 'arsredovisning')
.map((info) => info.idnummer)
for (const companyId of matchedCompanyIds) {
// Correlate by document idnummer when the message carries one (it does
// for arsredovisning events); otherwise fall back to the latest active
// submission for the company.
const base = serviceClient
.from('arsredovisning_submissions')
.select('id, status, fiscal_period_id, user_id, company_id')
.eq('company_id', companyId)
const filtered =
idnummerList.length > 0
? base.in('idnummer', idnummerList)
: base.in('status', ['uploaded', 'inkommen', 'forelagd', 'komplettering'])
const { data: rows } = await filtered.order('created_at', { ascending: false }).limit(1)
const submission = (rows as Pick<
ArsredovisningSubmission,
'id' | 'status' | 'fiscal_period_id' | 'user_id' | 'company_id'
>[] | null)?.[0]
if (!submission) continue
if (submission.status === mapped) continue
const update: Record<string, unknown> = { status: mapped }
if (mapped === 'registrerad') update.registered_at = new Date().toISOString()
const { error } = await serviceClient
.from('arsredovisning_submissions')
.update(update)
.eq('id', submission.id)
if (error) {
// Transition rejected by the DB state machine (or other write failure).
// Don't apply, but never silently — a divergence between our status and
// Bolagsverket's must be investigable.
log?.warn('handelse rejected — submission status not updated', {
submissionId: submission.id,
companyId,
fromStatus: submission.status,
toStatus: mapped,
bolagsverketStatus: message.data.status,
dbError: error.message,
})
continue
}
await eventBus.emit({
type: 'arsredovisning.status_changed',
payload: {
submissionId: submission.id,
fiscalPeriodId: submission.fiscal_period_id,
previousStatus: submission.status,
status: mapped,
bolagsverketStatus: message.data.status,
userId: submission.user_id,
companyId: submission.company_id,
},
})
if (mapped === 'registrerad') {
await eventBus.emit({
type: 'arsredovisning.registered',
payload: {
submissionId: submission.id,
fiscalPeriodId: submission.fiscal_period_id,
userId: submission.user_id,
companyId: submission.company_id,
},
})
}
if (mapped === 'forelagd') {
await eventBus.emit({
type: 'arsredovisning.forelagd',
payload: {
submissionId: submission.id,
fiscalPeriodId: submission.fiscal_period_id,
userId: submission.user_id,
companyId: submission.company_id,
},
})
}
}
}
/**
* Webhook entry: validate the `auth` header against stored subscriptions for
* the orgnr (GUIDE §5.4.5.2), ack test messages (status "test", nr -1), and
* apply real events.
*/
/**
* Constant-time secret comparison: hash both sides to equal-length digests
* first (timingSafeEqual requires equal lengths and a plain === leaks
* prefix-match timing).
*/
function secretMatches(expected: string, provided: string): boolean {
const a = createHash('sha256').update(expected).digest()
const b = createHash('sha256').update(provided).digest()
return timingSafeEqual(a, b)
}
export async function handleWebhook(
serviceClient: SupabaseClient,
message: HandelseMeddelande,
authHeader: string | null,
log?: Pick<ExtensionLogger, 'warn' | 'error'>,
): Promise<WebhookHandlingResult> {
if (!message || typeof message !== 'object' || !message.data) {
return { status: 400, body: { ok: false, reason: 'malformed' } }
}
// `message.id` is attacker-controllable until the auth check below, so it
// is used ONLY to look up candidate subscriptions; a delivery is accepted
// exclusively when its `auth` header matches a secret WE registered with
// Bolagsverket for exactly this orgnr (the eq below). A valid secret for a
// different orgnr can never authenticate a spoofed orgnr. Status payloads
// are still treated as untrusted: STATUS_MAP allowlists transitions and the
// DB status-machine trigger rejects illegal ones in applyHandelse.
const orgnr = String(message.id ?? '')
if (!/^\d{10}$/.test(orgnr)) {
return { status: 400, body: { ok: false, reason: 'malformed orgnr' } }
}
const { data: subs } = await serviceClient
.from('bolagsverket_subscriptions')
.select('company_id, auth_secret')
.eq('orgnr', orgnr)
const matching = ((subs as Array<{ company_id: string; auth_secret: string }> | null) ?? []).filter(
(sub) => authHeader !== null && secretMatches(sub.auth_secret, authHeader),
)
if (matching.length === 0) {
return { status: 401, body: { ok: false, reason: 'unknown subscription or bad auth' } }
}
if (message.data.status === 'test' || message.nr === -1) {
return { status: 200, body: { ok: true } }
}
await applyHandelse(
serviceClient,
message,
[...new Set(matching.map((sub) => sub.company_id))],
log,
)
return { status: 200, body: { ok: true } }
}
@@ -0,0 +1,23 @@
{
"id": "bolagsverket",
"sector": "general",
"exportName": "bolagsverketExtension",
"entryPoint": "@/extensions/general/bolagsverket",
"workspace": null,
"requiredEnvVars": [],
"optionalEnvVars": [
"BOLAGSVERKET_ENV",
"BOLAGSVERKET_CLIENT_CERT",
"BOLAGSVERKET_CLIENT_KEY",
"BOLAGSVERKET_CA"
],
"npmDependencies": [],
"definition": {
"name": "Bolagsverket — digital årsredovisning",
"category": "operations",
"icon": "Landmark",
"dataPattern": "core",
"description": "Lämna in årsredovisningen digitalt till Bolagsverket (iXBRL).",
"longDescription": "Ladda upp årsredovisningen som iXBRL till Bolagsverkets eget utrymme, låt undertecknaren signera fastställelseintyget med e-legitimation hos Bolagsverket, och följ ärendet i appen via händelseaviseringar. Kräver avtal med Bolagsverket och organisationscertifikat för acceptans- och produktionsmiljön; testmiljön kan användas utan certifikat. Certifikatet läses enbart från miljövariablerna BOLAGSVERKET_CLIENT_CERT/BOLAGSVERKET_CLIENT_KEY (aldrig från inställningar), och BOLAGSVERKET_ENV sätter taket (test < accept < prod) för vilken miljö ett företag kan välja i inställningarna — utan miljövariabeln gäller endast testmiljön."
}
}
+181
View File
@@ -0,0 +1,181 @@
/**
* DTOs for Bolagsverket's REST services for digital inlämning av
* årsredovisning, hand-written from Teknisk guide v3.4 (dev_docs/bokslut/
* GUIDE.md §5 + §8 trafikexempel) and Anslutningsanvisning v1.7.
*
* NOTE: the official OpenAPI 2.0 servicespecifikationer are still pending
* manual download (dev_docs/bokslut/INDEX.md checklist api/). When they
* land, reconcile these types against the specs the specs win on conflict
* (GUIDE §5.1).
*/
export type BolagsverketEnvironment = 'test' | 'accept' | 'prod'
export type HandlingTyp =
| 'arsredovisning_komplett'
| 'arsredovisning'
| 'revisionsberattelse'
// ---- hamta-arsredovisningsinformation/v1.4 ---------------------------------
export interface GrunduppgifterRakenskapsperiod {
from: string
tom: string
kravPaRevisionsberattelse: 'ja' | 'nej' | 'uppgift_saknas'
revisorsplikt: 'ja' | 'nej' | 'uppgift_saknas'
}
export interface GrunduppgifterForetradare {
fornamn: string
namn: string
personnummer: string | null
annanIdentitet: string | null
funktioner: Array<{ kod: string; text: string }>
}
export interface GrunduppgifterSvar {
orgnr: string
lopnummer: number | null
namn: string
status: Array<{ kod?: string; text?: string }>
rakenskapsperioder: GrunduppgifterRakenskapsperiod[]
foretradare: GrunduppgifterForetradare[]
}
export type ArendestatusTyp =
| 'arsred_inkommen'
| 'arsred_forelaggande_skickat'
| 'arsred_komplettering_inkommen'
| 'arsred_registrerad'
| 'arsred_avslutad_ej_registrerad'
| 'arsred_saknas'
export interface ArendestatusSvar {
orgnr: string
namn: string
hamtat: string
tidpunkt: string | null
typ: ArendestatusTyp
arendenummer: string | null
rakenskapsperiod: { from: string; tom: string } | null
}
// ---- skapa-inlamningtoken (v2.1) -------------------------------------------
export interface InlamningTokenSvar {
token: string
/** MUST be shown to and accepted by the user per company; re-show when
* avtalstextAndrad changes (GUIDE §4.2, §5.3.1). */
avtalstext: string
avtalstextAndrad: string
}
// ---- kontrollera (v2.1) ----------------------------------------------------
export interface KontrolleraUtfall {
kod: string
text: string
typ: string
tekniskinformation: Array<{
meddelande: string | null
element: string | null
varde: string | null
}> | null
}
export interface KontrolleraSvar {
orgnr: string
utfall: KontrolleraUtfall[] | null
}
// ---- inlamning (v2.1) ------------------------------------------------------
export interface InlamningBegaran {
undertecknare: string
epostadresser: string[]
kvittensepostadresser?: string[]
notifieringEpostadresser?: string[]
handling: { fil: string; typ: HandlingTyp }
}
export interface InlamningSvar {
orgnr: string
avsandare: string
undertecknare: string
handlingsinfo: {
typ: HandlingTyp
dokumentlangd: number
idnummer: string
sha256checksumma: string
}
url: string
}
// ---- skapa-kontrollsumma (v1.1) --------------------------------------------
export interface KontrollsummaSvar {
kontrollsumma: string
algoritm: string
}
// ---- handelser (v2.0) ------------------------------------------------------
export interface HandelseMeddelande {
typ: string
/** Orgnr of the company the event concerns. */
id: string
/** Per-company sequence number; -1 for the subscription test message. */
nr: number
tid: string
data: {
version: string
handlingsinfo?: Array<{
handling: 'arsredovisning' | 'revisionsberattelse'
idnummer: string
kontrollsumma?: { digest: string; algoritm: string; upplysning: string | null } | null
}>
status: ArendestatusTyp | 'test'
}
}
export interface HamtaHandelserSvar {
meddelanden: HandelseMeddelande[]
}
// ---- submission rows (DB) ---------------------------------------------------
export type SubmissionStatus =
| 'draft'
| 'kontrollerad'
| 'uploaded'
| 'inkommen'
| 'forelagd'
| 'komplettering'
| 'registrerad'
| 'avslutad'
| 'error'
export interface ArsredovisningSubmission {
id: string
company_id: string
user_id: string
fiscal_period_id: string
handling_typ: HandlingTyp
taxonomy_version: string
entry_point: string
environment: BolagsverketEnvironment
status: SubmissionStatus
undertecknare_namn: string | null
undertecknare_epost: string | null
idnummer: string | null
sha256_checksumma: string | null
kontrollsumma: string | null
bolagsverket_url: string | null
kontrollera_utfall: KontrolleraUtfall[] | null
dokument_id: string | null
error_message: string | null
uploaded_at: string | null
registered_at: string | null
created_at: string
updated_at: string
}
+73 -1
View File
@@ -18,6 +18,8 @@ import {
composeInboxAddress,
} from './lib/inbox-provisioning'
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
@@ -1648,6 +1650,31 @@ export const invoiceInboxExtension: Extension = {
return NextResponse.json({ error: 'Supplier not found' }, { status: 404 })
}
// Periodisering requires faktureringsmetoden — mirror the main
// /api/supplier-invoices guard so kontantmetod companies never store
// accrual fields the booking would silently ignore.
const hasAccrualItems = body.items.some(
(bodyItem) => bodyItem.accrual_period_start && bodyItem.accrual_period_end,
)
if (hasAccrualItems && body.reverse_charge) {
// Omvänd skattskyldighet: the expense line carries the VAT base for
// rutor 2032 — deferring the net to a 17xx interim account would
// corrupt the momsdeklaration. Same guard as /api/supplier-invoices.
return errorResponseFromCode('SI_CREATE_ACCRUAL_REVERSE_CHARGE', ctx.log)
}
if (hasAccrualItems) {
const { data: methodSettings } = await ctx.supabase
.from('company_settings')
.select('accounting_method')
.eq('company_id', ctx.companyId)
.single()
if ((methodSettings?.accounting_method || 'accrual') !== 'accrual') {
return errorResponseFromCode('SI_CREATE_INVALID_INPUT', ctx.log, {
details: { reason: 'periodisering requires faktureringsmetoden (accrual)' },
})
}
}
const { data: arrivalNum, error: arrivalError } = await ctx.supabase
.rpc('get_next_arrival_number', { p_company_id: ctx.companyId })
@@ -1675,6 +1702,21 @@ export const invoiceInboxExtension: Extension = {
// Self-assessed RC rate (0.06/0.12/0.25) or null — engine defaults
// to 25% huvudregeln when null for a reverse-charge invoice.
reverse_charge_rate: body.reverse_charge ? (bodyItem.reverse_charge_rate ?? null) : null,
// Periodisering: frozen onto the line; the balance account
// defaults from the cost account's BAS convention.
accrual_period_start:
bodyItem.accrual_period_start && bodyItem.accrual_period_end
? bodyItem.accrual_period_start
: null,
accrual_period_end:
bodyItem.accrual_period_start && bodyItem.accrual_period_end
? bodyItem.accrual_period_end
: null,
accrual_balance_account:
bodyItem.accrual_period_start && bodyItem.accrual_period_end
? (bodyItem.accrual_balance_account ??
suggestBalanceAccount('expense', bodyItem.account_number))
: null,
}
})
@@ -1788,9 +1830,10 @@ export const invoiceInboxExtension: Extension = {
...lineItem,
}))
const { error: itemsError } = await ctx.supabase
const { data: insertedItems, error: itemsError } = await ctx.supabase
.from('supplier_invoice_items')
.insert(itemInserts)
.select('id, sort_order')
if (itemsError) {
await ctx.supabase.from('supplier_invoices').delete().eq('id', invoice.id)
@@ -1832,6 +1875,35 @@ export const invoiceInboxExtension: Extension = {
.eq('id', item.document_id)
.eq('company_id', ctx.companyId)
}
if (hasAccrualItems) {
// Schedules + catch-up dissolutions for deferred lines. Never
// fatal — the registration entry is committed; failures are
// retried/surfaced via the periodiseringar page.
const idBySortOrder = new Map(
((insertedItems ?? []) as Array<{ id: string; sort_order: number }>).map(
(row) => [row.sort_order, row.id],
),
)
const itemsWithIds = items.map((lineItem) => ({
...lineItem,
id: idBySortOrder.get(lineItem.sort_order) ?? null,
}))
const scheduleResult = await createSchedulesForSupplierInvoice(
ctx.supabase,
ctx.companyId,
ctx.userId,
invoice as SupplierInvoice,
itemsWithIds as unknown as SupplierInvoiceItem[],
journalEntry.id,
)
if (scheduleResult.failed > 0) {
ctx.log.error('accrual schedule creation failed on inbox convert', {
supplierInvoiceId: invoice.id,
failed: scheduleResult.failed,
})
}
}
} else {
// createSupplierInvoiceRegistrationEntry returns null ONLY when no
// fiscal period covers invoice_date (every other failure throws).
@@ -69,6 +69,12 @@ export const ExtractionSchema = z.object({
dueDate: z.string().nullable(),
paymentReference: z.string().nullable(),
currency: z.string(),
// Service/coverage window the invoice charges for (insurance period,
// license term, "avtalsperiod"). Drives the periodisering prefill in the
// supplier-invoice form. Optional so cached raw outputs from before this
// field still validate.
servicePeriodStart: z.string().nullable().optional(),
servicePeriodEnd: z.string().nullable().optional(),
}),
lineItems: z.array(
z.object({
@@ -122,7 +128,9 @@ Return ONLY a single JSON object that matches this schema exactly. No prose, no
"invoiceDate": string | null, // ISO date YYYY-MM-DD
"dueDate": string | null, // ISO date YYYY-MM-DD
"paymentReference": string | null, // OCR / payment reference
"currency": string // ISO 4217 (SEK, USD, EUR, ...). Default "SEK" only if truly indeterminate.
"currency": string, // ISO 4217 (SEK, USD, EUR, ...). Default "SEK" only if truly indeterminate.
"servicePeriodStart": string | null, // ISO date — start of the service/coverage window the invoice charges for
"servicePeriodEnd": string | null // ISO date — end of that window
},
"lineItems": [
{
@@ -151,6 +159,7 @@ Rules:
- Currency: detect from the document (symbol $//kr or explicit code). Use the ISO 4217 code. Do NOT default to SEK if the document clearly shows another currency.
- "total" is the amount the buyer must pay (look for "Att betala", "Total", "Amount paid", "Amount due", "Balance"). Prefer this over Subtotal.
- Dates: convert any format to YYYY-MM-DD. If the document only shows month/year, leave null.
- servicePeriodStart/servicePeriodEnd: only when the document explicitly states the period the charge covers ("Avtalsperiod", "Period", "Försäkringstid", "Subscription period", coverage dates). Never infer from invoice/due dates. Month-only boundaries map to the first resp. last day of the month.
- Bankgiro/Plusgiro: only set when the document is for a Swedish supplier on a Swedish bank rail. Do not invent.
- Org.nr: only set when it is an actual Swedish organisation number (10 digits, Luhn-valid). For US/EU companies leave null even if they list an EIN/VAT number.
- VAT number: include the country prefix.
@@ -175,6 +184,8 @@ export function emptyResult(): InvoiceExtractionResult {
dueDate: null,
paymentReference: null,
currency: 'SEK',
servicePeriodStart: null,
servicePeriodEnd: null,
},
lineItems: [],
totals: { subtotal: null, vatAmount: null, total: null },
@@ -0,0 +1,23 @@
/**
* Pin tests for gnubok_list_accrual_schedules.
*
* The scope mapping is load-bearing: tools missing from TOOL_SCOPE_MAP are
* usable by ANY API key, so the reports:read pin guards against the tool
* silently becoming scope-less again.
*/
import { describe, it, expect } from 'vitest'
import { tools } from '../server'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
describe('gnubok_list_accrual_schedules — registration', () => {
it('is registered and read-only', () => {
const tool = tools.find((t) => t.name === 'gnubok_list_accrual_schedules')
expect(tool).toBeDefined()
expect(tool?.annotations.readOnlyHint).toBe(true)
expect(tool?.annotations.destructiveHint).toBe(false)
})
it('is mapped to reports:read scope', () => {
expect(TOOL_SCOPE_MAP.gnubok_list_accrual_schedules).toBe('reports:read')
})
})
@@ -100,6 +100,10 @@ describe('forbiddenOriginResponse', () => {
})
describe('mcp-server apiRoutes origin enforcement', () => {
// The dynamic import pulls in the full 9k-line server module; that parse
// alone takes ~4s and flirts with the 5s default timeout under full-suite
// parallel load. The test is import-bound, not logic-bound — give it
// explicit headroom instead of letting machine load decide the outcome.
it('rejects foreign-Origin requests on every /mcp method before dispatch', async () => {
const { mcpServerExtension } = await import('../index')
const routes = (mcpServerExtension.apiRoutes ?? []).filter((r) => r.path === '/mcp')
@@ -114,5 +118,5 @@ describe('mcp-server apiRoutes origin enforcement', () => {
)
expect(res.status, `${route.method} /mcp`).toBe(403)
}
})
}, 20_000)
})
@@ -43,6 +43,9 @@ describe('tools/list payload size guard', () => {
// 92 tools for Connectors Directory readiness; the ~10 longest descriptions
// were trimmed toward 180200 chars to partly offset. Headroom reserved for
// the upcoming Skatteverket tools.
// * Held at 36K when gnubok_list_accrual_schedules (add/bokslut) merged with
// the categorize vat_amount override (#717): the combination crossed the
// ceiling by ~75, offset by trimming the 8 longest descriptions to ~200 chars.
// Long-term answer to growth is leaning harder on gnubok_search_tools — if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
+64 -13
View File
@@ -7,6 +7,7 @@ import {
TOOL_SCOPE_MAP,
} from '@/lib/auth/api-keys'
import { createLogger } from '@/lib/logger'
import { roundOre, sumOre } from '@/lib/money'
import type { SupabaseClient } from '@supabase/supabase-js'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
@@ -2060,7 +2061,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_get_agent_briefing',
title: 'Get Agent Briefing',
description: 'Bootstrap this company\'s specialized accountant context in one call: profile_summary, the atoms loaded for the company (metadata only — call gnubok_load_skill for bodies), and the top-30 active memories. Call once at session start.',
description: 'Bootstrap this company\'s accountant context in one call: profile_summary, loaded atoms (metadata only — gnubok_load_skill for bodies), top-30 active memories. Call once at session start.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -4291,7 +4292,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_query_journal',
title: 'Query Journal Lines',
description: "Flexible journal-line query — replaces chained ledger calls for ad-hoc questions. Filters: accounts, date range, amount range, voucher series/number, source type, status, project, cost center, free-text. Returns lines with parent voucher metadata + totals.",
description: "Flexible journal-line query for ad-hoc questions. Filters: account, date, amount, voucher series/number, source type, status, project, cost center, free-text. Returns lines with voucher metadata + totals.",
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -4666,7 +4667,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_match_transaction_to_invoice',
title: 'Match Transaction to Invoice',
description: 'Match a bank transaction (income, amount>0) to a customer invoice. Confirm tx date/amount and invoice number/customer match before staging — preview mirrors what you pass. Supports partial payments and auto-storno of prior categorization.',
description: 'Match a bank transaction (income, amount>0) to a customer invoice. Confirm tx date/amount and invoice number/customer before staging. Supports partial payments and auto-storno of prior categorization.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -5273,7 +5274,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_find_voucher_candidates_for_invoice',
title: 'Find Voucher Candidates (Invoice)',
description: "List posted verifikat that could be this invoice's payment (faktureringsmetoden: credit 1510; kontantmetoden: debit a bank/cash account 19xx). Call before gnubok_link_invoice_to_voucher to mark a faktura paid against an existing verifikation (no new bokföring).",
description: "List posted verifikat that could be this invoice's payment (faktureringsmetoden: credit 1510; kontantmetoden: debit 19xx). Call before gnubok_link_invoice_to_voucher to mark the faktura paid (no new bokföring).",
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -5339,7 +5340,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_link_invoice_to_voucher',
title: 'Link Invoice to Voucher',
description: 'Markera en faktura som betald genom att länka till en befintlig bokförd verifikation (faktureringsmetoden: krediterar 1510; kontantmetoden: debiterar likvidkonto 19xx). Ingen ny verifikation skapas. Hitta kandidater med gnubok_find_voucher_candidates_for_invoice först.',
description: 'Markera en faktura som betald via länk till en befintlig verifikation (faktureringsmetoden: krediterar 1510; kontantmetoden: debiterar 19xx). Skapar ingen ny verifikation. Kör gnubok_find_voucher_candidates_for_invoice först.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -5426,7 +5427,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_find_voucher_candidates_for_supplier_invoice',
title: 'Find Voucher Candidates (Supplier Invoice)',
description: 'List posted verifikat that debit leverantörsskuld (2440) and could be the payment for this supplier invoice. Use before gnubok_link_supplier_invoice_to_voucher when marking a leverantörsfaktura paid against an existing verifikation (no new bokföring).',
description: 'List posted verifikat that debit leverantörsskuld (2440) and could be this supplier invoice\'s payment. Call before gnubok_link_supplier_invoice_to_voucher to mark the leverantörsfaktura paid (no new bokföring).',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -5492,7 +5493,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_link_supplier_invoice_to_voucher',
title: 'Link Supplier Invoice to Voucher',
description: 'Markera en leverantörsfaktura som betald genom att länka till en befintlig verifikation som redan debiterar leverantörsskuld (2440). Ingen ny verifikation skapas. Hitta kandidater med gnubok_find_voucher_candidates_for_supplier_invoice först.',
description: 'Markera en leverantörsfaktura som betald via länk till en befintlig verifikation som debiterar leverantörsskuld (2440). Skapar ingen ny verifikation. Kör gnubok_find_voucher_candidates_for_supplier_invoice först.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -5955,7 +5956,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_list_inbox_items',
title: 'List Inbox Items',
description: 'List document inbox items. Each has a `processed` flag covering all terminal links (transaction match, supplier invoice, or journal entry), so a booked receipt counts as done. unprocessed_only=true returns only docs still needing handling.',
description: 'List document inbox items. `processed` covers all terminal links (transaction, supplier invoice, journal entry); booked receipts count as done. unprocessed_only=true returns docs still needing handling.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -6090,7 +6091,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_create_supplier_invoice_from_inbox',
title: 'Create Supplier Invoice from Inbox',
description: "Atomic: turn an OCR'd inbox item into a staged supplier invoice. Resolves supplier (matched or via org_number/name), assembles line items from extracted_data, applies VAT + FX, attaches the source document. Stages for human review; honors dry_run.",
description: "Atomic: turn an OCR'd inbox item into a staged supplier invoice. Resolves supplier, builds lines from extracted_data, applies VAT + FX, attaches the document. Stages for human review; honors dry_run.",
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -8279,7 +8280,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_undo_sie_import',
title: 'Undo SIE Import',
description: 'Stage undo of a completed SIE import: hard-deletes its entries (transactions + opening balance), detaches docs, resets voucher_sequences, marks the row \'undone\' so the file can be re-imported. Use after a botched import. Period must be open. HIGH risk.',
description: 'Stage undo of a completed SIE import: hard-deletes its entries, detaches docs, resets voucher_sequences, marks the import \'undone\' for re-import. Use after a botched import. Period must be open. HIGH risk.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -8380,7 +8381,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_create_voucher',
title: 'Create Manual Voucher (Verifikation)',
description: 'Stage a manual verifikation with arbitrary balanced lines. Use for capitalization (1010), period-end accruals, FX adjustments, rättelseposter outside categorize_transaction. Pass inbox_item_id to book a kvitto direct links inbox + attaches doc. HIGH risk.',
description: 'Stage a manual verifikation with arbitrary balanced lines: capitalization (1010), accruals, FX adjustments, rättelser outside categorize_transaction. Pass inbox_item_id to book a kvitto direct (links + attaches doc). HIGH risk.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -8778,7 +8779,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_reverse_journal_entry',
title: 'Reverse Journal Entry (Storno)',
description: 'Stage a storno: inverts debits/credits; original stays visible per BFL 5 kap. Use only when the affärshändelse should never have been booked (duplicate, ghost, test). If booked wrong, use gnubok_correct_entry; for refunds, gnubok_credit_invoice. HIGH risk.',
description: 'Stage a storno: inverts debits/credits; original stays visible per BFL 5 kap. Only when the affärshändelse should never have been booked (duplicate, ghost, test). Booked wrong gnubok_correct_entry; refund gnubok_credit_invoice. HIGH risk.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -8977,6 +8978,56 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_list_accrual_schedules',
title: 'List Periodiseringar',
description:
'Löpande periodiseringar (17xx/29xx): monthly installments, dissolved and remaining amounts.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
status: {
type: 'string',
enum: ['active', 'completed', 'cancelled', 'all'],
description: "Default 'active'.",
},
},
},
outputSchema: { type: 'object', additionalProperties: true },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase, _actor) {
const status = (args.status as string) || 'active'
let query = supabase
.from('accrual_schedules')
.select('*, installments:accrual_schedule_installments(*)')
.eq('company_id', companyId)
.order('created_at', { ascending: false })
if (status !== 'all') query = query.eq('status', status)
const { data, error } = await query
if (error) throw new Error(error.message)
type InstallmentRow = { period_month: string; amount: number; status: string }
const schedules = ((data ?? []) as Array<Record<string, unknown>>).map((schedule) => {
const installments = ([...((schedule.installments as InstallmentRow[]) ?? [])]).sort(
(a, b) => a.period_month.localeCompare(b.period_month),
)
const dissolved = sumOre(
installments.filter((i) => i.status === 'posted').map((i) => Number(i.amount)),
)
const total = Number(schedule.total_amount)
return {
...schedule,
installments,
dissolved_amount: dissolved,
remaining_amount:
schedule.status === 'cancelled' ? 0 : roundOre(total - dissolved),
}
})
return { schedules, count: schedules.length }
},
},
{
name: 'gnubok_propose_annual_depreciation',
title: 'Propose Annual Depreciation (Avskrivning)',
@@ -9213,7 +9264,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_approve_pending_operation',
title: 'Approve Pending Operation',
description: "Commit a staged pending_operation when the user has explicitly authorised the operation_id. risk_level=high requires confirmed=true — surface the BFL 5 kap 5§ irreversibility to the user first. The /pending web UI offers an equivalent commit path.",
description: "Commit a staged pending_operation the user has explicitly authorised. risk_level=high requires confirmed=true — surface the BFL 5 kap 5§ irreversibility first. The /pending web UI offers an equivalent commit path.",
inputSchema: {
type: 'object',
additionalProperties: false,
+109 -1
View File
@@ -1,6 +1,7 @@
import { z } from 'zod'
import { normaliseSwish, isValidSwish } from '@/lib/payments/swish'
import { isSaneDateString } from '@/lib/utils'
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
// ============================================================
// Shared primitives
@@ -39,6 +40,61 @@ const vatRatePercent = z.union([z.literal(0), z.literal(6), z.literal(12), z.lit
/** Time string (HH:MM or HH:MM:SS) */
const timeString = z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/, 'Expected HH:MM or HH:MM:SS time format')
/** Periodisering: interim accounts. Förutbetalda kostnader live on 17xx. */
const prepaidExpenseAccount = z
.string()
.regex(/^17\d{2}$/, 'Balanskonto för periodiserad kostnad måste vara ett 17xx-konto')
/** Periodisering: förutbetalda intäkter live on 29xx. */
const deferredRevenueAccount = z
.string()
.regex(/^29\d{2}$/, 'Balanskonto för periodiserad intäkt måste vara ett 29xx-konto')
/**
* Shared periodisering period rules for invoice line items: both dates or
* neither, end after start, and a 2120 calendar month span. The amount-side
* rules differ per item shape and stay in each schema's superRefine.
*/
function validateAccrualPeriod(
item: { accrual_period_start?: string | null; accrual_period_end?: string | null },
ctx: z.RefinementCtx,
): void {
const start = item.accrual_period_start
const end = item.accrual_period_end
if (!start && !end) return
if (!start || !end) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['accrual_period_start'],
message: 'Ange både periodens start och slut för periodisering',
})
return
}
if (end < start) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['accrual_period_end'],
message: 'Periodens slut måste vara efter dess start',
})
return
}
const months = countCalendarMonths(start, end)
if (months < 2) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['accrual_period_end'],
message: 'Periodisering kräver minst 2 kalendermånader',
})
}
if (months > 120) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['accrual_period_end'],
message: 'Periodisering kan omfatta högst 120 månader',
})
}
}
// ============================================================
// Enum schemas (matching types/index.ts)
// ============================================================
@@ -145,6 +201,7 @@ export const JournalEntrySourceTypeSchema = z.enum([
'supplier_credit_note',
'currency_revaluation',
'reminder_fee',
'accrual',
])
/** Query params for GET /api/bookkeeping/voucher-sequences/next. */
@@ -225,8 +282,39 @@ export const CreateInvoiceItemSchema = z
work_type: z.string().max(64).nullable().optional(),
housing_designation: z.string().max(128).nullable().optional(),
apartment_number: z.string().max(32).nullable().optional(),
// Periodisering (förutbetald intäkt): defer the line's net revenue over
// the service period. The revenue entry credits the 29xx interim account
// instead of the revenue account; output VAT is never deferred.
accrual_period_start: isoDate.nullable().optional(),
accrual_period_end: isoDate.nullable().optional(),
accrual_balance_account: deferredRevenueAccount.nullable().optional(),
})
.superRefine((item, ctx) => {
validateAccrualPeriod(item, ctx)
const hasAccrual = Boolean(item.accrual_period_start || item.accrual_period_end)
if (hasAccrual) {
if (item.line_type === 'text') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['accrual_period_start'],
message: 'Textrader kan inte periodiseras',
})
}
if (item.deduction_type) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['accrual_period_start'],
message: 'ROT/RUT-rader kan inte periodiseras',
})
}
if (item.quantity * item.unit_price <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['accrual_period_start'],
message: 'Endast rader med positivt belopp kan periodiseras',
})
}
}
// Free-text rows skip the product-line requirements (description may be
// empty for a spacer; quantity/unit/price are ignored).
if (item.line_type === 'text') return
@@ -485,6 +573,12 @@ export const CreateSupplierInvoiceItemSchema = z.object({
quantity: z.number().optional(),
unit: z.string().optional(),
unit_price: z.number().optional(),
// Periodisering (förutbetald kostnad): defer the line's net cost over the
// service period. The registration entry debits the 17xx interim account
// instead of account_number; input VAT is never deferred.
accrual_period_start: isoDate.nullable().optional(),
accrual_period_end: isoDate.nullable().optional(),
accrual_balance_account: prepaidExpenseAccount.nullable().optional(),
}).refine(
(item) => {
if (item.vat_amount == null) return true
@@ -501,7 +595,21 @@ export const CreateSupplierInvoiceItemSchema = z.object({
message: 'vat_amount cannot exceed line_total × vat_rate',
path: ['vat_amount'],
},
)
).superRefine((item, ctx) => {
validateAccrualPeriod(item, ctx)
if (item.accrual_period_start || item.accrual_period_end) {
const lineTotal = item.amount != null
? item.amount
: (item.quantity ?? 1) * (item.unit_price ?? 0)
if (lineTotal <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['accrual_period_start'],
message: 'Endast rader med positivt belopp kan periodiseras',
})
}
}
})
export const CreateSupplierInvoiceSchema = z.object({
supplier_id: uuid,
+1
View File
@@ -202,6 +202,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_get_supplier_ledger: 'reports:read',
gnubok_list_fiscal_periods: 'reports:read',
gnubok_get_reconciliation_status: 'reports:read',
gnubok_list_accrual_schedules: 'reports:read',
// Document inbox
gnubok_upload_document: 'transactions:write',
gnubok_list_inbox_items: 'transactions:read',
@@ -26,6 +26,8 @@ describe('detectPeriodisering', () => {
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
error: null,
})
// accrual_schedules — löpande periodiseringar that exclude their invoices
mock.enqueue({ data: [], error: null })
// 2) invoices (none)
mock.enqueue({ data: [], error: null })
// 3) supplier_invoices — one 12-month annual license invoice
@@ -69,6 +71,8 @@ describe('detectPeriodisering', () => {
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
error: null,
})
// accrual_schedules — löpande periodiseringar that exclude their invoices
mock.enqueue({ data: [], error: null })
// Customer invoice for an annual subscription billed Dec 1 2025 covering
// Jan 1 2026 → Dec 31 2026 entirely. Entire amount belongs to next year.
mock.enqueue({
@@ -106,6 +110,8 @@ describe('detectPeriodisering', () => {
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
error: null,
})
// accrual_schedules — löpande periodiseringar that exclude their invoices
mock.enqueue({ data: [], error: null })
mock.enqueue({ data: [], error: null }) // invoices
mock.enqueue({
data: [
@@ -143,6 +149,8 @@ describe('detectPeriodisering', () => {
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
error: null,
})
// accrual_schedules — löpande periodiseringar that exclude their invoices
mock.enqueue({ data: [], error: null })
mock.enqueue({ data: [], error: null }) // invoices
mock.enqueue({
data: [
@@ -172,6 +180,8 @@ describe('detectPeriodisering', () => {
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
error: null,
})
// accrual_schedules — löpande periodiseringar that exclude their invoices
mock.enqueue({ data: [], error: null })
mock.enqueue({ data: [], error: null })
mock.enqueue({
data: [
@@ -201,6 +211,8 @@ describe('detectPeriodisering', () => {
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
error: null,
})
// accrual_schedules — löpande periodiseringar that exclude their invoices
mock.enqueue({ data: [], error: null })
mock.enqueue({ data: [], error: null })
mock.enqueue({
data: [
@@ -241,4 +253,39 @@ describe('detectPeriodisering', () => {
expect(result[0].source_invoice_id).toBe('sup-high')
expect(result[1].source_invoice_id).toBe('sup-medium')
})
it('excludes invoices already covered by a löpande accrual schedule', async () => {
mock.enqueue({
data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
error: null,
})
// accrual_schedules — sup-inv-1 is already deferred line-by-line
mock.enqueue({
data: [{ supplier_invoice_id: 'sup-inv-1', invoice_id: null }],
error: null,
})
mock.enqueue({ data: [], error: null }) // invoices
mock.enqueue({
data: [
{
id: 'sup-inv-1',
supplier_invoice_number: 'LF-100',
invoice_date: '2025-07-01',
subtotal: 12000,
notes: 'Mjukvarulicens period: 2025-07-01 till 2026-06-30',
suppliers: { name: 'Acme SaaS AB' },
supplier_invoice_items: [{ description: 'Årslicens', account_number: '5800' }],
},
],
error: null,
})
const result = await detectPeriodisering(
mock.supabase as never,
'company-1',
'period-1',
)
// Suggesting it again would periodisera the same belopp twice.
expect(result).toEqual([])
})
})
+25 -1
View File
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre } from '@/lib/money'
import { parseInvoiceDateRange } from './date-range-parser'
export type PeriodiseringSource = 'invoice' | 'supplier_invoice'
@@ -122,7 +123,7 @@ function buildSuggestion(args: {
if (daysAfterPeriodEnd <= 0) return null
const ratio = daysAfterPeriodEnd / totalDays
const periodisationAmount = Math.round(netAmount * ratio * 100) / 100
const periodisationAmount = roundOre(netAmount * ratio)
if (periodisationAmount <= 0) return null
@@ -181,6 +182,27 @@ export async function detectPeriodisering(
const periodStart = period.period_start as string
const periodEnd = period.period_end as string
// Invoices already covered by a löpande accrual schedule (periodisering
// skapad på fakturaraden) are handled month by month and must never be
// suggested again at year-end — that would periodisera the same belopp
// twice. Cancelled schedules don't exclude: their invoice was credited and
// the status filters below drop it anyway.
const { data: scheduleRows } = await supabase
.from('accrual_schedules')
.select('supplier_invoice_id, invoice_id')
.eq('company_id', companyId)
.neq('status', 'cancelled')
const coveredSupplierInvoices = new Set(
((scheduleRows ?? []) as Array<{ supplier_invoice_id: string | null }>)
.map((row) => row.supplier_invoice_id)
.filter(Boolean),
)
const coveredInvoices = new Set(
((scheduleRows ?? []) as Array<{ invoice_id: string | null }>)
.map((row) => row.invoice_id)
.filter(Boolean),
)
// Customer invoices — only "real" ones (sent/paid). Drafts and overdue
// get skipped: drafts haven't moved through the engine, overdue is just a
// status label that overlaps with sent here.
@@ -206,6 +228,7 @@ export async function detectPeriodisering(
const suggestions: PeriodiseringSuggestion[] = []
for (const row of (invoiceRows ?? []) as unknown as InvoiceRow[]) {
if (coveredInvoices.has(row.id)) continue
const itemDescs = (row.invoice_items ?? []).map((i) => i.description).filter(Boolean)
const customerName = row.customers?.name ?? 'Okänd kund'
const sourceLabel = row.invoice_number
@@ -225,6 +248,7 @@ export async function detectPeriodisering(
}
for (const row of (supplierRows ?? []) as unknown as SupplierInvoiceRow[]) {
if (coveredSupplierInvoices.has(row.id)) continue
const itemDescs = (row.supplier_invoice_items ?? []).map((i) => i.description).filter(Boolean)
const firstAccount = row.supplier_invoice_items?.[0]?.account_number ?? null
const supplierName = row.suppliers?.name ?? 'Okänd leverantör'
+6 -5
View File
@@ -60,7 +60,7 @@ export async function buildArsredovisningData(
.single(),
supabase
.from('company_settings')
.select('company_name, org_number, address, entity_type')
.select('company_name, org_number, city, entity_type')
.eq('company_id', companyId)
.maybeSingle(),
// Source-of-truth for entity_type and accounting_framework lives on
@@ -111,10 +111,11 @@ export async function buildArsredovisningData(
const accountingFramework: AccountingFramework =
companyRow?.accounting_framework === 'k3' ? 'k3' : 'k2'
type AddressShape = { city?: string | null; postal_city?: string | null } | null
const addressUnknown = (settings as { address?: AddressShape } | null)?.address ?? null
const city =
(addressUnknown && (addressUnknown.city ?? addressUnknown.postal_city)) || null
// company_settings stores the address as flat columns (address_line1,
// postal_code, city) — there is no `address` json column. Selecting one
// made the whole settings query fail, so every ÅR fell back to "Bolaget"
// with an empty org number.
const city = (settings as { city?: string | null } | null)?.city ?? null
// Merge precedence: caller overrides → persisted narrative → boilerplate
const persistedDescription = narrative?.description ?? undefined
@@ -0,0 +1,88 @@
/**
* Mechanical guards for TA §2.7.3 (repeated facts must be value-identical
* Bolagsverket rejects inconsistent duplicates) and TA §3.2 (valid XHTML with
* only the five XML escape entities).
*/
import { describe, expect, it } from 'vitest'
import { XMLValidator } from 'fast-xml-parser'
import { generateK2IxbrlDocument } from '../document/k2-document'
import { makeInput } from './fixtures'
import type { IxbrlArsredovisningInput } from '../types'
/** Parse every ix:nonFraction into { name, contextRef, value } where value
* includes the sign attribute (the rendered text is always absolute). */
function numericFacts(
xhtml: string,
): Array<{ name: string; contextRef: string; value: string }> {
const facts: Array<{ name: string; contextRef: string; value: string }> = []
for (const match of xhtml.matchAll(/<ix:nonFraction ([^>]*)>([^<]*)<\/ix:nonFraction>/g)) {
const attrs = match[1]
const name = attrs.match(/name="([^"]+)"/)?.[1] ?? ''
const contextRef = attrs.match(/contextRef="([^"]+)"/)?.[1] ?? ''
const sign = /sign="-"/.test(attrs) ? '-' : ''
facts.push({ name, contextRef, value: `${sign}${match[2]}` })
}
return facts
}
function assertNoConflictingDuplicates(input: IxbrlArsredovisningInput): void {
const { xhtml } = generateK2IxbrlDocument(input)
const groups = new Map<string, Set<string>>()
for (const fact of numericFacts(xhtml)) {
const key = `${fact.name}@${fact.contextRef}`
const values = groups.get(key) ?? new Set<string>()
values.add(fact.value)
groups.set(key, values)
}
expect(groups.size).toBeGreaterThan(0)
const conflicts = [...groups.entries()]
.filter(([, values]) => values.size > 1)
.map(([key, values]) => `${key}: ${[...values].join(' vs ')}`)
expect(conflicts).toEqual([])
}
describe('duplicate-fact consistency (TA §2.7.3)', () => {
it('every repeated name+context fact has exactly one distinct value (base fixture)', () => {
assertNoConflictingDuplicates(makeInput())
})
it('… with a proposed dividend', () => {
const input = makeInput()
input.forvaltningsberattelse.resultatdisposition.utdelning = 50_000
input.forvaltningsberattelse.resultatdisposition.balanserasINyRakning = 170_000
input.forvaltningsberattelse.egetKapital.utdelning = 50_000
assertNoConflictingDuplicates(input)
})
it('… with fri överkursfond in BR, eget kapital and resultatdisposition', () => {
const input = makeInput()
input.br['Overkursfond'] = { current: 50_000, previous: 50_000 }
// FrittEgetKapital is tagged both as the BR subtotal and the disposition
// "Summa" — keep the single source consistent like build-input does.
input.totals.frittEgetKapital = { current: 270_000, previous: 150_000 }
input.forvaltningsberattelse.resultatdisposition.overkursfond = 50_000
input.forvaltningsberattelse.resultatdisposition.summa = 270_000
input.forvaltningsberattelse.resultatdisposition.balanserasINyRakning = 270_000
assertNoConflictingDuplicates(input)
})
})
describe('XML escaping (TA §3.2)', () => {
it('company name and note bodies with <, & and " produce well-formed XML', () => {
const input = makeInput()
input.company.name = 'Müller & Söner <Test> "AB"'
input.forvaltningsberattelse.allmantOmVerksamheten =
'Handel med <komponenter> & "specialverktyg" där 1 < 2.'
input.noter.push({
number: 4,
title: 'Övrigt & "annat" <viktigt>',
body: 'Villkor: a < b & c > d, citerat som "fritt".',
})
const { xhtml } = generateK2IxbrlDocument(input)
expect(XMLValidator.validate(xhtml)).toBe(true)
expect(xhtml).toContain('Müller &amp; Söner &lt;Test&gt; "AB"')
// No raw < survives inside text nodes (only as markup).
expect(xhtml).not.toContain('<komponenter>')
})
})
+234
View File
@@ -0,0 +1,234 @@
/**
* Shared deterministic fixture for iXBRL generator tests: a small AB with a
* balanced BR (380 000 kr) and an RR netting to 120 000 kr, mapped through
* the real k2-mapper so concept amounts stay consistent with the mapping
* rules under test.
*
* Each year is a realistic post-bokslut TrialBalancePair:
* - `full` the booked state AFTER the year-end closing entry: every
* class 38 account is zeroed (equal debit/credit churn) and 2099
* carries the year's result.
* - `preClosing` the same year WITHOUT the closing entry
* (excludeYearEndClosing): RR accounts still open, 2099 only carries
* the prior-year churn from the resultatdisposition entry.
*/
import { mapTrialBalancesToK2, type TrialBalancePair, type TrialBalanceRowLike } from '../k2-mapper'
import type { IxbrlArsredovisningInput } from '../types'
const row = (
account: string,
name: string,
debit: number,
credit: number,
): TrialBalanceRowLike => ({
account_number: account,
account_name: name,
closing_debit: debit,
closing_credit: credit,
})
/** Current year WITHOUT the closing entry RR accounts open. 2099 nets to 0
* (prior-year result IB balanced away by the disposition entry). */
const CURRENT_PRE_CLOSING: TrialBalanceRowLike[] = [
row('1220', 'Inventarier', 80_000, 0),
row('1229', 'Ack avskrivningar', 0, 20_000),
row('1510', 'Kundfordringar', 50_000, 0),
row('1930', 'Bank', 270_000, 0),
row('2081', 'Aktiekapital', 0, 25_000),
row('2091', 'Balanserad vinst', 0, 100_000),
row('2099', 'Årets resultat', 40_000, 40_000),
row('2110', 'Periodiseringsfond', 0, 40_000),
row('2440', 'Leverantörsskulder', 0, 30_000),
row('2510', 'Skatteskulder', 0, 35_000),
row('2610', 'Utgående moms', 0, 20_000),
row('2941', 'Upplupna sociala avgifter', 0, 10_000),
row('3010', 'Försäljning', 0, 1_000_000),
row('4010', 'Inköp', 200_000, 0),
row('5010', 'Lokalhyra', 100_000, 0),
row('7010', 'Löner', 400_000, 0),
row('7510', 'Arbetsgivaravgifter', 125_660, 0),
row('7832', 'Avskrivningar', 20_000, 0),
row('8310', 'Ränteintäkter', 0, 1_000),
row('8410', 'Räntekostnader', 4_000, 0),
row('8811', 'Avsättning periodiseringsfond', 10_000, 0),
row('8910', 'Skatt', 21_340, 0),
]
/** Current year WITH the closing entry — class 38 zeroed, 2099 = 120 000. */
const CURRENT_FULL: TrialBalanceRowLike[] = [
row('1220', 'Inventarier', 80_000, 0),
row('1229', 'Ack avskrivningar', 0, 20_000),
row('1510', 'Kundfordringar', 50_000, 0),
row('1930', 'Bank', 270_000, 0),
row('2081', 'Aktiekapital', 0, 25_000),
row('2091', 'Balanserad vinst', 0, 100_000),
row('2099', 'Årets resultat', 40_000, 160_000),
row('2110', 'Periodiseringsfond', 0, 40_000),
row('2440', 'Leverantörsskulder', 0, 30_000),
row('2510', 'Skatteskulder', 0, 35_000),
row('2610', 'Utgående moms', 0, 20_000),
row('2941', 'Upplupna sociala avgifter', 0, 10_000),
row('3010', 'Försäljning', 1_000_000, 1_000_000),
row('4010', 'Inköp', 200_000, 200_000),
row('5010', 'Lokalhyra', 100_000, 100_000),
row('7010', 'Löner', 400_000, 400_000),
row('7510', 'Arbetsgivaravgifter', 125_660, 125_660),
row('7832', 'Avskrivningar', 20_000, 20_000),
row('8310', 'Ränteintäkter', 1_000, 1_000),
row('8410', 'Räntekostnader', 4_000, 4_000),
row('8811', 'Avsättning periodiseringsfond', 10_000, 10_000),
row('8910', 'Skatt', 21_340, 21_340),
]
const PREVIOUS_PRE_CLOSING: TrialBalanceRowLike[] = [
row('1220', 'Inventarier', 80_000, 0),
row('1229', 'Ack avskrivningar', 0, 12_000),
row('1930', 'Bank', 185_000, 0),
row('2081', 'Aktiekapital', 0, 25_000),
row('2091', 'Balanserad vinst', 0, 60_000),
row('2110', 'Periodiseringsfond', 0, 30_000),
row('2440', 'Leverantörsskulder', 0, 25_000),
row('2510', 'Skatteskulder', 0, 15_000),
row('2610', 'Utgående moms', 0, 8_000),
row('2941', 'Upplupna sociala avgifter', 0, 50_000),
row('3010', 'Försäljning', 0, 500_000),
row('4010', 'Inköp', 200_000, 0),
row('5010', 'Lokalhyra', 80_000, 0),
row('7010', 'Löner', 150_000, 0),
row('7832', 'Avskrivningar', 8_000, 0),
row('8410', 'Räntekostnader', 2_000, 0),
row('8910', 'Skatt', 20_000, 0),
]
const PREVIOUS_FULL: TrialBalanceRowLike[] = [
row('1220', 'Inventarier', 80_000, 0),
row('1229', 'Ack avskrivningar', 0, 12_000),
row('1930', 'Bank', 185_000, 0),
row('2081', 'Aktiekapital', 0, 25_000),
row('2091', 'Balanserad vinst', 0, 60_000),
row('2099', 'Årets resultat', 0, 40_000),
row('2110', 'Periodiseringsfond', 0, 30_000),
row('2440', 'Leverantörsskulder', 0, 25_000),
row('2510', 'Skatteskulder', 0, 15_000),
row('2610', 'Utgående moms', 0, 8_000),
row('2941', 'Upplupna sociala avgifter', 0, 50_000),
row('3010', 'Försäljning', 500_000, 500_000),
row('4010', 'Inköp', 200_000, 200_000),
row('5010', 'Lokalhyra', 80_000, 80_000),
row('7010', 'Löner', 150_000, 150_000),
row('7832', 'Avskrivningar', 8_000, 8_000),
row('8410', 'Räntekostnader', 2_000, 2_000),
row('8910', 'Skatt', 20_000, 20_000),
]
export const CURRENT: TrialBalancePair = {
full: CURRENT_FULL,
preClosing: CURRENT_PRE_CLOSING,
}
export const PREVIOUS: TrialBalancePair = {
full: PREVIOUS_FULL,
preClosing: PREVIOUS_PRE_CLOSING,
}
export function makeInput(): IxbrlArsredovisningInput {
const mapping = mapTrialBalancesToK2(CURRENT, PREVIOUS)
return {
company: { name: 'Testbolaget AB', orgNumber: '556999-9999', city: 'Sundsvall' },
period: { start: '2025-01-01', end: '2025-12-31' },
previousPeriod: { start: '2024-01-01', end: '2024-12-31' },
isFirstFiscalYear: false,
rr: mapping.rr,
br: mapping.br,
totals: mapping.totals,
forvaltningsberattelse: {
allmantOmVerksamheten:
'Bolaget bedriver konsultverksamhet inom IT.\n\nBolaget har sitt säte i Sundsvall.',
vasentligaHandelser: 'Inga väsentliga händelser har inträffat under räkenskapsåret.',
// Rows 0/1 mirror the mapper outputs (duplicate facts with the RR must
// be value-identical, TA §2.7.3) — same override build-input applies.
flerarsoversikt: [
{
year: '2025',
nettoomsattning: mapping.rr['Nettoomsattning'].current,
resultatEfterFinansiellaPoster: mapping.totals.resultatEfterFinansiellaPoster.current,
soliditetPct: 64.5,
},
{
year: '2024',
nettoomsattning: mapping.rr['Nettoomsattning'].previous ?? 0,
resultatEfterFinansiellaPoster:
mapping.totals.resultatEfterFinansiellaPoster.previous ?? 0,
soliditetPct: 49.4,
},
{
year: '2023',
nettoomsattning: 300_000,
resultatEfterFinansiellaPoster: 25_000,
soliditetPct: 41.0,
},
],
flerarsPerioder: [
{ start: '2025-01-01', end: '2025-12-31' },
{ start: '2024-01-01', end: '2024-12-31' },
{ start: '2023-01-01', end: '2023-12-31' },
],
egetKapital: {
aktiekapital: { ib: 25_000, ub: 25_000 },
balanseratResultat: { ib: 60_000, ub: 100_000 },
aretsResultat: { ib: 40_000, ub: 120_000 },
totalt: { ib: 125_000, ub: 245_000 },
ovrigaPoster: { ib: 0, ub: 0 },
balanserasINyRakning: 40_000,
utdelning: 0,
forandringAktiekapital: 0,
ovrigForandringBalanserat: 0,
aretsResultatRorelse: 120_000,
},
resultatdisposition: {
balanseratResultat: 100_000,
overkursfond: 0,
aretsResultat: 120_000,
summa: 220_000,
utdelning: 0,
balanserasINyRakning: 220_000,
kommentar: 'Styrelsen föreslår att årets resultat balanseras i ny räkning.',
},
},
noter: [
{
number: 1,
title: 'Redovisnings- och värderingsprinciper',
body: 'Årsredovisningen är upprättad i enlighet med Årsredovisningslagen och Bokföringsnämndens allmänna råd BFNAR 2016:10 Årsredovisning i mindre företag (K2).',
},
{ number: 2, title: 'Medelantal anställda', body: 'Medelantalet anställda har uppgått till 2.' },
{ number: 3, title: 'Långfristiga skulder', body: 'Inga skulder förfaller senare än fem år efter balansdagen.' },
],
medelantalAnstallda: { current: 2, previous: 1 },
underskrifter: {
ort: 'Sundsvall',
dateringsdatum: '2026-02-20',
signers: [
{ firstName: 'Karl', lastName: 'Karlsson', role: 'Styrelseledamot', signedDate: '2026-02-20' },
{
firstName: 'Karin',
lastName: 'Olsson',
role: 'Verkställande direktör',
signedDate: '2026-02-21',
},
],
harVd: true,
},
faststallelseintyg: {
arsstammaDatum: '2026-03-15',
signerFirstName: 'Karl',
signerLastName: 'Karlsson',
signerRole: 'Styrelseledamot',
genereratDatum: '2026-02-25',
},
programvara: { namn: 'Accounted - Accounted', version: '2026.1' },
entryPointId: 'k2-ab-risbs-2024-09-12',
warnings: [],
}
}
@@ -0,0 +1,132 @@
/**
* Golden tests against the official Bolagsverket/taxonomier.se example
* documents in dev_docs/bokslut/exempel/. They pin our generator's
* conventions (context naming, entity scheme, fact attributes, hidden
* vallistor, fastställelseintyg structure) to what a known-accepted filing
* actually looks like.
*/
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { getRegistry } from '../taxonomy/registry'
import { generateK2IxbrlDocument } from '../document/k2-document'
import { makeInput } from './fixtures'
const EXEMPEL_PATH = resolve(
__dirname,
'../../../../dev_docs/bokslut/exempel/k2/faststalld-arsredovisning-exempel-1-rev20240214.xhtml',
)
const official = readFileSync(EXEMPEL_PATH, 'utf8')
const generated = generateK2IxbrlDocument(makeInput()).xhtml
function factNames(xhtml: string): Set<string> {
const names = new Set<string>()
for (const match of xhtml.matchAll(/<ix:(?:nonFraction|nonNumeric|tuple) [^>]*name="([\w:-]+)"/g)) {
names.add(match[1])
}
return names
}
describe('golden: official K2 exempel-1', () => {
it('every concept the official example tags exists in our 2024-09-12 registry, modulo known removals', () => {
const registry = getRegistry('k2-ab-2024-09-12')
const missing: string[] = []
for (const qname of factNames(official)) {
const local = qname.split(':')[1]
if (!registry.concepts[local]) missing.push(qname)
}
// The official example targets the 2021-10-31 taxonomy. Exactly two of
// its concepts were removed in 2024-09-12 (verified against the element
// list): the equity-change total now reuses EgetKapital, and the
// underskrift-ort concept was dropped with BFN's updated signing rules.
// Our generator accounts for both. Anything beyond these two means the
// registry lost coverage — investigate before widening this list.
expect(missing.sort()).toEqual([
'se-gen-base:ForandringEgetKapitalTotalt',
'se-gen-base:UndertecknandeArsredovisningOrt',
])
})
it('our generated document follows the official context conventions', () => {
for (const fragment of [
'<xbrli:identifier scheme="http://www.bolagsverket.se">',
'<xbrli:context id="period0">',
'<xbrli:context id="balans0">',
'<xbrli:unit id="SEK">',
'<xbrli:measure>iso4217:SEK</xbrli:measure>',
]) {
expect(official).toContain(fragment)
expect(generated).toContain(fragment)
}
})
it('our hidden vallista set matches the official example for a standard AB', () => {
const officialHidden =
official.match(/<ix:hidden>([\s\S]*?)<\/ix:hidden>/)?.[1] ?? ''
for (const concept of [
'se-cd-base:SprakHandlingUpprattadList',
'se-cd-base:LandForetagetsSateList',
'se-cd-base:RedovisningsvalutaHandlingList',
'se-cd-base:BeloppsformatList',
'se-gen-base:FinansiellRapportList',
'se-cd-base:RakenskapsarForstaDag',
'se-cd-base:RakenskapsarSistaDag',
]) {
expect(officialHidden).toContain(concept)
expect(generated).toContain(concept)
}
})
it('our fastställelseintyg structure mirrors the official one', () => {
for (const fragment of [
'se-bol-base:ArsstammaIntygande',
'se-bol-base:FaststallelseResultatBalansrakning',
'se-bol-base:Arsstamma',
'se-bol-base:IntygandeOriginalInnehall',
'se-bol-base:UnderskriftFaststallelseintygElektroniskt',
'se-bol-base:UnderskriftFaststallelseintygForetradareTilltalsnamn',
'se-bol-base:UnderskriftFastallelseintygDatum',
'ID_DATUM_UNDERTECKNANDE_FASTSTALLELSEINTYG',
]) {
expect(official).toContain(fragment)
expect(generated).toContain(fragment)
}
// Same continuation pattern joining intygande + original-innehåll.
expect(official).toMatch(/continuedAt="intygande_forts"/)
expect(generated).toMatch(/continuedAt="intygande_forts"/)
})
it('shared monetary facts carry the same attribute conventions as the official file', () => {
// Official: <ix:nonFraction contextRef=… name=… unitRef="SEK" … format="ixt:numspacecomma">
const officialNetto = official.match(
/<ix:nonFraction[^>]*name="se-gen-base:Nettoomsattning"[^>]*>/,
)?.[0]
const generatedNetto = generated.match(
/<ix:nonFraction[^>]*name="se-gen-base:Nettoomsattning"[^>]*>/,
)?.[0]
expect(officialNetto).toBeDefined()
expect(generatedNetto).toBeDefined()
for (const attr of ['unitRef="SEK"', 'format="ixt:numspacecomma"']) {
expect(officialNetto).toContain(attr)
expect(generatedNetto).toContain(attr)
}
// The official RR shows costs with the minus OUTSIDE the fact element and
// no sign attribute — verify we do the same for Personalkostnader.
expect(official).toMatch(/-<ix:nonFraction[^>]*name="se-gen-base:Personalkostnader"/)
expect(generated).toMatch(/<ix:nonFraction[^>]*name="se-gen-base:Personalkostnader"/)
expect(generated).not.toMatch(/name="se-gen-base:Personalkostnader"[^>]*sign=/)
})
it('underskrifter use the same tuple + member concepts as the official file', () => {
for (const fragment of [
'se-gaap-ext:UnderskriftArsredovisningForetradareTuple',
'se-gen-base:UnderskriftHandlingTilltalsnamn',
'se-gen-base:UnderskriftHandlingEfternamn',
'se-gen-base:UnderskriftHandlingRoll',
]) {
expect(official).toContain(fragment)
expect(generated).toContain(fragment)
}
})
})
@@ -0,0 +1,225 @@
import { describe, expect, it } from 'vitest'
import { XMLValidator } from 'fast-xml-parser'
import { mapTrialBalancesToK2 } from '../k2-mapper'
import { generateK2IxbrlDocument, embedKontrollsumma } from '../document/k2-document'
import { makeInput, CURRENT } from './fixtures'
describe('generateK2IxbrlDocument', () => {
const { xhtml, warnings } = generateK2IxbrlDocument(makeInput())
it('produces well-formed XML (XHTML)', () => {
const validation = XMLValidator.validate(xhtml)
expect(validation).toBe(true)
})
it('declares the container per TA §3/§4: UTF-8, title, programvara meta, no scripts', () => {
expect(xhtml.startsWith('<?xml version="1.0" encoding="utf-8"?>')).toBe(true)
expect(xhtml).toContain('<title>')
expect(xhtml).toContain('<meta name="programvara" content="Accounted - Accounted"/>')
expect(xhtml).toContain('<meta name="programversion" content="2026.1"/>')
expect(xhtml).not.toContain('<script')
expect(xhtml).not.toContain('&nbsp;')
expect(xhtml).toContain('xmlns="http://www.w3.org/1999/xhtml"')
})
it('references the K2 2024-09-12 risbs entry point + COA (fastställelseintyg) schema', () => {
expect(xhtml).toContain(
'http://xbrl.taxonomier.se/se/fr/gaap/k2-all/ab/risbs/2024-09-12/se-k2-ab-risbs-2024-09-12.xsd',
)
expect(xhtml).toContain(
'http://xbrl.taxonomier.se/se/fr/gaap/coa/rplc/2020-12-01/se-coa-rplc-2020-12-01.xsd',
)
})
it('declares contexts per TA §2.16 with the bolagsverket entity scheme', () => {
for (const ctxId of ['period0', 'period1', 'period2', 'balans0', 'balans1', 'balans2']) {
expect(xhtml).toContain(`<xbrli:context id="${ctxId}">`)
}
expect(xhtml).toContain('scheme="http://www.bolagsverket.se"')
expect(xhtml).toContain('<xbrli:startDate>2025-01-01</xbrli:startDate>')
expect(xhtml).toContain('<xbrli:instant>2025-12-31</xbrli:instant>')
expect(xhtml).toContain('<xbrli:measure>iso4217:SEK</xbrli:measure>')
})
it('hides vallistor + räkenskapsårets dagar in ix:hidden (TA §2.15/§3.9.3)', () => {
expect(xhtml).toContain('<ix:hidden>')
expect(xhtml).toContain('se-mem-base:SprakSvenskaMember')
expect(xhtml).toContain('se-mem-base:LandSverigeMember')
expect(xhtml).toContain('se-mem-base:ValutaSvenskaKronorMember')
expect(xhtml).toContain(
'se-mem-base:FinansiellRapportStyrelsenVerkstallandeDirektorenAvgerArsredovisningMember',
)
expect(xhtml).toContain('se-cd-base:RakenskapsarForstaDag')
expect(xhtml).toContain('se-cd-base:RakenskapsarSistaDag')
})
it('emits the fastställelseintyg with checksum-exclusion wrapper and the magic date id (TA §4.44.5)', () => {
expect(xhtml).toContain('id="id-innehall-faststallelseintyg"')
expect(xhtml).toContain('id="ID_DATUM_UNDERTECKNANDE_FASTSTALLELSEINTYG"')
expect(xhtml).toContain('se-bol-base:ArsstammaIntygande')
expect(xhtml).toContain('se-bol-base:IntygandeOriginalInnehall')
expect(xhtml).toMatch(/<ix:continuation id="intygande_forts">/)
// AGM date tagged + generation date in the signing-date element.
expect(xhtml).toContain('>2026-03-15</ix:nonNumeric>')
expect(xhtml).toMatch(/ID_DATUM_UNDERTECKNANDE_FASTSTALLELSEINTYG">2026-02-25</)
})
it('tags RR and BR amounts with correct attributes and presentational minus', () => {
// Nettoomsättning current year, whole kronor.
expect(xhtml).toMatch(
/<ix:nonFraction contextRef="period0" name="se-gen-base:Nettoomsattning" unitRef="SEK" decimals="0" scale="0" format="ixt:numspacecomma">1 000 000<\/ix:nonFraction>/,
)
// Costs: minus outside the element, positive fact value (no sign attr).
expect(xhtml).toMatch(
/<ix:nonFraction contextRef="period0" name="se-gen-base:Personalkostnader"[^>]*>525 660<\/ix:nonFraction>/,
)
expect(xhtml).not.toMatch(/name="se-gen-base:Personalkostnader"[^>]*sign="-"/)
// Deviating sign: avsättning till periodiseringsfond (credit concept, debit value).
expect(xhtml).toMatch(/name="se-gen-base:ForandringPeriodiseringsfond"[^>]*sign="-"/)
// BR totals present in both years (3001/3002 kontrollera requirements).
expect(xhtml).toMatch(/name="se-gen-base:Tillgangar"[^>]*>380 000/)
expect(xhtml).toMatch(/name="se-gen-base:EgetKapitalSkulder"[^>]*>380 000/)
expect(xhtml).toMatch(/contextRef="balans1" name="se-gen-base:Tillgangar"[^>]*>253 000/)
})
it('tags the underskrifter tuple with per-signer dates (TA §2.9.1)', () => {
expect(xhtml).toContain('se-gaap-ext:UnderskriftArsredovisningForetradareTuple')
const tilltalsnamn = xhtml.match(/name="se-gen-base:UnderskriftHandlingTilltalsnamn"/g) ?? []
expect(tilltalsnamn).toHaveLength(2)
const dates = xhtml.match(/name="se-gen-base:UndertecknandeDatum"[^>]*tupleRef="/g) ?? []
expect(dates).toHaveLength(2)
expect(xhtml).toContain('>2026-02-21</ix:nonNumeric>')
// Datering av årsredovisning tagged (FY starts after 2024-07-01).
expect(xhtml).toContain('se-gen-base:UndertecknandeArsredovisningDatum')
})
it('tags flerårsöversikt incl. soliditet as procent with scale -2 (TA §2.12)', () => {
expect(xhtml).toMatch(
/<ix:nonFraction contextRef="balans0" name="se-gen-base:Soliditet" unitRef="procent" decimals="3" scale="-2" format="ixt:numspacecomma">64,5<\/ix:nonFraction>/,
)
expect(xhtml).toMatch(/contextRef="period2" name="se-gen-base:Nettoomsattning"/)
})
it('tags medelantal anställda with the custom unit (TA §2.14)', () => {
expect(xhtml).toMatch(
/<ix:nonFraction contextRef="period0" name="se-gen-base:MedelantaletAnstallda" unitRef="antal-anstallda"/,
)
expect(xhtml).toContain('<xbrli:measure>se-k2-type:AntalAnstallda</xbrli:measure>')
})
it('tags resultatdisposition (BÖR: förslag) consistently with BR', () => {
expect(xhtml).toMatch(/name="se-gen-base:ForslagDispositionBalanserasINyRakning"[^>]*>220 000/)
expect(xhtml).toMatch(/name="se-gen-base:ForslagDisposition"[^>]*>220 000/)
expect(xhtml).toMatch(/name="se-gen-base:FrittEgetKapital"[^>]*>220 000/)
})
it('does not tag utdelning when none is proposed', () => {
expect(xhtml).not.toContain('ForslagDispositionUtdelning')
})
it('reports no warnings for the happy path', () => {
expect(warnings).toEqual([])
})
it('embedKontrollsumma inserts the meta tags into head (TA §4.5.2)', () => {
const withChecksum = embedKontrollsumma(xhtml, 'abc123==', 'SHA-256')
expect(withChecksum).toContain(
'<meta name="ixbrl.innehall.kontrollsumman" content="abc123=="/>',
)
expect(withChecksum).toContain(
'<meta name="ixbrl.innehall.kontrollsumman.algoritm" content="SHA-256"/>',
)
expect(XMLValidator.validate(withChecksum)).toBe(true)
})
it('keeps the document under the 5 MB limit (TA §4.2.1)', () => {
expect(Buffer.byteLength(xhtml, 'utf8')).toBeLessThan(5 * 1024 * 1024)
})
})
describe('generateK2IxbrlDocument — dividend + first year variants', () => {
it('tags förslag till utdelning when proposed (TA §2.9.2 BÖR)', () => {
const input = makeInput()
input.forvaltningsberattelse.resultatdisposition.utdelning = 50_000
input.forvaltningsberattelse.resultatdisposition.balanserasINyRakning = 170_000
const { xhtml } = generateK2IxbrlDocument(input)
expect(xhtml).toMatch(/name="se-gen-base:ForslagDispositionUtdelning"[^>]*>50 000/)
})
it('tags fri överkursfond as its own concept in the disposition — identical to BR (TA §2.7.3)', () => {
const input = makeInput()
input.br['Overkursfond'] = { current: 50_000, previous: 50_000 }
input.forvaltningsberattelse.resultatdisposition.overkursfond = 50_000
input.forvaltningsberattelse.resultatdisposition.summa = 270_000
input.forvaltningsberattelse.resultatdisposition.balanserasINyRakning = 270_000
const { xhtml } = generateK2IxbrlDocument(input)
// BR row + disposition row: same concept, same context, same value.
const facts =
xhtml.match(
/<ix:nonFraction contextRef="balans0" name="se-gen-base:Overkursfond"[^>]*>50 000<\/ix:nonFraction>/g,
) ?? []
expect(facts).toHaveLength(2)
// BalanseratResultat stays strictly balanserat (not balanserat + 2097).
const balanserat =
xhtml.match(
/<ix:nonFraction contextRef="balans0" name="se-gen-base:BalanseratResultat"[^>]*>([\d ]+)</g,
) ?? []
expect(balanserat.length).toBeGreaterThan(0)
for (const fact of balanserat) expect(fact).toContain('>100 000<')
})
it('renders a deviating cost row WITHOUT the presentational minus (sign="-" carries the deviation)', () => {
const input = makeInput()
// Net income on a cost line (credit balance on 5xxx) — deviating sign.
input.rr['OvrigaExternaKostnader'] = { current: -5_000, previous: null }
const { xhtml } = generateK2IxbrlDocument(input)
expect(xhtml).toMatch(/name="se-gen-base:OvrigaExternaKostnader"[^>]*sign="-"/)
expect(xhtml).not.toMatch(/<ix:nonFraction[^>]*name="se-gen-base:OvrigaExternaKostnader"/)
})
it('omits the per-signer date fact for unsigned requests instead of fabricating one', () => {
const input = makeInput()
input.underskrifter.signers[1].signedDate = null
const { xhtml } = generateK2IxbrlDocument(input)
const dates = xhtml.match(/name="se-gen-base:UndertecknandeDatum"/g) ?? []
expect(dates).toHaveLength(1)
// Both signers still appear with their names.
const names = xhtml.match(/name="se-gen-base:UnderskriftHandlingTilltalsnamn"/g) ?? []
expect(names).toHaveLength(2)
expect(XMLValidator.validate(xhtml)).toBe(true)
})
it('renders a placeholder instead of a fabricated AGM date when none is recorded', () => {
const input = makeInput()
input.faststallelseintyg.arsstammaDatum = null
const { xhtml } = generateK2IxbrlDocument(input)
expect(xhtml).not.toContain('name="se-bol-base:Arsstamma"')
expect(xhtml).toContain('[datum för årsstämma saknas]')
expect(XMLValidator.validate(xhtml)).toBe(true)
})
it('omits comparison columns for the first fiscal year', () => {
const mapping = mapTrialBalancesToK2(CURRENT, null)
const input = makeInput()
input.previousPeriod = null
input.isFirstFiscalYear = true
input.rr = mapping.rr
input.br = mapping.br
input.totals = mapping.totals
input.forvaltningsberattelse.flerarsoversikt = input.forvaltningsberattelse.flerarsoversikt.slice(0, 1)
input.forvaltningsberattelse.flerarsPerioder = input.forvaltningsberattelse.flerarsPerioder.slice(0, 1)
input.forvaltningsberattelse.egetKapital.balanserasINyRakning = 0
const { xhtml } = generateK2IxbrlDocument(input)
expect(xhtml).not.toContain('<xbrli:context id="period1">')
expect(xhtml).not.toContain('<xbrli:context id="balans1">')
expect(XMLValidator.validate(xhtml)).toBe(true)
})
it('throws on unknown concepts instead of emitting invalid facts', () => {
const input = makeInput()
input.rr['PahittatBegrepp'] = { current: 1, previous: null }
// Unknown keys in rr are ignored (only mapped posts are rendered) — the
// throw-path is covered via the writer itself in fact-writer tests.
expect(() => generateK2IxbrlDocument(input)).not.toThrow()
})
})
@@ -0,0 +1,219 @@
import { describe, expect, it } from 'vitest'
import { mapTrialBalancesToK2, type TrialBalanceRowLike } from '../k2-mapper'
import { CURRENT, PREVIOUS } from './fixtures'
const row = (
account: string,
name: string,
debit: number,
credit: number,
): TrialBalanceRowLike => ({
account_number: account,
account_name: name,
closing_debit: debit,
closing_credit: credit,
})
describe('mapTrialBalancesToK2', () => {
// Realistic post-bokslut pairs: full TB has class 38 zeroed + 2099 booked;
// preClosing TB has the RR accounts open (see fixtures.ts).
const result = mapTrialBalancesToK2(CURRENT, PREVIOUS)
it('maps RR posts with natural orientation for both years', () => {
expect(result.rr['Nettoomsattning']).toEqual({ current: 1_000_000, previous: 500_000 })
expect(result.rr['RavarorFornodenheterKostnader']).toEqual({
current: 200_000,
previous: 200_000,
})
expect(result.rr['OvrigaExternaKostnader']).toEqual({ current: 100_000, previous: 80_000 })
expect(result.rr['Personalkostnader']).toEqual({ current: 525_660, previous: 150_000 })
expect(
result.rr['AvskrivningarNedskrivningarMateriellaImmateriellaAnlaggningstillgangar'],
).toEqual({ current: 20_000, previous: 8_000 })
expect(result.rr['OvrigaRanteintakterLiknandeResultatposter']).toEqual({
current: 1_000,
previous: 0,
})
expect(result.rr['RantekostnaderLiknandeResultatposter']).toEqual({
current: 4_000,
previous: 2_000,
})
// 8811 avsättning = debit → credit-oriented concept goes negative.
expect(result.rr['ForandringPeriodiseringsfond']).toEqual({ current: -10_000, previous: 0 })
expect(result.rr['SkattAretsResultat']).toEqual({ current: 21_340, previous: 20_000 })
})
it('computes RR subtotals down to årets resultat', () => {
expect(result.totals.rorelseintakter.current).toBe(1_000_000)
expect(result.totals.rorelsekostnader.current).toBe(845_660)
expect(result.totals.rorelseresultat.current).toBe(154_340)
expect(result.totals.finansiellaPoster.current).toBe(-3_000)
expect(result.totals.resultatEfterFinansiellaPoster.current).toBe(151_340)
expect(result.totals.bokslutsdispositioner.current).toBe(-10_000)
expect(result.totals.resultatForeSkatt.current).toBe(141_340)
expect(result.totals.aretsResultat.current).toBe(120_000)
expect(result.totals.aretsResultat.previous).toBe(40_000)
})
it('maps BR posts and nets contra accounts (ack. avskrivningar)', () => {
expect(result.br['InventarierVerktygInstallationer']).toEqual({
current: 60_000,
previous: 68_000,
})
expect(result.br['Kundfordringar']).toEqual({ current: 50_000, previous: 0 })
expect(result.br['KassaBankExklRedovisningsmedel']).toEqual({
current: 270_000,
previous: 185_000,
})
expect(result.br['Aktiekapital']).toEqual({ current: 25_000, previous: 25_000 })
expect(result.br['BalanseratResultat']).toEqual({ current: 100_000, previous: 60_000 })
expect(result.br['AretsResultatEgetKapital']).toEqual({ current: 120_000, previous: 40_000 })
expect(result.br['Periodiseringsfonder']).toEqual({ current: 40_000, previous: 30_000 })
expect(result.br['Leverantorsskulder']).toEqual({ current: 30_000, previous: 25_000 })
expect(result.br['Skatteskulder']).toEqual({ current: 35_000, previous: 15_000 })
// Moms (2610) lands in övriga kortfristiga skulder.
expect(result.br['OvrigaKortfristigaSkulder']).toEqual({ current: 20_000, previous: 8_000 })
expect(result.br['UpplupnaKostnaderForutbetaldaIntakter']).toEqual({
current: 10_000,
previous: 50_000,
})
})
it('balances: Summa tillgångar == Summa eget kapital och skulder (3005)', () => {
expect(result.totals.tillgangar.current).toBe(380_000)
expect(result.totals.egetKapitalSkulder.current).toBe(380_000)
expect(result.totals.tillgangar.previous).toBe(253_000)
expect(result.totals.egetKapitalSkulder.previous).toBe(253_000)
expect(result.warnings).toEqual([])
expect(result.unmappedAccounts).toEqual([])
})
it('reconciles RR-result against BR 2099', () => {
expect(result.totals.aretsResultat.current).toBe(result.br['AretsResultatEgetKapital'].current)
})
// Regression for the year-end-closing split: a realistic post-bokslut TB
// pair must yield NON-ZERO RR concepts (from the pre-closing TB) AND a BR
// that ties (from the full TB). Mapping a single TB can never do both: the
// closing entry zeroes class 38, so RR concepts would collapse to 0.
it('regression: post-bokslut pair gives non-zero RR and a balancing BR', () => {
const res = mapTrialBalancesToK2(CURRENT, PREVIOUS)
expect(res.rr['Nettoomsattning'].current).toBe(1_000_000)
expect(res.totals.aretsResultat.current).toBe(120_000)
expect(res.totals.aretsResultat.current).toBe(res.br['AretsResultatEgetKapital'].current)
expect(res.totals.tillgangar.current).toBe(res.totals.egetKapitalSkulder.current)
expect(res.warnings).toEqual([])
// Sanity: the full TB really has the RR accounts zeroed — mapping it as
// the RR source would produce an all-zero resultaträkning.
const wrong = mapTrialBalancesToK2(
{ full: CURRENT.full, preClosing: CURRENT.full },
null,
)
expect(wrong.rr['Nettoomsattning'].current).toBe(0)
})
it('handles first fiscal year (no previous trial balance)', () => {
const firstYear = mapTrialBalancesToK2(CURRENT, null)
expect(firstYear.rr['Nettoomsattning']).toEqual({ current: 1_000_000, previous: null })
expect(firstYear.totals.tillgangar.previous).toBeNull()
})
it('flags unmapped accounts (their balance never reaches the BR)', () => {
const broken = {
full: CURRENT.full,
preClosing: [...CURRENT.preClosing, row('9999', 'Internkonto', 5_000, 0)],
}
const res = mapTrialBalancesToK2(broken, null)
expect(res.unmappedAccounts).toHaveLength(1)
expect(res.unmappedAccounts[0].account).toBe('9999')
expect(res.warnings.some((w) => w.includes('9999'))).toBe(true)
})
it('warns when the mapped balance sheet does not balance (3005)', () => {
const brokenFull = CURRENT.full.map((r2) =>
r2.account_number === '1930' ? { ...r2, closing_debit: 275_000 } : r2,
)
const res = mapTrialBalancesToK2({ full: brokenFull, preClosing: CURRENT.preClosing }, null)
expect(res.warnings.some((w) => w.includes('3005'))).toBe(true)
})
it('warns when 2099 is not booked (RR ≠ BR result)', () => {
// "Bokslut not run" = the full TB equals the pre-closing TB (no closing
// entry exists), so 2099 carries no result.
const res = mapTrialBalancesToK2(
{ full: CURRENT.preClosing, preClosing: CURRENT.preClosing },
null,
)
expect(res.warnings.some((w) => w.includes('2099'))).toBe(true)
})
it('routes lagerförändringar per K2 split (4910 → råvaror, 4960 → handelsvaror, 4940 → förändring av lager)', () => {
const rows = [
row('3010', 'Försäljning', 0, 100_000),
row('4910', 'Förändring lager råvaror', 0, 5_000),
row('4940', 'Förändring produkter i arbete', 0, 7_000),
row('4960', 'Förändring lager handelsvaror', 3_000, 0),
]
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
expect(res.rr['RavarorFornodenheterKostnader'].current).toBe(-5_000)
expect(
res.rr['ForandringLagerProdukterIArbeteFardigaVarorPagaendeArbetenAnnansRakning'].current,
).toBe(7_000)
expect(res.rr['HandelsvarorKostnader'].current).toBe(3_000)
})
})
describe('mapTrialBalancesToK2 — öre-rounding residual smoothing', () => {
it('absorbs a ±1 kr BR residual into the largest equity/liability post', () => {
// Assets round UP twice (.50 each), liabilities round once up once down:
// rounded Tillgångar 202 vs rounded EK+skulder 201 although the TB ties
// exactly at 201,00. The +1 residual lands in the largest post on the
// equity/liabilities side (Leverantörsskulder).
const rows = [
row('1510', 'Kundfordringar', 100.5, 0),
row('1930', 'Bank', 100.5, 0),
row('2440', 'Leverantörsskulder', 0, 100.75),
row('2510', 'Skatteskulder', 0, 100.25),
]
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
expect(res.totals.tillgangar.current).toBe(202)
expect(res.totals.egetKapitalSkulder.current).toBe(202)
expect(res.br['Leverantorsskulder'].current).toBe(102)
expect(res.br['Skatteskulder'].current).toBe(100)
expect(res.warnings).toEqual([])
})
it('absorbs a ±1 kr RR residual so the RR result equals 2099 exactly', () => {
// Two revenue posts of 100,25 each round to 100 + 100 = 200, while the
// booked 2099 (200,50) rounds to 201. The 1 residual is absorbed by the
// largest RR post (Nettoomsättning).
const preClosing = [
row('1930', 'Bank', 200.5, 0),
row('3010', 'Försäljning', 0, 100.25),
row('3990', 'Övriga intäkter', 0, 100.25),
]
const full = [
row('1930', 'Bank', 200.5, 0),
row('2099', 'Årets resultat', 0, 200.5),
row('3010', 'Försäljning', 100.25, 100.25),
row('3990', 'Övriga intäkter', 100.25, 100.25),
]
const res = mapTrialBalancesToK2({ full, preClosing }, null)
expect(res.rr['Nettoomsattning'].current).toBe(101)
expect(res.totals.aretsResultat.current).toBe(201)
expect(res.br['AretsResultatEgetKapital'].current).toBe(201)
expect(res.totals.tillgangar.current).toBe(res.totals.egetKapitalSkulder.current)
expect(res.warnings).toEqual([])
})
it('leaves residuals beyond ±1 kr alone and reports them', () => {
const rows = [
row('1930', 'Bank', 1_000, 0),
row('2440', 'Leverantörsskulder', 0, 990),
]
const res = mapTrialBalancesToK2({ full: rows, preClosing: rows }, null)
expect(res.br['Leverantorsskulder'].current).toBe(990)
expect(res.warnings.some((w) => w.includes('3005'))).toBe(true)
})
})
@@ -0,0 +1,176 @@
import { describe, expect, it } from 'vitest'
import { runPreflightChecks } from '../validate/rules'
import { makeInput } from './fixtures'
import type { IxbrlArsredovisningInput } from '../types'
const TODAY = '2026-06-10'
const codes = (input: IxbrlArsredovisningInput): string[] =>
runPreflightChecks(input, TODAY).issues.map((issue) => issue.code)
describe('runPreflightChecks', () => {
it('passes the happy-path fixture with no errors', () => {
const result = runPreflightChecks(makeInput(), TODAY)
expect(result.errors).toEqual([])
expect(result.ok).toBe(true)
})
it('1020/1035 — company identity', () => {
const input = makeInput()
input.company.name = ' '
input.company.orgNumber = '12345'
const found = codes(input)
expect(found).toContain('1020')
expect(found).toContain('1035')
})
it('1051 — förvaltningsberättelse missing', () => {
const input = makeInput()
input.forvaltningsberattelse.allmantOmVerksamheten = ''
expect(codes(input)).toContain('1051')
})
it('1107/1201/1214 — underskrifter completeness', () => {
const noSigners = makeInput()
noSigners.underskrifter.signers = []
expect(codes(noSigners)).toContain('1107')
const nameless = makeInput()
nameless.underskrifter.signers[0].lastName = ''
expect(codes(nameless)).toContain('1201')
const dateless = makeInput()
dateless.underskrifter.signers[0].signedDate = ''
expect(codes(dateless)).toContain('1214')
})
it('1214 — an unsigned signature request (signedDate null) BLOCKS filing', () => {
// build-input never fabricates a signing date: an unsigned request keeps
// signedDate null, and that must surface as a blocking error, not a warn.
const input = makeInput()
input.underskrifter.signers[1].signedDate = null
const result = runPreflightChecks(input, TODAY)
const hit = result.issues.find((issue) => issue.code === '1214')
expect(hit?.severity).toBe('error')
expect(result.ok).toBe(false)
})
it('1103/1169 — fastställelseintyg completeness', () => {
const input = makeInput()
input.faststallelseintyg.arsstammaDatum = null
input.faststallelseintyg.signerLastName = ''
const found = codes(input)
expect(found).toContain('1103')
expect(found).toContain('1169')
})
it('1103 — missing AGM date is a blocking error (no today-fallback)', () => {
const input = makeInput()
input.faststallelseintyg.arsstammaDatum = null
const result = runPreflightChecks(input, TODAY)
const hit = result.issues.find((issue) => issue.code === '1103')
expect(hit?.severity).toBe('error')
expect(result.ok).toBe(false)
// The date-ordering rules must not crash or misfire on the null date.
const found = result.issues.map((issue) => issue.code)
expect(found).not.toContain('1101')
expect(found).not.toContain('1178')
})
it('1015 — fiscal year not yet ended', () => {
const input = makeInput()
input.period = { start: '2026-01-01', end: '2026-12-31' }
expect(codes(input)).toContain('1015')
})
it('1046 — fiscal year longer than 18 months', () => {
const input = makeInput()
input.period = { start: '2024-01-01', end: '2025-12-31' }
expect(codes(input)).toContain('1046')
})
it('1101 — AGM on or before period end', () => {
const input = makeInput()
input.faststallelseintyg.arsstammaDatum = '2025-12-31'
expect(codes(input)).toContain('1101')
})
it('1178 — AGM in the future', () => {
const input = makeInput()
input.faststallelseintyg.arsstammaDatum = '2026-09-01'
expect(codes(input)).toContain('1178')
})
it('1114 — signature date inside the fiscal year', () => {
const input = makeInput()
input.underskrifter.signers[0].signedDate = '2025-12-30'
expect(codes(input)).toContain('1114')
})
it('1183 — AGM before board signatures', () => {
const input = makeInput()
input.faststallelseintyg.arsstammaDatum = '2026-02-20'
input.underskrifter.signers[1].signedDate = '2026-02-21'
expect(codes(input)).toContain('1183')
})
it('1165 — FI generated before AGM is warn-level only', () => {
const input = makeInput()
input.faststallelseintyg.genereratDatum = '2026-03-01' // AGM is 2026-03-15
const result = runPreflightChecks(input, TODAY)
const hit = result.issues.find((issue) => issue.code === '1165')
expect(hit?.severity).toBe('warn')
expect(result.ok).toBe(true)
})
it('3005 — unbalanced balance sheet blocks', () => {
const input = makeInput()
input.totals.tillgangar = { current: 100, previous: null }
const result = runPreflightChecks(input, TODAY)
expect(result.issues.map((issue) => issue.code)).toContain('3005')
expect(result.ok).toBe(false)
})
it('3006/3007 — comparison figures required except first year', () => {
const input = makeInput()
input.totals.tillgangar = { ...input.totals.tillgangar, previous: null }
input.totals.aretsResultat = { ...input.totals.aretsResultat, previous: null }
const found = codes(input)
expect(found).toContain('3006')
expect(found).toContain('3007')
const firstYear = makeInput()
firstYear.isFirstFiscalYear = true
firstYear.totals.tillgangar = { ...firstYear.totals.tillgangar, previous: null }
firstYear.totals.aretsResultat = { ...firstYear.totals.aretsResultat, previous: null }
const firstYearCodes = codes(firstYear)
expect(firstYearCodes).not.toContain('3006')
expect(firstYearCodes).not.toContain('3007')
})
it('ACC-2099 — unbooked result blocks', () => {
const input = makeInput()
input.br['AretsResultatEgetKapital'] = { current: 0, previous: null }
expect(codes(input)).toContain('ACC-2099')
})
it('ACC-DISP/ACC-UTD — resultatdisposition consistency', () => {
const broken = makeInput()
broken.forvaltningsberattelse.resultatdisposition.balanserasINyRakning = 1
expect(codes(broken)).toContain('ACC-DISP')
const negative = makeInput()
negative.forvaltningsberattelse.resultatdisposition.summa = -5_000
negative.forvaltningsberattelse.resultatdisposition.utdelning = 1_000
negative.forvaltningsberattelse.resultatdisposition.balanserasINyRakning = -6_000
expect(codes(negative)).toContain('ACC-UTD')
})
it('carries mapper warnings as warn-level issues', () => {
const input = makeInput()
input.warnings = ['Konto 9999 täcks inte av K2-mappningen']
const result = runPreflightChecks(input, TODAY)
expect(result.warnings.some((issue) => issue.message.includes('9999'))).toBe(true)
expect(result.ok).toBe(true)
})
})
+386
View File
@@ -0,0 +1,386 @@
/**
* Assemble IxbrlArsredovisningInput for a fiscal period.
*
* Reuses the same sources as the PDF builder (buildArsredovisningData) for
* narrative texts, noter and flerårsöversikt, and adds what iXBRL needs on
* top: trial balances for BOTH years mapped to risbs concepts
* (jämförelsesiffror kontrollera 3006/3007), per-signer dates from the
* signature flow, and the fastställelseintyg undertecknare.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { buildArsredovisningData } from '@/lib/bokslut/arsredovisning/build-data'
import { listSignatureRequests } from '@/lib/bokslut/arsredovisning/signature-service'
import { computeMedelantalAnstallda } from '@/lib/salary/medelantal'
import { mapTrialBalancesToK2, type TrialBalancePair } from './k2-mapper'
import { resolveEntryPoint } from './taxonomy/entry-points'
import type {
EgetKapitalForandring,
FlerarsRow,
IxbrlArsredovisningInput,
IxbrlSigner,
Resultatdisposition,
} from './types'
/** TA §4.3.44.3.5: "<leverantör> - <produkt>", version "<huvud>.<revision>". */
export const PROGRAMVARA_NAMN = 'Accounted - Accounted'
export const PROGRAMVARA_VERSION = '2026.1'
export interface BuildIxbrlOptions {
/** Undertecknare of the fastställelseintyg (chosen in the wizard). When
* omitted, the first signed board member is used. */
undertecknare?: { firstName: string; lastName: string; role: string }
/** Proposed dividend in SEK (0 = balansera allt). */
proposedDividend?: number
/** Override "today" for deterministic tests (ISO date). */
todayIso?: string
}
function splitName(fullName: string): { firstName: string; lastName: string } {
const parts = fullName.trim().split(/\s+/)
if (parts.length === 1) return { firstName: parts[0], lastName: parts[0] }
return { firstName: parts.slice(0, -1).join(' '), lastName: parts[parts.length - 1] }
}
export async function buildIxbrlInput(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
options: BuildIxbrlOptions = {},
): Promise<IxbrlArsredovisningInput> {
const warnings: string[] = []
// Two TB variants per year (see TrialBalancePair): the FULL trial balance
// (year-end closing included → 2099 booked, class 38 zeroed) drives the
// BR; the PRE-CLOSING trial balance (excludeYearEndClosing — the same split
// lib/reports' generateIncomeStatement uses) drives the RR. A single TB can
// never serve both: with bokslut booked every RR concept would map to 0,
// without it the BR would not tie.
const [pdfData, periodRow, currentTbFull, currentTbPreClosing, signatureRequests] =
await Promise.all([
buildArsredovisningData(supabase, companyId, fiscalPeriodId),
supabase
.from('fiscal_periods')
.select('id, period_start, period_end, previous_period_id')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single(),
generateTrialBalance(supabase, companyId, fiscalPeriodId),
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeYearEndClosing: true }),
listSignatureRequests(supabase, companyId, fiscalPeriodId),
])
if (periodRow.error || !periodRow.data) throw new Error('Fiscal period not found')
const period = periodRow.data
if (pdfData.accounting_framework !== 'k2') {
throw new Error(
'Digital inlämning stöds ännu inte för K3 — generera PDF eller vänta på K3-stödet.',
)
}
const entryPoint = resolveEntryPoint('k2')
// Previous period: trial balances for jämförelsesiffror (same full/
// pre-closing split as the current year).
let previousPeriod: { start: string; end: string } | null = null
let previousTb: TrialBalancePair | null = null
if (period.previous_period_id) {
const { data: prev } = await supabase
.from('fiscal_periods')
.select('id, period_start, period_end')
.eq('id', period.previous_period_id)
.eq('company_id', companyId)
.maybeSingle()
if (prev) {
previousPeriod = { start: prev.period_start, end: prev.period_end }
try {
const [prevFull, prevPreClosing] = await Promise.all([
generateTrialBalance(supabase, companyId, prev.id),
generateTrialBalance(supabase, companyId, prev.id, { excludeYearEndClosing: true }),
])
previousTb = { full: prevFull.rows, preClosing: prevPreClosing.rows }
} catch {
warnings.push(
'Jämförelsesiffror kunde inte hämtas för föregående räkenskapsår — balans- och resultaträkning visas utan jämförelseår (kontrollera-kod 3006/3007 kan utlösas).',
)
previousPeriod = null
}
}
}
const mapping = mapTrialBalancesToK2(
{ full: currentTbFull.rows, preClosing: currentTbPreClosing.rows },
previousTb,
)
warnings.push(...mapping.warnings)
// ---- flerårsöversikt (reuse PDF rows; whole SEK) -------------------------
// PDF rows are oldest-first; iXBRL columns newest-first. Each row needs the
// matching fiscal-period range so the document can declare period2/3 +
// balans2/3 contexts explicitly.
const { data: allPeriods } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('company_id', companyId)
.order('period_start', { ascending: false })
const periodByName = new Map(
((allPeriods ?? []) as Array<{ name: string; period_start: string; period_end: string }>).map(
(p) => [p.name, p],
),
)
const flerarsoversikt: FlerarsRow[] = []
const flerarsPerioder: Array<{ start: string; end: string }> = []
for (const row of [...pdfData.forvaltningsberattelse.flerarsoversikt].reverse()) {
const match = periodByName.get(row.year)
if (!match) continue // can't tag a year without a known period range
flerarsoversikt.push({
year: row.year,
nettoomsattning: Math.round(row.net_revenue),
resultatEfterFinansiellaPoster: Math.round(row.result_after_financial),
soliditetPct: row.soliditet_pct,
})
flerarsPerioder.push({ start: match.period_start, end: match.period_end })
}
// Column 0 must be the current period and column 1 the previous one —
// the document reuses period0/period1 contexts for them. Anything else
// (e.g. a missed periodByName lookup shifting the rows) means the period
// chain is inconsistent; drop the table rather than tag amounts against
// the wrong period.
const flerarsMisaligned =
(flerarsPerioder.length > 0 && flerarsPerioder[0].start !== period.period_start) ||
(flerarsPerioder.length > 1 &&
previousPeriod !== null &&
flerarsPerioder[1].start !== previousPeriod.start)
if (flerarsMisaligned) {
warnings.push(
'Flerårsöversikten kunde inte knytas till räkenskapsperioderna — tabellen utelämnas ur iXBRL-dokumentet.',
)
flerarsoversikt.length = 0
flerarsPerioder.length = 0
}
// Duplicate-fact consistency (TA §2.7.3): the flerårsöversikt repeats
// Nettoomsattning / ResultatEfterFinansiellaPoster in the same contexts
// (period0/period1) as the RR, and repeated facts must be value-identical
// or Bolagsverket rejects the filing. The PDF rows are computed from the
// income statement (ALL class-3 revenue), while nettoomsättning per ÅRL is
// strictly 30003799 — so the current and previous year columns are
// overridden with the mapper outputs. Older years have no RR facts and
// keep the PDF values.
if (flerarsoversikt.length > 0) {
flerarsoversikt[0] = {
...flerarsoversikt[0],
nettoomsattning: mapping.rr['Nettoomsattning']?.current ?? 0,
resultatEfterFinansiellaPoster: mapping.totals.resultatEfterFinansiellaPoster.current,
}
if (flerarsoversikt.length > 1 && previousPeriod !== null) {
flerarsoversikt[1] = {
...flerarsoversikt[1],
nettoomsattning: mapping.rr['Nettoomsattning']?.previous ?? 0,
resultatEfterFinansiellaPoster:
mapping.totals.resultatEfterFinansiellaPoster.previous ?? 0,
}
}
}
// ---- eget kapital-förändring ---------------------------------------------
const br = mapping.br
const at = (concept: string): { ib: number; ub: number } => ({
ib: br[concept]?.previous ?? 0,
ub: br[concept]?.current ?? 0,
})
const aktiekapital = at('Aktiekapital')
const balanserat = at('BalanseratResultat')
const aretsRes = at('AretsResultatEgetKapital')
const totalt = {
ib: mapping.totals.egetKapital.previous ?? 0,
ub: mapping.totals.egetKapital.current,
}
const ovrigaPoster = {
ib:
totalt.ib - aktiekapital.ib - balanserat.ib - aretsRes.ib,
ub:
totalt.ub - aktiekapital.ub - balanserat.ub - aretsRes.ub,
}
// Movement derivation: föregående års resultat balanseras; vad som därutöver
// lämnat balanserat resultat antas vara utdelning (vanligaste fallet).
const balanserasINyRakning = aretsRes.ib
const balanseratResidual = balanserat.ub - (balanserat.ib + balanserasINyRakning)
const utdelning = balanseratResidual < 0 ? -balanseratResidual : 0
const ovrigForandringBalanserat = balanseratResidual > 0 ? balanseratResidual : 0
const egetKapital: EgetKapitalForandring = {
aktiekapital,
balanseratResultat: balanserat,
aretsResultat: aretsRes,
totalt,
ovrigaPoster,
balanserasINyRakning,
utdelning,
forandringAktiekapital: aktiekapital.ub - aktiekapital.ib,
ovrigForandringBalanserat,
aretsResultatRorelse: aretsRes.ub,
}
// ---- resultatdisposition --------------------------------------------------
// BalanseratResultat is tagged in BR and the eget kapital-table for the
// same context — the disposition row must carry the identical value
// (TA §2.7.3), so fri överkursfond (2097) is its own row tagged with the
// separate Overkursfond concept instead of being folded into balanserat.
const proposedDividend = Math.max(0, Math.round(options.proposedDividend ?? 0))
const dispBalanserat = br['BalanseratResultat']?.current ?? 0
const dispOverkursfond = br['Overkursfond']?.current ?? 0
const dispArets = br['AretsResultatEgetKapital']?.current ?? 0
const dispSumma = mapping.totals.frittEgetKapital.current
if (proposedDividend > dispSumma) {
warnings.push(
`Föreslagen utdelning (${proposedDividend} kr) överstiger fritt eget kapital (${dispSumma} kr).`,
)
}
const resultatdisposition: Resultatdisposition = {
balanseratResultat: dispBalanserat,
overkursfond: dispOverkursfond,
aretsResultat: dispArets,
summa: dispSumma,
utdelning: proposedDividend,
balanserasINyRakning: dispSumma - proposedDividend,
kommentar: pdfData.forvaltningsberattelse.resultatdisposition || null,
}
// ---- underskrifter ---------------------------------------------------------
// Every signature request becomes a signer row (the board must appear in
// the document), but ONLY actually-signed requests get a date — an unsigned
// request keeps signedDate null. Legal dates are never fabricated: the
// missing date renders as an omitted fact in the preview and preflight 1214
// blocks the submission path until everyone has signed.
const signedRequests = signatureRequests.filter((request) => request.status === 'signed')
const today = options.todayIso ?? new Date().toISOString().slice(0, 10)
const signers: IxbrlSigner[] = signatureRequests.map((request) => {
const { firstName, lastName } = splitName(request.signer_name)
return {
firstName,
lastName,
role: request.role || null,
signedDate: request.signed_at ? request.signed_at.slice(0, 10) : null,
}
})
if (signers.length === 0) {
warnings.push(
'Inga underskrifter är registrerade — årsredovisningen måste skrivas under av styrelsen (och ev. VD) innan inlämning (kontrollera-kod 1107/1201).',
)
}
if (signedRequests.length !== signatureRequests.length) {
warnings.push('Alla underskriftsförfrågningar är inte signerade ännu.')
}
const harVd = signers.some((signer) => /verkställande direktör|^vd$/i.test(signer.role ?? ''))
const latestSignatureDate = signers.reduce<string | null>(
(latest, signer) =>
signer.signedDate !== null && (latest === null || signer.signedDate > latest)
? signer.signedDate
: latest,
null,
)
// ---- fastställelseintyg ----------------------------------------------------
// A missing AGM date is NEVER replaced with today's date — it stays null,
// the document renders a placeholder and preflight 1103 blocks filing
// (mirrors Bolagsverket kontrollera 1103).
const agmDate = pdfData.forvaltningsberattelse.agm_date
if (!agmDate) {
warnings.push(
'Datum för årsstämma saknas — fastställelseintyget kan inte fyllas i (kontrollera-kod 1103).',
)
}
const fallbackSigner = signers[0] ?? { firstName: '', lastName: '', role: null }
const undertecknare = options.undertecknare ?? {
firstName: fallbackSigner.firstName,
lastName: fallbackSigner.lastName,
role: fallbackSigner.role ?? 'Styrelseledamot',
}
if (!undertecknare.firstName) {
warnings.push('Undertecknare av fastställelseintyget saknas (kontrollera-kod 1169).')
}
// ---- allmänt om verksamheten: ensure säte is mentioned ---------------------
let allmant = pdfData.forvaltningsberattelse.description
if (pdfData.company.city && !/säte/i.test(allmant)) {
allmant = `${allmant}\n\nBolaget har sitt säte i ${pdfData.company.city}.`
}
// ---- medelantal anställda ---------------------------------------------------
// Compute BOTH years with the real FTE helper (the same one the PDF note
// uses) over the employees table. The note-prose regex stays only as a
// last-resort fallback when the employees query fails.
let medelantalAnstallda: { current: number; previous: number | null }
const { data: employeeRows, error: employeesError } = await supabase
.from('employees')
.select('employment_start, employment_end, employment_degree')
.eq('company_id', companyId)
if (employeesError) {
medelantalAnstallda = extractMedelantal(pdfData.noter, null)
} else {
const employees = (employeeRows ?? []) as Array<{
employment_start: string
employment_end: string | null
employment_degree: number
}>
medelantalAnstallda = {
current: computeMedelantalAnstallda(employees, period.period_start, period.period_end),
previous: previousPeriod
? computeMedelantalAnstallda(employees, previousPeriod.start, previousPeriod.end)
: null,
}
}
return {
company: {
name: pdfData.company.name,
orgNumber: pdfData.company.org_number,
city: pdfData.company.city,
},
period: { start: period.period_start, end: period.period_end },
previousPeriod,
isFirstFiscalYear: previousPeriod === null,
rr: mapping.rr,
br: mapping.br,
totals: mapping.totals,
forvaltningsberattelse: {
allmantOmVerksamheten: allmant,
vasentligaHandelser: pdfData.forvaltningsberattelse.important_events,
flerarsoversikt,
flerarsPerioder,
egetKapital,
resultatdisposition,
},
noter: pdfData.noter.map((note) => ({ number: note.number, title: note.title, body: note.body })),
medelantalAnstallda,
underskrifter: {
ort: pdfData.company.city ?? '',
dateringsdatum: latestSignatureDate,
signers,
harVd,
},
faststallelseintyg: {
arsstammaDatum: agmDate ?? null,
signerFirstName: undertecknare.firstName,
signerLastName: undertecknare.lastName,
signerRole: undertecknare.role,
genereratDatum: today,
},
programvara: { namn: PROGRAMVARA_NAMN, version: PROGRAMVARA_VERSION },
entryPointId: entryPoint.id,
warnings: [...pdfData.warnings, ...warnings],
}
}
/** Pull the FTE figure out of the medelantal note body ("…uppgått till X."). */
function extractMedelantal(
noter: Array<{ title: string; body: string }>,
previous: number | null,
): { current: number; previous: number | null } {
const note = noter.find((n) => /medelantal.*anst/i.test(n.title))
if (!note) return { current: 0, previous }
const match = note.body.match(/uppgått till\s+([\d,.]+)/i)
if (!match) return { current: 0, previous }
const value = Number(match[1].replace(',', '.'))
return { current: Number.isFinite(value) ? value : 0, previous }
}
+349
View File
@@ -0,0 +1,349 @@
/**
* Inline-XBRL fact emission primitives.
*
* A FactWriter instance is scoped to one document. It
* - validates every emitted fact against the generated taxonomy registry
* (unknown concept / wrong periodType / wrong datatype throws at
* generation time instead of earning a 4001/4008 from Bolagsverket),
* - tracks which contexts and units were actually referenced so the
* ix:header only declares what the document uses (TA §2.17),
* - collects ix:hidden facts (vallistor per TA §2.15/§3.9.3).
*
* Naming follows TA §2.16: contexts period0/period1/, balans0/balans1/,
* units SEK / procent / antal-anstallda.
*/
import type { TaxonomyEntryPoint } from '../taxonomy/entry-points'
import {
mustGetConcept,
type TaxonomyRegistry,
type TaxonomyConcept,
} from '../taxonomy/registry'
import { el, escapeText, formatPercentAbs, formatSekAbs, selfClosing, type Attrs } from './xml'
interface ContextDef {
id: string
kind: 'duration' | 'instant'
startDate?: string
endDate?: string
instant?: string
}
const UNIT_MEASURES: Record<string, string> = {
SEK: 'iso4217:SEK',
procent: 'xbrli:pure',
'antal-anstallda': 'se-k2-type:AntalAnstallda',
}
export interface MoneyOptions {
/** Render a presentational minus before the element (costs in RR). The
* fact value itself stays oriented to the concept's natural balance. */
displayMinus?: boolean
/** Show the amount wrapped in a span with this class (sum/total styling). */
spanClass?: string
id?: string
tupleRef?: string
order?: string
}
export class FactWriter {
private readonly contexts = new Map<string, ContextDef>()
private readonly usedContexts = new Set<string>()
private readonly usedUnits = new Set<string>()
private readonly hiddenFacts: string[] = []
private tupleCounter = 0
constructor(
private readonly entryPoint: TaxonomyEntryPoint,
private readonly registry: TaxonomyRegistry,
private readonly entityOrgNumber: string,
) {}
// ---- contexts -----------------------------------------------------------
addDurationContext(id: string, startDate: string, endDate: string): void {
this.contexts.set(id, { id, kind: 'duration', startDate, endDate })
}
addInstantContext(id: string, instant: string): void {
this.contexts.set(id, { id, kind: 'instant', instant })
}
hasContext(id: string): boolean {
return this.contexts.has(id)
}
private resolveContext(id: string, concept: TaxonomyConcept, name: string): void {
const ctx = this.contexts.get(id)
if (!ctx) throw new Error(`Fact ${name}: context "${id}" is not declared`)
if (concept.periodType === 'duration' && ctx.kind !== 'duration') {
throw new Error(`Fact ${name}: duration concept tagged with instant context "${id}"`)
}
if (concept.periodType === 'instant' && ctx.kind !== 'instant') {
throw new Error(`Fact ${name}: instant concept tagged with duration context "${id}"`)
}
this.usedContexts.add(id)
}
private qname(concept: TaxonomyConcept, name: string): string {
if (!this.entryPoint.namespaces[concept.ns]) {
throw new Error(`Fact ${name}: namespace prefix "${concept.ns}" missing from entry point`)
}
return `${concept.ns}:${name}`
}
// ---- numeric facts ------------------------------------------------------
/**
* Whole-SEK monetary fact. `value` is oriented to the concept's natural
* balance (credit-positive for credit concepts, debit-positive for debit
* concepts); negative values get the `sign="-"` attribute per TA §2.10.6.
*/
money(name: string, contextRef: string, value: number, opts: MoneyOptions = {}): string {
const concept = mustGetConcept(this.registry, name)
if (concept.dataType !== 'xbrli:monetaryItemType') {
throw new Error(`Fact ${name}: money() used on ${concept.dataType}`)
}
this.resolveContext(contextRef, concept, name)
this.usedUnits.add('SEK')
const rounded = Math.round(value)
const attrs: Attrs = {
contextRef,
name: this.qname(concept, name),
unitRef: 'SEK',
decimals: '0',
scale: '0',
format: 'ixt:numspacecomma',
sign: rounded < 0 ? '-' : null,
id: opts.id ?? null,
tupleRef: opts.tupleRef ?? null,
order: opts.order ?? null,
}
let markup = el('ix:nonFraction', attrs, formatSekAbs(rounded))
if (opts.spanClass) markup = el('span', { class: opts.spanClass }, markup)
// Presentational minus is an XOR: a cost row (displayMinus) with its
// natural sign shows "X", but a DEVIATING cost (negative fact value,
// sign="-" — i.e. net income on a cost line) displays positive per the
// RR convention; conversely a deviating income row displays "X".
if ((opts.displayMinus ?? false) !== rounded < 0) markup = `${markup}`
return markup
}
/** Percent fact (xbrli:pure) per TA §2.12 — text "35,5", scale 2. */
percent(name: string, contextRef: string, valuePct: number): string {
const concept = mustGetConcept(this.registry, name)
if (concept.dataType !== 'xbrli:pureItemType') {
throw new Error(`Fact ${name}: percent() used on ${concept.dataType}`)
}
this.resolveContext(contextRef, concept, name)
this.usedUnits.add('procent')
const attrs: Attrs = {
contextRef,
name: this.qname(concept, name),
unitRef: 'procent',
decimals: '3',
scale: '-2',
format: 'ixt:numspacecomma',
sign: valuePct < 0 ? '-' : null,
}
const markup = el('ix:nonFraction', attrs, formatPercentAbs(valuePct))
return valuePct < 0 ? `${markup}` : markup
}
/** Antal-fact (medelantal anställda) per TA §2.14, one decimal. */
antalAnstallda(name: string, contextRef: string, value: number): string {
const concept = mustGetConcept(this.registry, name)
this.resolveContext(contextRef, concept, name)
this.usedUnits.add('antal-anstallda')
const isWhole = Number.isInteger(value)
return el(
'ix:nonFraction',
{
contextRef,
name: this.qname(concept, name),
unitRef: 'antal-anstallda',
decimals: isWhole ? '0' : '1',
scale: '0',
format: 'ixt:numspacecomma',
},
isWhole ? String(value) : value.toFixed(1).replace('.', ','),
)
}
// ---- non-numeric facts --------------------------------------------------
/** Plain-text fact; content is escaped. */
textPlain(
name: string,
contextRef: string,
content: string,
opts: { id?: string; tupleRef?: string; order?: string; continuedAt?: string } = {},
): string {
return this.nonNumeric(name, contextRef, escapeText(content), opts)
}
/** Fact wrapping pre-built XHTML (e.g. <p>…</p> paragraphs). */
textHtml(
name: string,
contextRef: string,
innerXhtml: string,
opts: { id?: string; continuedAt?: string } = {},
): string {
return this.nonNumeric(name, contextRef, innerXhtml, opts)
}
/** ISO date fact (TA §2.11 format YYYY-MM-DD — no format attribute). */
date(
name: string,
contextRef: string,
isoDate: string,
opts: { id?: string; tupleRef?: string; order?: string } = {},
): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(isoDate)) {
throw new Error(`Fact ${name}: "${isoDate}" is not an ISO date`)
}
return this.nonNumeric(name, contextRef, isoDate, opts)
}
private nonNumeric(
name: string,
contextRef: string,
inner: string,
opts: { id?: string; tupleRef?: string; order?: string; continuedAt?: string },
): string {
const concept = mustGetConcept(this.registry, name)
if (concept.kind !== 'item') throw new Error(`Fact ${name}: is a tuple, not an item`)
this.resolveContext(contextRef, concept, name)
return el(
'ix:nonNumeric',
{
contextRef,
name: this.qname(concept, name),
id: opts.id ?? null,
tupleRef: opts.tupleRef ?? null,
order: opts.order ?? null,
continuedAt: opts.continuedAt ?? null,
},
inner,
)
}
// ---- vallistor (hidden enumeration facts, TA §2.15 / §3.9.3) ------------
hiddenEnum(name: string, contextRef: string, memberQName: string): void {
const concept = mustGetConcept(this.registry, name)
this.resolveContext(contextRef, concept, name)
const memberLocal = memberQName.split(':')[1]
if (memberLocal) {
// Members live in the registry too (se-mem-base) — validate when known.
const member = this.registry.concepts[memberLocal]
if (!member) throw new Error(`Vallista ${name}: unknown member ${memberQName}`)
}
this.hiddenFacts.push(
el('ix:nonNumeric', { name: this.qname(concept, name), contextRef }, escapeText(memberQName)),
)
}
/** Hidden plain fact (räkenskapsårets första/sista dag in allmän info). */
hiddenDate(name: string, contextRef: string, isoDate: string): void {
this.hiddenFacts.push(this.date(name, contextRef, isoDate))
}
/** Hidden boolean fact (e.g. ArsredovisningEjTaggadInformation, TA §2.22). */
hiddenBoolean(name: string, contextRef: string, value: boolean): void {
this.hiddenFacts.push(this.nonNumeric(name, contextRef, value ? 'true' : 'false', {}))
}
/** Hidden tuple + members (avskrivningsprincip notes etc.). */
hiddenTuple(tupleName: string, members: Array<{ name: string; context: string; value: string }>): void {
const tupleId = this.declareTupleId(tupleName)
const parts: string[] = [this.tupleDeclaration(tupleName, tupleId)]
members.forEach((member, index) => {
parts.push(
this.textPlain(member.name, member.context, member.value, {
tupleRef: tupleId,
order: `${index + 1}.0`,
}),
)
})
this.hiddenFacts.push(parts.join('\n'))
}
// ---- tuples --------------------------------------------------------------
declareTupleId(tupleName: string): string {
const tuple = this.registry.tuples[tupleName]
if (!tuple) throw new Error(`Tuple ${tupleName} not in taxonomy registry`)
this.tupleCounter += 1
return `${tupleName}${this.tupleCounter}`
}
tupleDeclaration(tupleName: string, tupleId: string): string {
const tuple = this.registry.tuples[tupleName]
if (!tuple) throw new Error(`Tuple ${tupleName} not in taxonomy registry`)
return selfClosing('ix:tuple', { name: `${tuple.ns}:${tupleName}`, tupleID: tupleId })
}
// ---- header assembly -----------------------------------------------------
/**
* Render the full ix:header (hidden + references + resources). Call after
* the body has been generated so only referenced contexts/units exist.
*/
renderHeader(): string {
const hidden =
this.hiddenFacts.length > 0 ? el('ix:hidden', {}, this.hiddenFacts.join('\n')) : ''
const references = el(
'ix:references',
{},
this.entryPoint.schemaRefs
.map((href) => selfClosing('link:schemaRef', { 'xlink:type': 'simple', 'xlink:href': href }))
.join('\n'),
)
const contextXml: string[] = []
for (const id of [...this.usedContexts].sort()) {
const ctx = this.contexts.get(id)
if (!ctx) continue
const period =
ctx.kind === 'duration'
? el(
'xbrli:period',
{},
el('xbrli:startDate', {}, ctx.startDate ?? '') +
el('xbrli:endDate', {}, ctx.endDate ?? ''),
)
: el('xbrli:period', {}, el('xbrli:instant', {}, ctx.instant ?? ''))
contextXml.push(
el(
'xbrli:context',
{ id },
el(
'xbrli:entity',
{},
el(
'xbrli:identifier',
{ scheme: 'http://www.bolagsverket.se' },
escapeText(this.entityOrgNumber),
),
) + period,
),
)
}
const unitXml: string[] = []
for (const unitId of [...this.usedUnits].sort()) {
unitXml.push(
el('xbrli:unit', { id: unitId }, el('xbrli:measure', {}, UNIT_MEASURES[unitId])),
)
}
const resources = el('ix:resources', {}, contextXml.join('\n') + '\n' + unitXml.join('\n'))
return el(
'div',
{ style: 'display:none' },
el('ix:header', {}, [hidden, references, resources].filter(Boolean).join('\n')),
)
}
}
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
/**
* Minimal XML/XHTML emission helpers for the iXBRL generator.
*
* Open decision #3 in the implementation plan (React renderToStaticMarkup vs
* dedicated builder) is resolved in favour of a dedicated builder: TA §3.2
* requires *valid XHTML* with only the five XML escape entities, and React's
* HTML serializer makes no such guarantee (named entities, void-element
* forms, attribute quirks). A hand-rolled escaper keeps the output auditable
* byte-for-byte against the official examples.
*/
/** Escape text content using only the five XML entities (TA §3.2.4). */
export function escapeText(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
/** Escape an attribute value (double-quoted attributes). */
export function escapeAttr(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
export type Attrs = Record<string, string | number | null | undefined>
export function attrString(attrs: Attrs): string {
const parts: string[] = []
for (const [key, value] of Object.entries(attrs)) {
if (value === null || value === undefined) continue
parts.push(`${key}="${escapeAttr(String(value))}"`)
}
return parts.length > 0 ? ' ' + parts.join(' ') : ''
}
export function el(tag: string, attrs: Attrs, children: string): string {
return `<${tag}${attrString(attrs)}>${children}</${tag}>`
}
export function selfClosing(tag: string, attrs: Attrs): string {
return `<${tag}${attrString(attrs)}/>`
}
/**
* Turn user-authored multi-line text into XHTML paragraphs. Blank lines split
* paragraphs; single newlines become <br/>. All content is escaped.
*/
export function paragraphs(text: string, className?: string): string {
const classAttr = className ? ` class="${escapeAttr(className)}"` : ''
return text
.split(/\r?\n\s*\r?\n/)
.map((block) => block.trim())
.filter((block) => block.length > 0)
.map(
(block) =>
`<p${classAttr}>${block
.split(/\r?\n/)
.map((line) => escapeText(line))
.join('<br/>')}</p>`,
)
.join('\n')
}
/**
* Format a whole-SEK amount for ixt:numspacecomma groups of three digits
* separated by REGULAR spaces (U+0020; NBSP fails the transform regex).
* The sign is never part of the transformed text negative handling lives
* on the ix:nonFraction `sign` attribute / presentational minus outside.
*/
export function formatSekAbs(value: number): string {
const abs = Math.abs(Math.round(value))
return abs.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
}
/** Percent with one decimal for ixt:numspacecomma ("35,5"). */
export function formatPercentAbs(value: number): string {
const abs = Math.abs(value)
return abs.toFixed(1).replace('.', ',')
}
+840
View File
@@ -0,0 +1,840 @@
/**
* BAS trial balance K2 risbs concept amounts.
*
* Maps account-level closing balances (current + previous fiscal year) onto
* the K2 AB `risbs` uppställningsform (full kostnadsslagsindelad RR + full
* BR). Account ranges follow BAS 2025/2026 as shipped in
* lib/bookkeeping/bas-data/ and are cross-checked against the INK2R mappings
* in lib/reports/ink2/ink2-engine.ts (same ÅRL structure, coarser posts).
*
* Sign conventions: every produced amount is oriented to the concept's
* natural balance credit-balance concepts are positive when the underlying
* accounts carry a net credit; debit concepts positive on net debit. The
* document layer adds presentational minuses for cost rows and `sign="-"`
* for genuinely deviating values (TA §2.10.6).
*/
import type { ConceptAmount, ConceptAmounts } from './types'
export interface TrialBalanceRowLike {
account_number: string
account_name: string
closing_debit: number
closing_credit: number
}
/**
* Per-year trial balance pair. The year-end closing entry (source_type
* 'year_end') zeroes every class 38 account into 2099, so a single TB can
* never serve both statements:
* - `full` (including the closing entry) carries the booked 2099 and the
* correct equity it drives the BR concepts.
* - `preClosing` (generateTrialBalance with excludeYearEndClosing: true)
* still has the RR accounts open it drives the RR concepts.
* Mirrors how lib/reports' generateIncomeStatement/generateBalanceSheet split
* the same source.
*/
export interface TrialBalancePair {
full: TrialBalanceRowLike[]
preClosing: TrialBalanceRowLike[]
}
interface Range {
start: string
end: string
}
interface PostMapping {
concept: string
/** Orientation of the produced amount. */
balance: 'debit' | 'credit'
ranges: Range[]
}
const r = (start: string, end: string): Range => ({ start, end })
/** RR — kostnadsslagsindelad (risbs), in uppställningsform order. */
export const K2_RR_MAPPINGS: PostMapping[] = [
{ concept: 'Nettoomsattning', balance: 'credit', ranges: [r('3000', '3799')] },
{
concept: 'ForandringLagerProdukterIArbeteFardigaVarorPagaendeArbetenAnnansRakning',
balance: 'credit',
// Lagerförändring for own production + pågående arbeten. Changes in
// råvarulager (49104929) belong to RavarorFornodenheterKostnader and
// handelsvaror (49604969) to HandelsvarorKostnader per K2 RR.
ranges: [r('4930', '4959'), r('4970', '4999')],
},
{ concept: 'AktiveratArbeteEgenRakning', balance: 'credit', ranges: [r('3800', '3899')] },
{ concept: 'OvrigaRorelseintakter', balance: 'credit', ranges: [r('3900', '3999')] },
{
concept: 'RavarorFornodenheterKostnader',
balance: 'debit',
ranges: [r('4000', '4599'), r('4700', '4899'), r('4910', '4929')],
},
{
concept: 'HandelsvarorKostnader',
balance: 'debit',
ranges: [r('4600', '4699'), r('4960', '4969')],
},
{ concept: 'OvrigaExternaKostnader', balance: 'debit', ranges: [r('5000', '6999')] },
{ concept: 'Personalkostnader', balance: 'debit', ranges: [r('7000', '7699')] },
{
concept: 'AvskrivningarNedskrivningarMateriellaImmateriellaAnlaggningstillgangar',
balance: 'debit',
ranges: [r('7800', '7899')],
},
{
concept: 'NedskrivningarOmsattningstillgangarUtoverNormalaNedskrivningar',
balance: 'debit',
ranges: [r('7700', '7799')],
},
{ concept: 'OvrigaRorelsekostnader', balance: 'debit', ranges: [r('7900', '7999')] },
{ concept: 'ResultatAndelarKoncernforetag', balance: 'credit', ranges: [r('8000', '8099')] },
{
concept: 'ResultatAndelarIntresseforetagGemensamtStyrda',
balance: 'credit',
ranges: [r('8100', '8199')],
},
{
concept: 'ResultatOvrigaforetagAgarintresse',
balance: 'credit',
ranges: [r('8200', '8269')],
},
{
concept: 'ResultatOvrigaFinansiellaAnlaggningstillgangar',
balance: 'credit',
ranges: [r('8270', '8299')],
},
{
concept: 'OvrigaRanteintakterLiknandeResultatposter',
balance: 'credit',
ranges: [r('8300', '8399')],
},
{
concept: 'NedskrivningarFinansiellaAnlaggningstillgangarKortfristigaPlaceringar',
balance: 'debit',
ranges: [r('8500', '8599')],
},
{
concept: 'RantekostnaderLiknandeResultatposter',
balance: 'debit',
ranges: [r('8400', '8499')],
},
{ concept: 'ErhallnaKoncernbidrag', balance: 'credit', ranges: [r('8820', '8829')] },
{ concept: 'LamnadeKoncernbidrag', balance: 'debit', ranges: [r('8830', '8839')] },
{ concept: 'ForandringPeriodiseringsfond', balance: 'credit', ranges: [r('8810', '8819')] },
{ concept: 'ForandringOveravskrivningar', balance: 'credit', ranges: [r('8850', '8859')] },
{
concept: 'OvrigaBokslutsdispositioner',
balance: 'credit',
ranges: [r('8840', '8849'), r('8860', '8899')],
},
{ concept: 'SkattAretsResultat', balance: 'debit', ranges: [r('8900', '8949')] },
{ concept: 'OvrigaSkatter', balance: 'debit', ranges: [r('8950', '8989')] },
]
/** BR — full balansräkning (risbs), in uppställningsform order. */
export const K2_BR_MAPPINGS: PostMapping[] = [
{ concept: 'TecknatEjInbetaltKapital', balance: 'debit', ranges: [r('1690', '1699')] },
// Immateriella anläggningstillgångar
{
concept: 'KoncessionerPatentLicenserVarumarkenLiknandeRattigheter',
balance: 'debit',
ranges: [r('1000', '1059'), r('1090', '1099')],
},
{ concept: 'HyresratterLiknandeRattigheter', balance: 'debit', ranges: [r('1060', '1069')] },
{ concept: 'Goodwill', balance: 'debit', ranges: [r('1070', '1079')] },
{
concept: 'ForskottImmateriellaAnlaggningstillgangar',
balance: 'debit',
ranges: [r('1080', '1089')],
},
// Materiella anläggningstillgångar
{
concept: 'ByggnaderMark',
balance: 'debit',
ranges: [r('1100', '1119'), r('1130', '1179'), r('1190', '1199')],
},
{
concept: 'MaskinerAndraTekniskaAnlaggningar',
balance: 'debit',
ranges: [r('1210', '1219')],
},
{
concept: 'InventarierVerktygInstallationer',
balance: 'debit',
ranges: [r('1220', '1279')],
},
{
concept: 'ForbattringsutgifterAnnansFastighet',
balance: 'debit',
ranges: [r('1120', '1129')],
},
{
concept: 'OvrigaMateriellaAnlaggningstillgangar',
balance: 'debit',
ranges: [r('1290', '1299')],
},
{
concept: 'PagaendeNyanlaggningarForskottMateriellaAnlaggningstillgangar',
balance: 'debit',
ranges: [r('1180', '1189'), r('1280', '1289')],
},
// Finansiella anläggningstillgångar
{ concept: 'AndelarKoncernforetag', balance: 'debit', ranges: [r('1310', '1319')] },
{
concept: 'FordringarKoncernforetagLangfristiga',
balance: 'debit',
ranges: [r('1320', '1329')],
},
{
concept: 'AndelarIntresseforetagGemensamtStyrdaForetag',
balance: 'debit',
ranges: [r('1330', '1335'), r('1338', '1339')],
},
{
concept: 'FordringarIntresseforetagGemensamtStyrdaForetagLangfristiga',
balance: 'debit',
ranges: [r('1340', '1345'), r('1348', '1349')],
},
{ concept: 'AgarintressenOvrigaForetag', balance: 'debit', ranges: [r('1336', '1337')] },
{
concept: 'FordringarOvrigaForetagAgarintresseLangfristiga',
balance: 'debit',
ranges: [r('1346', '1347')],
},
{
concept: 'AndraLangfristigaVardepappersinnehav',
balance: 'debit',
ranges: [r('1350', '1359'), r('1380', '1389')],
},
{ concept: 'LanDelagareNarstaende', balance: 'debit', ranges: [r('1360', '1369')] },
{
concept: 'AndraLangfristigaFordringar',
balance: 'debit',
ranges: [r('1370', '1379'), r('1390', '1399')],
},
// Varulager m.m.
{ concept: 'LagerRavarorFornodenheter', balance: 'debit', ranges: [r('1400', '1439')] },
{ concept: 'LagerVarorUnderTillverkning', balance: 'debit', ranges: [r('1440', '1449')] },
{ concept: 'LagerFardigaVarorHandelsvaror', balance: 'debit', ranges: [r('1450', '1469')] },
{
concept: 'PagaendeArbetenAnnansRakningOmsattningstillgangar',
balance: 'debit',
ranges: [r('1470', '1479')],
},
{ concept: 'ForskottTillLeverantorer', balance: 'debit', ranges: [r('1480', '1489')] },
{ concept: 'OvrigaLagertillgangar', balance: 'debit', ranges: [r('1490', '1499')] },
// Kortfristiga fordringar
{
concept: 'Kundfordringar',
balance: 'debit',
ranges: [r('1500', '1559'), r('1590', '1599')],
},
{
concept: 'FordringarKoncernforetagKortfristiga',
balance: 'debit',
ranges: [r('1560', '1569'), r('1660', '1669')],
},
{
concept: 'FordringarIntresseforetagGemensamtStyrdaForetagKortfristiga',
balance: 'debit',
ranges: [r('1570', '1572'), r('1670', '1672')],
},
{
concept: 'FordringarOvrigaforetagAgarintresseKortfristiga',
balance: 'debit',
ranges: [r('1573', '1579'), r('1673', '1679')],
},
{
concept: 'OvrigaFordringarKortfristiga',
balance: 'debit',
ranges: [r('1580', '1589'), r('1600', '1619'), r('1630', '1659'), r('1680', '1689')],
},
{ concept: 'UpparbetadEjFaktureradIntakt', balance: 'debit', ranges: [r('1620', '1629')] },
{
concept: 'ForutbetaldaKostnaderUpplupnaIntakter',
balance: 'debit',
ranges: [r('1700', '1799')],
},
// Kortfristiga placeringar
{
concept: 'AndelarKoncernforetagKortfristiga',
balance: 'debit',
ranges: [r('1860', '1869')],
},
{
concept: 'OvrigaKortfristigaPlaceringar',
balance: 'debit',
ranges: [r('1800', '1859'), r('1870', '1899')],
},
// Kassa och bank
{ concept: 'KassaBankExklRedovisningsmedel', balance: 'debit', ranges: [r('1900', '1989')] },
{ concept: 'Redovisningsmedel', balance: 'debit', ranges: [r('1990', '1999')] },
// Eget kapital
{ concept: 'Aktiekapital', balance: 'credit', ranges: [r('2080', '2081')] },
{ concept: 'EjRegistreratAktiekapital', balance: 'credit', ranges: [r('2082', '2082')] },
{ concept: 'OverkursfondBunden', balance: 'credit', ranges: [r('2087', '2087')] },
{ concept: 'Uppskrivningsfond', balance: 'credit', ranges: [r('2085', '2085')] },
// 2083/2084 (medlems-/förlagsinsatser) and 2088/2089 (övriga bundna fonder)
// lack own risbs posts for AB — closest bundet-EK post is Reservfond; the
// mapper flags them for review when present.
{
concept: 'Reservfond',
balance: 'credit',
ranges: [r('2083', '2084'), r('2086', '2086'), r('2088', '2089')],
},
{ concept: 'Overkursfond', balance: 'credit', ranges: [r('2097', '2097')] },
{
concept: 'BalanseratResultat',
balance: 'credit',
ranges: [r('2090', '2096'), r('2098', '2098')],
},
{ concept: 'AretsResultatEgetKapital', balance: 'credit', ranges: [r('2099', '2099')] },
// Obeskattade reserver
{ concept: 'Periodiseringsfonder', balance: 'credit', ranges: [r('2100', '2129')] },
{ concept: 'AckumuleradeOveravskrivningar', balance: 'credit', ranges: [r('2150', '2159')] },
{
concept: 'OvrigaObeskattadeReserver',
balance: 'credit',
ranges: [r('2130', '2149'), r('2160', '2199')],
},
// Avsättningar
{
concept: 'AvsattningarPensionerLiknandeForpliktelserEnligtLag',
balance: 'credit',
ranges: [r('2210', '2219')],
},
{
concept: 'OvrigaAvsattningarPensionerLiknandeForpliktelser',
balance: 'credit',
ranges: [r('2220', '2229')],
},
{ concept: 'OvrigaAvsattningar', balance: 'credit', ranges: [r('2230', '2299')] },
// Långfristiga skulder
{ concept: 'Obligationslan', balance: 'credit', ranges: [r('2300', '2329')] },
{ concept: 'CheckrakningskreditLangfristig', balance: 'credit', ranges: [r('2330', '2339')] },
{
concept: 'OvrigaLangfristigaSkulderKreditinstitut',
balance: 'credit',
ranges: [r('2340', '2359')],
},
{ concept: 'SkulderKoncernforetagLangfristiga', balance: 'credit', ranges: [r('2360', '2369')] },
{
concept: 'SkulderIntresseforetagGemensamtStyrdaForetagLangfristiga',
balance: 'credit',
ranges: [r('2370', '2372')],
},
{
concept: 'SkulderOvrigaForetagAgarintresseLangfristiga',
balance: 'credit',
ranges: [r('2373', '2379')],
},
{ concept: 'OvrigaLangfristigaSkulder', balance: 'credit', ranges: [r('2380', '2399')] },
// Kortfristiga skulder — ranges per BAS 2025/2026 as shipped in
// lib/bookkeeping/bas-data/class-2-equity-liabilities.ts (2410 = andra
// kortfristiga låneskulder, 2420 = förskott från kunder, 2430 = pågående
// arbeten, 2450 = fakturerad ej upparbetad, 2460 = koncern, 2470 =
// intresse/gem styrda/ägarintresse, 2480 = kontokredit, 2492 = växelskulder).
{ concept: 'ForskottFranKunder', balance: 'credit', ranges: [r('2420', '2429')] },
{ concept: 'CheckrakningskreditKortfristig', balance: 'credit', ranges: [r('2480', '2489')] },
{
concept: 'OvrigaKortfristigaSkulderKreditinstitut',
balance: 'credit',
ranges: [r('2410', '2419')],
},
{
concept: 'PagaendeArbetenAnnansRakningKortfristigaSkulder',
balance: 'credit',
ranges: [r('2430', '2439')],
},
{ concept: 'FaktureradEjUpparbetadIntakt', balance: 'credit', ranges: [r('2450', '2459')] },
{ concept: 'Leverantorsskulder', balance: 'credit', ranges: [r('2440', '2449')] },
{ concept: 'Vaxelskulder', balance: 'credit', ranges: [r('2492', '2492')] },
{ concept: 'SkulderKoncernforetagKortfristiga', balance: 'credit', ranges: [r('2460', '2469')] },
{
concept: 'SkulderIntresseforetagGemensamtStyrdaForetagKortfristiga',
balance: 'credit',
ranges: [r('2470', '2472')],
},
{
concept: 'SkulderOvrigaForetagAgarintresseKortfristiga',
balance: 'credit',
ranges: [r('2473', '2479')],
},
{ concept: 'Skatteskulder', balance: 'credit', ranges: [r('2500', '2599')] },
{
concept: 'OvrigaKortfristigaSkulder',
balance: 'credit',
ranges: [r('2400', '2409'), r('2490', '2491'), r('2493', '2499'), r('2600', '2899')],
},
{
concept: 'UpplupnaKostnaderForutbetaldaIntakter',
balance: 'credit',
ranges: [r('2900', '2999')],
},
]
/** Accounts that map to a "nearest" post and deserve a manual-review nudge. */
const RECLASSIFIED_ACCOUNTS: Record<string, string> = {
'2083': 'Medlemsinsatser (2083) redovisas under Reservfond — granska klassificeringen.',
'2084': 'Förlagsinsatser (2084) redovisas under Reservfond — granska klassificeringen.',
'2088': 'Fond för yttre underhåll (2088) redovisas under Reservfond — granska klassificeringen.',
'2089': 'Fond för utvecklingsutgifter (2089) redovisas under Reservfond — granska klassificeringen (K2 tillåter inte aktivering av egenupparbetade utgifter).',
}
export interface K2MappingResult {
rr: ConceptAmounts
br: ConceptAmounts
/** Computed RR subtotals + BR totals, same orientation rules. */
totals: {
rorelseintakter: ConceptAmount
rorelsekostnader: ConceptAmount
rorelseresultat: ConceptAmount
finansiellaPoster: ConceptAmount
resultatEfterFinansiellaPoster: ConceptAmount
bokslutsdispositioner: ConceptAmount
resultatForeSkatt: ConceptAmount
aretsResultat: ConceptAmount
anlaggningstillgangar: ConceptAmount
immateriellaAnlaggningstillgangar: ConceptAmount
materiellaAnlaggningstillgangar: ConceptAmount
finansiellaAnlaggningstillgangar: ConceptAmount
varulager: ConceptAmount
kortfristigaFordringar: ConceptAmount
kortfristigaPlaceringar: ConceptAmount
kassaBank: ConceptAmount
omsattningstillgangar: ConceptAmount
tillgangar: ConceptAmount
bundetEgetKapital: ConceptAmount
frittEgetKapital: ConceptAmount
egetKapital: ConceptAmount
obeskattadeReserver: ConceptAmount
avsattningar: ConceptAmount
langfristigaSkulder: ConceptAmount
kortfristigaSkulder: ConceptAmount
egetKapitalSkulder: ConceptAmount
}
warnings: string[]
/** Accounts with balances that no mapping covered (should be none). */
unmappedAccounts: Array<{ account: string; name: string; balance: number }>
}
function netBalance(row: TrialBalanceRowLike, orientation: 'debit' | 'credit'): number {
const net = row.closing_debit - row.closing_credit
return orientation === 'debit' ? net : -net
}
function inRanges(account: string, ranges: Range[]): boolean {
return ranges.some((range) => account >= range.start && account <= range.end)
}
function sumForMapping(rows: TrialBalanceRowLike[], mapping: PostMapping): number {
let total = 0
for (const row of rows) {
if (inRanges(row.account_number, mapping.ranges)) {
total += netBalance(row, mapping.balance)
}
}
return Math.round(total)
}
function amount(
mapping: PostMapping,
current: TrialBalanceRowLike[],
previous: TrialBalanceRowLike[] | null,
): ConceptAmount {
return {
current: sumForMapping(current, mapping),
previous: previous ? sumForMapping(previous, mapping) : null,
}
}
function add(a: ConceptAmount, b: ConceptAmount, sign = 1): ConceptAmount {
return {
current: a.current + sign * b.current,
previous:
a.previous === null && b.previous === null
? null
: (a.previous ?? 0) + sign * (b.previous ?? 0),
}
}
const ZERO: ConceptAmount = { current: 0, previous: null }
function sumConcepts(amounts: ConceptAmounts, concepts: string[], signs?: number[]): ConceptAmount {
let total: ConceptAmount = { current: 0, previous: null }
concepts.forEach((concept, index) => {
total = add(total, amounts[concept] ?? ZERO, signs?.[index] ?? 1)
})
return total
}
/**
* Map current + previous trial balance pairs onto the K2 risbs posts.
*
* RR concepts come from the pre-closing TB (year-end closing excluded the
* closing entry zeroes class 38); BR concepts come from the full TB (the
* closing entry books 2099). See TrialBalancePair.
*
* `previous = null` first fiscal year (jämförelsesiffror omitted,
* which kontrollera 3006/3007 accepts only for year one).
*/
export function mapTrialBalancesToK2(
current: TrialBalancePair,
previous: TrialBalancePair | null,
): K2MappingResult {
const warnings: string[] = []
const rr: ConceptAmounts = {}
const br: ConceptAmounts = {}
for (const mapping of K2_RR_MAPPINGS) {
rr[mapping.concept] = amount(mapping, current.preClosing, previous?.preClosing ?? null)
}
for (const mapping of K2_BR_MAPPINGS) {
br[mapping.concept] = amount(mapping, current.full, previous?.full ?? null)
}
// Reclassification + unmapped sweep over balance-carrying accounts. Both TB
// variants are swept: the full TB exposes unmapped BR accounts, the
// pre-closing TB exposes unmapped RR accounts (zeroed in the full TB).
const allMappings = [...K2_RR_MAPPINGS, ...K2_BR_MAPPINGS]
const unmappedAccounts: K2MappingResult['unmappedAccounts'] = []
const seenReclass = new Set<string>()
for (const rows of [
current.full,
current.preClosing,
previous?.full ?? [],
previous?.preClosing ?? [],
]) {
for (const row of rows) {
const balance = Math.round(row.closing_debit - row.closing_credit)
if (balance === 0) continue
const reclass = RECLASSIFIED_ACCOUNTS[row.account_number]
if (reclass && !seenReclass.has(row.account_number)) {
seenReclass.add(row.account_number)
warnings.push(reclass)
}
const covered = allMappings.some((mapping) => inRanges(row.account_number, mapping.ranges))
if (!covered && !unmappedAccounts.some((u) => u.account === row.account_number)) {
unmappedAccounts.push({ account: row.account_number, name: row.account_name, balance })
}
}
}
for (const u of unmappedAccounts) {
warnings.push(
`Konto ${u.account} (${u.name}) med saldo ${u.balance} kr täcks inte av K2-mappningen — beloppet saknas i årsredovisningen.`,
)
}
let totals = computeTotals(rr, br)
// ---- öre-rounding residual smoothing ------------------------------------
// Every tagged post is independently rounded to whole SEK, so the sum of
// rounded posts can drift by ±1 kr from the rounded exact total even though
// the underlying trial balance ties to the öre. Bolagsverket compares the
// tagged totals exactly (kontrollera 3005), so a ±1 kr residual is
// distributed back into a line item instead of tolerated. Deterministic
// rule, per year:
// - BR: the residual (Tillgångar Eget kapital och skulder) is added to
// the largest post (by absolute value) on the equity/liabilities side,
// excluding AretsResultatEgetKapital, whose value must stay equal to
// the booked 2099 / RR result (ties broken toward the LATER post in
// the uppställningsform, so liabilities win over aktiekapital).
// - RR: the residual (RR-resultat konto 2099) is absorbed by the
// largest RR post: cost posts are increased by the residual, income
// posts decreased (ties broken toward the EARLIER post).
// Residuals beyond ±1 kr are real bookkeeping errors and are left for the
// exact balance checks below.
let smoothedAny = false
for (const field of ['current', 'previous'] as const) {
if (field === 'previous' && previous === null) continue
const rrSmoothed = smoothRrResidual(rr, br, totals, field)
const brSmoothed = smoothBrResidual(br, totals, field)
smoothedAny = smoothedAny || rrSmoothed || brSmoothed
}
if (smoothedAny) totals = computeTotals(rr, br)
// Internal consistency: the RR result must equal BR 2099 (årets resultat)
// EXACTLY — if the year-end closing hasn't booked the result yet, warn
// (the BR will not balance against RR otherwise). Rounding residuals were
// smoothed above, so any remaining difference is a data problem.
const brResult = br['AretsResultatEgetKapital'] ?? ZERO
if (totals.aretsResultat.current !== brResult.current) {
warnings.push(
`Årets resultat enligt resultaträkningen (${totals.aretsResultat.current} kr) stämmer inte med konto 2099 (${brResult.current} kr). Kontrollera att bokslutet är genomfört (resultatdisposition bokad).`,
)
}
if (totals.tillgangar.current !== totals.egetKapitalSkulder.current) {
warnings.push(
`Balansräkningen balanserar inte: Summa tillgångar ${totals.tillgangar.current} kr ≠ Summa eget kapital och skulder ${totals.egetKapitalSkulder.current} kr (kontrollera-kod 3005).`,
)
}
return { rr, br, totals, warnings, unmappedAccounts }
}
function pickLargestConcept(
amounts: ConceptAmounts,
mappings: PostMapping[],
field: 'current' | 'previous',
exclude: ReadonlySet<string>,
tieBreak: 'first' | 'last',
): string | null {
let best: string | null = null
let bestAbs = -1
for (const mapping of mappings) {
if (exclude.has(mapping.concept)) continue
const value = amounts[mapping.concept]?.[field]
if (value === null || value === undefined || value === 0) continue
const abs = Math.abs(value)
if (abs > bestAbs || (abs === bestAbs && tieBreak === 'last')) {
best = mapping.concept
bestAbs = abs
}
}
return best
}
function adjustConcept(
amounts: ConceptAmounts,
concept: string,
field: 'current' | 'previous',
delta: number,
): void {
const existing = amounts[concept] ?? { current: 0, previous: null }
amounts[concept] = { ...existing, [field]: (existing[field] ?? 0) + delta }
}
/** Absorb a ±1 kr rounding residual between the RR result and BR 2099. */
function smoothRrResidual(
rr: ConceptAmounts,
br: ConceptAmounts,
totals: K2MappingResult['totals'],
field: 'current' | 'previous',
): boolean {
const target = br['AretsResultatEgetKapital']?.[field]
const result = totals.aretsResultat[field]
if (target === null || target === undefined || result === null) return false
const diff = result - target
if (diff === 0 || Math.abs(diff) > 1) return false
const concept = pickLargestConcept(rr, K2_RR_MAPPINGS, field, new Set(), 'first')
if (!concept) return false
const balance = K2_RR_MAPPINGS.find((mapping) => mapping.concept === concept)?.balance
// Debit (cost) posts enter the result with weight 1, credit (income)
// posts with +1 — adjust so the recomputed result lands on the 2099 value.
adjustConcept(rr, concept, field, balance === 'debit' ? diff : -diff)
return true
}
/** Equity/liability-side posts (everything from Aktiekapital onwards). */
const EQ_LIAB_MAPPINGS = K2_BR_MAPPINGS.slice(
K2_BR_MAPPINGS.findIndex((mapping) => mapping.concept === 'Aktiekapital'),
)
/** Absorb a ±1 kr rounding residual between the two BR sides. */
function smoothBrResidual(
br: ConceptAmounts,
totals: K2MappingResult['totals'],
field: 'current' | 'previous',
): boolean {
const assets = totals.tillgangar[field]
const eqLiab = totals.egetKapitalSkulder[field]
if (assets === null || eqLiab === null) return false
const diff = assets - eqLiab
if (diff === 0 || Math.abs(diff) > 1) return false
const concept =
pickLargestConcept(br, EQ_LIAB_MAPPINGS, field, new Set(['AretsResultatEgetKapital']), 'last') ??
'BalanseratResultat'
// All equity/liability posts are credit-oriented: adding the residual
// raises the eget kapital och skulder side to match Tillgångar.
adjustConcept(br, concept, field, diff)
return true
}
function computeTotals(rr: ConceptAmounts, br: ConceptAmounts): K2MappingResult['totals'] {
// ---- RR subtotals (credit-positive orientation) ----
const rorelseintakter = sumConcepts(rr, [
'Nettoomsattning',
'ForandringLagerProdukterIArbeteFardigaVarorPagaendeArbetenAnnansRakning',
'AktiveratArbeteEgenRakning',
'OvrigaRorelseintakter',
])
const rorelsekostnader = sumConcepts(rr, [
'RavarorFornodenheterKostnader',
'HandelsvarorKostnader',
'OvrigaExternaKostnader',
'Personalkostnader',
'AvskrivningarNedskrivningarMateriellaImmateriellaAnlaggningstillgangar',
'NedskrivningarOmsattningstillgangarUtoverNormalaNedskrivningar',
'OvrigaRorelsekostnader',
])
const rorelseresultat = add(rorelseintakter, rorelsekostnader, -1)
const finansiellaPoster = sumConcepts(
rr,
[
'ResultatAndelarKoncernforetag',
'ResultatAndelarIntresseforetagGemensamtStyrda',
'ResultatOvrigaforetagAgarintresse',
'ResultatOvrigaFinansiellaAnlaggningstillgangar',
'OvrigaRanteintakterLiknandeResultatposter',
'NedskrivningarFinansiellaAnlaggningstillgangarKortfristigaPlaceringar',
'RantekostnaderLiknandeResultatposter',
],
[1, 1, 1, 1, 1, -1, -1],
)
const resultatEfterFinansiellaPoster = add(rorelseresultat, finansiellaPoster)
const bokslutsdispositioner = sumConcepts(
rr,
[
'ErhallnaKoncernbidrag',
'LamnadeKoncernbidrag',
'ForandringPeriodiseringsfond',
'ForandringOveravskrivningar',
'OvrigaBokslutsdispositioner',
],
[1, -1, 1, 1, 1],
)
const resultatForeSkatt = add(resultatEfterFinansiellaPoster, bokslutsdispositioner)
const skatter = sumConcepts(rr, ['SkattAretsResultat', 'OvrigaSkatter'])
const aretsResultat = add(resultatForeSkatt, skatter, -1)
// ---- BR totals ----
const immateriella = sumConcepts(br, [
'KoncessionerPatentLicenserVarumarkenLiknandeRattigheter',
'HyresratterLiknandeRattigheter',
'Goodwill',
'ForskottImmateriellaAnlaggningstillgangar',
])
const materiella = sumConcepts(br, [
'ByggnaderMark',
'MaskinerAndraTekniskaAnlaggningar',
'InventarierVerktygInstallationer',
'ForbattringsutgifterAnnansFastighet',
'OvrigaMateriellaAnlaggningstillgangar',
'PagaendeNyanlaggningarForskottMateriellaAnlaggningstillgangar',
])
const finansiella = sumConcepts(br, [
'AndelarKoncernforetag',
'FordringarKoncernforetagLangfristiga',
'AndelarIntresseforetagGemensamtStyrdaForetag',
'FordringarIntresseforetagGemensamtStyrdaForetagLangfristiga',
'AgarintressenOvrigaForetag',
'FordringarOvrigaForetagAgarintresseLangfristiga',
'AndraLangfristigaVardepappersinnehav',
'LanDelagareNarstaende',
'AndraLangfristigaFordringar',
])
const anlaggningstillgangar = add(add(immateriella, materiella), finansiella)
const varulager = sumConcepts(br, [
'LagerRavarorFornodenheter',
'LagerVarorUnderTillverkning',
'LagerFardigaVarorHandelsvaror',
'PagaendeArbetenAnnansRakningOmsattningstillgangar',
'ForskottTillLeverantorer',
'OvrigaLagertillgangar',
])
const kortfristigaFordringar = sumConcepts(br, [
'Kundfordringar',
'FordringarKoncernforetagKortfristiga',
'FordringarIntresseforetagGemensamtStyrdaForetagKortfristiga',
'FordringarOvrigaforetagAgarintresseKortfristiga',
'OvrigaFordringarKortfristiga',
'UpparbetadEjFaktureradIntakt',
'ForutbetaldaKostnaderUpplupnaIntakter',
])
const kortfristigaPlaceringar = sumConcepts(br, [
'AndelarKoncernforetagKortfristiga',
'OvrigaKortfristigaPlaceringar',
])
const kassaBank = sumConcepts(br, ['KassaBankExklRedovisningsmedel', 'Redovisningsmedel'])
const omsattningstillgangar = add(
add(varulager, kortfristigaFordringar),
add(kortfristigaPlaceringar, kassaBank),
)
const tillgangar = add(
add(br['TecknatEjInbetaltKapital'] ?? ZERO, anlaggningstillgangar),
omsattningstillgangar,
)
const bundetEgetKapital = sumConcepts(br, [
'Aktiekapital',
'EjRegistreratAktiekapital',
'OverkursfondBunden',
'Uppskrivningsfond',
'Reservfond',
])
const frittEgetKapital = sumConcepts(br, [
'Overkursfond',
'BalanseratResultat',
'AretsResultatEgetKapital',
])
const egetKapital = add(bundetEgetKapital, frittEgetKapital)
const obeskattadeReserver = sumConcepts(br, [
'Periodiseringsfonder',
'AckumuleradeOveravskrivningar',
'OvrigaObeskattadeReserver',
])
const avsattningar = sumConcepts(br, [
'AvsattningarPensionerLiknandeForpliktelserEnligtLag',
'OvrigaAvsattningarPensionerLiknandeForpliktelser',
'OvrigaAvsattningar',
])
const langfristigaSkulder = sumConcepts(br, [
'Obligationslan',
'CheckrakningskreditLangfristig',
'OvrigaLangfristigaSkulderKreditinstitut',
'SkulderKoncernforetagLangfristiga',
'SkulderIntresseforetagGemensamtStyrdaForetagLangfristiga',
'SkulderOvrigaForetagAgarintresseLangfristiga',
'OvrigaLangfristigaSkulder',
])
const kortfristigaSkulder = sumConcepts(br, [
'ForskottFranKunder',
'CheckrakningskreditKortfristig',
'OvrigaKortfristigaSkulderKreditinstitut',
'PagaendeArbetenAnnansRakningKortfristigaSkulder',
'FaktureradEjUpparbetadIntakt',
'Leverantorsskulder',
'Vaxelskulder',
'SkulderKoncernforetagKortfristiga',
'SkulderIntresseforetagGemensamtStyrdaForetagKortfristiga',
'SkulderOvrigaForetagAgarintresseKortfristiga',
'Skatteskulder',
'OvrigaKortfristigaSkulder',
'UpplupnaKostnaderForutbetaldaIntakter',
])
const egetKapitalSkulder = add(
add(add(egetKapital, obeskattadeReserver), add(avsattningar, langfristigaSkulder)),
kortfristigaSkulder,
)
return {
rorelseintakter,
rorelsekostnader,
rorelseresultat,
finansiellaPoster,
resultatEfterFinansiellaPoster,
bokslutsdispositioner,
resultatForeSkatt,
aretsResultat,
anlaggningstillgangar,
immateriellaAnlaggningstillgangar: immateriella,
materiellaAnlaggningstillgangar: materiella,
finansiellaAnlaggningstillgangar: finansiella,
varulager,
kortfristigaFordringar,
kortfristigaPlaceringar,
kassaBank,
omsattningstillgangar,
tillgangar,
bundetEgetKapital,
frittEgetKapital,
egetKapital,
obeskattadeReserver,
avsattningar,
langfristigaSkulder,
kortfristigaSkulder,
egetKapitalSkulder,
}
}
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest'
import { getRegistry, getConcept, mustGetConcept } from '../registry'
import { getEntryPoint, resolveEntryPoint, K2_AB_RISBS_2024_09_12 } from '../entry-points'
describe('taxonomy registry (k2-ab-2024-09-12)', () => {
const registry = getRegistry('k2-ab-2024-09-12')
it('loads with the expected shape', () => {
expect(registry._meta.taxonomy).toBe('k2-ab')
expect(registry._meta.version).toBe('2024-09-12')
expect(registry._meta.conceptCount).toBeGreaterThan(1000)
expect(registry._meta.tupleCount).toBeGreaterThan(10)
})
it('exposes the RR risbs concepts with correct balance/period attributes', () => {
const netto = mustGetConcept(registry, 'Nettoomsattning')
expect(netto.ns).toBe('se-gen-base')
expect(netto.balance).toBe('credit')
expect(netto.periodType).toBe('duration')
expect(netto.dataType).toBe('xbrli:monetaryItemType')
const personal = mustGetConcept(registry, 'Personalkostnader')
expect(personal.balance).toBe('debit')
const aretsResultat = mustGetConcept(registry, 'AretsResultat')
expect(aretsResultat.balance).toBe('credit')
expect(aretsResultat.sections).toContain('rr-kostnadsslagsindelad')
})
it('exposes the BR totals used by kontrollera rules 3001/3002', () => {
const tillgangar = mustGetConcept(registry, 'Tillgangar')
expect(tillgangar.periodType).toBe('instant')
expect(tillgangar.balance).toBe('debit')
const ekSkulder = mustGetConcept(registry, 'EgetKapitalSkulder')
expect(ekSkulder.periodType).toBe('instant')
expect(ekSkulder.balance).toBe('credit')
})
it('includes the fastställelseintyg concepts from comp-base (se-bol-base)', () => {
for (const name of [
'ArsstammaIntygande',
'IntygandeOriginalInnehall',
'UnderskriftFastallelseintygDatum',
'Arsstamma',
'FaststallelseResultatBalansrakning',
]) {
const concept = mustGetConcept(registry, name)
expect(concept.ns).toBe('se-bol-base')
}
// The signing-date element is instant (tagged against balans0).
expect(mustGetConcept(registry, 'UnderskriftFastallelseintygDatum').periodType).toBe(
'instant',
)
})
it('models the underskrifter tuple with the per-signer date member', () => {
const tuple = registry.tuples['UnderskriftArsredovisningForetradareTuple']
expect(tuple).toBeDefined()
expect(tuple.ns).toBe('se-gaap-ext')
const memberNames = tuple.members.map((m) => m.name)
expect(memberNames).toContain('UnderskriftHandlingTilltalsnamn')
expect(memberNames).toContain('UnderskriftHandlingEfternamn')
expect(memberNames).toContain('UnderskriftHandlingRoll')
// TA §2.9.1: DatumForUndertecknande per signer — element name UndertecknandeDatum.
expect(memberNames).toContain('UndertecknandeDatum')
})
it('exposes vallista concepts and their members', () => {
expect(mustGetConcept(registry, 'SprakHandlingUpprattadList').dataType).toBe(
'enum:enumerationItemType',
)
expect(getConcept(registry, 'SprakSvenskaMember')?.ns).toBe('se-mem-base')
})
it('throws on unknown concepts and registries', () => {
expect(() => mustGetConcept(registry, 'PåhittatBegrepp')).toThrow(/not in taxonomy/)
expect(() => getRegistry('k9-hund')).toThrow(/Unknown taxonomy registry/)
expect(getConcept(registry, 'PåhittatBegrepp')).toBeNull()
})
})
describe('entry points', () => {
it('resolves the K2 AB risbs MVP entry point', () => {
const ep = resolveEntryPoint('k2')
expect(ep).toBe(K2_AB_RISBS_2024_09_12)
expect(ep.schemaRefs[0]).toContain('k2-all/ab/risbs/2024-09-12')
// Fastställelseintyg (certificate of adoption) schema must ride along.
expect(ep.schemaRefs[1]).toContain('coa/rplc/2020-12-01')
expect(getEntryPoint(ep.id)).toBe(ep)
})
it('keeps base-concept namespaces on the 2021-10-31 generation', () => {
const ns = K2_AB_RISBS_2024_09_12.namespaces
expect(ns['se-gen-base']).toBe('http://www.taxonomier.se/se/fr/gen-base/2021-10-31')
expect(ns['se-bol-base']).toBe('http://www.bolagsverket.se/se/fr/comp-base/2020-12-01')
expect(ns['se-gaap-ext']).toBe('http://www.taxonomier.se/se/fr/gaap/gaap-ext/2024-09-12')
})
it('rejects K3 with an actionable message', () => {
expect(() => resolveEntryPoint('k3')).toThrow(/K3/)
})
})
@@ -0,0 +1,90 @@
/**
* Taxonomy entry points accepted by Bolagsverket's inlämningstjänst.
*
* An entry point fixes three things for a generated instance document:
* 1. The schemaRefs (the årsredovisning report + the fastställelseintyg
* "COA" report certificate of adoption exactly as in the official
* examples; allowed ÅR/FI/RB combinations per
* dev_docs/bokslut/kombinationer-taxonomirapporter-v1.4-screenshot.png).
* 2. The xmlns prefix namespace URI map. The 2024-09-12 K2 generation
* still uses the 2021-10-31 base-concept namespaces (se-gen-base,
* se-cd-base, se-mem-base) verified against the entry-point XSD import
* chain in taxonomi-paket-2024-09-12_rev20250312.zip.
* 3. Which generated concept registry validates the emitted facts.
*
* MVP ships K2 AB `risbs` (full RR + full BR matches the current PDF
* layout). The other K2 forms and K3 land in M6.
*/
export type Uppstallningsform = 'risbs' | 'risab' | 'raibs' | 'raiab' | 'full'
export interface TaxonomyEntryPoint {
id: string
regelverk: 'K2' | 'K3'
foretagsform: 'AB'
uppstallningsform: Uppstallningsform
/** Version directory of the ÅR taxonomy, e.g. "2024-09-12". */
taxonomyVersion: string
/** Version of the fastställelseintyg (COA) taxonomy. */
faststallelseintygVersion: string
/** link:schemaRef hrefs, in document order. */
schemaRefs: string[]
/** xmlns declarations for the <html> root, prefix → URI. */
namespaces: Record<string, string>
/** Registry id resolved via getRegistry(). */
registryId: string
}
export const K2_AB_RISBS_2024_09_12: TaxonomyEntryPoint = {
id: 'k2-ab-risbs-2024-09-12',
regelverk: 'K2',
foretagsform: 'AB',
uppstallningsform: 'risbs',
taxonomyVersion: '2024-09-12',
faststallelseintygVersion: '2020-12-01',
schemaRefs: [
'http://xbrl.taxonomier.se/se/fr/gaap/k2-all/ab/risbs/2024-09-12/se-k2-ab-risbs-2024-09-12.xsd',
'http://xbrl.taxonomier.se/se/fr/gaap/coa/rplc/2020-12-01/se-coa-rplc-2020-12-01.xsd',
],
namespaces: {
ix: 'http://www.xbrl.org/2013/inlineXBRL',
xbrli: 'http://www.xbrl.org/2003/instance',
link: 'http://www.xbrl.org/2003/linkbase',
xlink: 'http://www.w3.org/1999/xlink',
iso4217: 'http://www.xbrl.org/2003/iso4217',
ixt: 'http://www.xbrl.org/inlineXBRL/transformation/2010-04-20',
'se-gen-base': 'http://www.taxonomier.se/se/fr/gen-base/2021-10-31',
'se-cd-base': 'http://www.taxonomier.se/se/fr/cd-base/2021-10-31',
'se-mem-base': 'http://www.taxonomier.se/se/fr/mem-base/2021-10-31',
'se-bol-base': 'http://www.bolagsverket.se/se/fr/comp-base/2020-12-01',
'se-gaap-ext': 'http://www.taxonomier.se/se/fr/gaap/gaap-ext/2024-09-12',
'se-k2-type': 'http://www.taxonomier.se/se/fr/k2/datatype',
},
registryId: 'k2-ab-2024-09-12',
}
const ENTRY_POINTS: Record<string, TaxonomyEntryPoint> = {
[K2_AB_RISBS_2024_09_12.id]: K2_AB_RISBS_2024_09_12,
}
export function getEntryPoint(id: string): TaxonomyEntryPoint {
const ep = ENTRY_POINTS[id]
if (!ep) {
throw new Error(
`Unknown taxonomy entry point "${id}" — known: ${Object.keys(ENTRY_POINTS).join(', ')}`,
)
}
return ep
}
/**
* Resolve the entry point for a company's filing. Today only K2 AB risbs is
* supported; K3 callers get a descriptive error the UI can surface instead of
* a generated-but-rejectable document.
*/
export function resolveEntryPoint(framework: 'k2' | 'k3'): TaxonomyEntryPoint {
if (framework === 'k2') return K2_AB_RISBS_2024_09_12
throw new Error(
'Digital inlämning stöds ännu inte för K3 — generera PDF eller vänta på K3-stödet (M6).',
)
}
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
/**
* Typed access to the generated taxonomy concept registry.
*
* The registry JSON is generated from the official element lists by
* scripts/generate-taxonomy-registry.ts never edit it by hand. CI guards
* staleness via `npm run taxonomy:check`.
*
* Taxonomy versions are data, not code: each version is its own generated
* file selected through lib/bokslut/ixbrl/taxonomy/entry-points.ts, so the
* September 2026 generation (dimensions instead of tuples) can ship as a new
* registry + emitter without touching the K2 2024-09-12 path.
*/
import k2Ab20240912 from './generated/k2-ab-2024-09-12.json'
export interface TaxonomyConcept {
/** Namespace prefix (e.g. "se-gen-base"); URI resolved per entry point. */
ns: string
/** Official Standardrubrik presentation label. */
label: string
abstract: boolean
dataType: string | null
balance: 'debit' | 'credit' | null
periodType: 'duration' | 'instant' | null
kind: 'item' | 'tuple'
sections: string[]
}
export interface TaxonomyTupleMember {
name: string
ns: string
required: boolean
}
export interface TaxonomyRegistry {
_meta: {
taxonomy: string
version: string
revision: string
conceptCount: number
tupleCount: number
}
concepts: Record<string, TaxonomyConcept>
tuples: Record<string, { ns: string; members: TaxonomyTupleMember[] }>
}
const REGISTRIES: Record<string, TaxonomyRegistry> = {
'k2-ab-2024-09-12': k2Ab20240912 as unknown as TaxonomyRegistry,
}
export function getRegistry(id: string): TaxonomyRegistry {
const registry = REGISTRIES[id]
if (!registry) {
throw new Error(
`Unknown taxonomy registry "${id}" — known: ${Object.keys(REGISTRIES).join(', ')}`,
)
}
return registry
}
export function getConcept(
registry: TaxonomyRegistry,
name: string,
): TaxonomyConcept | null {
return registry.concepts[name] ?? null
}
/**
* Lookup that throws on unknown concepts. The document builder uses this for
* every fact it emits, so a typo'd element name fails generation instead of
* producing an instance Bolagsverket rejects with 4001/4008.
*/
export function mustGetConcept(
registry: TaxonomyRegistry,
name: string,
): TaxonomyConcept {
const concept = registry.concepts[name]
if (!concept) {
throw new Error(`Concept "${name}" not in taxonomy ${registry._meta.taxonomy} ${registry._meta.version}`)
}
return concept
}
+149
View File
@@ -0,0 +1,149 @@
/**
* Input model for the iXBRL årsredovisning generator.
*
* This is a separate shape from ArsredovisningData (the PDF model) because
* iXBRL needs concept-keyed amounts for BOTH years (jämförelsesiffror are
* mandatory kontrollera codes 3006/3007), while the PDF model carries
* label-based single-year lines. The mapper (k2-mapper.ts) produces the
* concept-keyed parts from trial balances; build-input.ts assembles the rest
* from the same sources the PDF uses.
*/
/** Amounts in whole SEK, oriented to the concept's natural balance:
* credit-balance concepts are positive when credit, debit-balance concepts
* positive when debit. Negative = deviates from natural sign (`sign="-"`). */
export interface ConceptAmount {
current: number
previous: number | null
}
export type ConceptAmounts = Record<string, ConceptAmount>
export interface FlerarsRow {
/** Label for the column, e.g. "2025". */
year: string
nettoomsattning: number
resultatEfterFinansiellaPoster: number
/** Percent with one decimal, e.g. 35.5 — null when not computable. */
soliditetPct: number | null
}
export interface EgetKapitalForandring {
/** IB/UB per tagged column, whole SEK (credit-positive). */
aktiekapital: { ib: number; ub: number }
balanseratResultat: { ib: number; ub: number }
aretsResultat: { ib: number; ub: number }
totalt: { ib: number; ub: number }
/** Untagged residual columns (reservfond, överkursfond …), 0 when absent. */
ovrigaPoster: { ib: number; ub: number }
/** Movement rows (whole SEK). */
balanserasINyRakning: number
utdelning: number
forandringAktiekapital: number
/** Balanserat-column residual that is not utdelning/balansering. */
ovrigForandringBalanserat: number
aretsResultatRorelse: number
}
export interface Resultatdisposition {
/** Balanserat resultat ONLY (20902096 + 2098) must stay value-identical
* to the BalanseratResultat fact in BR/eget kapital (TA §2.7.3). */
balanseratResultat: number
/** Fri överkursfond (2097), shown as its own row tagged Overkursfond. */
overkursfond: number
aretsResultat: number
summa: number
utdelning: number
balanserasINyRakning: number
/** Optional styrelsens kommentar (free text from the narrative editor). */
kommentar: string | null
}
export interface IxbrlNote {
number: number
title: string
body: string
}
export interface IxbrlSigner {
firstName: string
lastName: string
/** Visible role label, e.g. "Styrelseledamot", "Verkställande direktör". */
role: string | null
/** ISO date for DatumForUndertecknande (per-signer, TA §2.9.1).
* Null when the signature request has not been signed yet the date fact
* is then omitted (never fabricated) and preflight 1214 blocks filing. */
signedDate: string | null
}
export interface IxbrlArsredovisningInput {
company: {
name: string
/** Formatted with dash, e.g. "556999-9999". */
orgNumber: string
/** Säte (city) — used in underskrifter and allmänt om verksamheten. */
city: string | null
}
period: { start: string; end: string }
previousPeriod: { start: string; end: string } | null
/** True when this is the company's first fiscal year jämförelsesiffror
* may then legitimately be absent (3006/3007 exemption). */
isFirstFiscalYear: boolean
/** RR concept-keyed amounts (kostnadsslagsindelad, risbs posts). */
rr: ConceptAmounts
/** BR concept-keyed amounts (risbs posts). */
br: ConceptAmounts
/** Computed subtotals/totals from the mapper (same orientation rules). */
totals: import('./k2-mapper').K2MappingResult['totals']
forvaltningsberattelse: {
allmantOmVerksamheten: string
vasentligaHandelser: string
flerarsoversikt: FlerarsRow[]
/** Period ranges aligned 1:1 with flerarsoversikt rows (newest first).
* Index 0/1 reuse period0/period1; 2/3 get their own contexts. */
flerarsPerioder: Array<{ start: string; end: string }>
egetKapital: EgetKapitalForandring
resultatdisposition: Resultatdisposition
}
noter: IxbrlNote[]
/** Medelantal anställda (FTE) for current + previous year. */
medelantalAnstallda: { current: number; previous: number | null }
underskrifter: {
ort: string
/** Datering av årsredovisning (the day the board fixed the content).
* Tagged only for fiscal years beginning 2024-07-01 or later (element
* list note on UndertecknandeArsredovisningDatum). */
dateringsdatum: string | null
signers: IxbrlSigner[]
/** True when a VD is among the signers — drives FinansiellRapportList. */
harVd: boolean
}
faststallelseintyg: {
/** AGM date must be > räkenskapsårets sista dag (kontrollera 1101).
* Null when no AGM date is recorded: the document renders a visible
* placeholder instead of a fabricated date and preflight 1103 blocks
* filing (Bolagsverket kontrollera 1103 semantics). */
arsstammaDatum: string | null
/** The företrädare who will sign at Bolagsverket. */
signerFirstName: string
signerLastName: string
signerRole: string
/** Document generation date Bolagsverket overwrites at actual signing
* (TA §4.4: set today's date). */
genereratDatum: string
}
/** TA §4.3 head metadata. Name standard "<leverantör> - <produkt>". */
programvara: { namn: string; version: string }
/** Entry point id resolved via getEntryPoint (taxonomy version is data). */
entryPointId: string
/** Non-blocking issues collected while building (unmapped accounts etc.). */
warnings: string[]
}
+223
View File
@@ -0,0 +1,223 @@
/**
* Pre-flight validation of an iXBRL årsredovisning local mirror of the
* Bolagsverket `kontrollera` service (GUIDE.md Appendix E codes).
*
* Runs on the assembled IxbrlArsredovisningInput BEFORE generation/upload so
* the wizard can surface actionable issues without an API round-trip, and so
* self-hosted installs without Bolagsverket credentials still get the checks.
*
* Severity:
* - 'error' Bolagsverket would reject or föreläggande is near-certain;
* the wizard blocks Skicka in.
* - 'warn' kontrollera warn-level utfall; filing is allowed
* (GUIDE §4.2.2) but the user should review.
*/
import type { IxbrlArsredovisningInput } from '../types'
export interface PreflightIssue {
/** Bolagsverket kontrollera code where one exists, else our own ACC-xxx. */
code: string
severity: 'error' | 'warn'
message: string
}
export interface PreflightResult {
issues: PreflightIssue[]
errors: PreflightIssue[]
warnings: PreflightIssue[]
ok: boolean
}
type Rule = (input: IxbrlArsredovisningInput, today: string) => PreflightIssue | null
const issue = (code: string, severity: 'error' | 'warn', message: string): PreflightIssue => ({
code,
severity,
message,
})
function monthsBetween(startIso: string, endIso: string): number {
const start = new Date(`${startIso}T00:00:00Z`)
const end = new Date(`${endIso}T00:00:00Z`)
return (
(end.getUTCFullYear() - start.getUTCFullYear()) * 12 +
(end.getUTCMonth() - start.getUTCMonth()) +
(end.getUTCDate() >= start.getUTCDate() ? 0 : -1)
)
}
const RULES: Rule[] = [
// ---- completeness -------------------------------------------------------
(input) =>
input.company.name.trim().length === 0
? issue('1020', 'error', 'Företagsnamnet saknas i årsredovisningen.')
: null,
(input) =>
/^\d{6}-?\d{4}$/.test(input.company.orgNumber.trim())
? null
: issue('1035', 'error', `Organisationsnumret "${input.company.orgNumber}" är inte giltigt (förväntat format NNNNNN-NNNN).`),
(input) =>
input.forvaltningsberattelse.allmantOmVerksamheten.trim().length === 0
? issue('1051', 'error', 'Förvaltningsberättelsen saknas (Allmänt om verksamheten är tom).')
: null,
(input) => {
const hasRr =
Object.values(input.rr).some((a) => a.current !== 0) ||
input.totals.aretsResultat.current !== 0
return hasRr
? null
: issue('1060', 'warn', 'Resultaträkningen verkar sakna belopp — kontrollera att räkenskapsåret innehåller bokförda transaktioner.')
},
(input) => {
const hasBr = input.totals.tillgangar.current !== 0
return hasBr
? null
: issue('1064', 'warn', 'Balansräkningen verkar sakna belopp (Summa tillgångar är 0).')
},
(input) =>
input.underskrifter.signers.length === 0
? issue('1107', 'error', 'Underskrifter saknas — årsredovisningen måste skrivas under av styrelsen (och ev. VD).')
: null,
(input) =>
input.underskrifter.signers.some(
(signer) => !signer.firstName.trim() || !signer.lastName.trim(),
)
? issue('1201', 'error', 'Det saknas för- eller efternamn på den eller de som skrivit under årsredovisningen.')
: null,
(input) =>
input.underskrifter.signers.some((signer) => !signer.signedDate)
? issue('1214', 'error', 'Datum för underskrifter saknas — alla underskrifter måste ha ett datum.')
: null,
(input) =>
!input.faststallelseintyg.signerFirstName.trim() ||
!input.faststallelseintyg.signerLastName.trim()
? issue('1169', 'error', 'Namnförtydligandet saknas i fastställelseintyget (välj undertecknare).')
: null,
(input) =>
input.faststallelseintyg.arsstammaDatum
? null
: issue('1103', 'error', 'Datum för årsstämman saknas i fastställelseintyget.'),
// ---- date ordering ------------------------------------------------------
(input, today) =>
input.period.end >= today
? issue('1015', 'error', `Räkenskapsårets sista dag (${input.period.end}) har inte passerats ännu.`)
: null,
(input) =>
monthsBetween(input.period.start, input.period.end) >= 18
? issue('1046', 'error', `Räkenskapsåret ${input.period.start} ${input.period.end} är längre än 18 månader.`)
: null,
(input) =>
input.faststallelseintyg.arsstammaDatum &&
input.faststallelseintyg.arsstammaDatum <= input.period.end
? issue('1101', 'error', `Datum för årsstämman (${input.faststallelseintyg.arsstammaDatum}) får inte vara tidigare än eller samma som räkenskapsårets sista dag (${input.period.end}).`)
: null,
(input, today) =>
input.faststallelseintyg.arsstammaDatum !== null &&
input.faststallelseintyg.arsstammaDatum > today
? issue('1178', 'error', `Datum för årsstämman (${input.faststallelseintyg.arsstammaDatum}) får inte vara senare än dagens datum — håll årsstämman innan inlämning.`)
: null,
(input) => {
const bad = input.underskrifter.signers.find(
(signer) => signer.signedDate && signer.signedDate <= input.period.end,
)
return bad
? issue('1114', 'error', `Datum för underskrift (${bad.signedDate}) får inte vara tidigare än eller samma som räkenskapsårets sista dag (${input.period.end}).`)
: null
},
(input) => {
const agm = input.faststallelseintyg.arsstammaDatum
if (!agm) return null
const late = input.underskrifter.signers.find(
(signer) => signer.signedDate && signer.signedDate > agm,
)
return late
? issue('1183', 'error', `Datum för årsstämman (${agm}) är tidigare än styrelsens underskrift (${late.signedDate}).`)
: null
},
(input) => {
const datering = input.underskrifter.dateringsdatum
if (!datering) return null
const earliest = input.underskrifter.signers.reduce<string | null>(
(min, signer) =>
signer.signedDate !== null && (min === null || signer.signedDate < min)
? signer.signedDate
: min,
null,
)
return earliest && datering > earliest
? issue('1232', 'warn', `Datum för årsredovisningen (${datering}) är senare än styrelsens tidigaste underskrift (${earliest}).`)
: null
},
(input) =>
input.faststallelseintyg.arsstammaDatum &&
input.faststallelseintyg.genereratDatum < input.faststallelseintyg.arsstammaDatum
? issue('1165', 'warn', 'Datum för underskrift av fastställelseintyget sätts till genereringsdagen, som ligger före årsstämman — Bolagsverket skriver över datumet vid signering.')
: null,
// ---- balance checks -----------------------------------------------------
// Exact comparisons: Bolagsverket compares the tagged totals exactly, and
// the mapper already absorbs legitimate ±1 kr rounding residuals.
(input) => {
const assets = input.totals.tillgangar.current
const eqLiab = input.totals.egetKapitalSkulder.current
return assets !== eqLiab
? issue('3005', 'error', `"Summa tillgångar" (${assets} kr) och "Summa eget kapital och skulder" (${eqLiab} kr) stämmer inte överens.`)
: null
},
(input) => {
if (input.isFirstFiscalYear) return null
const prev = input.totals.tillgangar.previous
return prev === null
? issue('3006', 'error', 'Jämförelsesiffror saknas i balansräkningen. De behövs om det inte är företagets första räkenskapsår.')
: null
},
(input) => {
if (input.isFirstFiscalYear) return null
const prev = input.totals.aretsResultat.previous
return prev === null
? issue('3007', 'error', 'Jämförelsesiffror saknas i resultaträkningen. De behövs om det inte är företagets första räkenskapsår.')
: null
},
(input) => {
const rrResult = input.totals.aretsResultat.current
const brResult = input.br['AretsResultatEgetKapital']?.current ?? 0
return rrResult !== brResult
? issue('ACC-2099', 'error', `Årets resultat enligt resultaträkningen (${rrResult} kr) stämmer inte med eget kapital-posten Årets resultat (${brResult} kr) — kör bokslutet (resultatdisposition) innan inlämning.`)
: null
},
// ---- resultatdisposition ------------------------------------------------
(input) => {
const rd = input.forvaltningsberattelse.resultatdisposition
return Math.abs(rd.utdelning + rd.balanserasINyRakning - rd.summa) > 1
? issue('ACC-DISP', 'error', `Resultatdispositionen går inte ihop: utdelning (${rd.utdelning}) + balanseras (${rd.balanserasINyRakning}) ≠ summa (${rd.summa}).`)
: null
},
(input) => {
const rd = input.forvaltningsberattelse.resultatdisposition
return rd.summa < 0 && rd.utdelning > 0
? issue('ACC-UTD', 'error', 'Utdelning kan inte föreslås när fritt eget kapital är negativt.')
: null
},
]
export function runPreflightChecks(
input: IxbrlArsredovisningInput,
todayIso?: string,
): PreflightResult {
const today = todayIso ?? new Date().toISOString().slice(0, 10)
const issues: PreflightIssue[] = []
for (const rule of RULES) {
const result = rule(input, today)
if (result) issues.push(result)
}
// Mapper warnings (unmapped accounts, reclassifications) ride along as warn.
for (const warning of input.warnings) {
issues.push(issue('ACC-WARN', 'warn', warning))
}
const errors = issues.filter((item) => item.severity === 'error')
const warnings = issues.filter((item) => item.severity === 'warn')
return { issues, errors, warnings, ok: errors.length === 0 }
}
+1 -1
View File
@@ -76,7 +76,7 @@ describe('engine.pg — triggers & RPCs that mocks cannot catch', () => {
// the voucher_sequences user_id NOT NULL check before ON CONFLICT could
// arbitrate (commit_journal_entry got the fallback in 20260421170500;
// next_voucher_number — the storno/correction path — did not until
// 20260611130000).
// 20260623130000).
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const first = await getPool().query<{ n: number }>(
@@ -0,0 +1,370 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type {
CreateJournalEntryInput,
Invoice,
InvoiceItem,
SupplierInvoice,
SupplierInvoiceItem,
} from '@/types'
import { makeSupplierInvoice } from '@/tests/helpers'
import { roundOre } from '@/lib/money'
// Periodisering booking behaviour: lines with an accrual period book their
// net to the 17xx/29xx interim account instead of the P&L account, while
// VAT and AR/AP lines stay untouched.
vi.mock('../engine', () => ({
findFiscalPeriod: vi.fn().mockResolvedValue('period-1'),
createJournalEntry: vi.fn().mockImplementation(
async (_supabase: unknown, _companyId: string, _userId: string, input: CreateJournalEntryInput) => ({
id: 'entry-1',
...input,
lines: input.lines,
})
),
}))
vi.mock('../vat-entries', async (importOriginal) => {
const actual = await importOriginal<typeof import('../vat-entries')>()
return {
...actual,
generateSalesVatLines: vi.fn().mockImplementation(
({ vatTreatment, baseAmount }: { vatTreatment: string; baseAmount: number }) => {
const rate = vatTreatment === 'standard_25' ? 0.25
: vatTreatment === 'reduced_12' ? 0.12
: vatTreatment === 'reduced_6' ? 0.06 : 0
if (rate === 0) return []
const account = vatTreatment === 'standard_25' ? '2611'
: vatTreatment === 'reduced_12' ? '2621' : '2631'
return [{
account_number: account,
debit_amount: 0,
credit_amount: roundOre(baseAmount * rate),
line_description: 'Utgående moms',
}]
},
),
}
})
const { createJournalEntry } = await import('../engine')
const mockedCreateEntry = vi.mocked(createJournalEntry)
const { createSupplierInvoiceRegistrationEntry, createSupplierCreditNoteEntry } =
await import('../supplier-invoice-entries')
const { createInvoiceJournalEntry, createCreditNoteJournalEntry } =
await import('../invoice-entries')
function makeSupplierItem(overrides: Partial<SupplierInvoiceItem> = {}): SupplierInvoiceItem {
const lineTotal = overrides.line_total ?? 12000
const vatRate = overrides.vat_rate ?? 0.25
return {
id: 'si-item-1',
supplier_invoice_id: 'si-1',
sort_order: 0,
description: 'Företagsförsäkring 2026',
quantity: 1,
unit: 'st',
unit_price: lineTotal,
line_total: lineTotal,
account_number: '6310',
vat_code: null,
vat_rate: vatRate,
vat_amount: overrides.vat_amount ?? roundOre(lineTotal * vatRate),
reverse_charge_rate: null,
created_at: '2026-01-15T00:00:00Z',
...overrides,
}
}
function makeCustomerInvoice(overrides: Partial<Invoice> & { items?: InvoiceItem[] }): Invoice {
return {
id: 'inv-1',
user_id: 'user-1',
customer_id: 'cust-1',
invoice_number: 'F-100',
invoice_date: '2026-01-15',
due_date: '2026-02-14',
currency: 'SEK',
exchange_rate: null,
subtotal: 12000,
subtotal_sek: null,
vat_amount: 3000,
vat_amount_sek: null,
total: 15000,
total_sek: null,
vat_treatment: 'standard_25',
vat_rate: 25,
moms_ruta: '05',
status: 'sent',
paid_at: null,
credited_invoice_id: null,
document_type: 'invoice',
created_at: '2026-01-15T00:00:00Z',
updated_at: '2026-01-15T00:00:00Z',
items: [],
...overrides,
} as Invoice
}
function makeCustomerItem(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
return {
id: 'item-1',
invoice_id: 'inv-1',
sort_order: 0,
description: 'Serviceavtal 2026',
quantity: 1,
unit: 'st',
unit_price: 12000,
line_total: 12000,
vat_rate: 25,
vat_amount: 3000,
created_at: '2026-01-15T00:00:00Z',
...overrides,
}
}
function entryLines() {
return mockedCreateEntry.mock.calls[0][3].lines
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('supplier invoice registration with periodisering', () => {
it('debits the 17xx interim account instead of the cost account', async () => {
const invoice = makeSupplierInvoice({
id: 'si-1',
subtotal: 12000,
vat_amount: 3000,
total: 15000,
}) as SupplierInvoice
const items = [
makeSupplierItem({
accrual_period_start: '2026-01-01',
accrual_period_end: '2026-12-31',
accrual_balance_account: '1730',
}),
]
await createSupplierInvoiceRegistrationEntry(
{} as never, 'company-1', 'user-1', invoice, items, 'swedish_business', 'Försäkrings AB',
)
const lines = entryLines()
expect(lines).toContainEqual(
expect.objectContaining({ account_number: '1730', debit_amount: 12000 }),
)
expect(lines.some((l) => l.account_number === '6310')).toBe(false)
expect(lines).toContainEqual(
expect.objectContaining({ account_number: '2641', debit_amount: 3000 }),
)
expect(lines).toContainEqual(
expect.objectContaining({ account_number: '2440', credit_amount: 15000 }),
)
})
it('falls back to the BAS-conventional interim account when none is set', async () => {
const invoice = makeSupplierInvoice({ id: 'si-1' }) as SupplierInvoice
const items = [
makeSupplierItem({
accrual_period_start: '2026-01-01',
accrual_period_end: '2026-12-31',
accrual_balance_account: null,
}),
]
await createSupplierInvoiceRegistrationEntry(
{} as never, 'company-1', 'user-1', invoice, items, 'swedish_business',
)
// 6310 försäkring → 1730 Förutbetalda försäkringspremier
expect(entryLines()).toContainEqual(
expect.objectContaining({ account_number: '1730', debit_amount: 12000 }),
)
})
it('books mixed invoices with deferred and ordinary lines side by side', async () => {
const invoice = makeSupplierInvoice({ id: 'si-1' }) as SupplierInvoice
const items = [
makeSupplierItem({
accrual_period_start: '2026-01-01',
accrual_period_end: '2026-12-31',
accrual_balance_account: '1730',
}),
makeSupplierItem({
id: 'si-item-2',
sort_order: 1,
description: 'Kontorsmaterial',
line_total: 500,
account_number: '6110',
}),
]
await createSupplierInvoiceRegistrationEntry(
{} as never, 'company-1', 'user-1', invoice, items, 'swedish_business',
)
const lines = entryLines()
expect(lines).toContainEqual(
expect.objectContaining({ account_number: '1730', debit_amount: 12000 }),
)
expect(lines).toContainEqual(
expect.objectContaining({ account_number: '6110', debit_amount: 500 }),
)
})
})
describe('supplier credit note with periodisering', () => {
it('credits the interim account when given the original deferred items', async () => {
const creditNote = makeSupplierInvoice({
id: 'si-credit-1',
is_credit_note: true,
}) as SupplierInvoice
const originalItems = [
makeSupplierItem({
accrual_period_start: '2026-01-01',
accrual_period_end: '2026-12-31',
accrual_balance_account: '1730',
}),
]
await createSupplierCreditNoteEntry(
{} as never, 'company-1', 'user-1', creditNote, originalItems, 'swedish_business',
)
const lines = entryLines()
expect(lines).toContainEqual(
expect.objectContaining({ account_number: '1730', credit_amount: 12000 }),
)
expect(lines.some((l) => l.account_number === '6310')).toBe(false)
expect(lines).toContainEqual(
expect.objectContaining({ account_number: '2440', debit_amount: 15000 }),
)
})
})
describe('customer invoice with periodisering', () => {
it('credits 29xx instead of revenue; output VAT untouched', async () => {
const invoice = makeCustomerInvoice({
items: [
makeCustomerItem({
accrual_period_start: '2026-01-01',
accrual_period_end: '2026-12-31',
accrual_balance_account: '2970',
}),
],
})
await createInvoiceJournalEntry({} as never, 'company-1', 'user-1', invoice, 'aktiebolag')
const lines = entryLines()
expect(lines).toContainEqual(
expect.objectContaining({ account_number: '1510', debit_amount: 15000 }),
)
expect(lines).toContainEqual(
expect.objectContaining({ account_number: '2970', credit_amount: 12000 }),
)
expect(lines.some((l) => l.account_number === '3001')).toBe(false)
expect(lines).toContainEqual(
expect.objectContaining({ account_number: '2611', credit_amount: 3000 }),
)
})
it('keeps ordinary lines on revenue accounts next to deferred lines', async () => {
const invoice = makeCustomerInvoice({
subtotal: 13000,
vat_amount: 3250,
total: 16250,
items: [
makeCustomerItem({
accrual_period_start: '2026-01-01',
accrual_period_end: '2026-12-31',
accrual_balance_account: '2970',
}),
makeCustomerItem({
id: 'item-2',
sort_order: 1,
description: 'Konsulttimmar',
unit_price: 1000,
line_total: 1000,
vat_amount: 250,
}),
],
})
await createInvoiceJournalEntry({} as never, 'company-1', 'user-1', invoice, 'aktiebolag')
const lines = entryLines()
expect(lines).toContainEqual(
expect.objectContaining({ account_number: '2970', credit_amount: 12000 }),
)
expect(lines).toContainEqual(
expect.objectContaining({ account_number: '3001', credit_amount: 1000 }),
)
})
it('never defers reverse-charge lines (3308 keeps the full amount)', async () => {
const invoice = makeCustomerInvoice({
vat_treatment: 'reverse_charge',
vat_amount: 0,
total: 12000,
items: [
makeCustomerItem({
vat_rate: 0,
vat_amount: 0,
accrual_period_start: '2026-01-01',
accrual_period_end: '2026-12-31',
accrual_balance_account: '2970',
}),
],
})
await createInvoiceJournalEntry({} as never, 'company-1', 'user-1', invoice, 'aktiebolag')
const lines = entryLines()
expect(lines).toContainEqual(
expect.objectContaining({ account_number: '3308', credit_amount: 12000 }),
)
expect(lines.some((l) => l.account_number === '2970')).toBe(false)
})
})
describe('customer credit note with periodisering', () => {
it('debits the interim account via the copied accrual fields', async () => {
const creditNote = makeCustomerInvoice({
id: 'kr-1',
invoice_number: 'KR-F-100',
subtotal: -12000,
vat_amount: -3000,
total: -15000,
credited_invoice_id: 'inv-1',
items: [
makeCustomerItem({
id: 'kr-item-1',
invoice_id: 'kr-1',
quantity: -1,
line_total: -12000,
vat_amount: -3000,
accrual_period_start: '2026-01-01',
accrual_period_end: '2026-12-31',
accrual_balance_account: '2970',
}),
],
})
await createCreditNoteJournalEntry(
{} as never, 'company-1', 'user-1', creditNote, 'aktiebolag', 'Kund AB', 'A-42',
)
const lines = entryLines()
expect(lines).toContainEqual(
expect.objectContaining({ account_number: '2970', debit_amount: 12000 }),
)
expect(lines.some((l) => l.account_number === '3001')).toBe(false)
expect(lines).toContainEqual(
expect.objectContaining({ account_number: '1510', credit_amount: 15000 }),
)
})
})
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import {
isValidBalanceAccount,
suggestBalanceAccount,
} from '@/lib/bookkeeping/accruals/account-suggestions'
describe('suggestBalanceAccount', () => {
it('maps cost accounts to the BAS-conventional interim account', () => {
expect(suggestBalanceAccount('expense', '5010')).toBe('1710') // lokalhyra
expect(suggestBalanceAccount('expense', '5220')).toBe('1720') // hyra inventarier
expect(suggestBalanceAccount('expense', '5615')).toBe('1720') // leasing personbil
expect(suggestBalanceAccount('expense', '6310')).toBe('1730') // försäkring
expect(suggestBalanceAccount('expense', '8410')).toBe('1740') // ränta
expect(suggestBalanceAccount('expense', '6540')).toBe('1790') // IT-tjänster -> övrigt
})
it('always suggests 2970 for revenue', () => {
expect(suggestBalanceAccount('revenue', '3001')).toBe('2970')
expect(suggestBalanceAccount('revenue', '3041')).toBe('2970')
})
})
describe('isValidBalanceAccount', () => {
it('mirrors the DB CHECK ranges', () => {
expect(isValidBalanceAccount('expense', '1730')).toBe(true)
expect(isValidBalanceAccount('expense', '1790')).toBe(true)
expect(isValidBalanceAccount('expense', '2970')).toBe(false)
expect(isValidBalanceAccount('expense', '5010')).toBe(false)
expect(isValidBalanceAccount('revenue', '2970')).toBe(true)
expect(isValidBalanceAccount('revenue', '2990')).toBe(true)
expect(isValidBalanceAccount('revenue', '1790')).toBe(false)
})
})
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import {
computeInstallmentAmounts,
computeInstallments,
countCalendarMonths,
dayAfter,
firstOfMonth,
listCalendarMonths,
maxIsoDate,
} from '@/lib/bookkeeping/accruals/compute'
import { sumOre } from '@/lib/money'
describe('firstOfMonth', () => {
it('truncates to the first of the month', () => {
expect(firstOfMonth('2026-01-15')).toBe('2026-01-01')
expect(firstOfMonth('2026-12-31')).toBe('2026-12-01')
expect(firstOfMonth('2026-06-01')).toBe('2026-06-01')
})
})
describe('countCalendarMonths', () => {
it('counts months touched, inclusive', () => {
expect(countCalendarMonths('2026-01-01', '2026-01-31')).toBe(1)
expect(countCalendarMonths('2026-01-10', '2026-12-31')).toBe(12)
// Mid-month start still touches both end months.
expect(countCalendarMonths('2026-01-31', '2026-02-01')).toBe(2)
})
it('handles year boundaries and brutet räkenskapsår', () => {
expect(countCalendarMonths('2026-11-15', '2027-02-14')).toBe(4)
expect(countCalendarMonths('2026-07-01', '2027-06-30')).toBe(12)
})
it('throws when the end month precedes the start month', () => {
expect(() => countCalendarMonths('2026-06-01', '2026-01-31')).toThrow()
})
})
describe('listCalendarMonths', () => {
it('lists first-of-month dates across a year boundary', () => {
expect(listCalendarMonths('2026-11-15', '2027-02-14')).toEqual([
'2026-11-01',
'2026-12-01',
'2027-01-01',
'2027-02-01',
])
})
})
describe('computeInstallmentAmounts', () => {
it('splits evenly when the amount divides cleanly', () => {
expect(computeInstallmentAmounts(12000, 12)).toEqual(Array(12).fill(1000))
})
it('distributes remainder öre from the first month and sums exactly', () => {
const amounts = computeInstallmentAmounts(10000, 12)
expect(amounts.slice(0, 4)).toEqual([833.34, 833.34, 833.34, 833.34])
expect(amounts.slice(4)).toEqual(Array(8).fill(833.33))
const sum = Math.round(amounts.reduce((a, b) => a + b, 0) * 100) / 100
expect(sum).toBe(10000)
})
it('sums exactly for awkward totals (property check)', () => {
const cases: Array<[number, number]> = [
[0.13, 12],
[1, 3],
[99.99, 7],
[12345.67, 11],
[50000, 36],
[3333.33, 2],
]
for (const [total, months] of cases) {
const amounts = computeInstallmentAmounts(total, months)
expect(amounts).toHaveLength(months)
for (const amount of amounts) {
expect(amount).toBeGreaterThan(0)
}
const sum = sumOre(amounts)
expect(sum).toBe(total)
// No installment differs by more than 1 öre from any other.
const min = Math.min(...amounts)
const max = Math.max(...amounts)
expect(Math.round((max - min) * 100)).toBeLessThanOrEqual(1)
}
})
it('rejects totals too small to give every month an öre', () => {
expect(() => computeInstallmentAmounts(0.05, 12)).toThrow(/too small/i)
})
})
describe('computeInstallments', () => {
it('pairs months with amounts', () => {
const plan = computeInstallments(12000, '2026-01-15', '2026-12-31')
expect(plan).toHaveLength(12)
expect(plan[0]).toEqual({ period_month: '2026-01-01', amount: 1000 })
expect(plan[11]).toEqual({ period_month: '2026-12-01', amount: 1000 })
})
})
describe('maxIsoDate / dayAfter', () => {
it('returns the latest date and ignores null/undefined', () => {
expect(maxIsoDate('2026-01-01', '2026-03-15', null, undefined)).toBe('2026-03-15')
expect(maxIsoDate('2026-01-01')).toBe('2026-01-01')
expect(() => maxIsoDate(null, undefined)).toThrow()
})
it('computes the day after across month and year boundaries', () => {
expect(dayAfter('2026-03-31')).toBe('2026-04-01')
expect(dayAfter('2026-12-31')).toBe('2027-01-01')
expect(dayAfter('2026-02-28')).toBe('2026-03-01')
expect(dayAfter('2028-02-28')).toBe('2028-02-29')
expect(dayAfter('2026-06-14')).toBe('2026-06-15')
})
})
@@ -0,0 +1,569 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
cancelSchedulesForSource,
createAccrualSchedule,
dissolveScheduleNow,
postDueInstallments,
} from '@/lib/bookkeeping/accruals/service'
import {
createJournalEntry,
findFiscalPeriod,
reverseEntry,
} from '@/lib/bookkeeping/engine'
import {
CannotReverseNonPostedError,
EntryAlreadyReversedError,
} from '@/lib/bookkeeping/errors'
import {
ACCRUAL_NOTHING_TO_DISSOLVE,
ACCRUAL_SCHEDULE_NOT_ACTIVE,
ACCRUAL_SCHEDULE_NOT_FOUND,
} from '@/lib/bookkeeping/accruals/errors'
import type { SupabaseClient } from '@supabase/supabase-js'
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn(),
findFiscalPeriod: vi.fn(),
reverseEntry: vi.fn(),
}))
const mockCreateJournalEntry = vi.mocked(createJournalEntry)
const mockFindFiscalPeriod = vi.mocked(findFiscalPeriod)
const mockReverseEntry = vi.mocked(reverseEntry)
const COMPANY = 'company-1'
const USER = 'user-1'
function makeSchedule(overrides: Record<string, unknown> = {}) {
return {
id: 'sched-1',
user_id: USER,
company_id: COMPANY,
direction: 'expense',
supplier_invoice_id: 'si-1',
supplier_invoice_item_id: 'sii-1',
invoice_id: null,
invoice_item_id: null,
balance_account: '1730',
target_account: '6310',
total_amount: 12000,
period_start: '2026-01-01',
period_end: '2026-12-31',
months: 12,
origin_journal_entry_id: 'je-origin',
posting_floor_date: '2026-01-15',
status: 'active',
description: 'Försäkring 2026',
created_at: '2026-01-15T00:00:00Z',
updated_at: '2026-01-15T00:00:00Z',
...overrides,
}
}
function makeInstallment(overrides: Record<string, unknown> = {}) {
return {
id: 'inst-1',
user_id: USER,
company_id: COMPANY,
schedule_id: 'sched-1',
period_month: '2026-01-01',
amount: 1000,
status: 'pending',
journal_entry_id: null,
posted_at: null,
last_error: null,
created_at: '2026-01-15T00:00:00Z',
updated_at: '2026-01-15T00:00:00Z',
schedule: makeSchedule(),
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
mockFindFiscalPeriod.mockResolvedValue('fp-1')
mockCreateJournalEntry.mockResolvedValue({ id: 'je-new' } as never)
mockReverseEntry.mockResolvedValue({ id: 'je-storno' } as never)
})
describe('postDueInstallments', () => {
it('posts a due expense installment with Dr target / Cr balance', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment()] }, // due installments
{ data: { bookkeeping_locked_through: null } }, // company_settings
{ data: [{ id: 'inst-1' }] }, // CAS claim
{ count: 0 }, // remaining pending
{ data: null }, // schedule -> completed
])
const result = await postDueInstallments(
supabase as unknown as SupabaseClient,
COMPANY,
{ userId: USER, today: '2026-01-20' },
)
expect(result).toMatchObject({ posted: 1, failed: 0, skipped: 0 })
expect(mockCreateJournalEntry).toHaveBeenCalledTimes(1)
const input = mockCreateJournalEntry.mock.calls[0][3]
expect(input.source_type).toBe('accrual')
expect(input.source_id).toBe('sched-1')
// Floor: period month 2026-01-01 but origin entry dated 2026-01-15.
expect(input.entry_date).toBe('2026-01-15')
expect(input.lines).toEqual([
expect.objectContaining({ account_number: '6310', debit_amount: 1000, credit_amount: 0 }),
expect.objectContaining({ account_number: '1730', debit_amount: 0, credit_amount: 1000 }),
])
})
it('flips the lines for revenue schedules (Dr 29xx / Cr 3xxx)', async () => {
const revenueSchedule = makeSchedule({
id: 'sched-2',
direction: 'revenue',
supplier_invoice_id: null,
invoice_id: 'inv-1',
balance_account: '2970',
target_account: '3001',
posting_floor_date: '2026-01-01',
})
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment({ schedule_id: 'sched-2', schedule: revenueSchedule })] },
{ data: { bookkeeping_locked_through: null } },
{ data: [{ id: 'inst-1' }] },
{ count: 1 }, // still pending months -> schedule stays active
])
await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
const input = mockCreateJournalEntry.mock.calls[0][3]
expect(input.lines).toEqual([
expect.objectContaining({ account_number: '2970', debit_amount: 1000, credit_amount: 0 }),
expect.objectContaining({ account_number: '3001', debit_amount: 0, credit_amount: 1000 }),
])
})
it('shifts the entry date past the company lock date', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment({ period_month: '2026-02-01' })] },
{ data: { bookkeeping_locked_through: '2026-03-31' } },
{ data: [{ id: 'inst-1' }] },
{ count: 0 },
{ data: null },
])
await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-04-10',
})
expect(mockCreateJournalEntry.mock.calls[0][3].entry_date).toBe('2026-04-01')
})
it('skips months that have not begun yet', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [] }, // the .lte filter excludes future months server-side
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
expect(result.posted).toBe(0)
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('reverses its own entry when the CAS claim is lost', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment()] },
{ data: { bookkeeping_locked_through: null } },
{ data: [] }, // CAS claim lost (another runner won)
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
expect(result).toMatchObject({ posted: 0, skipped: 1, failed: 0 })
expect(mockReverseEntry).toHaveBeenCalledWith(
expect.anything(),
COMPANY,
USER,
'je-new',
)
})
it('reverses its own entry when the CAS claim UPDATE itself errors', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment()] },
{ data: { bookkeeping_locked_through: null } },
{ data: null, error: { message: 'connection reset' } }, // claim errored
{ data: null }, // last_error update in the catch
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
expect(result).toMatchObject({ posted: 0, skipped: 0, failed: 1 })
expect(result.errors[0].message).toMatch(/Failed to mark installment posted/)
// Without the storno the next cron run would double-book the month.
expect(mockReverseEntry).toHaveBeenCalledWith(
expect.anything(),
COMPANY,
USER,
'je-new',
)
})
it('records the reversal failure too when storno after claim error fails', async () => {
mockReverseEntry.mockRejectedValueOnce(new Error('Bokföringen är låst'))
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment()] },
{ data: { bookkeeping_locked_through: null } },
{ data: null, error: { message: 'connection reset' } }, // claim errored
{ data: null }, // last_error update in the catch
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
expect(result.failed).toBe(1)
expect(result.errors[0].message).toMatch(/misslyckades också/)
expect(result.errors[0].message).toMatch(/Bokföringen är låst/)
})
it('clamps the posting date to the next open fiscal period when the date falls in a closed one', async () => {
mockFindFiscalPeriod.mockResolvedValueOnce(null) // 2026 period is closed
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment()] },
{ data: { bookkeeping_locked_through: null } },
{ data: { id: 'fp-next', period_start: '2027-01-01' } }, // next open period
{ data: [{ id: 'inst-1' }] }, // CAS claim
{ count: 0 },
{ data: null },
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
expect(result).toMatchObject({ posted: 1, failed: 0 })
const input = mockCreateJournalEntry.mock.calls[0][3]
expect(input.entry_date).toBe('2027-01-01')
expect(input.fiscal_period_id).toBe('fp-next')
})
it('records an actionable last_error when no open period exists at or after the date', async () => {
mockFindFiscalPeriod.mockResolvedValueOnce(null)
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeInstallment()] },
{ data: { bookkeeping_locked_through: null } },
{ data: null }, // no open period after the date either
{ data: null }, // last_error update
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
expect(result).toMatchObject({ posted: 0, failed: 1 })
expect(result.errors[0].message).toMatch(/eller senare/)
expect(result.errors[0].message).toMatch(/skapa nästa räkenskapsår/)
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('records last_error and continues when posting fails', async () => {
mockCreateJournalEntry.mockRejectedValueOnce(new Error('Bokföringen är låst'))
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: [
makeInstallment({ id: 'inst-1' }),
makeInstallment({ id: 'inst-2', period_month: '2026-02-01' }),
],
},
{ data: { bookkeeping_locked_through: null } },
{ data: null }, // last_error update for inst-1
{ data: [{ id: 'inst-2' }] }, // CAS claim for inst-2
{ count: 0 },
{ data: null },
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-02-20',
})
expect(result).toMatchObject({ posted: 1, failed: 1 })
expect(result.errors[0]).toMatchObject({ installmentId: 'inst-1' })
})
it('ignores installments whose schedule is not active', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{
data: [
makeInstallment({ schedule: makeSchedule({ status: 'cancelled' }) }),
],
},
])
const result = await postDueInstallments(supabase as unknown as SupabaseClient, COMPANY, {
userId: USER,
today: '2026-01-20',
})
expect(result.posted).toBe(0)
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
})
describe('createAccrualSchedule', () => {
const spec = {
direction: 'expense' as const,
supplierInvoiceId: 'si-1',
supplierInvoiceItemId: 'sii-1',
balanceAccount: '1730',
targetAccount: '6310',
totalAmountSek: 12000,
periodStart: '2026-01-01',
periodEnd: '2026-12-31',
description: 'Försäkring 2026',
}
it('creates the schedule and 12 installments without posting when postCatchUp=false', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeSchedule() }, // schedule insert
{ data: null }, // installments insert
])
const schedule = await createAccrualSchedule(
supabase as unknown as SupabaseClient,
COMPANY,
USER,
spec,
{ originJournalEntryId: 'je-origin', postingFloorDate: '2026-01-15', postCatchUp: false },
)
expect(schedule.id).toBe('sched-1')
expect(mockCreateJournalEntry).not.toHaveBeenCalled()
})
it('cleans up the schedule when installment insert fails', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeSchedule() },
{ data: null, error: { message: 'boom' } },
{ data: null }, // cleanup delete
])
await expect(
createAccrualSchedule(
supabase as unknown as SupabaseClient,
COMPANY,
USER,
spec,
{ postingFloorDate: '2026-01-15', postCatchUp: false },
),
).rejects.toThrow(/installments/i)
})
})
describe('dissolveScheduleNow', () => {
it('posts one combined entry for the remaining months', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeSchedule() }, // schedule fetch
{
data: [
makeInstallment({ id: 'inst-11', period_month: '2026-11-01', amount: 1000 }),
makeInstallment({ id: 'inst-12', period_month: '2026-12-01', amount: 1000 }),
],
},
{ data: { bookkeeping_locked_through: null } },
{ data: [{ id: 'inst-11' }, { id: 'inst-12' }] }, // claim both
{ data: null }, // schedule completed
])
const result = await dissolveScheduleNow(
supabase as unknown as SupabaseClient,
COMPANY,
USER,
'sched-1',
{ today: '2026-10-15' },
)
expect(result).toMatchObject({ journalEntryId: 'je-new', amount: 2000 })
const input = mockCreateJournalEntry.mock.calls[0][3]
expect(input.entry_date).toBe('2026-10-15')
expect(input.lines[0]).toMatchObject({ account_number: '6310', debit_amount: 2000 })
})
it('reverses the combined entry when the claim is incomplete', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeSchedule() },
{
data: [
makeInstallment({ id: 'inst-11', period_month: '2026-11-01', amount: 1000 }),
makeInstallment({ id: 'inst-12', period_month: '2026-12-01', amount: 1000 }),
],
},
{ data: { bookkeeping_locked_through: null } },
{ data: [{ id: 'inst-11' }] }, // only one claimed
])
await expect(
dissolveScheduleNow(supabase as unknown as SupabaseClient, COMPANY, USER, 'sched-1', {
today: '2026-10-15',
}),
).rejects.toThrow(/ändrades samtidigt/i)
expect(mockReverseEntry).toHaveBeenCalled()
})
it('throws a typed not-found error with a stable code', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([{ data: null, error: { message: 'No rows' } }])
await expect(
dissolveScheduleNow(supabase as unknown as SupabaseClient, COMPANY, USER, 'missing'),
).rejects.toMatchObject({
code: ACCRUAL_SCHEDULE_NOT_FOUND,
message: 'Periodiseringen hittades inte',
})
})
it('throws a typed not-active error with a stable code', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([{ data: makeSchedule({ status: 'completed' }) }])
await expect(
dissolveScheduleNow(supabase as unknown as SupabaseClient, COMPANY, USER, 'sched-1'),
).rejects.toMatchObject({
code: ACCRUAL_SCHEDULE_NOT_ACTIVE,
message: 'Periodiseringen är inte aktiv',
})
})
it('throws a typed nothing-to-dissolve error with a stable code', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: makeSchedule() }, // schedule fetch
{ data: [] }, // nothing pending
{ count: 0 }, // completeScheduleIfDone: remaining pending
{ data: null }, // schedule -> completed
])
await expect(
dissolveScheduleNow(supabase as unknown as SupabaseClient, COMPANY, USER, 'sched-1'),
).rejects.toMatchObject({
code: ACCRUAL_NOTHING_TO_DISSOLVE,
message: 'Det finns inget kvar att lösa upp',
})
})
})
describe('cancelSchedulesForSource', () => {
it('cancels pending months, reverses posted ones, and cancels the schedule', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeSchedule()] }, // schedules for source
{ data: null }, // pending -> cancelled
{ data: [{ id: 'inst-1', journal_entry_id: 'je-jan' }] }, // posted rows
{ data: null }, // schedule -> cancelled
])
const result = await cancelSchedulesForSource(
supabase as unknown as SupabaseClient,
COMPANY,
USER,
{ supplierInvoiceId: 'si-1' },
{ reversalDate: '2026-05-01' },
)
expect(result).toEqual({ cancelledSchedules: 1, reversedEntries: 1, failedReversals: 0 })
expect(mockReverseEntry).toHaveBeenCalledWith(
expect.anything(),
COMPANY,
USER,
'je-jan',
'2026-05-01',
)
})
it('leaves the schedule active and reports the failure when a storno fails', async () => {
mockReverseEntry.mockRejectedValueOnce(new Error('Bokföringen är låst'))
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeSchedule()] }, // schedules for source
{ data: null }, // pending -> cancelled
{ data: [{ id: 'inst-1', journal_entry_id: 'je-jan' }] }, // posted rows
{ data: null }, // last_error update on the stuck installment
// NOTE: no schedule -> cancelled update is queued; the schedule must
// stay 'active' so the UI keeps showing the un-reversed remainder.
])
const result = await cancelSchedulesForSource(
supabase as unknown as SupabaseClient,
COMPANY,
USER,
{ supplierInvoiceId: 'si-1' },
)
expect(result).toEqual({ cancelledSchedules: 0, reversedEntries: 0, failedReversals: 1 })
})
it('treats already-reversed dissolutions as success and still cancels the schedule', async () => {
mockReverseEntry
.mockRejectedValueOnce(new EntryAlreadyReversedError())
.mockRejectedValueOnce(new CannotReverseNonPostedError('reversed'))
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueMany([
{ data: [makeSchedule()] },
{ data: null }, // pending -> cancelled
{
data: [
{ id: 'inst-1', journal_entry_id: 'je-jan' },
{ id: 'inst-2', journal_entry_id: 'je-feb' },
],
},
{ data: null }, // schedule -> cancelled
])
const result = await cancelSchedulesForSource(
supabase as unknown as SupabaseClient,
COMPANY,
USER,
{ supplierInvoiceId: 'si-1' },
)
expect(result).toEqual({ cancelledSchedules: 1, reversedEntries: 0, failedReversals: 0 })
})
it('requires a source id', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
cancelSchedulesForSource(supabase as unknown as SupabaseClient, COMPANY, USER, {}),
).rejects.toThrow(/source invoice id/i)
})
})
@@ -0,0 +1,67 @@
/**
* Default interim (balance) account per P&L account for periodisering.
*
* Förutbetalda kostnader sit on 17xx interimsfordringar; the specific account
* follows BAS convention by cost type. Förutbetalda intäkter sit on 2970
* (29xx interimsskulder). The user can always override in the form these
* are suggestions, and the DB CHECK only enforces the 17xx/29xx range.
*/
import type { AccrualDirection } from '@/types'
export const DEFAULT_PREPAID_EXPENSE_ACCOUNT = '1790' // Övriga förutbetalda kostnader och upplupna intäkter
export const DEFAULT_DEFERRED_REVENUE_ACCOUNT = '2970' // Förutbetalda intäkter
/** Suggest the interim account for a deferred line booked to targetAccount. */
export function suggestBalanceAccount(
direction: AccrualDirection,
targetAccount: string,
): string {
if (direction === 'revenue') {
return DEFAULT_DEFERRED_REVENUE_ACCOUNT
}
// Hyror (lokalkostnader 50xx) → 1710 Förutbetalda hyreskostnader
if (targetAccount.startsWith('50')) return '1710'
// Hyra/leasing av anläggningstillgångar (52xx) and leasing personbilar
// (5615) → 1720 Förutbetalda leasingavgifter
if (targetAccount.startsWith('52') || targetAccount === '5615') return '1720'
// Försäkringar (63xx, primarily 6310) → 1730 Förutbetalda försäkringspremier
if (targetAccount.startsWith('63')) return '1730'
// Räntekostnader (84xx) → 1740 Förutbetalda räntekostnader
if (targetAccount.startsWith('84')) return '1740'
return DEFAULT_PREPAID_EXPENSE_ACCOUNT
}
/** DB CHECK mirror: 17xx for expense schedules, 29xx for revenue schedules. */
export function isValidBalanceAccount(
direction: AccrualDirection,
account: string,
): boolean {
return direction === 'expense' ? /^17\d{2}$/.test(account) : /^29\d{2}$/.test(account)
}
interface AccrualItemFields {
accrual_period_start?: string | null
accrual_period_end?: string | null
accrual_balance_account?: string | null
}
/** True when the line carries a complete periodisering period. */
export function itemHasAccrual(item: AccrualItemFields): boolean {
return Boolean(item.accrual_period_start && item.accrual_period_end)
}
/**
* The account a line's net amount is actually booked to: the interim account
* for deferred lines, otherwise the line's own P&L account. Used by the
* entry generators so VAT/AP/AR lines stay untouched while the net moves to
* 17xx/29xx.
*/
export function resolveBookingAccount(
direction: AccrualDirection,
item: AccrualItemFields,
plAccount: string,
): string {
if (!itemHasAccrual(item)) return plAccount
return item.accrual_balance_account ?? suggestBalanceAccount(direction, plAccount)
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Pure date/amount math for periodisering (accrual schedules).
*
* All dates are ISO strings (YYYY-MM-DD) handled numerically never via
* `new Date()` so the result is independent of server timezone. Amounts
* round via `roundOre` from `@/lib/money`, never `toFixed()`.
*/
export interface InstallmentPlan {
/** First day of the calendar month, ISO date. */
period_month: string
amount: number
}
function parseIso(date: string): { year: number; month: number } {
const year = Number(date.slice(0, 4))
const month = Number(date.slice(5, 7))
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) {
throw new Error(`Invalid ISO date: ${date}`)
}
return { year, month }
}
function toMonthIso(year: number, month: number): string {
return `${year}-${String(month).padStart(2, '0')}-01`
}
/** '2026-01-15' → '2026-01-01' */
export function firstOfMonth(date: string): string {
const { year, month } = parseIso(date)
return toMonthIso(year, month)
}
/** Number of calendar months touched by [periodStart, periodEnd], inclusive. */
export function countCalendarMonths(periodStart: string, periodEnd: string): number {
const start = parseIso(periodStart)
const end = parseIso(periodEnd)
const months = (end.year - start.year) * 12 + (end.month - start.month) + 1
if (months < 1) {
throw new Error(`Period end ${periodEnd} precedes period start ${periodStart}`)
}
return months
}
/** First-of-month ISO dates for every calendar month in the period. */
export function listCalendarMonths(periodStart: string, periodEnd: string): string[] {
const months = countCalendarMonths(periodStart, periodEnd)
const start = parseIso(periodStart)
const result: string[] = []
for (let i = 0; i < months; i++) {
const total = start.year * 12 + (start.month - 1) + i
result.push(toMonthIso(Math.floor(total / 12), (total % 12) + 1))
}
return result
}
/**
* Split a total over N months so the installments sum to the total EXACTLY.
* Even split in öre; the remainder öre are distributed one per month from
* the first month, so no installment differs by more than 1 öre.
*
* Throws when the total is too small to give every month at least 1 öre
* the DB CHECK requires every installment amount > 0.
*/
export function computeInstallmentAmounts(totalAmount: number, months: number): number[] {
if (!Number.isInteger(months) || months < 1) {
throw new Error(`Invalid month count: ${months}`)
}
const totalOre = Math.round(totalAmount * 100)
if (totalOre < months) {
throw new Error(
`Amount ${totalAmount} is too small to spread over ${months} months`,
)
}
const baseOre = Math.floor(totalOre / months)
const remainder = totalOre - baseOre * months
const amounts: number[] = []
for (let i = 0; i < months; i++) {
amounts.push((baseOre + (i < remainder ? 1 : 0)) / 100)
}
return amounts
}
/** Full plan: one installment per calendar month in the period. */
export function computeInstallments(
totalAmount: number,
periodStart: string,
periodEnd: string,
): InstallmentPlan[] {
const monthList = listCalendarMonths(periodStart, periodEnd)
const amounts = computeInstallmentAmounts(totalAmount, monthList.length)
return monthList.map((period_month, i) => ({ period_month, amount: amounts[i] }))
}
/** Latest of any number of ISO dates (lexicographic compare is safe). */
export function maxIsoDate(...dates: Array<string | null | undefined>): string {
const present = dates.filter((d): d is string => Boolean(d))
if (present.length === 0) throw new Error('maxIsoDate requires at least one date')
return present.reduce((a, b) => (a >= b ? a : b))
}
/** '2026-03-31' → '2026-04-01' (day after, calendar-correct). */
export function dayAfter(date: string): string {
const year = Number(date.slice(0, 4))
const month = Number(date.slice(5, 7))
const day = Number(date.slice(8, 10))
// Date.UTC handles month/year rollover; we only ever format back to ISO.
const next = new Date(Date.UTC(year, month - 1, day + 1))
return next.toISOString().slice(0, 10)
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Typed domain errors for the periodisering (accrual schedule) service.
*
* Mirrors the lib/bookkeeping/errors.ts pattern: each class carries a stable
* `code` so API routes dispatch on the code instead of matching Swedish prose.
* The Swedish messages are user-facing and intentionally unchanged from the
* original plain-Error throws.
*/
export const ACCRUAL_SCHEDULE_NOT_FOUND = 'ACCRUAL_SCHEDULE_NOT_FOUND' as const
export const ACCRUAL_SCHEDULE_NOT_ACTIVE = 'ACCRUAL_SCHEDULE_NOT_ACTIVE' as const
export const ACCRUAL_NOTHING_TO_DISSOLVE = 'ACCRUAL_NOTHING_TO_DISSOLVE' as const
export class AccrualScheduleNotFoundError extends Error {
readonly code = ACCRUAL_SCHEDULE_NOT_FOUND
constructor() {
super('Periodiseringen hittades inte')
this.name = 'AccrualScheduleNotFoundError'
}
}
export class AccrualScheduleNotActiveError extends Error {
readonly code = ACCRUAL_SCHEDULE_NOT_ACTIVE
constructor(public readonly currentStatus: string) {
super('Periodiseringen är inte aktiv')
this.name = 'AccrualScheduleNotActiveError'
}
}
export class AccrualNothingToDissolveError extends Error {
readonly code = ACCRUAL_NOTHING_TO_DISSOLVE
constructor() {
super('Det finns inget kvar att lösa upp')
this.name = 'AccrualNothingToDissolveError'
}
}
export function isAccrualError(
err: unknown,
): err is
| AccrualScheduleNotFoundError
| AccrualScheduleNotActiveError
| AccrualNothingToDissolveError {
return (
err instanceof AccrualScheduleNotFoundError ||
err instanceof AccrualScheduleNotActiveError ||
err instanceof AccrualNothingToDissolveError
)
}
+196
View File
@@ -0,0 +1,196 @@
/**
* Bridges invoice flows to accrual schedules: after the registration/revenue
* entry is committed, every line that carries a periodisering period gets a
* schedule (+ synchronous catch-up posting for months that already began).
*
* Idempotent per line: lines already covered by a schedule are skipped, so
* event replays (supplier_invoice.confirmed) can never double-schedule.
* Failures are logged and counted, never thrown the origin entry is already
* committed and must not be rolled back by a schedule hiccup; the caller
* surfaces a warning instead.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Invoice, InvoiceItem, SupplierInvoice, SupplierInvoiceItem } from '@/types'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { createAccrualSchedule } from '@/lib/bookkeeping/accruals/service'
import {
itemHasAccrual,
suggestBalanceAccount,
} from '@/lib/bookkeeping/accruals/account-suggestions'
import { getRevenueAccount } from '@/lib/bookkeeping/invoice-entries'
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import type { EntityType } from '@/types'
import { createLogger } from '@/lib/logger'
const log = createLogger('bookkeeping.accruals.from-invoices')
export interface ScheduleCreationResult {
created: number
failed: number
}
export async function createSchedulesForSupplierInvoice(
supabase: SupabaseClient,
companyId: string,
userId: string,
invoice: SupplierInvoice,
items: SupplierInvoiceItem[],
originJournalEntryId: string,
): Promise<ScheduleCreationResult> {
const accrualItems = items.filter(itemHasAccrual)
const result: ScheduleCreationResult = { created: 0, failed: 0 }
if (accrualItems.length === 0) return result
const { data: existing } = await supabase
.from('accrual_schedules')
.select('supplier_invoice_item_id')
.eq('company_id', companyId)
.eq('supplier_invoice_id', invoice.id)
const covered = new Set(
((existing ?? []) as Array<{ supplier_invoice_item_id: string | null }>).map(
(row) => row.supplier_invoice_item_id,
),
)
for (const item of accrualItems) {
if (item.id && covered.has(item.id)) continue
try {
const totalSek =
Math.round(
resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate) *
100,
) / 100
await createAccrualSchedule(
supabase,
companyId,
userId,
{
direction: 'expense',
supplierInvoiceId: invoice.id,
supplierInvoiceItemId: item.id ?? null,
balanceAccount:
item.accrual_balance_account ??
suggestBalanceAccount('expense', item.account_number),
targetAccount: item.account_number,
totalAmountSek: totalSek,
periodStart: item.accrual_period_start as string,
periodEnd: item.accrual_period_end as string,
description: `${item.description} (leverantörsfaktura ${invoice.supplier_invoice_number})`,
},
{
originJournalEntryId,
// The registration entry is dated invoice_date — dissolutions may
// never precede it.
postingFloorDate: invoice.invoice_date,
},
)
result.created++
} catch (error) {
result.failed++
log.error('failed to create accrual schedule for supplier invoice line', error, {
companyId,
entityId: invoice.id,
})
}
}
return result
}
export async function createSchedulesForCustomerInvoice(
supabase: SupabaseClient,
companyId: string,
userId: string,
invoice: Invoice,
items: InvoiceItem[],
originJournalEntryId: string,
entityType: EntityType = 'enskild_firma',
): Promise<ScheduleCreationResult> {
const accrualItems = items.filter(
(item) => itemHasAccrual(item) && item.line_type !== 'text' && !item.deduction_type,
)
const result: ScheduleCreationResult = { created: 0, failed: 0 }
if (accrualItems.length === 0) return result
const { data: existing } = await supabase
.from('accrual_schedules')
.select('invoice_item_id')
.eq('company_id', companyId)
.eq('invoice_id', invoice.id)
const covered = new Set(
((existing ?? []) as Array<{ invoice_item_id: string | null }>).map(
(row) => row.invoice_item_id,
),
)
for (const item of accrualItems) {
if (item.id && covered.has(item.id)) continue
const target = resolveRevenueTarget(item, invoice.vat_treatment, entityType)
// reverse_charge/export lines keep their statutory account (3308/3305) so
// ruta 39/40 in the momsdeklaration stay correct — never deferred. The
// generator applies the same exclusion, so the net stays on 3308/3305.
if (target.special) continue
try {
const targetAccount = target.account
const totalSek =
Math.round(
resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate) *
100,
) / 100
await createAccrualSchedule(
supabase,
companyId,
userId,
{
direction: 'revenue',
invoiceId: invoice.id,
invoiceItemId: item.id ?? null,
balanceAccount:
item.accrual_balance_account ?? suggestBalanceAccount('revenue', targetAccount),
targetAccount,
totalAmountSek: totalSek,
periodStart: item.accrual_period_start as string,
periodEnd: item.accrual_period_end as string,
description: `${item.description} (faktura ${invoice.invoice_number ?? ''})`.trim(),
},
{
originJournalEntryId,
postingFloorDate: invoice.invoice_date,
},
)
result.created++
} catch (error) {
result.failed++
log.error('failed to create accrual schedule for invoice line', error, {
companyId,
entityId: invoice.id,
})
}
}
return result
}
/**
* Resolve a line's revenue account exactly the way generatePerRateLines does:
* per-line override only for ordinary domestic rates; reverse_charge/export
* force the statutory account and are flagged `special` (never deferrable).
*/
function resolveRevenueTarget(
item: InvoiceItem,
invoiceTreatment: Invoice['vat_treatment'],
entityType: EntityType,
): { account: string; special: boolean } {
const rate = item.vat_rate ?? 0
const treatment =
rate === 0 && (invoiceTreatment === 'reverse_charge' || invoiceTreatment === 'export')
? invoiceTreatment
: getVatTreatmentForRate(rate)
const special = treatment === 'reverse_charge' || treatment === 'export'
const account =
!special && item.revenue_account
? item.revenue_account
: getRevenueAccount(treatment, entityType)
return { account, special }
}
+691
View File
@@ -0,0 +1,691 @@
/**
* Periodisering (accrual schedule) service.
*
* A schedule spreads one invoice line's net amount over the calendar months
* of a service period. The origin entry (supplier invoice registration /
* customer invoice revenue entry) books the net to an interim account
* (17xx/29xx); this service creates and posts the monthly dissolution
* entries:
*
* expense: Dr target (5xxx/6xxx) / Cr balance (17xx)
* revenue: Dr balance (29xx) / Cr target (3xxx)
*
* All entries go through the engine (source_type 'accrual') never direct
* inserts. Posting dates are max(period_month, posting_floor_date,
* company lock date + 1) so catch-up months book correctly and the interim
* account never goes negative.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
AccrualDirection,
AccrualSchedule,
AccrualScheduleInstallment,
CreateJournalEntryLineInput,
} from '@/types'
import {
createJournalEntry,
findFiscalPeriod,
reverseEntry,
} from '@/lib/bookkeeping/engine'
import {
CannotReverseNonPostedError,
EntryAlreadyReversedError,
} from '@/lib/bookkeeping/errors'
import {
AccrualNothingToDissolveError,
AccrualScheduleNotActiveError,
AccrualScheduleNotFoundError,
} from '@/lib/bookkeeping/accruals/errors'
import {
computeInstallments,
dayAfter,
firstOfMonth,
maxIsoDate,
} from '@/lib/bookkeeping/accruals/compute'
import { roundOre, sumOre } from '@/lib/money'
import { createLogger } from '@/lib/logger'
const log = createLogger('bookkeeping.accruals')
export interface AccrualScheduleSpec {
direction: AccrualDirection
supplierInvoiceId?: string
supplierInvoiceItemId?: string | null
invoiceId?: string
invoiceItemId?: string | null
/** Interim account: 17xx (expense) / 29xx (revenue). */
balanceAccount: string
/** The P&L account the amount dissolves to. */
targetAccount: string
/** Net amount in SEK as booked on the origin entry (ex VAT). */
totalAmountSek: number
periodStart: string
periodEnd: string
description: string
}
export interface PostDueResult {
posted: number
failed: number
skipped: number
errors: Array<{ installmentId: string; message: string }>
}
type ScheduleRow = AccrualSchedule
type InstallmentRow = AccrualScheduleInstallment & { schedule?: ScheduleRow | null }
function todayIso(): string {
return new Date().toISOString().slice(0, 10)
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
async function fetchLockDateFloor(
supabase: SupabaseClient,
companyId: string,
): Promise<string | null> {
const { data } = await supabase
.from('company_settings')
.select('bookkeeping_locked_through')
.eq('company_id', companyId)
.maybeSingle()
const lockedThrough = (data as { bookkeeping_locked_through?: string | null } | null)
?.bookkeeping_locked_through
return lockedThrough ? dayAfter(lockedThrough) : null
}
/**
* Earliest OPEN fiscal period starting after `date`. Used to clamp a posting
* date forward when the computed date falls inside a closed period (bokslut
* done) same spirit as the company lock-date floor.
*/
async function findNextOpenPeriodStart(
supabase: SupabaseClient,
companyId: string,
date: string,
): Promise<{ fiscalPeriodId: string; periodStart: string } | null> {
const { data } = await supabase
.from('fiscal_periods')
.select('id, period_start')
.eq('company_id', companyId)
.eq('is_closed', false)
.gt('period_start', date)
.order('period_start', { ascending: true })
.limit(1)
.maybeSingle()
const row = data as { id: string; period_start: string } | null
return row ? { fiscalPeriodId: row.id, periodStart: row.period_start } : null
}
function dissolutionLines(
schedule: Pick<ScheduleRow, 'direction' | 'balance_account' | 'target_account'>,
amount: number,
lineDescription: string,
): CreateJournalEntryLineInput[] {
if (schedule.direction === 'expense') {
return [
{
account_number: schedule.target_account,
debit_amount: amount,
credit_amount: 0,
line_description: lineDescription,
},
{
account_number: schedule.balance_account,
debit_amount: 0,
credit_amount: amount,
line_description: lineDescription,
},
]
}
return [
{
account_number: schedule.balance_account,
debit_amount: amount,
credit_amount: 0,
line_description: lineDescription,
},
{
account_number: schedule.target_account,
debit_amount: 0,
credit_amount: amount,
line_description: lineDescription,
},
]
}
/**
* Create a schedule + its monthly installments for one deferred invoice
* line, then immediately post every installment whose month has already
* begun (catch-up). Catch-up failures do not throw they are recorded on
* the installment (last_error) and retried by the daily cron.
*/
export async function createAccrualSchedule(
supabase: SupabaseClient,
companyId: string,
userId: string,
spec: AccrualScheduleSpec,
options: {
originJournalEntryId?: string | null
/** Entry date of the origin entry; floors all dissolution dates. */
postingFloorDate: string
/** Skip synchronous catch-up posting (tests / bulk imports). */
postCatchUp?: boolean
today?: string
},
): Promise<AccrualSchedule> {
const plan = computeInstallments(spec.totalAmountSek, spec.periodStart, spec.periodEnd)
const { data: schedule, error: scheduleError } = await supabase
.from('accrual_schedules')
.insert({
user_id: userId,
company_id: companyId,
direction: spec.direction,
supplier_invoice_id: spec.supplierInvoiceId ?? null,
supplier_invoice_item_id: spec.supplierInvoiceItemId ?? null,
invoice_id: spec.invoiceId ?? null,
invoice_item_id: spec.invoiceItemId ?? null,
balance_account: spec.balanceAccount,
target_account: spec.targetAccount,
total_amount: roundOre(spec.totalAmountSek),
period_start: spec.periodStart,
period_end: spec.periodEnd,
months: plan.length,
origin_journal_entry_id: options.originJournalEntryId ?? null,
posting_floor_date: options.postingFloorDate,
status: 'active',
description: spec.description,
})
.select('*')
.single()
if (scheduleError || !schedule) {
throw new Error(
`Failed to create accrual schedule: ${scheduleError?.message ?? 'no row returned'}`,
)
}
const scheduleRow = schedule as ScheduleRow
const { error: installmentError } = await supabase
.from('accrual_schedule_installments')
.insert(
plan.map((installment) => ({
user_id: userId,
company_id: companyId,
schedule_id: scheduleRow.id,
period_month: installment.period_month,
amount: installment.amount,
status: 'pending',
})),
)
if (installmentError) {
// Schedule without installments is inert but confusing — clean it up.
await supabase.from('accrual_schedules').delete().eq('id', scheduleRow.id)
throw new Error(`Failed to create accrual installments: ${installmentError.message}`)
}
if (options.postCatchUp !== false) {
const result = await postDueInstallments(supabase, companyId, {
userId,
scheduleId: scheduleRow.id,
today: options.today,
})
if (result.failed > 0) {
log.warn('accrual catch-up posting failed for some installments', {
companyId,
scheduleId: scheduleRow.id,
failed: result.failed,
})
}
}
return scheduleRow
}
/**
* Post every pending installment whose calendar month has begun.
* Used by the daily cron, the manual "Bokför förfallna" action, and the
* synchronous catch-up at schedule creation (scheduleId filter).
*
* Each installment books independently; one failure never blocks the rest.
*/
export async function postDueInstallments(
supabase: SupabaseClient,
companyId: string,
options: {
/** Falls back to each schedule's creator for cron runs. */
userId?: string
scheduleId?: string
today?: string
} = {},
): Promise<PostDueResult> {
const today = options.today ?? todayIso()
const result: PostDueResult = { posted: 0, failed: 0, skipped: 0, errors: [] }
let query = supabase
.from('accrual_schedule_installments')
.select('*, schedule:accrual_schedules(*)')
.eq('company_id', companyId)
.eq('status', 'pending')
.lte('period_month', firstOfMonth(today))
.order('period_month', { ascending: true })
if (options.scheduleId) {
query = query.eq('schedule_id', options.scheduleId)
}
const { data, error } = await query
if (error) {
throw new Error(`Failed to load due accrual installments: ${error.message}`)
}
const due = ((data ?? []) as InstallmentRow[]).filter(
(installment) => installment.schedule?.status === 'active',
)
if (due.length === 0) return result
const lockFloor = await fetchLockDateFloor(supabase, companyId)
const touchedSchedules = new Set<string>()
for (const installment of due) {
const schedule = installment.schedule as ScheduleRow
try {
let entryDate = maxIsoDate(
installment.period_month,
schedule.posting_floor_date,
lockFloor,
)
let fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate)
if (!fiscalPeriodId) {
// The computed date can fall inside a CLOSED fiscal period (bokslut
// done while the company lock date lags behind). Clamp forward to the
// start of the earliest open period — same spirit as the lockFloor —
// instead of retrying the same impossible date forever.
const clamped = await findNextOpenPeriodStart(supabase, companyId, entryDate)
if (!clamped) {
throw new Error(
`Ingen öppen räkenskapsperiod för ${entryDate} eller senare — ` +
'skapa nästa räkenskapsår för att kunna bokföra periodiseringen',
)
}
entryDate = clamped.periodStart
fiscalPeriodId = clamped.fiscalPeriodId
}
const monthLabel = installment.period_month.slice(0, 7)
const description = schedule.description
? `Periodisering ${monthLabel}: ${schedule.description}`
: `Periodisering ${monthLabel}`
const entry = await createJournalEntry(
supabase,
companyId,
options.userId ?? schedule.user_id,
{
fiscal_period_id: fiscalPeriodId,
entry_date: entryDate,
description,
source_type: 'accrual',
source_id: schedule.id,
lines: dissolutionLines(schedule, installment.amount, description),
},
)
// CAS claim: only the runner that flips pending→posted keeps its entry.
// A concurrent runner (cron + manual button) loses the race and stornos
// its own entry so the ledger nets to a single dissolution.
const { data: claimed, error: claimError } = await supabase
.from('accrual_schedule_installments')
.update({
status: 'posted',
journal_entry_id: entry.id,
posted_at: new Date().toISOString(),
last_error: null,
})
.eq('id', installment.id)
.eq('status', 'pending')
.select('id')
if (claimError) {
// The journal entry is already committed but the installment was NOT
// flipped to posted — without a storno the next run would book the
// same month twice. Mirror the dissolveScheduleNow handling: reverse
// our own entry best-effort, then record the failure (the catch below
// writes last_error on the installment).
let reversalNote = ''
try {
await reverseEntry(
supabase,
companyId,
options.userId ?? schedule.user_id,
entry.id,
)
} catch (reversalError) {
log.error('failed to reverse accrual entry after claim error', reversalError, {
companyId,
installmentId: installment.id,
entryId: entry.id,
})
reversalNote =
`; storno av verifikat ${entry.id} misslyckades också: ` +
getErrorMessage(reversalError)
}
throw new Error(
`Failed to mark installment posted: ${claimError.message}${reversalNote}`,
)
}
if (!claimed || claimed.length === 0) {
log.warn('accrual installment claimed concurrently; reversing duplicate entry', {
companyId,
installmentId: installment.id,
entryId: entry.id,
})
await reverseEntry(
supabase,
companyId,
options.userId ?? schedule.user_id,
entry.id,
)
result.skipped++
continue
}
touchedSchedules.add(schedule.id)
result.posted++
} catch (error) {
const message = getErrorMessage(error)
log.error('failed to post accrual installment', error, {
companyId,
installmentId: installment.id,
scheduleId: schedule.id,
})
result.failed++
result.errors.push({ installmentId: installment.id, message })
await supabase
.from('accrual_schedule_installments')
.update({ last_error: message })
.eq('id', installment.id)
.eq('status', 'pending')
}
}
for (const scheduleId of touchedSchedules) {
await completeScheduleIfDone(supabase, companyId, scheduleId)
}
return result
}
async function completeScheduleIfDone(
supabase: SupabaseClient,
companyId: string,
scheduleId: string,
): Promise<void> {
const { count, error } = await supabase
.from('accrual_schedule_installments')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('schedule_id', scheduleId)
.eq('status', 'pending')
if (error || count === null || count > 0) return
await supabase
.from('accrual_schedules')
.update({ status: 'completed' })
.eq('id', scheduleId)
.eq('company_id', companyId)
.eq('status', 'active')
}
/**
* Dissolve everything that remains on a schedule in one entry, dated today
* (clamped by lock date / posting floor). Used when the service period ends
* early or the user simply wants the rest expensed now.
*/
export async function dissolveScheduleNow(
supabase: SupabaseClient,
companyId: string,
userId: string,
scheduleId: string,
options: { today?: string } = {},
): Promise<{ journalEntryId: string; amount: number }> {
const today = options.today ?? todayIso()
const { data: scheduleData, error: scheduleError } = await supabase
.from('accrual_schedules')
.select('*')
.eq('company_id', companyId)
.eq('id', scheduleId)
.single()
if (scheduleError || !scheduleData) {
throw new AccrualScheduleNotFoundError()
}
const schedule = scheduleData as ScheduleRow
if (schedule.status !== 'active') {
throw new AccrualScheduleNotActiveError(schedule.status)
}
const { data: pendingData, error: pendingError } = await supabase
.from('accrual_schedule_installments')
.select('*')
.eq('company_id', companyId)
.eq('schedule_id', scheduleId)
.eq('status', 'pending')
if (pendingError) {
throw new Error(`Failed to load installments: ${pendingError.message}`)
}
const pending = (pendingData ?? []) as InstallmentRow[]
if (pending.length === 0) {
await completeScheduleIfDone(supabase, companyId, scheduleId)
throw new AccrualNothingToDissolveError()
}
const amount = sumOre(pending.map((installment) => installment.amount))
const lockFloor = await fetchLockDateFloor(supabase, companyId)
const entryDate = maxIsoDate(today, schedule.posting_floor_date, lockFloor)
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate)
if (!fiscalPeriodId) {
throw new Error(`Ingen öppen räkenskapsperiod för ${entryDate}`)
}
const description = schedule.description
? `Periodisering, slutupplösning: ${schedule.description}`
: 'Periodisering, slutupplösning'
const entry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: entryDate,
description,
source_type: 'accrual',
source_id: schedule.id,
lines: dissolutionLines(schedule, amount, description),
})
const pendingIds = pending.map((installment) => installment.id)
const { data: claimed, error: claimError } = await supabase
.from('accrual_schedule_installments')
.update({
status: 'posted',
journal_entry_id: entry.id,
posted_at: new Date().toISOString(),
last_error: null,
})
.in('id', pendingIds)
.eq('status', 'pending')
.select('id')
if (claimError || !claimed || claimed.length !== pendingIds.length) {
// Concurrent posting changed the set under us — undo our combined entry
// and let the caller retry against the new state. If the storno itself
// fails, the combined entry stands while some installments point at the
// cron's entries — the interim account would dissolve twice. Surface
// both failures and pin the alert on the installments so the
// periodiseringar UI shows the stuck state instead of nothing.
try {
await reverseEntry(supabase, companyId, userId, entry.id)
} catch (reversalError) {
const message = getErrorMessage(reversalError)
log.error('failed to reverse combined dissolution entry after lost claim race', reversalError, {
companyId,
scheduleId,
entryId: entry.id,
})
await supabase
.from('accrual_schedule_installments')
.update({
last_error: `Storno av samlad upplösning (verifikat ${entry.id}) misslyckades — kontrollera interimskontot: ${message}`.slice(0, 2_000),
})
.in('id', pendingIds)
.eq('company_id', companyId)
throw new Error(
`Periodiseringen ändrades samtidigt och vändningen av det samlade verifikatet misslyckades: ${message}`,
)
}
throw new Error('Periodiseringen ändrades samtidigt — försök igen')
}
await supabase
.from('accrual_schedules')
.update({ status: 'completed' })
.eq('id', scheduleId)
.eq('company_id', companyId)
.eq('status', 'active')
return { journalEntryId: entry.id, amount }
}
/**
* Cancel all schedules belonging to a credited/cancelled invoice:
* pending installments are cancelled, already-posted dissolutions are
* reversed (storno), and the schedule is marked cancelled. The caller's
* credit-note entry reverses the interim account at its full original
* amount, so the net of origin + dissolutions + stornos + credit is zero.
*
* A schedule is only marked cancelled when ALL its posted dissolutions
* reversed cleanly otherwise it stays 'active' (so the UI keeps showing
* the un-reversed remainder instead of remaining=0 while 17xx/29xx is still
* unbalanced) and the stuck installments get a descriptive last_error.
* Callers should surface `failedReversals > 0` as a warning.
*/
export async function cancelSchedulesForSource(
supabase: SupabaseClient,
companyId: string,
userId: string,
source: { supplierInvoiceId?: string; invoiceId?: string },
options: { reversalDate?: string } = {},
): Promise<{
cancelledSchedules: number
reversedEntries: number
failedReversals: number
}> {
if (!source.supplierInvoiceId && !source.invoiceId) {
throw new Error('cancelSchedulesForSource requires a source invoice id')
}
let query = supabase
.from('accrual_schedules')
.select('*')
.eq('company_id', companyId)
.neq('status', 'cancelled')
query = source.supplierInvoiceId
? query.eq('supplier_invoice_id', source.supplierInvoiceId)
: query.eq('invoice_id', source.invoiceId as string)
const { data, error } = await query
if (error) {
throw new Error(`Failed to load accrual schedules: ${error.message}`)
}
const schedules = (data ?? []) as ScheduleRow[]
let cancelledSchedules = 0
let reversedEntries = 0
let failedReversals = 0
for (const schedule of schedules) {
// Cancel pending months first so the posting cron cannot book new
// dissolutions for a credited invoice while (or after) we storno the
// already-posted ones.
await supabase
.from('accrual_schedule_installments')
.update({ status: 'cancelled' })
.eq('company_id', companyId)
.eq('schedule_id', schedule.id)
.eq('status', 'pending')
const { data: postedData } = await supabase
.from('accrual_schedule_installments')
.select('id, journal_entry_id')
.eq('company_id', companyId)
.eq('schedule_id', schedule.id)
.eq('status', 'posted')
let scheduleFailures = 0
for (const installment of (postedData ?? []) as Array<{
id: string
journal_entry_id: string | null
}>) {
if (!installment.journal_entry_id) continue
try {
await reverseEntry(
supabase,
companyId,
userId,
installment.journal_entry_id,
options.reversalDate,
)
reversedEntries++
} catch (error) {
// A dissolution stornoed by an earlier (partially failed) cancel run
// is fine — idempotent re-credit, treated as success.
if (
error instanceof EntryAlreadyReversedError ||
(error instanceof CannotReverseNonPostedError &&
error.currentStatus === 'reversed')
) {
continue
}
scheduleFailures++
const message = getErrorMessage(error)
log.warn('could not reverse accrual dissolution during cancel', {
companyId,
scheduleId: schedule.id,
journalEntryId: installment.journal_entry_id,
message,
})
// Posted installments freeze their financial fields, but last_error
// stays writable — surface the stuck storno in the periodiseringar UI.
await supabase
.from('accrual_schedule_installments')
.update({
last_error: `Storno vid kreditering misslyckades: ${message}`,
})
.eq('id', installment.id)
.eq('company_id', companyId)
}
}
if (scheduleFailures > 0) {
failedReversals += scheduleFailures
continue
}
await supabase
.from('accrual_schedules')
.update({ status: 'cancelled' })
.eq('id', schedule.id)
.eq('company_id', companyId)
cancelledSchedules++
}
return { cancelledSchedules, reversedEntries, failedReversals }
}
@@ -2,6 +2,7 @@ import { eventBus } from '@/lib/events/bus'
import type { EventPayload } from '@/lib/events/types'
import { createClient } from '@/lib/supabase/server'
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matching'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { createLogger } from '@/lib/logger'
@@ -83,6 +84,23 @@ async function handleSupplierInvoiceConfirmed(
.from('supplier_invoices')
.update({ registration_journal_entry_id: journalEntry.id })
.eq('id', supplierInvoice.id)
// Lines with a periodisering period get their schedule + catch-up
// dissolutions. Idempotent per line, so a replayed event is safe.
const scheduleResult = await createSchedulesForSupplierInvoice(
supabase,
companyId,
userId,
supplierInvoice,
items as SupplierInvoiceItem[],
journalEntry.id,
)
if (scheduleResult.failed > 0) {
log.error('accrual schedule creation failed for confirmed supplier invoice', {
supplierInvoiceId: supplierInvoice.id,
failed: scheduleResult.failed,
})
}
}
} catch (err) {
log.error('Failed to create registration journal entry:', err)
+26 -4
View File
@@ -1,5 +1,6 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { resolveBookingAccount } from './accruals/account-suggestions'
import { generateSalesVatLines } from './vat-entries'
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import { computeDeduction } from '@/lib/invoices/rot-rut-rules'
@@ -48,6 +49,12 @@ function buildInvoiceDescription(
/**
* Group invoice items by VAT rate and generate per-rate revenue + VAT lines.
* Returns credit lines only (revenue + VAT). The caller adds the debit side.
*
* options.deferAccruals: substitute the 29xx interim account for lines with a
* periodisering period. Only the callers that also create/cancel accrual
* schedules may pass true (invoice entry + credit note) the cash-method
* entry books revenue directly even if a line carries stale accrual fields,
* since no schedule would ever dissolve the interim balance.
*/
function generatePerRateLines(
items: InvoiceItem[],
@@ -55,7 +62,8 @@ function generatePerRateLines(
entityType: EntityType,
invoiceTagText: string,
currency?: string | null,
exchangeRate?: number | null
exchangeRate?: number | null,
options?: { deferAccruals?: boolean }
): CreateJournalEntryLineInput[] {
const lines: CreateJournalEntryLineInput[] = []
const isForeign = currency != null && currency !== 'SEK'
@@ -132,9 +140,16 @@ function generatePerRateLines(
// a per-line override only applies to ordinary domestic rates so EU/export
// sales keep landing in the right VAT-declaration ruta.
const isSpecialTreatment = treatment === 'reverse_charge' || treatment === 'export'
const account = !isSpecialTreatment && item.revenue_account
const plAccount = !isSpecialTreatment && item.revenue_account
? item.revenue_account
: getRevenueAccount(treatment, entityType)
// Periodiserade lines credit the 29xx interim account (förutbetalda
// intäkter) instead of revenue; the schedule dissolves it monthly. Output
// VAT below is untouched — moms is never deferred. Special treatments are
// never deferred (ruta 39/40 must reflect the full period's sales).
const account = isSpecialTreatment || !options?.deferAccruals
? plAccount
: resolveBookingAccount('revenue', item, plAccount)
const group = rateGroups.get(rate) ?? { vatAmount: 0, byAccount: new Map<string, number>() }
group.vatAmount += item.vat_amount || 0
@@ -297,7 +312,10 @@ export async function createInvoiceJournalEntry(
if (invoice.items && invoice.items.length > 0) {
creditLines.push(...generatePerRateLines(
invoice.items, invoice.vat_treatment, entityType, tag,
invoice.currency, invoice.exchange_rate
invoice.currency, invoice.exchange_rate,
// Schedules are created right after this entry commits (send/mark-sent
// flows), so deferring to 29xx here is safe.
{ deferAccruals: true }
))
} else {
// Fallback: no items available, use invoice-level amounts
@@ -523,7 +541,11 @@ export async function createCreditNoteJournalEntry(
// Use absolute items for generatePerRateLines, then swap debit/credit
const creditLines = generatePerRateLines(
creditNote.items, creditNote.vat_treatment, entityType, tag,
creditNote.currency, creditNote.exchange_rate
creditNote.currency, creditNote.exchange_rate,
// Credit-note items carry the original's accrual fields so the reversal
// hits the same 29xx interim account; the original's schedule is
// cancelled/stornoed by the credit flow.
{ deferAccruals: true }
)
for (const line of creditLines) {
debitLines.push({
+15 -6
View File
@@ -1,5 +1,6 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { resolveBookingAccount } from './accruals/account-suggestions'
import {
generateReverseChargeLines,
generateReverseChargeBasisLines,
@@ -68,12 +69,16 @@ export async function createSupplierInvoiceRegistrationEntry(
const desc = buildSupplierDescription('Leverantörsfaktura', invoice.supplier_invoice_number, supplierName, `(ankomstnr ${invoice.arrival_number})`)
const isForeign = invoice.currency !== 'SEK'
// Aggregate expense amounts by account number and convert to SEK
// Aggregate expense amounts by account number and convert to SEK.
// Periodiserade lines book their net to the 17xx interim account instead
// of the cost account (resolveBookingAccount); VAT and 2440 are untouched —
// moms is never deferred (redovisas på fakturadatum).
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
const bookingAccount = resolveBookingAccount('expense', item, item.account_number)
const current = expenseByAccount.get(bookingAccount) || 0
const itemSek = resolveSekAmount(item.line_total, null, invoice.currency, invoice.exchange_rate)
expenseByAccount.set(item.account_number, current + itemSek)
expenseByAccount.set(bookingAccount, current + itemSek)
}
// Debit: Expense accounts (in SEK)
@@ -538,13 +543,17 @@ export async function createSupplierCreditNoteEntry(
const desc = buildSupplierDescription('Kreditfaktura leverantör', creditNote.supplier_invoice_number, supplierName, `(ankomstnr ${creditNote.arrival_number})`)
const lines: CreateJournalEntryLineInput[] = []
// Credit: Expense accounts (reverse, in SEK)
// Credit: Expense accounts (reverse, in SEK). The caller passes the
// ORIGINAL invoice's items so deferred lines reverse against the same 17xx
// interim account they were registered on (the schedule's posted
// dissolutions are stornoed separately by cancelSchedulesForSource).
const creditLines: CreateJournalEntryLineInput[] = []
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
const bookingAccount = resolveBookingAccount('expense', item, item.account_number)
const current = expenseByAccount.get(bookingAccount) || 0
const itemSek = Math.abs(resolveSekAmount(item.line_total, null, creditNote.currency, creditNote.exchange_rate))
expenseByAccount.set(item.account_number, current + itemSek)
expenseByAccount.set(bookingAccount, current + itemSek)
}
for (const [accountNumber, amount] of expenseByAccount) {
@@ -47,7 +47,13 @@ vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: vi.fn(() => makeClient()),
}))
import { uploadDocument, createNewVersion, verifyIntegrity, _resetBucketVerified } from '../document-service'
import {
uploadDocument,
createNewVersion,
verifyIntegrity,
validateDocumentMagicBytes,
_resetBucketVerified,
} from '../document-service'
// A minimal valid PDF byte sequence (header + EOF) — passes magic-byte check.
function pdfBuffer(payload = 'test'): ArrayBuffer {
@@ -62,6 +68,54 @@ beforeEach(() => {
results = []
})
describe('validateDocumentMagicBytes — application/xhtml+xml', () => {
const toBuffer = (text: string, bom = false): ArrayBuffer => {
const bytes = new TextEncoder().encode(bom ? `${text}` : text)
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
}
it('accepts content starting with an XML declaration', () => {
const xhtml = '<?xml version="1.0" encoding="UTF-8"?>\n<html xmlns="http://www.w3.org/1999/xhtml"></html>'
expect(validateDocumentMagicBytes(toBuffer(xhtml), 'application/xhtml+xml')).toBeNull()
})
it('accepts content starting with an HTML doctype or <html>, case-insensitively', () => {
expect(
validateDocumentMagicBytes(toBuffer('<!DOCTYPE html>\n<html></html>'), 'application/xhtml+xml'),
).toBeNull()
expect(
validateDocumentMagicBytes(toBuffer('<!doctype HTML><html></html>'), 'application/xhtml+xml'),
).toBeNull()
expect(
validateDocumentMagicBytes(toBuffer('<HTML xmlns="http://www.w3.org/1999/xhtml"></HTML>'), 'application/xhtml+xml'),
).toBeNull()
})
it('accepts a UTF-8 BOM and leading whitespace before the marker', () => {
expect(
validateDocumentMagicBytes(toBuffer('\n <?xml version="1.0"?><html></html>', true), 'application/xhtml+xml'),
).toBeNull()
})
it('rejects content that is not XHTML/XML', () => {
expect(validateDocumentMagicBytes(toBuffer('just some text'), 'application/xhtml+xml')).toMatch(
/kunde inte verifieras/,
)
expect(validateDocumentMagicBytes(pdfBuffer(), 'application/xhtml+xml')).toMatch(
/kunde inte verifieras/,
)
})
it('does not loosen validation for other declared types', () => {
// XHTML bytes declared as PDF must still be rejected.
expect(validateDocumentMagicBytes(toBuffer('<?xml version="1.0"?>'), 'application/pdf')).toMatch(
/kunde inte verifieras/,
)
// And a real PDF still passes as PDF.
expect(validateDocumentMagicBytes(pdfBuffer(), 'application/pdf')).toBeNull()
})
})
describe('uploadDocument', () => {
it('computes SHA-256 hash, stores metadata, emits document.uploaded', async () => {
const doc = makeDocumentAttachment({
+21
View File
@@ -88,6 +88,23 @@ function detectFileMagic(bytes: Uint8Array): string | null {
return null
}
/**
* XHTML/XML has no binary magic number. For the declared type
* application/xhtml+xml (system-generated iXBRL årsredovisningar) we instead
* require the content to start with an XML declaration, an HTML doctype, or
* an <html> root element (after an optional UTF-8 BOM and leading
* whitespace). This branch is consulted ONLY for that declared type it
* never loosens detection for PDF/PNG/JPEG/WEBP uploads.
*/
function looksLikeXhtml(bytes: Uint8Array): boolean {
const offset = bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF ? 3 : 0
const head = Buffer.from(bytes.slice(offset, offset + 256))
.toString('utf8')
.replace(/^[\s]+/, '')
.toLowerCase()
return head.startsWith('<?xml') || head.startsWith('<!doctype html') || head.startsWith('<html')
}
/**
* Verify the buffer actually contains a file of the declared type.
* Returns an error string or null if valid. HEIC has many ftyp brands so
@@ -96,6 +113,10 @@ function detectFileMagic(bytes: Uint8Array): string | null {
*/
export function validateDocumentMagicBytes(buffer: ArrayBuffer, declaredMimeType: string): string | null {
if (declaredMimeType === 'image/heic') return null
if (declaredMimeType === 'application/xhtml+xml') {
if (looksLikeXhtml(new Uint8Array(buffer))) return null
return `Filinnehållet kunde inte verifieras som ${declaredMimeType}. Filen verkar inte vara ett XHTML/XML-dokument.`
}
const detected = detectFileMagic(new Uint8Array(buffer))
if (!detected) {
return `Filinnehållet kunde inte verifieras som ${declaredMimeType}. Filen verkar vara skadad eller inte en riktig binärfil — vid uppladdning via API, kontrollera att file_content_base64 är base64-kodade råbytes, inte en textrepresentation.`
+18 -1
View File
@@ -179,6 +179,13 @@ interface ErrorResponseContext {
details?: unknown
/** When known, override the http status from the registry entry. */
status?: number
/**
* Override the registry messages when the route computes a dynamic message
* (e.g. interpolating a rolling year range). Provide both or neither so the
* sv/en pair never drifts apart.
*/
messageSv?: string
messageEn?: string
}
interface MinimalLogger {
@@ -404,5 +411,15 @@ export function errorResponseFromCode(
const entry = entryFor(code)
log.error(code, ctx.reason ?? entry.message_en, { requestId: ctx.requestId })
const status = ctx.status ?? entry.httpStatus
return buildResponse(code, { ...entry, httpStatus: status }, ctx.requestId, ctx.details)
return buildResponse(
code,
{
...entry,
httpStatus: status,
...(ctx.messageSv ? { message_sv: ctx.messageSv } : {}),
...(ctx.messageEn ? { message_en: ctx.messageEn } : {}),
},
ctx.requestId,
ctx.details,
)
}

Some files were not shown because too many files have changed in this diff Show More