diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index bdaa9a16..61a190f1 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -80,6 +80,13 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i const totalDebit = lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0) const totalCredit = lines.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0) + const foreignLines = lines.filter(l => l.currency && l.currency !== 'SEK' && l.amount_in_currency != null) + const hasForeignCurrency = foreignLines.length > 0 + // For the summary: use the first foreign line's data (the settlement line) + const foreignCurrency = hasForeignCurrency ? foreignLines[0].currency! : null + const foreignTotal = hasForeignCurrency ? Math.abs(Number(foreignLines[0].amount_in_currency) || 0) : 0 + const foreignExchangeRate = hasForeignCurrency ? (Number(foreignLines[0].exchange_rate) || null) : null + const canCorrect = entry.status === 'posted' && entry.source_type !== 'storno' && @@ -168,6 +175,24 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i Antal rader {lines.length} + {hasForeignCurrency && ( + <> +
+ Belopp i utländsk valuta + + {foreignTotal.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {foreignCurrency} + +
+ {foreignExchangeRate && ( +
+ Omräkningskurs + + 1 {foreignCurrency} = {foreignExchangeRate.toLocaleString('sv-SE', { minimumFractionDigits: 4, maximumFractionDigits: 4 })} SEK + +
+ )} + + )} diff --git a/app/api/bookkeeping/fiscal-periods/[id]/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/route.ts index 0b304e76..ea5123ff 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/route.ts @@ -68,8 +68,18 @@ export async function PATCH( const newStart = body.period_start || period.period_start const newEnd = body.period_end || period.period_end + // First period for this company may start on any day (BFL 3 kap.) + const { count: earlierCount } = await supabase + .from('fiscal_periods') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .neq('id', id) + .lt('period_start', newStart) + + const isFirstPeriod = !earlierCount || earlierCount === 0 + // Validate period duration (max 18 months per BFL 3 kap.) - const durationError = validatePeriodDuration(newStart, newEnd) + const durationError = validatePeriodDuration(newStart, newEnd, { isFirstPeriod }) if (durationError) { return NextResponse.json({ error: durationError }, { status: 400 }) } @@ -111,11 +121,11 @@ export async function PATCH( .single() if (updateError) { - // Database CHECK constraints will catch invalid month boundaries + // Database trigger/constraint will catch invalid month boundaries const msg = updateError.message - if (msg.includes('period_start') || msg.includes('period_end')) { + if (msg.includes('period_start') || msg.includes('period_end') || msg.includes('first of a month') || msg.includes('1st of a month')) { return NextResponse.json( - { error: 'Perioden måste börja den 1:a i en månad och sluta sista dagen i en månad' }, + { error: 'Perioden måste sluta sista dagen i en månad. Efterföljande perioder måste börja den 1:a.' }, { status: 400 } ) } diff --git a/app/api/bookkeeping/fiscal-periods/route.ts b/app/api/bookkeeping/fiscal-periods/route.ts index 4396f43f..d0e09b53 100644 --- a/app/api/bookkeeping/fiscal-periods/route.ts +++ b/app/api/bookkeeping/fiscal-periods/route.ts @@ -42,12 +42,6 @@ export async function POST(request: Request) { if (!validation.success) return validation.response const body = validation.data - // Validate period duration (max 18 months per BFL 3 kap.) - const durationError = validatePeriodDuration(body.period_start, body.period_end) - if (durationError) { - return NextResponse.json({ error: durationError }, { status: 400 }) - } - // Enforce continuity: new period must chain from the latest existing period (BFL 3:1) const { data: latest } = await supabase .from('fiscal_periods') @@ -57,6 +51,15 @@ export async function POST(request: Request) { .limit(1) .maybeSingle() + // First period for this company may start on any day (BFL 3 kap.) + const isFirstPeriod = !latest + + // Validate period duration (max 18 months per BFL 3 kap.) + const durationError = validatePeriodDuration(body.period_start, body.period_end, { isFirstPeriod }) + if (durationError) { + return NextResponse.json({ error: durationError }, { status: 400 }) + } + if (latest) { const prev = new Date(latest.period_end + 'T00:00:00') prev.setDate(prev.getDate() + 1) diff --git a/app/api/currency/rate/route.ts b/app/api/currency/rate/route.ts new file mode 100644 index 00000000..c7208e42 --- /dev/null +++ b/app/api/currency/rate/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' +import { fetchExchangeRate } from '@/lib/currency/riksbanken' +import type { Currency } from '@/types' + +const VALID_CURRENCIES: Currency[] = ['EUR', 'USD', 'GBP', 'NOK', 'DKK'] + +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const currency = searchParams.get('currency') as Currency | null + const dateStr = searchParams.get('date') + + if (!currency || !VALID_CURRENCIES.includes(currency)) { + return NextResponse.json({ error: 'Invalid currency' }, { status: 400 }) + } + + const date = dateStr ? new Date(dateStr) : undefined + const rate = await fetchExchangeRate(currency, date) + + if (!rate) { + return NextResponse.json({ error: 'Could not fetch exchange rate' }, { status: 502 }) + } + + return NextResponse.json({ data: rate }) +} diff --git a/app/companies/new/page.tsx b/app/companies/new/page.tsx index ccc83aa3..12b91821 100644 --- a/app/companies/new/page.tsx +++ b/app/companies/new/page.tsx @@ -6,11 +6,11 @@ import Image from 'next/image' import Link from 'next/link' import * as Sentry from '@sentry/nextjs' import { createClient } from '@/lib/supabase/client' -import { switchCompany } from '@/lib/company/actions' +import { createCompanyFromOnboarding } from '@/lib/company/actions' +import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period' import { useToast } from '@/components/ui/use-toast' import { Loader2, ArrowLeft } from 'lucide-react' import { cn } from '@/lib/utils' -import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import type { CompanyLookupResult } from '@/lib/company-lookup/types' import type { CompanySettings, EntityType, MomsPeriod } from '@/types' @@ -71,7 +71,6 @@ function NewCompanyContent() { const [isSaving, setIsSaving] = useState(false) const [currentStep, setCurrentStep] = useState(1) const [settings, setSettings] = useState>({}) - const [companyId, setCompanyId] = useState(null) const ticEnabled = ENABLED_EXTENSION_IDS.has('tic') const [ticLookup, setTicLookup] = useState(null) @@ -109,274 +108,88 @@ function NewCompanyContent() { checkAuth() }, [supabase, router]) - const saveSettings = async (updates: Partial, nextStep?: number) => { - const targetStep = nextStep ?? currentStep - setIsSaving(true) - - try { - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - router.push('/login') - return false - } - - const updatedSettings = { - ...settings, - ...updates, - onboarding_step: targetStep, - } - - if (!companyId) { - logError('save aborted: no companyId', { step: targetStep }) - return false - } - - const { - id: _id, user_id: _uid, company_id: _cid, created_at: _ca, updated_at: _ua, - is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye, - ...settingsToSave - } = updatedSettings as Record - - const { error } = await supabase - .from('company_settings') - .upsert({ ...settingsToSave, company_id: companyId }, { onConflict: 'company_id' }) - - if (error) { - logError('save failed', { message: error.message, step: targetStep, code: error.code }) - toast({ title: 'Fel', description: error.message || 'Kunde inte spara. Försök igen.', variant: 'destructive' }) - return false - } - - setSettings(updatedSettings) - return true - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - logError('saveSettings threw', { message, step: targetStep }) - Sentry.captureException(err) - toast({ title: 'Fel', description: 'Ett oväntat fel uppstod. Försök igen.', variant: 'destructive' }) - return false - } finally { - setIsSaving(false) - } - } - const handleNext = async (stepData: Partial) => { if (currentStep === 1 && stepData.entity_type && stepData.entity_type !== settings.entity_type) { stepData = { ...stepData, org_number: '', company_name: '' } setTicLookup(null) } - // Step 1: Create the new company - let activeCompanyId = companyId + const mergedSettings = { ...settings, ...stepData } - if (currentStep === 1 && !activeCompanyId) { - try { - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - router.push('/login') - return - } - - // Atomically create company + owner membership + set active - const { data: newCompanyId, error: companyError } = await supabase.rpc('create_company_with_owner', { - p_name: 'Nytt företag', - p_entity_type: stepData.entity_type, - p_team_id: teamId, + // Validate fiscal period at step 3 before advancing + if (currentStep === 3) { + const periodResult = computeFiscalPeriod(mergedSettings) + if (periodResult.error) { + toast({ + title: 'Ogiltigt räkenskapsår', + description: translatePeriodError(periodResult.error), + variant: 'destructive', }) - - if (companyError || !newCompanyId) { - logError('company creation failed', { message: companyError?.message }) - toast({ title: 'Fel', description: 'Kunde inte skapa företag. Försök igen.', variant: 'destructive' }) - return - } - - activeCompanyId = newCompanyId - setCompanyId(activeCompanyId) - console.log(LOG, 'created company', activeCompanyId) - } catch (err) { - logError('company creation threw', { error: String(err) }) - Sentry.captureException(err) - toast({ title: 'Fel', description: 'Kunde inte skapa företag. Försök igen.', variant: 'destructive' }) return } } - if (!activeCompanyId) { - logError('handleNext aborted: no companyId', { step: currentStep }) + // Steps 1-3: collect data client-side only, advance step + if (currentStep < totalSteps) { + setSettings(mergedSettings) + setCurrentStep(currentStep + 1) return } - const nextStep = currentStep + 1 + // Step 4 (final): create everything via server action. + // Going through a server action ensures that if the Next.js server is + // unreachable, nothing touches Supabase — no ghost companies. + const periodResult = computeFiscalPeriod(mergedSettings) + if (periodResult.error) { + toast({ + title: 'Ogiltigt räkenskapsår', + description: translatePeriodError(periodResult.error), + variant: 'destructive', + }) + return + } - // Direct save for step 1 (React batching: companyId state not yet updated) - const needsDirectSave = currentStep === 1 && !companyId - const success = needsDirectSave - ? await (async () => { - setIsSaving(true) - try { - const updatedSettings = { ...settings, ...stepData, onboarding_step: nextStep } - const { - id: _id, user_id: _uid, company_id: _cid, created_at: _ca, updated_at: _ua, - is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye, - ...settingsToSave - } = updatedSettings as Record + if (!teamId) { + logError('handleNext aborted: no teamId') + toast({ title: 'Fel', description: 'Kunde inte hitta team. Ladda om sidan.', variant: 'destructive' }) + return + } - const { error } = await supabase - .from('company_settings') - .upsert({ ...settingsToSave, company_id: activeCompanyId }, { onConflict: 'company_id' }) + setIsSaving(true) + try { + const result = await createCompanyFromOnboarding({ + teamId, + settings: mergedSettings as Record, + fiscalPeriod: { + startDate: periodResult.startStr, + endDate: periodResult.endStr, + name: periodResult.periodName, + }, + }) - if (error) { - logError('save failed', { message: error.message, step: nextStep }) - toast({ title: 'Fel', description: error.message || 'Kunde inte spara. Försök igen.', variant: 'destructive' }) - return false - } - - setSettings(updatedSettings) - return true - } catch (err) { - logError('saveSettings threw', { message: String(err), step: nextStep }) - Sentry.captureException(err) - return false - } finally { - setIsSaving(false) - } - })() - : await saveSettings(stepData, nextStep) - - if (!success) return - - // Seed chart of accounts after step 1 - if (currentStep === 1 && stepData.entity_type) { - try { - const { error: rpcError } = await supabase.rpc('seed_chart_of_accounts', { - p_company_id: activeCompanyId, - p_entity_type: stepData.entity_type, + if (result.error || !result.companyId) { + logError('create company action failed', { error: result.error }) + toast({ + title: 'Fel', + description: result.error || 'Kunde inte skapa företag. Försök igen.', + variant: 'destructive', }) - if (rpcError) { - logError('COA seeding failed', { entity_type: stepData.entity_type, message: rpcError.message }) - } - } catch (err) { - logError('COA seeding threw', { error: String(err) }) - Sentry.captureException(err) - } - } - - // Create fiscal period after step 3 - if (currentStep === 3 && activeCompanyId) { - try { - const isFirstYear = stepData.is_first_fiscal_year as boolean | undefined - const firstYearStart = stepData.first_year_start as string | undefined - const firstYearEnd = stepData.first_year_end as string | undefined - - let startStr: string - let endStr: string - let periodName: string - - if (isFirstYear && firstYearStart && firstYearEnd) { - startStr = firstYearStart - endStr = firstYearEnd - const startYear = new Date(firstYearStart).getFullYear() - const endYear = new Date(firstYearEnd).getFullYear() - periodName = startYear === endYear - ? `Första räkenskapsåret ${startYear}` - : `Första räkenskapsåret ${startYear}/${endYear}` - } else { - let startMonth = stepData.fiscal_year_start_month || settings.fiscal_year_start_month || 1 - if (settings.entity_type === 'enskild_firma') startMonth = 1 - - const currentYear = new Date().getFullYear() - startStr = `${currentYear}-${String(startMonth).padStart(2, '0')}-01` - - let endYear: number - let endMonth: number - if (startMonth === 1) { - endYear = currentYear - endMonth = 12 - } else { - endYear = currentYear + 1 - endMonth = startMonth - 1 - } - const lastDay = new Date(endYear, endMonth, 0).getDate() - endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` - - periodName = startMonth === 1 - ? `Räkenskapsår ${currentYear}` - : `Räkenskapsår ${currentYear}/${currentYear + 1}` - } - - const validationError = validatePeriodDuration(startStr, endStr) - if (validationError) { - logError('fiscal period validation failed', { validationError, startStr, endStr }) - toast({ - title: 'Ogiltigt räkenskapsår', - description: translatePeriodError(validationError), - variant: 'destructive', - }) - setCurrentStep(3) - return - } - - // Clean up empty fiscal periods - const { data: existingPeriods } = await supabase - .from('fiscal_periods') - .select('id') - .eq('company_id', activeCompanyId) - - if (existingPeriods && existingPeriods.length > 0) { - for (const ep of existingPeriods) { - const { count } = await supabase - .from('journal_entries') - .select('id', { count: 'exact', head: true }) - .eq('fiscal_period_id', ep.id) - - if (count === 0) { - await supabase.from('fiscal_periods').delete().eq('id', ep.id) - } - } - } - - const { error: upsertError } = await supabase.from('fiscal_periods').upsert({ - company_id: activeCompanyId, - name: periodName, - period_start: startStr, - period_end: endStr, - }, { onConflict: 'company_id,period_start,period_end' }) - - if (upsertError) { - logError('fiscal period upsert failed', { message: upsertError.message, startStr, endStr }) - } - } catch (err) { - logError('fiscal period creation threw', { error: String(err) }) - Sentry.captureException(err) - } - } - - // Final step: mark complete, switch to new company, redirect - if (nextStep > totalSteps) { - const finalSuccess = await saveSettings({ onboarding_complete: true }, totalSteps) - if (!finalSuccess) { - logError('failed to set onboarding_complete') return } - // Update company name from settings - if (settings.company_name || stepData.company_name) { - await supabase - .from('companies') - .update({ name: settings.company_name || stepData.company_name }) - .eq('id', activeCompanyId) - } - - // Switch active company to the new one - await switchCompany(activeCompanyId) - + console.log(LOG, 'created company', result.companyId) toast({ title: 'Företag skapat!', description: 'Du har nu bytt till det nya företaget.', }) router.push('/') - } else { - setCurrentStep(nextStep) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + logError('create company action threw', { error: message }) + Sentry.captureException(err) + toast({ title: 'Fel', description: 'Ett oväntat fel uppstod. Försök igen.', variant: 'destructive' }) + } finally { + setIsSaving(false) } } diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index e89558cf..5c758764 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -1,13 +1,13 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { useToast } from '@/components/ui/use-toast' -import { Plus, Trash2, AlertTriangle } from 'lucide-react' +import { Plus, Trash2, AlertTriangle, Loader2 } from 'lucide-react' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent' import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' @@ -16,7 +16,16 @@ import { getErrorMessage } from '@/lib/errors/get-error-message' import { formatCurrency } from '@/lib/utils' import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' -import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType } from '@/types' +import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType, Currency } from '@/types' + +const CURRENCIES: { value: Currency; label: string }[] = [ + { value: 'SEK', label: 'SEK' }, + { value: 'EUR', label: 'EUR' }, + { value: 'USD', label: 'USD' }, + { value: 'GBP', label: 'GBP' }, + { value: 'NOK', label: 'NOK' }, + { value: 'DKK', label: 'DKK' }, +] export interface FormLine { account_number: string @@ -66,6 +75,12 @@ export default function JournalEntryForm({ const [showNoDocWarning, setShowNoDocWarning] = useState(false) const [uploadedFiles, setUploadedFiles] = useState([]) const [accounts, setAccounts] = useState([]) + const [entryCurrency, setEntryCurrency] = useState('SEK') + const [exchangeRate, setExchangeRate] = useState('') + const [isFetchingRate, setIsFetchingRate] = useState(false) + const [foreignAmount, setForeignAmount] = useState('') + + const isForeign = entryCurrency !== 'SEK' const isUploading = uploadedFiles.some((f) => f.status === 'uploading') @@ -94,6 +109,31 @@ export default function JournalEntryForm({ fetchAccounts() }, []) + // Fetch exchange rate from Riksbanken when currency changes + const fetchRate = useCallback(async (currency: Currency) => { + if (currency === 'SEK') return + setIsFetchingRate(true) + try { + const res = await fetch(`/api/currency/rate?currency=${currency}&date=${entryDate}`) + if (res.ok) { + const { data } = await res.json() + if (data?.rate) { + setExchangeRate(String(data.rate)) + } + } + } catch { + // Non-critical — user can enter rate manually + } finally { + setIsFetchingRate(false) + } + }, [entryDate]) + + useEffect(() => { + if (entryCurrency !== 'SEK') { + fetchRate(entryCurrency) + } + }, [entryCurrency, fetchRate]) + const addLine = () => { setLines([...lines, { ...BLANK_LINE }]) } @@ -142,6 +182,19 @@ export default function JournalEntryForm({ const totalCredit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0) const isBalanced = Math.round((totalDebit - totalCredit) * 100) === 0 && totalDebit > 0 + const rate = parseFloat(exchangeRate) || 0 + // If user has manually entered a foreign amount, use that; otherwise derive from SEK total + const parsedForeignInput = parseFloat(foreignAmount) || 0 + const computedForeignAmount = isForeign && rate > 0 + ? (parsedForeignInput > 0 + ? parsedForeignInput + : (totalDebit > 0 ? Math.round(totalDebit / rate * 100) / 100 : 0)) + : 0 + // The expected SEK equivalent based on foreign amount × rate + const computedSekAmount = isForeign && rate > 0 && computedForeignAmount > 0 + ? Math.round(computedForeignAmount * rate * 100) / 100 + : 0 + const handleReview = () => { if (!selectedPeriod || !description || !isBalanced) return const hasDocuments = uploadedFiles.some((f) => f.status === 'uploaded') @@ -155,17 +208,34 @@ export default function JournalEntryForm({ const handleConfirm = async () => { setIsSubmitting(true) + let currencyMetaApplied = false const entryLines: CreateJournalEntryLineInput[] = lines .filter((l) => l.account_number && (l.debit_amount || l.credit_amount)) - .map((l) => ({ - account_number: l.account_number, - debit_amount: parseFloat(l.debit_amount) || 0, - credit_amount: parseFloat(l.credit_amount) || 0, - line_description: l.line_description || undefined, - ...(l.currency ? { currency: l.currency } : {}), - ...(l.amount_in_currency != null ? { amount_in_currency: l.amount_in_currency } : {}), - ...(l.exchange_rate != null ? { exchange_rate: l.exchange_rate } : {}), - })) + .map((l) => { + const base: CreateJournalEntryLineInput = { + account_number: l.account_number, + debit_amount: parseFloat(l.debit_amount) || 0, + credit_amount: parseFloat(l.credit_amount) || 0, + line_description: l.line_description || undefined, + } + + // Attach currency metadata from pre-populated data (e.g. transaction flow) + if (l.currency) { + base.currency = l.currency + if (l.amount_in_currency != null) base.amount_in_currency = l.amount_in_currency + if (l.exchange_rate != null) base.exchange_rate = l.exchange_rate + } + // Attach currency metadata from the entry-level currency selector (manual flow) + // Applied to the first bank/cash account (class 19xx) only + else if (isForeign && rate > 0 && l.account_number.startsWith('19') && !currencyMetaApplied) { + base.currency = entryCurrency + base.amount_in_currency = computedForeignAmount + base.exchange_rate = rate + currencyMetaApplied = true + } + + return base + }) const url = submitUrl ?? '/api/bookkeeping/journal-entries' @@ -225,6 +295,9 @@ export default function JournalEntryForm({ setDescription('') setUploadedFiles([]) setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }]) + setEntryCurrency('SEK') + setExchangeRate('') + setForeignAmount('') onCreated?.() if (journalEntryId) { onEntryCreated?.(journalEntryId) @@ -272,6 +345,71 @@ export default function JournalEntryForm({ + {/* Currency section */} +
+
+ + +
+ {isForeign && ( + <> +
+ +
+ setExchangeRate(e.target.value)} + placeholder="0,0000" + className="h-8 pr-8" + step="0.0001" + min="0" + /> + {isFetchingRate && ( + + )} +
+
+
+ + 0 && !parsedForeignInput ? computedForeignAmount.toFixed(2) : '')} + onChange={(e) => setForeignAmount(e.target.value)} + placeholder="0,00" + className="mt-1 h-8" + step="0.01" + min="0" + /> +
+ {rate > 0 && computedForeignAmount > 0 && ( +

+ {computedForeignAmount.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {entryCurrency} × {rate.toLocaleString('sv-SE', { minimumFractionDigits: 4 })} = {computedSekAmount.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK +

+ )} + + )} +
+ {/* Entry lines — mobile cards */}
{lines.map((line, index) => ( diff --git a/components/dashboard/WelcomeOnboarding.tsx b/components/dashboard/WelcomeOnboarding.tsx index b69c1c1e..75a0f549 100644 --- a/components/dashboard/WelcomeOnboarding.tsx +++ b/components/dashboard/WelcomeOnboarding.tsx @@ -4,11 +4,11 @@ import { useState, useEffect } from 'react' import { useRouter } from 'next/navigation' import * as Sentry from '@sentry/nextjs' import { createClient } from '@/lib/supabase/client' -import { switchCompany } from '@/lib/company/actions' +import { createCompanyFromOnboarding } from '@/lib/company/actions' +import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period' import { useToast } from '@/components/ui/use-toast' import { Loader2, Building2, Plus } from 'lucide-react' import { cn } from '@/lib/utils' -import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import type { CompanyLookupResult, EnrichmentCompanyRole } from '@/lib/company-lookup/types' import type { CompanySettings, EntityType, MomsPeriod } from '@/types' @@ -73,7 +73,6 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE const [isSaving, setIsSaving] = useState(false) const [currentStep, setCurrentStep] = useState(1) const [settings, setSettings] = useState>({}) - const [companyId, setCompanyId] = useState(null) const ticEnabled = ENABLED_EXTENSION_IDS.has('tic') const [ticLookup, setTicLookup] = useState(null) const [enrichmentCompanies, setEnrichmentCompanies] = useState([]) @@ -138,291 +137,82 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE loadEnrichment() }, [supabase, router]) - const saveSettings = async (updates: Partial, nextStep?: number) => { - const targetStep = nextStep ?? currentStep - setIsSaving(true) - - try { - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - router.push('/login') - return false - } - - const updatedSettings = { - ...settings, - ...updates, - onboarding_step: targetStep, - } - - if (!companyId) { - logError('save aborted: no companyId', { step: targetStep }) - return false - } - - const { - id: _id, user_id: _uid, company_id: _cid, created_at: _ca, updated_at: _ua, - is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye, - ...settingsToSave - } = updatedSettings as Record - - const { error } = await supabase - .from('company_settings') - .upsert({ ...settingsToSave, company_id: companyId }, { onConflict: 'company_id' }) - - if (error) { - logError('save failed', { message: error.message, step: targetStep, code: error.code }) - toast({ title: 'Fel', description: error.message || 'Kunde inte spara. Försök igen.', variant: 'destructive' }) - return false - } - - setSettings(updatedSettings) - return true - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - logError('saveSettings threw', { message, step: targetStep }) - Sentry.captureException(err) - toast({ title: 'Fel', description: 'Ett oväntat fel uppstod. Försök igen.', variant: 'destructive' }) - return false - } finally { - setIsSaving(false) - } - } - const handleNext = async (stepData: Partial) => { if (currentStep === 1 && stepData.entity_type && stepData.entity_type !== settings.entity_type) { stepData = { ...stepData, org_number: '', company_name: '' } setTicLookup(null) } - let activeCompanyId = companyId + const mergedSettings = { ...settings, ...stepData } - if (currentStep === 1 && !activeCompanyId) { - try { - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - router.push('/login') - return - } - - const { data: newCompanyId, error: rpcError } = await supabase.rpc('create_company_with_owner', { - p_name: 'Mitt företag', - p_entity_type: stepData.entity_type, - p_team_id: teamId, - }) - - if (rpcError || !newCompanyId) { - logError('company creation failed', { message: rpcError?.message, code: rpcError?.code }) - toast({ title: 'Fel', description: 'Kunde inte skapa företag. Försök igen.', variant: 'destructive' }) - return - } - - activeCompanyId = newCompanyId - setCompanyId(activeCompanyId) - console.log(LOG, 'created company', activeCompanyId) - } catch (err) { - logError('company creation threw', { error: String(err) }) - Sentry.captureException(err) - toast({ title: 'Fel', description: 'Kunde inte skapa företag. Försök igen.', variant: 'destructive' }) - return - } - } - - if (!activeCompanyId) { - logError('handleNext aborted: no companyId', { step: currentStep }) - return - } - - const nextStep = currentStep + 1 - - const needsDirectSave = currentStep === 1 && !companyId - const success = needsDirectSave - ? await (async () => { - setIsSaving(true) - try { - const updatedSettings = { ...settings, ...stepData, onboarding_step: nextStep } - const { - id: _id, user_id: _uid, company_id: _cid, created_at: _ca, updated_at: _ua, - is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye, - ...settingsToSave - } = updatedSettings as Record - - const { error } = await supabase - .from('company_settings') - .upsert({ ...settingsToSave, company_id: activeCompanyId }, { onConflict: 'company_id' }) - - if (error) { - logError('save failed', { message: error.message, step: nextStep, code: error.code }) - toast({ title: 'Fel', description: error.message || 'Kunde inte spara. Försök igen.', variant: 'destructive' }) - return false - } - - setSettings(updatedSettings) - return true - } catch (err) { - logError('saveSettings threw', { message: String(err), step: nextStep }) - Sentry.captureException(err) - return false - } finally { - setIsSaving(false) - } - })() - : await saveSettings(stepData, nextStep) - - if (!success) { - logError('handleNext aborted: saveSettings failed', { step: currentStep }) - return - } - - // After step 2: sync company name to companies table - if (currentStep === 2 && stepData.company_name && activeCompanyId) { - const { error: nameError } = await supabase - .from('companies') - .update({ name: stepData.company_name }) - .eq('id', activeCompanyId) - - if (nameError) { - logError('failed to sync company name', { message: nameError.message }) - } - } - - // After step 1: seed chart of accounts - if (currentStep === 1 && stepData.entity_type) { - try { - const { error: rpcError } = await supabase.rpc('seed_chart_of_accounts', { - p_company_id: activeCompanyId, - p_entity_type: stepData.entity_type, - }) - if (rpcError) { - logError('COA seeding failed', { entity_type: stepData.entity_type, message: rpcError.message }) - } - } catch (err) { - logError('COA seeding threw', { error: String(err) }) - Sentry.captureException(err) - } - } - - // After step 3: create fiscal period - if (currentStep === 3 && activeCompanyId) { - try { - const isFirstYear = stepData.is_first_fiscal_year as boolean | undefined - const firstYearStart = stepData.first_year_start as string | undefined - const firstYearEnd = stepData.first_year_end as string | undefined - - let startStr: string - let endStr: string - let periodName: string - - if (isFirstYear && firstYearStart && firstYearEnd) { - startStr = firstYearStart - endStr = firstYearEnd - const startYear = new Date(firstYearStart).getFullYear() - const endYear = new Date(firstYearEnd).getFullYear() - periodName = startYear === endYear - ? `Första räkenskapsåret ${startYear}` - : `Första räkenskapsåret ${startYear}/${endYear}` - } else { - let startMonth = stepData.fiscal_year_start_month || settings.fiscal_year_start_month || 1 - if (settings.entity_type === 'enskild_firma') startMonth = 1 - - const currentYear = new Date().getFullYear() - startStr = `${currentYear}-${String(startMonth).padStart(2, '0')}-01` - - let endYear: number - let endMonth: number - if (startMonth === 1) { - endYear = currentYear - endMonth = 12 - } else { - endYear = currentYear + 1 - endMonth = startMonth - 1 - } - const lastDay = new Date(endYear, endMonth, 0).getDate() - endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` - - periodName = startMonth === 1 - ? `Räkenskapsår ${currentYear}` - : `Räkenskapsår ${currentYear}/${currentYear + 1}` - } - - const validationError = validatePeriodDuration(startStr, endStr) - if (validationError) { - logError('fiscal period validation failed', { validationError, startStr, endStr }) - toast({ - title: 'Ogiltigt räkenskapsår', - description: translatePeriodError(validationError), - variant: 'destructive', - }) - setCurrentStep(3) - return - } - - // Clean up empty fiscal periods - const { data: existingPeriods } = await supabase - .from('fiscal_periods') - .select('id') - .eq('company_id', activeCompanyId) - - if (existingPeriods && existingPeriods.length > 0) { - for (const ep of existingPeriods) { - const { count } = await supabase - .from('journal_entries') - .select('id', { count: 'exact', head: true }) - .eq('fiscal_period_id', ep.id) - - if (count === 0) { - await supabase.from('fiscal_periods').delete().eq('id', ep.id) - } - } - } - - const { error: upsertError } = await supabase.from('fiscal_periods').upsert({ - company_id: activeCompanyId, - name: periodName, - period_start: startStr, - period_end: endStr, - }, { onConflict: 'company_id,period_start,period_end' }) - - if (upsertError) { - logError('fiscal period upsert failed', { message: upsertError.message, startStr, endStr }) - } - } catch (err) { - logError('fiscal period creation threw', { error: String(err) }) - Sentry.captureException(err) + // Validate fiscal period at step 3 before advancing + if (currentStep === 3) { + const periodResult = computeFiscalPeriod(mergedSettings) + if (periodResult.error) { toast({ - title: 'Kunde inte skapa räkenskapsår', - description: 'Ett fel uppstod när räkenskapsåret skulle skapas. Försök igen.', + title: 'Ogiltigt räkenskapsår', + description: translatePeriodError(periodResult.error), variant: 'destructive', }) + return } } - if (nextStep > totalSteps) { - const finalSuccess = await saveSettings({ onboarding_complete: true }, totalSteps) - if (!finalSuccess) { - logError('failed to set onboarding_complete after all steps') + // Steps 1-3: collect data client-side only, advance step + if (currentStep < totalSteps) { + setSettings(mergedSettings) + setCurrentStep(currentStep + 1) + return + } + + // Step 4 (final): create everything via server action. + // Going through a server action ensures that if the Next.js server is + // unreachable, nothing touches Supabase — no ghost companies. + const periodResult = computeFiscalPeriod(mergedSettings) + if (periodResult.error) { + toast({ + title: 'Ogiltigt räkenskapsår', + description: translatePeriodError(periodResult.error), + variant: 'destructive', + }) + return + } + + setIsSaving(true) + try { + const result = await createCompanyFromOnboarding({ + teamId, + settings: mergedSettings as Record, + fiscalPeriod: { + startDate: periodResult.startStr, + endDate: periodResult.endStr, + name: periodResult.periodName, + }, + }) + + if (result.error || !result.companyId) { + logError('create company action failed', { error: result.error }) + toast({ + title: 'Fel', + description: result.error || 'Kunde inte skapa företag. Försök igen.', + variant: 'destructive', + }) return } - // Update company name - if (settings.company_name || stepData.company_name) { - await supabase - .from('companies') - .update({ name: settings.company_name || stepData.company_name }) - .eq('id', activeCompanyId) - } - - // Switch to the new company - await switchCompany(activeCompanyId) - - console.log(LOG, 'onboarding completed') + console.log(LOG, 'onboarding completed', result.companyId) toast({ title: 'Välkommen!', description: 'Ditt företag är nu redo.', }) router.push('/') - } else { - setCurrentStep(nextStep) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + logError('create company action threw', { error: message }) + Sentry.captureException(err) + toast({ title: 'Fel', description: 'Ett oväntat fel uppstod. Försök igen.', variant: 'destructive' }) + } finally { + setIsSaving(false) } } diff --git a/components/onboarding/Step3TaxRegistration.tsx b/components/onboarding/Step3TaxRegistration.tsx index c85847c8..eb3e4c75 100644 --- a/components/onboarding/Step3TaxRegistration.tsx +++ b/components/onboarding/Step3TaxRegistration.tsx @@ -174,7 +174,12 @@ export default function Step3TaxRegistration({ const firstYearEnd = watch('first_year_end') const fiscalYearEndMonth = watch('fiscal_year_end_month') - // State for first-year start date selectors (month/year) + // State for first-year start date selectors (day/month/year) + const [startDay, setStartDay] = useState( + initialData.first_year_start + ? parseDateParts(initialData.first_year_start).day + : 1 + ) const [startMonth, setStartMonth] = useState( initialData.first_year_start ? parseDateParts(initialData.first_year_start).month @@ -377,22 +382,53 @@ export default function Step3TaxRegistration({ const currentYear = new Date().getFullYear() const years = Array.from({ length: 7 }, (_, i) => currentYear - 5 + i) - const handleMonthChange = (month: number) => { - setStartMonth(month) - if (month && startYear) { - field.onChange(`${startYear}-${String(month).padStart(2, '0')}-01`) + const updateField = (day: number, month: number, year: number) => { + if (month && year) { + const maxDay = lastDayOfMonth(year, month) + const clampedDay = Math.min(day, maxDay) + field.onChange(`${year}-${String(month).padStart(2, '0')}-${String(clampedDay).padStart(2, '0')}`) } } const handleYearChange = (year: number) => { setStartYear(year) - if (startMonth && year) { - field.onChange(`${year}-${String(startMonth).padStart(2, '0')}-01`) - } + updateField(startDay, startMonth, year) } + const handleMonthChange = (month: number) => { + setStartMonth(month) + // Clamp day if needed when month changes + if (startYear) { + const maxDay = lastDayOfMonth(startYear, month) + if (startDay > maxDay) setStartDay(maxDay) + } + updateField(startDay, month, startYear) + } + + const handleDayChange = (day: number) => { + setStartDay(day) + updateField(day, startMonth, startYear) + } + + const maxDays = startMonth && startYear + ? lastDayOfMonth(startYear, startMonth) + : 31 + return ( -
+
+ @@ -424,7 +460,7 @@ export default function Step3TaxRegistration({ }} />

- Månaden verksamheten startade. Räkenskapsåret börjar alltid den 1:a. + Datumet företaget registrerades. Första räkenskapsåret kan börja valfri dag.

{errors.first_year_start && (

{errors.first_year_start.message}

diff --git a/lib/bookkeeping/__tests__/validate-period-duration.test.ts b/lib/bookkeeping/__tests__/validate-period-duration.test.ts index 4a7ddb1d..10e64fd2 100644 --- a/lib/bookkeeping/__tests__/validate-period-duration.test.ts +++ b/lib/bookkeeping/__tests__/validate-period-duration.test.ts @@ -48,12 +48,42 @@ describe('validatePeriodDuration', () => { ) }) - it('returns error when start is not 1st of month', () => { + it('returns error when start is not 1st of month (default)', () => { expect(validatePeriodDuration('2025-01-15', '2025-12-31')).toBe( 'Period start must be the 1st of a month' ) }) + it('returns error when start is not 1st of month (isFirstPeriod: false)', () => { + expect(validatePeriodDuration('2025-03-25', '2025-12-31', { isFirstPeriod: false })).toBe( + 'Period start must be the 1st of a month' + ) + }) + + it('allows mid-month start for first fiscal period', () => { + expect(validatePeriodDuration('2025-03-25', '2025-12-31', { isFirstPeriod: true })).toBeNull() + }) + + it('allows mid-month start for first period (October)', () => { + expect(validatePeriodDuration('2025-10-15', '2025-12-31', { isFirstPeriod: true })).toBeNull() + }) + + it('still allows day-1 start for first period', () => { + expect(validatePeriodDuration('2025-10-01', '2025-12-31', { isFirstPeriod: true })).toBeNull() + }) + + it('enforces end-of-month even for first period', () => { + expect(validatePeriodDuration('2025-03-25', '2025-12-15', { isFirstPeriod: true })).toBe( + 'Period end must be the last day of a month' + ) + }) + + it('enforces 18-month max for first period with mid-month start', () => { + const result = validatePeriodDuration('2025-01-15', '2026-12-31', { isFirstPeriod: true }) + expect(result).toContain('months') + expect(result).toContain('18 months') + }) + it('returns error when end is not last day of month', () => { expect(validatePeriodDuration('2025-01-01', '2025-12-15')).toBe( 'Period end must be the last day of a month' diff --git a/lib/bookkeeping/validate-period-duration.ts b/lib/bookkeeping/validate-period-duration.ts index 73b38329..e8a4f5fc 100644 --- a/lib/bookkeeping/validate-period-duration.ts +++ b/lib/bookkeeping/validate-period-duration.ts @@ -17,7 +17,8 @@ export function parseDateParts(dateStr: string): { year: number; month: number; /** * Calculate the number of months between two dates (inclusive of partial months). - * Assumes start is 1st of month and end is last of month. + * Uses year/month arithmetic only — a mid-month start counts the start month fully, + * which is conservative for the 18-month cap check. */ export function monthsBetween(start: string, end: string): number { const s = parseDateParts(start) @@ -25,11 +26,16 @@ export function monthsBetween(start: string, end: string): number { return (e.year - s.year) * 12 + (e.month - s.month) + 1 } +export interface ValidatePeriodOptions { + /** Allow any start day (not just 1st of month) for the first fiscal period per BFL 3 kap. */ + isFirstPeriod?: boolean +} + /** * Validate a fiscal period's duration and date constraints. * Returns null if valid, or an error message string if invalid. */ -export function validatePeriodDuration(start: string, end: string): string | null { +export function validatePeriodDuration(start: string, end: string, options?: ValidatePeriodOptions): string | null { const startParts = parseDateParts(start) const endParts = parseDateParts(end) @@ -38,8 +44,8 @@ export function validatePeriodDuration(start: string, end: string): string | nul return 'Period end must be after period start' } - // start must be 1st of month - if (startParts.day !== 1) { + // start must be 1st of month — unless this is the first fiscal period (BFL 3 kap.) + if (startParts.day !== 1 && !options?.isFirstPeriod) { return 'Period start must be the 1st of a month' } diff --git a/lib/company/actions.ts b/lib/company/actions.ts index ed1351fa..5685a2b7 100644 --- a/lib/company/actions.ts +++ b/lib/company/actions.ts @@ -20,3 +20,126 @@ export async function switchCompany(companyId: string): Promise<{ error?: string return { error: 'Du har inte tillgång till detta företag.' } } } + +/** + * Create a company from onboarding wizard data. + * + * This runs on the server so that if the Next.js server is unavailable when + * the user clicks the final "Fortsätt" button, the action never reaches + * Supabase and no ghost company is created. All operations (company, + * membership, chart of accounts, settings, fiscal period, active company) + * happen sequentially; if any step after company creation fails the company + * is rolled back to avoid partial state. + */ +export async function createCompanyFromOnboarding(params: { + teamId: string + settings: Record + fiscalPeriod: { + startDate: string + endDate: string + name: string + } +}): Promise<{ companyId?: string; error?: string }> { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return { error: 'Unauthorized' } + } + + const entityType = params.settings.entity_type as string | undefined + if (entityType !== 'enskild_firma' && entityType !== 'aktiebolag') { + return { error: 'Ogiltig företagsform.' } + } + + const companyName = (params.settings.company_name as string | undefined) || 'Mitt företag' + + // 1. Create company + owner membership atomically via RPC + const { data: newCompanyId, error: companyError } = await supabase.rpc('create_company_with_owner', { + p_name: companyName, + p_entity_type: entityType, + p_team_id: params.teamId, + }) + + if (companyError || !newCompanyId) { + console.error('[createCompanyFromOnboarding] company creation failed', companyError) + return { error: 'Kunde inte skapa företag. Försök igen.' } + } + + // Helper: roll back the company if a subsequent step fails. Deletes in FK order. + const rollback = async (reason: string, err: unknown) => { + console.error(`[createCompanyFromOnboarding] rolling back ${newCompanyId}: ${reason}`, err) + await supabase.from('company_settings').delete().eq('company_id', newCompanyId) + await supabase.from('fiscal_periods').delete().eq('company_id', newCompanyId) + await supabase.from('chart_of_accounts').delete().eq('company_id', newCompanyId) + await supabase.from('company_members').delete().eq('company_id', newCompanyId) + await supabase.from('companies').delete().eq('id', newCompanyId) + } + + // 2. Seed chart of accounts + const { error: coaError } = await supabase.rpc('seed_chart_of_accounts', { + p_company_id: newCompanyId, + p_entity_type: entityType, + }) + if (coaError) { + await rollback('COA seeding failed', coaError) + return { error: 'Kunde inte skapa kontoplan. Försök igen.' } + } + + // 3. Save settings (strip UI-only and managed fields) + const { + id: _id, + user_id: _uid, + company_id: _cid, + created_at: _ca, + updated_at: _ua, + is_first_fiscal_year: _ify, + first_year_start: _fys, + first_year_end: _fye, + ...settingsToSave + } = params.settings + + const { error: settingsError } = await supabase + .from('company_settings') + .upsert( + { + ...settingsToSave, + company_id: newCompanyId, + onboarding_complete: true, + onboarding_step: 4, + }, + { onConflict: 'company_id' }, + ) + + if (settingsError) { + await rollback('settings upsert failed', settingsError) + return { error: 'Kunde inte spara inställningar. Försök igen.' } + } + + // 4. Create fiscal period + const { error: periodError } = await supabase.from('fiscal_periods').upsert( + { + company_id: newCompanyId, + name: params.fiscalPeriod.name, + period_start: params.fiscalPeriod.startDate, + period_end: params.fiscalPeriod.endDate, + }, + { onConflict: 'company_id,period_start,period_end' }, + ) + + if (periodError) { + await rollback('fiscal period upsert failed', periodError) + return { error: 'Kunde inte skapa räkenskapsår. Försök igen.' } + } + + // 5. Set as active company + try { + await setActiveCompany(supabase, user.id, newCompanyId) + } catch (err) { + // Non-fatal: the company was created successfully; the user can switch manually + console.error('[createCompanyFromOnboarding] setActiveCompany failed', err) + } + + revalidatePath('/') + return { companyId: newCompanyId } +} diff --git a/lib/company/compute-fiscal-period.ts b/lib/company/compute-fiscal-period.ts new file mode 100644 index 00000000..ba0db221 --- /dev/null +++ b/lib/company/compute-fiscal-period.ts @@ -0,0 +1,68 @@ +import { parseDateParts, validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration' +import type { CompanySettings } from '@/types' + +export interface ComputedFiscalPeriod { + error: string | null + startStr: string + endStr: string + periodName: string +} + +/** + * Derive the first fiscal period for a newly created company from the + * onboarding wizard's collected settings. Handles both the "first fiscal year" + * case (custom start/end dates per BFL 3 kap.) and the standard case where the + * period is bootstrapped from `fiscal_year_start_month`. + * + * Returns the computed dates, a Swedish period name, and a validation error + * (null if valid). + */ +export function computeFiscalPeriod( + s: Partial & Record, +): ComputedFiscalPeriod { + const isFirstYear = s.is_first_fiscal_year as boolean | undefined + const firstYearStart = s.first_year_start as string | undefined + const firstYearEnd = s.first_year_end as string | undefined + + let startStr: string + let endStr: string + let periodName: string + + if (isFirstYear && firstYearStart && firstYearEnd) { + startStr = firstYearStart + endStr = firstYearEnd + const startYear = parseDateParts(firstYearStart).year + const endYear = parseDateParts(firstYearEnd).year + periodName = startYear === endYear + ? `Första räkenskapsåret ${startYear}` + : `Första räkenskapsåret ${startYear}/${endYear}` + } else { + let startMonth = (s.fiscal_year_start_month as number) || 1 + if (s.entity_type === 'enskild_firma') startMonth = 1 + const currentYear = new Date().getFullYear() + startStr = `${currentYear}-${String(startMonth).padStart(2, '0')}-01` + + let endYear: number + let endMonth: number + if (startMonth === 1) { + endYear = currentYear + endMonth = 12 + } else { + endYear = currentYear + 1 + endMonth = startMonth - 1 + } + const lastDay = new Date(endYear, endMonth, 0).getDate() + endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` + + periodName = startMonth === 1 + ? `Räkenskapsår ${currentYear}` + : `Räkenskapsår ${currentYear}/${currentYear + 1}` + } + + const validationError = validatePeriodDuration(startStr, endStr, { isFirstPeriod: !!isFirstYear }) + if (validationError) { + return { error: validationError, startStr: '', endStr: '', periodName: '' } + } + + return { error: null, startStr, endStr, periodName } +} diff --git a/lib/core/bookkeeping/period-service.ts b/lib/core/bookkeeping/period-service.ts index 5b498de6..d7a860ac 100644 --- a/lib/core/bookkeeping/period-service.ts +++ b/lib/core/bookkeeping/period-service.ts @@ -162,8 +162,8 @@ export async function createNextPeriod( const nextStartStr = nextStart.toISOString().split('T')[0] const nextEndStr = nextEnd.toISOString().split('T')[0] - // Validate period duration (max 18 months per BFL 3 kap.) - const durationError = validatePeriodDuration(nextStartStr, nextEndStr) + // Validate period duration — subsequent periods always start on 1st of month + const durationError = validatePeriodDuration(nextStartStr, nextEndStr, { isFirstPeriod: false }) if (durationError) { throw new Error(durationError) } diff --git a/supabase/migrations/20260409165300_allow_custom_first_fiscal_period_start.sql b/supabase/migrations/20260409165300_allow_custom_first_fiscal_period_start.sql new file mode 100644 index 00000000..c4a12652 --- /dev/null +++ b/supabase/migrations/20260409165300_allow_custom_first_fiscal_period_start.sql @@ -0,0 +1,36 @@ +-- Allow the first fiscal period of a company to start on any day of the month, +-- per BFL 3 kap. (the first fiscal year starts on the company registration date). +-- Subsequent periods must still start on the 1st of a month. + +-- Drop the unconditional CHECK constraint that enforces day-1 starts +ALTER TABLE public.fiscal_periods + DROP CONSTRAINT IF EXISTS fiscal_period_start_first_of_month; + +-- Replace with a trigger that only enforces day-1 for non-first periods +CREATE OR REPLACE FUNCTION enforce_first_of_month_for_subsequent_periods() +RETURNS trigger AS $$ +BEGIN + -- If this period starts on the 1st, no check needed + IF EXTRACT(DAY FROM NEW.period_start) = 1 THEN + RETURN NEW; + END IF; + + -- Allow any start day only if this is the first fiscal period for the company + IF EXISTS ( + SELECT 1 FROM public.fiscal_periods + WHERE company_id = NEW.company_id + AND id IS DISTINCT FROM NEW.id + ) THEN + RAISE EXCEPTION 'Non-first fiscal period must start on the 1st of a month'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS enforce_period_start_day ON public.fiscal_periods; + +CREATE TRIGGER enforce_period_start_day + BEFORE INSERT OR UPDATE ON public.fiscal_periods + FOR EACH ROW + EXECUTE FUNCTION enforce_first_of_month_for_subsequent_periods();