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