Fix/company creation bug (#212)
* feat: enhance JournalEntryForm with currency selection and exchange rate fetching - Added currency selection to JournalEntryForm, allowing users to choose from multiple currencies (SEK, EUR, USD, GBP, NOK, DKK). - Implemented fetching of exchange rates from Riksbanken API based on selected currency and entry date. - Updated calculations for foreign amounts and SEK equivalents based on user input and fetched exchange rates. - Improved form handling to reset currency-related fields when switching back to SEK. feat: refactor WelcomeOnboarding to streamline company creation process - Replaced direct company switching with a new server action to create a company from onboarding data. - Added validation for fiscal period during onboarding steps, allowing for mid-month starts for the first fiscal period. - Enhanced error handling and rollback mechanisms to ensure data integrity during company creation. fix: update Step3TaxRegistration to allow flexible first-year start dates - Modified date selection to include day, month, and year for the first-year start date. - Updated validation messages to reflect changes in fiscal year start date handling. test: expand validate-period-duration tests for fiscal period validation - Added tests to validate that mid-month starts are allowed for the first fiscal period. - Ensured that subsequent periods must start on the 1st of the month and enforced maximum duration constraints. feat: implement currency rate API endpoint - Created a new API route to fetch exchange rates for specified currencies, ensuring user authentication. - Validated currency input and handled errors for invalid requests. chore: update database constraints for fiscal periods - Modified database constraints to allow custom start dates for the first fiscal period while enforcing day-1 starts for subsequent periods. * fix: implement computeFiscalPeriod function for onboarding and refactor JournalEntryForm * Fixed date issue * Added migration
This commit is contained in:
@@ -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
|
||||
<span className="text-muted-foreground">Antal rader</span>
|
||||
<span>{lines.length}</span>
|
||||
</div>
|
||||
{hasForeignCurrency && (
|
||||
<>
|
||||
<div className="border-t pt-2 mt-2 flex justify-between">
|
||||
<span className="text-muted-foreground">Belopp i utländsk valuta</span>
|
||||
<span className="tabular-nums font-medium">
|
||||
{foreignTotal.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {foreignCurrency}
|
||||
</span>
|
||||
</div>
|
||||
{foreignExchangeRate && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Omräkningskurs</span>
|
||||
<span className="tabular-nums">
|
||||
1 {foreignCurrency} = {foreignExchangeRate.toLocaleString('sv-SE', { minimumFractionDigits: 4, maximumFractionDigits: 4 })} SEK
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
+57
-244
@@ -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<Partial<CompanySettings>>({})
|
||||
const [companyId, setCompanyId] = useState<string | null>(null)
|
||||
const ticEnabled = ENABLED_EXTENSION_IDS.has('tic')
|
||||
const [ticLookup, setTicLookup] = useState<CompanyLookupResult | null>(null)
|
||||
|
||||
@@ -109,274 +108,88 @@ function NewCompanyContent() {
|
||||
checkAuth()
|
||||
}, [supabase, router])
|
||||
|
||||
const saveSettings = async (updates: Partial<CompanySettings>, 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<string, unknown>
|
||||
|
||||
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<CompanySettings>) => {
|
||||
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<string, unknown>
|
||||
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<string, unknown>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<UploadedFile[]>([])
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [entryCurrency, setEntryCurrency] = useState<Currency>('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({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Currency section */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="w-24">
|
||||
<Label className="text-xs text-muted-foreground">Valuta</Label>
|
||||
<Select value={entryCurrency} onValueChange={(v) => {
|
||||
setEntryCurrency(v as Currency)
|
||||
if (v === 'SEK') {
|
||||
setExchangeRate('')
|
||||
setForeignAmount('')
|
||||
}
|
||||
}}>
|
||||
<SelectTrigger className="mt-1 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CURRENCIES.map((c) => (
|
||||
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{isForeign && (
|
||||
<>
|
||||
<div className="w-40">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Omräkningskurs (1 {entryCurrency} = ? SEK)
|
||||
</Label>
|
||||
<div className="relative mt-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={exchangeRate}
|
||||
onChange={(e) => setExchangeRate(e.target.value)}
|
||||
placeholder="0,0000"
|
||||
className="h-8 pr-8"
|
||||
step="0.0001"
|
||||
min="0"
|
||||
/>
|
||||
{isFetchingRate && (
|
||||
<Loader2 className="absolute right-2 top-1.5 h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Belopp i {entryCurrency}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={foreignAmount || (computedForeignAmount > 0 && !parsedForeignInput ? computedForeignAmount.toFixed(2) : '')}
|
||||
onChange={(e) => setForeignAmount(e.target.value)}
|
||||
placeholder="0,00"
|
||||
className="mt-1 h-8"
|
||||
step="0.01"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
{rate > 0 && computedForeignAmount > 0 && (
|
||||
<p className="text-xs text-muted-foreground pb-1">
|
||||
{computedForeignAmount.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} {entryCurrency} × {rate.toLocaleString('sv-SE', { minimumFractionDigits: 4 })} = {computedSekAmount.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Entry lines — mobile cards */}
|
||||
<div className="sm:hidden space-y-3">
|
||||
{lines.map((line, index) => (
|
||||
|
||||
@@ -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<Partial<CompanySettings>>({})
|
||||
const [companyId, setCompanyId] = useState<string | null>(null)
|
||||
const ticEnabled = ENABLED_EXTENSION_IDS.has('tic')
|
||||
const [ticLookup, setTicLookup] = useState<CompanyLookupResult | null>(null)
|
||||
const [enrichmentCompanies, setEnrichmentCompanies] = useState<EnrichmentCompanyRole[]>([])
|
||||
@@ -138,291 +137,82 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE
|
||||
loadEnrichment()
|
||||
}, [supabase, router])
|
||||
|
||||
const saveSettings = async (updates: Partial<CompanySettings>, 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<string, unknown>
|
||||
|
||||
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<CompanySettings>) => {
|
||||
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<string, unknown>
|
||||
|
||||
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<string, unknown>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<number>(
|
||||
initialData.first_year_start
|
||||
? parseDateParts(initialData.first_year_start).day
|
||||
: 1
|
||||
)
|
||||
const [startMonth, setStartMonth] = useState<number>(
|
||||
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 (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Select
|
||||
value={startYear ? startYear.toString() : ''}
|
||||
onValueChange={(v) => { if (v) handleYearChange(parseInt(v)) }}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="År" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{years.map((y) => (
|
||||
<SelectItem key={y} value={y.toString()}>{y}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={startMonth ? startMonth.toString() : ''}
|
||||
onValueChange={(v) => { if (v) handleMonthChange(parseInt(v)) }}
|
||||
@@ -407,15 +443,15 @@ export default function Step3TaxRegistration({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={startYear ? startYear.toString() : ''}
|
||||
onValueChange={(v) => { if (v) handleYearChange(parseInt(v)) }}
|
||||
value={startDay.toString()}
|
||||
onValueChange={(v) => { if (v) handleDayChange(parseInt(v)) }}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="År" />
|
||||
<SelectValue placeholder="Dag" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{years.map((y) => (
|
||||
<SelectItem key={y} value={y.toString()}>{y}</SelectItem>
|
||||
{Array.from({ length: maxDays }, (_, i) => i + 1).map((d) => (
|
||||
<SelectItem key={d} value={d.toString()}>{d}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -424,7 +460,7 @@ export default function Step3TaxRegistration({
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
{errors.first_year_start && (
|
||||
<p className="text-xs text-destructive">{errors.first_year_start.message}</p>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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<CompanySettings> & Record<string, unknown>,
|
||||
): 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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user