diff --git a/.compliance/ropa.yaml b/.compliance/ropa.yaml index 6f2b8c45..a15c7ff8 100644 --- a/.compliance/ropa.yaml +++ b/.compliance/ropa.yaml @@ -321,3 +321,47 @@ processing_activities: - service_role_only_writes - error_message_truncated_500_chars - no_tool_args_or_results_persisted + + - id: arsredovisning.bolagsverket.submit + name: Digital inlämning av årsredovisning till Bolagsverket + purpose: >- + Lämna in årsredovisningen (iXBRL) digitalt till Bolagsverkets eget + utrymme (ÅRL 8 kap.). Avsändarens och undertecknarens personnummer + krävs av Bolagsverkets API (skapa-inlamningtoken, lamna-in) och + används transient i anropen; i databasen sparas endast + företagssaltade SHA-256-hashar. Fastställelseintyget signeras med + e-legitimation hos Bolagsverket, aldrig i appen. + lawful_basis: art_6_1_c # legal obligation (ÅRL filing duty) + special_category_basis: null + controller: gnubok-tenant + processor: anthropic-na # software supplier; Bolagsverket is recipient, not processor + data_subjects: + - business_owner # avsändare/undertecknare (styrelseledamot, VD) + data_categories: + - user.government_id # personnummer (transient; at rest only salted SHA-256 hash) + - user.name # undertecknarens namn + - user.contact # undertecknarens/kvittens e-post + - user.financial # årsredovisningens finansiella innehåll + recipients: + - name: Bolagsverket + country: SE + role: legal_recipient + international_transfers: + applicable: false + mechanism: null + note: Sweden-to-Sweden flow to a public authority; no third-country transfer. + retention: + duration: 7y + basis: bfl_7_kap # filed .xhtml archived as räkenskapsinformation + stored_in: + - arsredovisning_submissions # status, idnummer, checksums, PNR hashes only + - document_attachments # the exact filed iXBRL bytes (WORM) + security_measures: + - mtls_client_certificate_to_bolagsverket + - mtls_key_env_only_injected_from_secret_manager # never in settings/DB; see .env.example custody note + - pinned_endpoint_allowlist_per_environment # HOSTS map in client.ts; not env-configurable + - personnummer_never_persisted_plaintext_salted_sha256_only + - personnummer_never_logged + - webhook_secret_constant_time_validation + - rls_company_scoped + - immutable_status_machine_trigger diff --git a/.env.example b/.env.example index a3383cd5..08649777 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,24 @@ CRON_SECRET=generate-a-random-secret # FORTNOX_CLIENT_ID= # FORTNOX_CLIENT_SECRET= # FORTNOX_REDIRECT_URI= +# Björn Lundén app credentials (OAuth2 client credentials; per-company +# User-Key is entered by the user in the migration wizard) +# BJORN_LUNDEN_CLIENT_ID= +# BJORN_LUNDEN_CLIENT_SECRET= +# Bolagsverket — digital inlämning av årsredovisning (bolagsverket extension). +# BOLAGSVERKET_ENV is test | accept | prod (default test) and also caps which +# environment a company may select in settings (test < accept < prod). +# Certificate material is read from env ONLY (PEM or base64-wrapped PEM) — +# never from extension settings or the database. +# +# SECRET CUSTODY (prod): never keep the real mTLS private key in a plaintext +# .env file. Inject these at runtime from a secrets manager (Vercel encrypted +# env vars, AWS Secrets Manager, Vault, Doppler, …), restrict read access to +# the deploy pipeline, and rotate the client certificate/key on the cadence +# agreed with Bolagsverket (and immediately on suspected exposure). Outbound +# hosts are pinned per environment in extensions/general/bolagsverket/lib/ +# client.ts (HOSTS) — the endpoint is not configurable via env. +# BOLAGSVERKET_ENV= +# BOLAGSVERKET_CLIENT_CERT= +# BOLAGSVERKET_CLIENT_KEY= +# BOLAGSVERKET_CA= diff --git a/.gitattributes b/.gitattributes index 421fb8d0..e0ae0cd5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,3 +6,8 @@ Dockerfile text eol=lf docker-entrypoint.sh text eol=lf docker/Caddyfile text eol=lf docker/crontab.* text eol=lf + +# Generated taxonomy registry is compared byte-for-byte by `npm run +# taxonomy:check` (CI); keep LF on Windows checkouts so the local check +# matches what the generator writes. +lib/bokslut/ixbrl/taxonomy/generated/*.json text eol=lf linguist-generated=true diff --git a/.github/workflows/core-build.yml b/.github/workflows/core-build.yml index d91e8a49..ad9fde39 100644 --- a/.github/workflows/core-build.yml +++ b/.github/workflows/core-build.yml @@ -16,6 +16,12 @@ jobs: # seed migration (npm run skills:generate). Keeps prod skill content from # silently drifting out of sync. No DB needed — reads files + manifest. run: npm run skills:check + - name: Verify taxonomy registry is in sync with the element lists + # Fails if dev_docs/bokslut/taxonomi/** changed without regenerating + # lib/bokslut/ixbrl/taxonomy/generated/ (npm run taxonomy:generate). + # The iXBRL generator emits facts strictly from the generated registry, + # so drift here means filings tagged against a stale concept set. + run: npm run taxonomy:check - name: Reset extensions config run: echo '{"extensions":[]}' > extensions.config.json - run: npm run setup:extensions diff --git a/.gitignore b/.gitignore index af8b4398..cf2cf427 100644 --- a/.gitignore +++ b/.gitignore @@ -60,8 +60,26 @@ supabase/.temp/ # scripts/inject-public-branding.mjs — runs via predev/prebuild) /public/sw.js -# dev docs (internal reference, not published) -/dev_docs +# dev docs (internal reference, not published) — EXCEPT the iXBRL taxonomy +# sources and the official Bolagsverket example, which CI needs: the committed +# lib/bokslut/ixbrl/taxonomy/generated/ registry is regenerated from them by +# `npm run taxonomy:check` (core-build.yml) and the golden test pins against +# the example document. +/dev_docs/* +!/dev_docs/bokslut/ +/dev_docs/bokslut/* +!/dev_docs/bokslut/taxonomi/ +/dev_docs/bokslut/taxonomi/* +!/dev_docs/bokslut/taxonomi/taxonomi-paket-2024-09-12_rev20250312.zip +!/dev_docs/bokslut/taxonomi/dokumentation/ +/dev_docs/bokslut/taxonomi/dokumentation/* +!/dev_docs/bokslut/taxonomi/dokumentation/k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx +!/dev_docs/bokslut/taxonomi/dokumentation/tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx +!/dev_docs/bokslut/exempel/ +/dev_docs/bokslut/exempel/* +!/dev_docs/bokslut/exempel/k2/ +/dev_docs/bokslut/exempel/k2/* +!/dev_docs/bokslut/exempel/k2/faststalld-arsredovisning-exempel-1-rev20240214.xhtml # Extension registry (auto-generated but defaults are committed) # Run `npm run setup:extensions` to regenerate after changing extensions.config.json diff --git a/app/(dashboard)/bookkeeping/periodiseringar/page.tsx b/app/(dashboard)/bookkeeping/periodiseringar/page.tsx new file mode 100644 index 00000000..d8d6abd6 --- /dev/null +++ b/app/(dashboard)/bookkeeping/periodiseringar/page.tsx @@ -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('active') + const [schedules, setSchedules] = useState([]) + const [dueCount, setDueCount] = useState(0) + const [isLoading, setIsLoading] = useState(true) + const [expanded, setExpanded] = useState>(new Set()) + const [isPosting, setIsPosting] = useState(false) + const [dissolveTarget, setDissolveTarget] = useState(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 ( +
+ + + {(dueCount > 0 || blockedInstallments > 0) && ( +
+
+ +
+

+ {dueCount > 0 + ? `${dueCount} ${dueCount === 1 ? 'månad väntar' : 'månader väntar'} på att bokföras` + : 'Periodiseringar med fel'} +

+

+ {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.'} +

+
+
+ {canWrite && dueCount > 0 && ( + + )} +
+ )} + + setStatusFilter(v as StatusFilter)}> + + Aktiva + Avslutade + Alla + + + + {isLoading ? ( + + + + + + + + ) : schedules.length === 0 ? ( + + ) : ( + + + + + + + Beskrivning + Konto + Period + Totalt + Kvar + Status + + + + + {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 ( + + toggleExpanded(schedule.id)} + > + + + + + {schedule.description || '—'} + + {sourceHref && ( + e.stopPropagation()} + className="text-xs text-muted-foreground underline-offset-2 hover:underline" + > + {schedule.supplier_invoice_id ? 'Leverantörsfaktura' : 'Kundfaktura'} + + )} + + + {schedule.balance_account} → {schedule.target_account} + + + {formatDate(schedule.period_start)} – {formatDate(schedule.period_end)} + + + {formatCurrency(schedule.total_amount)} + + + {formatCurrency(remaining)} + + + {badge.label} + + + {canWrite && schedule.status === 'active' && remaining > 0 && ( + + )} + + + {isOpen && ( + + +
+
+ + + + + + + + + + {schedule.installments.map((installment) => ( + + + + + + + ))} + +
MånadBeloppStatusVerifikat
+ {monthLabel(installment.period_month)} + + {formatCurrency(installment.amount)} + + {installment.status === 'posted' ? ( + Bokförd + ) : installment.status === 'cancelled' ? ( + Makulerad + ) : installment.last_error ? ( + + Fel + + {installment.last_error} + + + ) : ( + Väntar + )} + + {installment.journal_entry_id ? ( + + Öppna verifikat + + ) : ( + + )} +
+
+ + + )} + + ) + })} + + + + + )} + + {dissolveTarget && ( + !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" + > +
+

{dissolveTarget.description || 'Periodisering'}

+

+ {dissolveTarget.target_account} ← {dissolveTarget.balance_account} ·{' '} + {formatDate(dissolveTarget.period_start)} – {formatDate(dissolveTarget.period_end)} +

+
+
+ )} + + ) +} diff --git a/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx b/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx index a0a4dc4b..c2d541ff 100644 --- a/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx @@ -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() { Ladda ner PDF (utkast) - + {/* Bolagsverket-delarna blurras tills integrationen är godkänd — + rubriken, instruktionstexten och PDF-knappen förblir skarpa. */} + + + +
{data.warnings.length > 0 && (

Innan inlämning till Bolagsverket:

@@ -697,14 +719,19 @@ export default function ArsredovisningPage() {
)}
- Notis om digital inlämning: 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. + Notis om digital inlämning: 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{' '} + Digital inlämning nedan för att granska, validera och lämna + in årsredovisningen som iXBRL — PDF:en ovan är ett läsexemplar. +
+ + {data.accounting_framework === 'k2' && periodId && ( + + )} ) } diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 4808579d..bb246a67 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -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 + !!(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 (
{/* Header */} @@ -534,6 +543,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st {displayStatusLabel} + {hasAccruedItems && ( + + + {t('badge_accrued')} + + )}

{t('created_at', { date: formatDate(invoice.created_at) })} @@ -690,7 +705,21 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st ) : (

-
{item.description}
+
+ {item.description} + {itemHasAccrual(item) && ( +

+ + + {t('accrual_line_info', { + from: accrualMonth(item.accrual_period_start!), + to: accrualMonth(item.accrual_period_end!), + })} + {item.accrual_balance_account && ` · ${item.accrual_balance_account}`} + +

+ )} +
{item.quantity}
{item.unit}
@@ -712,6 +741,18 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st ) : (

{item.description}

+ {itemHasAccrual(item) && ( +

+ + + {t('accrual_line_info', { + from: accrualMonth(item.accrual_period_start!), + to: accrualMonth(item.accrual_period_end!), + })} + {item.accrual_balance_account && ` · ${item.accrual_balance_account}`} + +

+ )}
{item.quantity} {item.unit} × {formatCurrency(item.unit_price, invoice.currency)}
diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index d3ea13f8..61956099 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -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 @@ -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() { - + {t('deduction_menu_label')} - {t('deduction_none')} - {t('deduction_rot')} - {t('deduction_rut')} + {t('deduction_none')} + {t('deduction_rot')} + {t('deduction_rut')} + {canUseAccrual && !watchItems[index]?.deduction_type && ( + <> + + toggleAccrual(index)} className="py-2"> + + {watchItems[index]?.accrual_balance_account != null + ? ta('row_menu_remove') + : ta('row_menu_add')} + + + )} remove(index)} > @@ -1325,6 +1397,37 @@ export default function NewInvoicePage() {
)} + {/* 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 && ( +
+ { + 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 && ( +

+ {errors.items[index].accrual_period_end?.message} +

+ )} +
+ )} + {/* Mobile summary row */}
{t('row_label', { index: index + 1 })} @@ -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, }) } > diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx index 13f5a450..0fcefa4c 100644 --- a/app/(dashboard)/supplier-invoices/[id]/page.tsx +++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx @@ -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 = { registered: 'secondary', approved: 'default', @@ -431,6 +438,12 @@ export default function SupplierInvoiceDetailPage() { {statusLabels[invoice.status] || invoice.status} + {items.some(itemHasAccrual) && ( + + + {t('badge_accrued')} + + )}

{t('header_subtitle', { @@ -646,7 +659,21 @@ export default function SupplierInvoiceDetailPage() { {items.map((item) => ( - {item.description} + + {item.description} + {itemHasAccrual(item) && ( +

+ + + {t('accrual_line_info', { + from: accrualMonth(item.accrual_period_start!), + to: accrualMonth(item.accrual_period_end!), + })} + {item.accrual_balance_account && ` · ${item.accrual_balance_account}`} + +

+ )} + {item.quantity} {item.unit} {formatAmount(item.unit_price)} @@ -664,6 +691,18 @@ export default function SupplierInvoiceDetailPage() { {items.map((item) => (
{item.description}
+ {itemHasAccrual(item) && ( +

+ + + {t('accrual_line_info', { + from: accrualMonth(item.accrual_period_start!), + to: accrualMonth(item.accrual_period_end!), + })} + {item.accrual_balance_account && ` · ${item.accrual_balance_account}`} + +

+ )}
{item.quantity} {item.unit} × {formatAmount(item.unit_price)} {formatAmount(item.line_total)} kr diff --git a/app/(dashboard)/supplier-invoices/new/page.tsx b/app/(dashboard)/supplier-invoices/new/page.tsx index 0837aa31..c8956487 100644 --- a/app/(dashboard)/supplier-invoices/new/page.tsx +++ b/app/(dashboard)/supplier-invoices/new/page.tsx @@ -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 ( + { + 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() { {fields.map((field, index) => ( - + + - {fields.length > 1 && ( - - )} +
+ {canUseAccrual && ( + + )} + {fields.length > 1 && ( + + )} +
+ {canUseAccrual && isAccrualOpen(index) && ( + + + {renderAccrualPanel(index, `accrual-desktop-${index}`)} + + + )} +
))} @@ -1465,11 +1630,31 @@ export default function NewSupplierInvoicePage() {
{t('row_label', { index: index + 1 })} - {fields.length > 1 && ( - - )} +
+ {canUseAccrual && ( + + )} + {fields.length > 1 && ( + + )} +
@@ -1543,6 +1728,8 @@ export default function NewSupplierInvoicePage() { {formatAmount(itemTotals[index]?.vatAmount ?? 0)}
+ {canUseAccrual && isAccrualOpen(index) && + renderAccrualPanel(index, `accrual-mobile-${index}`)}
))}
diff --git a/app/api/bookkeeping/accruals/[id]/dissolve/__tests__/route.test.ts b/app/api/bookkeeping/accruals/[id]/dissolve/__tests__/route.test.ts new file mode 100644 index 00000000..01056ae9 --- /dev/null +++ b/app/api/bookkeeping/accruals/[id]/dissolve/__tests__/route.test.ts @@ -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/) + }) +}) diff --git a/app/api/bookkeeping/accruals/[id]/dissolve/route.ts b/app/api/bookkeeping/accruals/[id]/dissolve/route.ts new file mode 100644 index 00000000..eae88e85 --- /dev/null +++ b/app/api/bookkeeping/accruals/[id]/dissolve/route.ts @@ -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 }, +) diff --git a/app/api/bookkeeping/accruals/post-due/cron/__tests__/route.test.ts b/app/api/bookkeeping/accruals/post-due/cron/__tests__/route.test.ts new file mode 100644 index 00000000..5eff737f --- /dev/null +++ b/app/api/bookkeeping/accruals/post-due/cron/__tests__/route.test.ts @@ -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 = {} + 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() + }) +}) diff --git a/app/api/bookkeeping/accruals/post-due/cron/route.ts b/app/api/bookkeeping/accruals/post-due/cron/route.ts new file mode 100644 index 00000000..94aaab35 --- /dev/null +++ b/app/api/bookkeeping/accruals/post-due/cron/route.ts @@ -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 diff --git a/app/api/bookkeeping/accruals/post-due/route.ts b/app/api/bookkeeping/accruals/post-due/route.ts new file mode 100644 index 00000000..56339859 --- /dev/null +++ b/app/api/bookkeeping/accruals/post-due/route.ts @@ -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 }, +) diff --git a/app/api/bookkeeping/accruals/route.ts b/app/api/bookkeeping/accruals/route.ts new file mode 100644 index 00000000..d0c92d10 --- /dev/null +++ b/app/api/bookkeeping/accruals/route.ts @@ -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 }) + }, +) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/ixbrl/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/ixbrl/__tests__/route.test.ts new file mode 100644 index 00000000..d9b60a86 --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/ixbrl/__tests__/route.test.ts @@ -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('') + 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) + }) +}) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/ixbrl/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/ixbrl/route.ts new file mode 100644 index 00000000..c922928f --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/ixbrl/route.ts @@ -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 }) + } + }, +) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/ixbrl/validate/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/ixbrl/validate/route.ts new file mode 100644 index 00000000..0eb9a181 --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/ixbrl/validate/route.ts @@ -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 }) + } + }, +) diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts index baddb8f1..3091bba7 100644 --- a/app/api/invoices/[id]/mark-sent/route.ts +++ b/app/api/invoices/[id]/mark-sent/route.ts @@ -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 }) diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index 11002936..3ee37383 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -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) diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index da6a60af..82ec5d16 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -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 } : {}), + }) } diff --git a/app/api/supplier-invoices/[id]/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/__tests__/route.test.ts new file mode 100644 index 00000000..b7f10094 --- /dev/null +++ b/app/api/supplier-invoices/[id]/__tests__/route.test.ts @@ -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) + }) +}) diff --git a/app/api/supplier-invoices/[id]/credit/route.ts b/app/api/supplier-invoices/[id]/credit/route.ts index 0685c8f0..4a9bb854 100644 --- a/app/api/supplier-invoices/[id]/credit/route.ts +++ b/app/api/supplier-invoices/[id]/credit/route.ts @@ -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 }, diff --git a/app/api/supplier-invoices/[id]/route.ts b/app/api/supplier-invoices/[id]/route.ts index 341a60c0..29d89508 100644 --- a/app/api/supplier-invoices/[id]/route.ts +++ b/app/api/supplier-invoices/[id]/route.ts @@ -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) diff --git a/app/api/supplier-invoices/__tests__/route.test.ts b/app/api/supplier-invoices/__tests__/route.test.ts index 020f7d31..13b89ac3 100644 --- a/app/api/supplier-invoices/__tests__/route.test.ts +++ b/app/api/supplier-invoices/__tests__/route.test.ts @@ -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', diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index d177d95d..530c18c7 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -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 diff --git a/components/bokslut/DigitalInlamning.tsx b/components/bokslut/DigitalInlamning.tsx new file mode 100644 index 00000000..9939981e --- /dev/null +++ b/components/bokslut/DigitalInlamning.tsx @@ -0,0 +1,698 @@ +'use client' + +/** + * Digital inlämning av årsredovisning (iXBRL → Bolagsverket). + * + * Three steps below the year-end ÅR editors: + * 1. Granska — the generated iXBRL rendered in an iframe (the XHTML *is* + * the filed presentation) + pre-flight validation results + download + * for manual filing (the self-hosted/no-extension path). + * 2. Skicka in — only when the bolagsverket extension responds: avtalstext + * acceptance → kontrollera-utfall → upload till eget utrymme → kvittens + * with "signera hos Bolagsverket"-link. The fastställelseintyg is signed + * with e-legitimation at Bolagsverket, never here. + * 3. Status — submission history driven by webhooks + polling fallback. + * + * Year-end surface: copy stays Swedish in both locales (see i18n rules). + */ + +import { useCallback, useEffect, useState } from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useToast } from '@/components/ui/use-toast' +import { formatDate } from '@/lib/utils' +import { + ExternalLink, + FileDown, + Loader2, + RefreshCcw, + SearchCheck, + Send, + ShieldCheck, +} from 'lucide-react' + +/** Inlämningen till Bolagsverket väntar på avtal + organisationscertifikat + * (M0). Tills dess visas hela digital inlämning-sektionen blurrad med en + * "Kommer snart"-skylt — endast PDF-nedladdningen på ÅR-sidan är användbar. + * Flippa till false när integrationen är godkänd. Importeras också av + * ÅR-sidan som blurrar sina Bolagsverket-delar med samma flagga. */ +export const INLAMNING_COMING_SOON = true + +interface PreflightIssue { + code: string + severity: 'error' | 'warn' + message: string +} + +interface ValidateResponse { + ok: boolean + issues: PreflightIssue[] + error_count: number + warning_count: number + generated_bytes: number + entry_point: string +} + +interface KontrolleraUtfall { + kod: string + text: string + typ: string +} + +interface SubmissionRow { + id: string + status: string + environment: string + idnummer: string | null + kontrollsumma: string | null + sha256_checksumma: string | null + bolagsverket_url: string | null + undertecknare_namn: string | null + kontrollera_utfall: KontrolleraUtfall[] | null + error_message: string | null + uploaded_at: string | null + registered_at: string | null + created_at: string +} + +type SubmitOutcome = + | { outcome: 'avtal_required'; avtalstext: string; avtalstextAndrad: string } + | { outcome: 'preflight_failed'; issues: PreflightIssue[] } + | { outcome: 'kontrollera_stopped'; submissionId: string; utfall: KontrolleraUtfall[] } + | { outcome: 'uploaded'; submissionId: string; idnummer: string; url: string; utfall: KontrolleraUtfall[] } + +/** + * Normalize a Swedish personnummer to the 12-digit ÅÅÅÅMMDDNNNN form the + * Bolagsverket token API requires. 10-digit input gets its century inferred: + * a 2-digit year greater than the current year's last two digits → 19xx, + * otherwise 20xx; the '+' separator (person 100+ years) shifts one more + * century back. Returns null when the input is neither 10 nor 12 digits. + */ +function normalizePnr(raw: string): string | null { + const trimmed = raw.trim() + const digits = trimmed.replace(/\D/g, '') + if (digits.length === 12) return digits + if (digits.length !== 10) return null + const now = new Date() + const currentCentury = Math.floor(now.getFullYear() / 100) + const currentYy = now.getFullYear() % 100 + const yy = Number(digits.slice(0, 2)) + let century = yy > currentYy ? currentCentury - 1 : currentCentury + if (trimmed.includes('+')) century -= 1 + return `${century}${digits}` +} + +const STATUS_BADGES: Record = { + draft: { label: 'Utkast', variant: 'outline' }, + kontrollerad: { label: 'Kontrollerad', variant: 'secondary' }, + uploaded: { label: 'Uppladdad — väntar på signering', variant: 'warning' }, + inkommen: { label: 'Inkommen till Bolagsverket', variant: 'secondary' }, + forelagd: { label: 'Föreläggande — åtgärd krävs', variant: 'destructive' }, + komplettering: { label: 'Komplettering inlämnad', variant: 'secondary' }, + registrerad: { label: 'Registrerad', variant: 'success' }, + avslutad: { label: 'Avslutad utan registrering', variant: 'destructive' }, + error: { label: 'Fel', variant: 'destructive' }, +} + +export function DigitalInlamning({ periodId }: { periodId: string }) { + const { toast } = useToast() + const ixbrlUrl = `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/ixbrl` + + const [showPreview, setShowPreview] = useState(false) + const [validating, setValidating] = useState(false) + const [validation, setValidation] = useState(null) + + // Extension availability: probe the status route; 404 = not enabled. + const [extensionActive, setExtensionActive] = useState(null) + const [environment, setEnvironment] = useState('test') + + // Submission form + const [avsandarePnr, setAvsandarePnr] = useState('') + const [pnr, setPnr] = useState('') + const [fornamn, setFornamn] = useState('') + const [efternamn, setEfternamn] = useState('') + const [roll, setRoll] = useState('Styrelseledamot') + const [epost, setEpost] = useState('') + const [submitting, setSubmitting] = useState(false) + const [avtal, setAvtal] = useState<{ text: string; andrad: string; accepted: boolean } | null>(null) + const [utfall, setUtfall] = useState(null) + const [kvittens, setKvittens] = useState<{ idnummer: string; url: string } | null>(null) + + // Proposed dividend (utdelning) for the resultatdisposition. There is no + // persisted dividend proposal in the year-end flow yet, so the value is + // entered here and forwarded to the preview, the download and the + // submission so all three render the same disposition. + const [utdelning, setUtdelning] = useState('') + const parsedUtdelning = Math.round(Number(utdelning.replace(/\s/g, '').replace(',', '.'))) + const utdelningValue = Number.isFinite(parsedUtdelning) && parsedUtdelning > 0 ? parsedUtdelning : 0 + const previewUrl = utdelningValue > 0 ? `${ixbrlUrl}?utdelning=${utdelningValue}` : ixbrlUrl + const downloadUrl = + utdelningValue > 0 ? `${ixbrlUrl}?download=1&utdelning=${utdelningValue}` : `${ixbrlUrl}?download=1` + + const [submissions, setSubmissions] = useState([]) + const [loadingSubmissions, setLoadingSubmissions] = useState(false) + const [submissionsError, setSubmissionsError] = useState(null) + + const loadSubmissions = useCallback(async () => { + setLoadingSubmissions(true) + try { + const res = await fetch( + `/api/extensions/ext/bolagsverket/submissions?fiscal_period_id=${periodId}`, + ) + if (res.ok) { + const body = await res.json() + setSubmissions((body.data ?? []) as SubmissionRow[]) + setSubmissionsError(null) + } else { + setSubmissionsError('Kunde inte hämta inlämningshistoriken — försök igen.') + } + } catch { + // Non-blocking: the call sites fire-and-forget (`void loadSubmissions()`), + // so a network failure must surface here instead of as an unhandled + // rejection. + setSubmissionsError('Kunde inte hämta inlämningshistoriken — försök igen.') + } finally { + setLoadingSubmissions(false) + } + }, [periodId]) + + useEffect(() => { + let cancelled = false + fetch('/api/extensions/ext/bolagsverket/status') + .then(async (res) => { + if (cancelled) return + if (!res.ok) { + setExtensionActive(false) + return + } + const body = await res.json() + setExtensionActive(true) + setEnvironment(body.data?.environment ?? 'test') + void loadSubmissions() + }) + .catch(() => { + if (!cancelled) setExtensionActive(false) + }) + return () => { + cancelled = true + } + }, [loadSubmissions]) + + const handleValidate = async () => { + setValidating(true) + try { + const res = await fetch( + `${ixbrlUrl}/validate${utdelningValue > 0 ? `?utdelning=${utdelningValue}` : ''}`, + ) + const body = await res.json() + if (body?.error) { + toast({ title: 'Kunde inte validera', description: body.error.message, variant: 'destructive' }) + return + } + setValidation(body.data as ValidateResponse) + } catch { + toast({ title: 'Kunde inte validera', variant: 'destructive' }) + } finally { + setValidating(false) + } + } + + const handleSubmit = async (opts: { ignoreWarnings?: boolean } = {}) => { + // The Bolagsverket token API needs 12 digits (ÅÅÅÅMMDDNNNN); 10-digit + // input is normalized client-side with a century pivot. + const normalizedAvsandare = normalizePnr(avsandarePnr) + const normalizedPnr = normalizePnr(pnr) + if (!normalizedAvsandare || !normalizedPnr) { + toast({ title: 'Ange personnummer med 10 eller 12 siffror', variant: 'destructive' }) + return + } + if (!fornamn.trim() || !efternamn.trim() || !epost.trim()) { + toast({ title: 'Fyll i undertecknarens namn och e-post', variant: 'destructive' }) + return + } + setSubmitting(true) + setUtfall(null) + try { + const res = await fetch('/api/extensions/ext/bolagsverket/submissions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + fiscal_period_id: periodId, + avsandare_pnr: normalizedAvsandare, + undertecknare: { + pnr: normalizedPnr, + fornamn: fornamn.trim(), + efternamn: efternamn.trim(), + roll, + epost: epost.trim(), + }, + ...(utdelningValue > 0 ? { utdelning: utdelningValue } : {}), + ...(avtal?.accepted ? { accepted_avtalstext_andrad: avtal.andrad } : {}), + ...(opts.ignoreWarnings ? { ignore_warnings: true } : {}), + }), + }) + const body = await res.json() + if (body?.error) { + toast({ title: 'Inlämningen misslyckades', description: body.error.message, variant: 'destructive' }) + return + } + const result = body.data as SubmitOutcome + if (result.outcome === 'avtal_required') { + setAvtal({ text: result.avtalstext, andrad: result.avtalstextAndrad, accepted: false }) + return + } + if (result.outcome === 'preflight_failed') { + setValidation({ + ok: false, + issues: result.issues, + error_count: result.issues.filter((issue) => issue.severity === 'error').length, + warning_count: result.issues.filter((issue) => issue.severity === 'warn').length, + generated_bytes: 0, + entry_point: '', + }) + toast({ + title: 'Årsredovisningen är inte komplett', + description: 'Åtgärda punkterna under Granska & validera och försök igen.', + variant: 'destructive', + }) + return + } + if (result.outcome === 'kontrollera_stopped') { + setUtfall(result.utfall) + void loadSubmissions() + return + } + setKvittens({ idnummer: result.idnummer, url: result.url }) + setUtfall(result.utfall.length > 0 ? result.utfall : null) + setAvtal(null) + void loadSubmissions() + toast({ title: 'Uppladdad till Bolagsverkets eget utrymme' }) + } catch { + toast({ title: 'Inlämningen misslyckades', variant: 'destructive' }) + } finally { + setSubmitting(false) + } + } + + const handlePollEvents = async () => { + try { + const res = await fetch('/api/extensions/ext/bolagsverket/poll-events', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + const body = await res.json() + if (body?.error) { + toast({ title: 'Kunde inte hämta händelser', description: body.error.message, variant: 'destructive' }) + return + } + void loadSubmissions() + toast({ title: 'Status uppdaterad från Bolagsverket' }) + } catch { + toast({ title: 'Kunde inte hämta händelser', variant: 'destructive' }) + } + } + + const blockingErrors = validation !== null && validation.error_count > 0 + const utfallHasErrors = (utfall ?? []).some((item) => item.typ?.toLowerCase() === 'error') + + return ( +
+
+ {/* Steg: Granska & validera */} + + + Digital inlämning — granska & validera (iXBRL) +

+ Bolagsverket tar emot årsredovisningen som iXBRL (XHTML). Dokumentet nedan är + exakt det som lämnas in — granska det som den slutliga presentationen. +

+
+ +
+ + setUtdelning(event.target.value)} + /> +

+ Ingår i resultatdispositionen i dokumentet — 0 betyder att allt + balanseras i ny räkning. Beloppet följer med förhandsgranskning, + nedladdning och inlämning. +

+
+
+ + + +
+ + {showPreview && ( +