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,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
|
||||
|
||||
Reference in New Issue
Block a user