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
@@ -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'
+10 -4
View File
@@ -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'
}
+123
View File
@@ -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 }
}
+68
View File
@@ -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 }
}
+2 -2
View File
@@ -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)
}