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§. +

+
+
+ ) +}