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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user