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:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 20–32) 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 },
|
||||
)
|
||||
@@ -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 })
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
// 20–32 — 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
|
||||
|
||||
@@ -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 & 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 då ä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 så 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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
* §5–6). 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 10–12 siffror'),
|
||||
undertecknare: z.object({
|
||||
pnr: z.string().regex(/^\d{10,12}$/, 'Personnummer anges med 10–12 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 6–8 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."
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 20–32 — 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 180–200 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.
|
||||
|
||||
@@ -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
@@ -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 2–120 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,
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 & Söner <Test> "AB"')
|
||||
// No raw < survives inside text nodes (only as markup).
|
||||
expect(xhtml).not.toContain('<komponenter>')
|
||||
})
|
||||
})
|
||||
@@ -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 3–8 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 3–8 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(' ')
|
||||
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.4–4.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 3–8 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 3–8, 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)
|
||||
})
|
||||
})
|
||||
@@ -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.4–4.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 3–8 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 3000–3799 — 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 }
|
||||
}
|
||||
@@ -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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/** Escape an attribute value (double-quoted attributes). */
|
||||
export function escapeAttr(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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('.', ',')
|
||||
}
|
||||
@@ -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 3–8 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 (4910–4929) belong to RavarorFornodenheterKostnader and
|
||||
// handelsvaror (4960–4969) 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 3–8); 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).',
|
||||
)
|
||||
}
|
||||
+13572
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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 (2090–2096 + 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[]
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.`
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user