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:
Mattsson
2026-04-10 11:02:28 +02:00
committed by GitHub
parent e8928b7885
commit aa405b9a74
14 changed files with 664 additions and 555 deletions
+25
View File
@@ -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 }
)
}
+9 -6
View File
@@ -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)
+31
View File
@@ -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
View File
@@ -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)
}
}