- 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.
{errors.first_year_start && (
{errors.first_year_start.message}
diff --git a/lib/bookkeeping/__tests__/validate-period-duration.test.ts b/lib/bookkeeping/__tests__/validate-period-duration.test.ts
index 4a7ddb1d..10e64fd2 100644
--- a/lib/bookkeeping/__tests__/validate-period-duration.test.ts
+++ b/lib/bookkeeping/__tests__/validate-period-duration.test.ts
@@ -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'
diff --git a/lib/bookkeeping/validate-period-duration.ts b/lib/bookkeeping/validate-period-duration.ts
index 73b38329..e8a4f5fc 100644
--- a/lib/bookkeeping/validate-period-duration.ts
+++ b/lib/bookkeeping/validate-period-duration.ts
@@ -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'
}
diff --git a/lib/company/actions.ts b/lib/company/actions.ts
index ed1351fa..5685a2b7 100644
--- a/lib/company/actions.ts
+++ b/lib/company/actions.ts
@@ -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
+ 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 }
+}
diff --git a/lib/company/compute-fiscal-period.ts b/lib/company/compute-fiscal-period.ts
new file mode 100644
index 00000000..ba0db221
--- /dev/null
+++ b/lib/company/compute-fiscal-period.ts
@@ -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 & Record,
+): 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 }
+}
diff --git a/lib/core/bookkeeping/period-service.ts b/lib/core/bookkeeping/period-service.ts
index 5b498de6..d7a860ac 100644
--- a/lib/core/bookkeeping/period-service.ts
+++ b/lib/core/bookkeeping/period-service.ts
@@ -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)
}
diff --git a/supabase/migrations/20260409165300_allow_custom_first_fiscal_period_start.sql b/supabase/migrations/20260409165300_allow_custom_first_fiscal_period_start.sql
new file mode 100644
index 00000000..c4a12652
--- /dev/null
+++ b/supabase/migrations/20260409165300_allow_custom_first_fiscal_period_start.sql
@@ -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();