From 08991218ee78b05095da76530bc5004ba2a82f84 Mon Sep 17 00:00:00 2001
From: Mattsson <111893710+mattssonn@users.noreply.github.com>
Date: Tue, 21 Apr 2026 14:44:03 +0200
Subject: [PATCH] Feat/change fiscal year (#300)
* feat: enhance journal entry handling with follow-up entries and related RPC
* fix: improve validation for journal entry lines to ensure proper submission criteria
* fix: enhance OAuth error handling and user feedback in Arcim migration process
* fix: add OAuth error translation for user-friendly feedback in Fortnox integration
* feat: add fiscal period editor and related API for entry count
* feat: enforce calendar year for individual firms and improve fiscal period validation
---
app/(dashboard)/settings/company/page.tsx | 3 +
.../[id]/entry-count/__tests__/route.test.ts | 73 ++++
.../fiscal-periods/[id]/entry-count/route.ts | 42 +++
.../bookkeeping/fiscal-periods/[id]/route.ts | 20 +-
.../onboarding/Step3TaxRegistration.tsx | 26 +-
components/settings/FiscalPeriodEditor.tsx | 331 ++++++++++++++++++
6 files changed, 492 insertions(+), 3 deletions(-)
create mode 100644 app/api/bookkeeping/fiscal-periods/[id]/entry-count/__tests__/route.test.ts
create mode 100644 app/api/bookkeeping/fiscal-periods/[id]/entry-count/route.ts
create mode 100644 components/settings/FiscalPeriodEditor.tsx
diff --git a/app/(dashboard)/settings/company/page.tsx b/app/(dashboard)/settings/company/page.tsx
index 31f0eefd..0340a29a 100644
--- a/app/(dashboard)/settings/company/page.tsx
+++ b/app/(dashboard)/settings/company/page.tsx
@@ -3,6 +3,7 @@
import { CompanyDangerZone } from '@/components/settings/CompanyDangerZone'
import { CompanyInfoForm } from '@/components/settings/CompanyInfoForm'
import { CompanyMembersSection } from '@/components/settings/CompanyMembersSection'
+import { FiscalPeriodEditor } from '@/components/settings/FiscalPeriodEditor'
import { LogoUpload } from '@/components/settings/LogoUpload'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
@@ -51,6 +52,8 @@ export default function CompanySettingsPage() {
+
+
)
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/entry-count/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/entry-count/__tests__/route.test.ts
new file mode 100644
index 00000000..cb55c1b7
--- /dev/null
+++ b/app/api/bookkeeping/fiscal-periods/[id]/entry-count/__tests__/route.test.ts
@@ -0,0 +1,73 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('@/lib/supabase/server', () => ({
+ createClient: vi.fn(),
+}))
+vi.mock('@/lib/company/context', () => ({
+ requireCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+
+import { createClient } from '@/lib/supabase/server'
+import { createQueuedMockSupabase } from '@/tests/helpers'
+import { GET } from '../route'
+
+function mkReq() {
+ return new Request('http://localhost/api/bookkeeping/fiscal-periods/period-1/entry-count')
+}
+
+function mkParams(id = 'period-1') {
+ return { params: Promise.resolve({ id }) }
+}
+
+describe('GET /api/bookkeeping/fiscal-periods/[id]/entry-count', () => {
+ 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 period does not belong to company', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ supabase.auth.getUser.mockResolvedValue({ data: { user: { id: 'user-1' } } })
+ enqueue({ data: null, error: null })
+ vi.mocked(createClient).mockResolvedValue(supabase as never)
+
+ const res = await GET(mkReq(), mkParams())
+ expect(res.status).toBe(404)
+ })
+
+ it('returns posted count on happy path', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ supabase.auth.getUser.mockResolvedValue({ data: { user: { id: 'user-1' } } })
+ // fiscal_periods lookup
+ enqueue({ data: { id: 'period-1' }, error: null })
+ // journal_entries count
+ enqueue({ count: 3, error: null })
+ vi.mocked(createClient).mockResolvedValue(supabase as never)
+
+ const res = await GET(mkReq(), mkParams())
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.data.posted_count).toBe(3)
+ })
+
+ it('returns 0 when no posted entries exist', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ supabase.auth.getUser.mockResolvedValue({ data: { user: { id: 'user-1' } } })
+ enqueue({ data: { id: 'period-1' }, error: null })
+ enqueue({ count: 0, error: null })
+ vi.mocked(createClient).mockResolvedValue(supabase as never)
+
+ const res = await GET(mkReq(), mkParams())
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.data.posted_count).toBe(0)
+ })
+})
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/entry-count/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/entry-count/route.ts
new file mode 100644
index 00000000..6ab7badc
--- /dev/null
+++ b/app/api/bookkeeping/fiscal-periods/[id]/entry-count/route.ts
@@ -0,0 +1,42 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { requireCompanyId } from '@/lib/company/context'
+
+export async function GET(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const { id } = await params
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const { data: period, error: fetchError } = await supabase
+ .from('fiscal_periods')
+ .select('id')
+ .eq('id', id)
+ .eq('company_id', companyId)
+ .maybeSingle()
+
+ if (fetchError || !period) {
+ return NextResponse.json({ error: 'Räkenskapsår hittades inte' }, { status: 404 })
+ }
+
+ const { count, error: countError } = await supabase
+ .from('journal_entries')
+ .select('id', { count: 'exact', head: true })
+ .eq('company_id', companyId)
+ .eq('fiscal_period_id', id)
+ .in('status', ['posted', 'reversed'])
+
+ if (countError) {
+ return NextResponse.json({ error: countError.message }, { status: 500 })
+ }
+
+ return NextResponse.json({ data: { posted_count: count ?? 0 } })
+}
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/route.ts
index ac8082d7..e4ab97a1 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/route.ts
@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate'
-import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
+import { validatePeriodDuration, parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { z } from 'zod'
@@ -72,6 +72,24 @@ export async function PATCH(
const newStart = body.period_start || period.period_start
const newEnd = body.period_end || period.period_end
+ // Enskild firma must use calendar year per BFL 3 kap.
+ const { data: companyRow } = await supabase
+ .from('companies')
+ .select('entity_type')
+ .eq('id', companyId)
+ .single()
+
+ if (companyRow?.entity_type === 'enskild_firma') {
+ const s = parseDateParts(newStart)
+ const e = parseDateParts(newEnd)
+ if (s.month !== 1 || s.day !== 1 || e.month !== 12 || e.day !== 31) {
+ return NextResponse.json(
+ { error: 'Enskild firma måste använda kalenderår (1 januari – 31 december) enligt BFL 3 kap.' },
+ { status: 400 }
+ )
+ }
+ }
+
// First period for this company may start on any day (BFL 3 kap.)
const { count: earlierCount } = await supabase
.from('fiscal_periods')
diff --git a/components/onboarding/Step3TaxRegistration.tsx b/components/onboarding/Step3TaxRegistration.tsx
index eb3e4c75..c4ede231 100644
--- a/components/onboarding/Step3TaxRegistration.tsx
+++ b/components/onboarding/Step3TaxRegistration.tsx
@@ -14,6 +14,10 @@ import { Loader2, ArrowRight, ArrowLeft, Check, CalendarDays } from 'lucide-reac
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import { monthsBetween, parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
+import {
+ DestructiveConfirmDialog,
+ useDestructiveConfirm,
+} from '@/components/ui/destructive-confirm-dialog'
import type { EntityType } from '@/types'
const schema = z.object({
@@ -149,6 +153,7 @@ export default function Step3TaxRegistration({
}: Step3Props) {
const isEF = entityType === 'enskild_firma'
const { toast } = useToast()
+ const { dialogProps, confirm } = useDestructiveConfirm()
const {
handleSubmit,
@@ -215,7 +220,7 @@ export default function Step3TaxRegistration({
return getABFirstYearEndDates(parsedStart.year, parsedStart.month, abEndMonth)
}, [parsedStart, isEF, abEndMonth])
- const onSubmit = (data: FormData) => {
+ const onSubmit = async (data: FormData) => {
let fiscalYearStartMonth: number
let firstStart: string | undefined
let firstEnd: string | undefined
@@ -235,6 +240,22 @@ export default function Step3TaxRegistration({
fiscalYearStartMonth = endMonth === 12 ? 1 : endMonth + 1
}
+ // Warn on non-calendar fiscal year for AB (EF is always calendar year)
+ if (!isEF && fiscalYearStartMonth !== 1) {
+ const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1
+ const endLabel = firstEnd
+ ? `${parseDateParts(firstEnd).day} ${monthNames[parseDateParts(firstEnd).month - 1].toLowerCase()} ${parseDateParts(firstEnd).year}`
+ : monthNames[endMonth - 1].toLowerCase()
+ const ok = await confirm({
+ title: 'Är du säker på brutet räkenskapsår?',
+ description: `Du har valt ett räkenskapsår som inte följer kalenderåret (slutar ${endLabel}). De flesta svenska företag använder kalenderår (1 januari – 31 december). Du kan ändra detta senare i inställningarna, men endast innan du har bokfört något.`,
+ confirmLabel: 'Ja, fortsätt',
+ cancelLabel: 'Ändra val',
+ variant: 'warning',
+ })
+ if (!ok) return
+ }
+
const output: Step3Output = {
f_skatt: data.f_skatt,
fiscal_year_start_month: fiscalYearStartMonth,
@@ -248,6 +269,7 @@ export default function Step3TaxRegistration({
return (
+
F-skatt och räkenskapsår
@@ -590,7 +612,7 @@ export default function Step3TaxRegistration({
{fiscalYearEndMonth === 12
? `1 januari \u2013 31 december (kalenderår)`
- : `1 ${monthNames[fiscalYearEndMonth].toLowerCase()} \u2013 ${lastDayOfMonth(2025, fiscalYearEndMonth)} ${monthNames[fiscalYearEndMonth - 1].toLowerCase()}`}
+ : `1 ${monthNames[fiscalYearEndMonth].toLowerCase()} \u2013 ${lastDayOfMonth(new Date().getFullYear(), fiscalYearEndMonth)} ${monthNames[fiscalYearEndMonth - 1].toLowerCase()}`}
12 månader
diff --git a/components/settings/FiscalPeriodEditor.tsx b/components/settings/FiscalPeriodEditor.tsx
new file mode 100644
index 00000000..03f89e55
--- /dev/null
+++ b/components/settings/FiscalPeriodEditor.tsx
@@ -0,0 +1,331 @@
+'use client'
+
+import { useEffect, useMemo, useState } from 'react'
+import { useCompany } from '@/contexts/CompanyContext'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { useToast } from '@/components/ui/use-toast'
+import {
+ DestructiveConfirmDialog,
+ useDestructiveConfirm,
+} from '@/components/ui/destructive-confirm-dialog'
+import { Loader2, CalendarDays, Info, Lock } from 'lucide-react'
+import { monthsBetween, parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
+import type { FiscalPeriod } from '@/types'
+
+function formatSwedishDate(dateStr: string): string {
+ const months = [
+ 'januari', 'februari', 'mars', 'april', 'maj', 'juni',
+ 'juli', 'augusti', 'september', 'oktober', 'november', 'december',
+ ]
+ const { year, month, day } = parseDateParts(dateStr)
+ return `${day} ${months[month - 1]} ${year}`
+}
+
+function isCalendarYear(period: { period_start: string; period_end: string }): boolean {
+ const s = parseDateParts(period.period_start)
+ const e = parseDateParts(period.period_end)
+ return s.month === 1 && s.day === 1 && e.month === 12 && e.day === 31
+}
+
+export function FiscalPeriodEditor() {
+ const { company, role } = useCompany()
+ const { toast } = useToast()
+ const { dialogProps, confirm } = useDestructiveConfirm()
+
+ const [period, setPeriod] = useState(null)
+ const [postedCount, setPostedCount] = useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+ const [loadError, setLoadError] = useState(null)
+
+ const [startDate, setStartDate] = useState('')
+ const [endDate, setEndDate] = useState('')
+ const [isSaving, setIsSaving] = useState(false)
+
+ const isEF = company?.entity_type === 'enskild_firma'
+ const canEdit = role === 'owner' || role === 'admin'
+
+ useEffect(() => {
+ if (!company) return
+ let cancelled = false
+
+ async function load() {
+ setIsLoading(true)
+ setLoadError(null)
+ try {
+ const res = await fetch('/api/bookkeeping/fiscal-periods')
+ if (!res.ok) throw new Error('Kunde inte hämta räkenskapsår')
+ const { data } = (await res.json()) as { data: FiscalPeriod[] }
+ if (!data || data.length === 0) {
+ if (!cancelled) {
+ setPeriod(null)
+ setIsLoading(false)
+ }
+ return
+ }
+ const sorted = [...data].sort((a, b) => a.period_start.localeCompare(b.period_start))
+ const first = sorted[0]
+
+ const countRes = await fetch(`/api/bookkeeping/fiscal-periods/${first.id}/entry-count`)
+ if (!countRes.ok) throw new Error('Kunde inte hämta verifikationsantal')
+ const { data: countData } = (await countRes.json()) as { data: { posted_count: number } }
+
+ if (cancelled) return
+ setPeriod(first)
+ setPostedCount(countData.posted_count)
+ setStartDate(first.period_start)
+ setEndDate(first.period_end)
+ } catch (err) {
+ if (!cancelled) {
+ setLoadError(err instanceof Error ? err.message : 'Okänt fel')
+ }
+ } finally {
+ if (!cancelled) setIsLoading(false)
+ }
+ }
+
+ void load()
+ return () => {
+ cancelled = true
+ }
+ }, [company])
+
+ const durationMonths = useMemo(() => {
+ if (!startDate || !endDate || endDate <= startDate) return null
+ return monthsBetween(startDate, endDate)
+ }, [startDate, endDate])
+
+ const efCalendarYearInvalid = useMemo(() => {
+ if (!isEF || !startDate || !endDate) return false
+ return !isCalendarYear({ period_start: startDate, period_end: endDate })
+ }, [isEF, startDate, endDate])
+
+ const exceedsMaxDuration = durationMonths !== null && durationMonths > 18
+
+ const isBlocked =
+ !!period && (period.locked_at || period.is_closed || (postedCount ?? 0) > 0)
+
+ const isDirty =
+ period !== null && (startDate !== period.period_start || endDate !== period.period_end)
+
+ if (!company || !canEdit) return null
+
+ async function handleSave() {
+ if (!period || !company) return
+ if (!isDirty) return
+
+ const ok = await confirm({
+ title: 'Ändra första räkenskapsåret?',
+ description: `Detta ändrar ditt första räkenskapsår från ${formatSwedishDate(period.period_start)} – ${formatSwedishDate(period.period_end)} till ${formatSwedishDate(startDate)} – ${formatSwedishDate(endDate)}. Ändringen är bara tillåten eftersom inga verifikationer är bokförda ännu. Fortsätt?`,
+ confirmLabel: 'Ja, ändra räkenskapsår',
+ cancelLabel: 'Avbryt',
+ variant: 'warning',
+ })
+ if (!ok) return
+
+ setIsSaving(true)
+ try {
+ const newName = `Räkenskapsår ${parseDateParts(endDate).year}`
+ const res = await fetch(`/api/bookkeeping/fiscal-periods/${period.id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ period_start: startDate,
+ period_end: endDate,
+ name: newName,
+ }),
+ })
+ const body = await res.json().catch(() => ({}))
+ if (!res.ok) {
+ throw new Error(body.error || 'Kunde inte ändra räkenskapsår')
+ }
+ setPeriod(body.data as FiscalPeriod)
+ toast({
+ title: 'Räkenskapsår uppdaterat',
+ description: `${formatSwedishDate(body.data.period_start)} – ${formatSwedishDate(body.data.period_end)}`,
+ })
+ } catch (err) {
+ toast({
+ title: 'Kunde inte ändra räkenskapsår',
+ description: err instanceof Error ? err.message : 'Försök igen.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ function handleReset() {
+ if (!period) return
+ setStartDate(period.period_start)
+ setEndDate(period.period_end)
+ }
+
+ return (
+ <>
+
+
+
+ Första räkenskapsår
+
+
+ Om du valde fel räkenskapsår vid uppstart kan du justera det här — så länge du inte har bokfört någon verifikation ännu.
+
+
+
+ {isLoading ? (
+
+
+ Hämtar räkenskapsår...
+
+ ) : loadError ? (
+ {loadError}
+ ) : !period ? (
+ Inget räkenskapsår hittades.
+ ) : isBlocked ? (
+
+ ) : (
+
+
+
+
+
Ändra med omsorg.
+
+ Ändringen påverkar öppningsbalanser och rapporter.
+ {isEF && ' Enskild firma måste använda kalenderår enligt BFL 3 kap.'}
+
+
+
+
+
+
+
+
setStartDate(e.target.value)}
+ />
+
+ Första räkenskapsåret kan börja valfri dag.
+
+
+
+
+
setEndDate(e.target.value)}
+ />
+
+ Måste vara sista dagen i en månad.
+
+
+
+
+ {startDate && endDate && endDate > startDate && (
+
+
+
+ Föreslaget räkenskapsår
+
+
+ {formatSwedishDate(startDate)} – {formatSwedishDate(endDate)}
+
+ {durationMonths !== null && (
+
+ {durationMonths} månader
+ {exceedsMaxDuration && ' — över 18 månader är inte tillåtet (BFL 3 kap.)'}
+
+ )}
+ {efCalendarYearInvalid && (
+
+ Enskild firma måste använda kalenderår (1 januari – 31 december).
+
+ )}
+
+ )}
+
+
+
+
+
+
+ )}
+
+
+ >
+ )
+}
+
+function BlockedState({
+ period,
+ postedCount,
+}: {
+ period: FiscalPeriod
+ postedCount: number
+}) {
+ const reason = period.locked_at
+ ? 'Räkenskapsåret är låst.'
+ : period.is_closed
+ ? 'Räkenskapsåret är stängt.'
+ : `${postedCount} bokförd${postedCount === 1 ? '' : 'a'} verifikation${postedCount === 1 ? '' : 'er'} finns redan i perioden.`
+
+ return (
+
+
+
+
+
Räkenskapsåret kan inte längre ändras
+
{reason}
+
+
+
+
+ Nuvarande period:{' '}
+
+ {formatSwedishDate(period.period_start)} – {formatSwedishDate(period.period_end)}
+
+ {isCalendarYear(period) ? ' (kalenderår)' : ' (brutet räkenskapsår)'}
+
+
+ Om du måste börja om kan du radera företaget längst ner på sidan och skapa ett nytt.
+ Bokföringsdata behålls i 7 år enligt BFL 7 kap. 2§.
+
+
+
+ )
+}