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:
co-authored by
Claude Fable 5
parent
8e8b63a200
commit
db8983ba9e
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user