diff --git a/lib/import/__tests__/sie-import.test.ts b/lib/import/__tests__/sie-import.test.ts index 1f07aa1f..352d87a7 100644 --- a/lib/import/__tests__/sie-import.test.ts +++ b/lib/import/__tests__/sie-import.test.ts @@ -323,12 +323,12 @@ describe('ensureFiscalPeriod validation', () => { // actionable Swedish error instead of a raw Postgres message. type Supabase = Parameters[0] - it('rejects mid-month start when another period exists for the company', async () => { + it('rejects mid-month start when an earlier period already exists', async () => { const { supabase, enqueueMany } = createQueuedMockSupabase() enqueueMany([ { data: null, error: null }, // containing check — no match { data: [], error: null }, // overlapping check — none - { data: null, error: null, count: 1 }, // count existing — 1 period already + { data: [{ id: 'earlier' }], error: null }, // earlier period exists ]) await expect( @@ -338,7 +338,7 @@ describe('ensureFiscalPeriod validation', () => { '2026-04-16', '2026-12-31', ), - ).rejects.toThrow(/endast företagets första räkenskapsår får börja mitt i månaden/) + ).rejects.toThrow(/kronologiskt första räkenskapsår får börja mitt i månaden/) }) it('rejects end date that is not the last day of the month', async () => { @@ -346,7 +346,7 @@ describe('ensureFiscalPeriod validation', () => { enqueueMany([ { data: null, error: null }, { data: [], error: null }, - { data: null, error: null, count: 0 }, // first fiscal period + { data: [], error: null }, // no earlier period ]) await expect( @@ -364,7 +364,7 @@ describe('ensureFiscalPeriod validation', () => { enqueueMany([ { data: null, error: null }, { data: [], error: null }, - { data: null, error: null, count: 0 }, // no existing periods + { data: [], error: null }, // no earlier period { data: { id: 'new-period-id' }, error: null }, // insert result ]) @@ -378,6 +378,29 @@ describe('ensureFiscalPeriod validation', () => { expect(id).toBe('new-period-id') }) + it('allows mid-month start when importing a retroactive earliest period', async () => { + // Scenario: onboarding created a 2026 fiscal period, user now imports + // an SIE for their förlängt första räkenskapsår 2017-07-28 – 2018-12-31. + // The 2017 period is chronologically earliest, so mid-month start is + // legal under BFL 3 kap. + const { supabase, enqueueMany } = createQueuedMockSupabase() + enqueueMany([ + { data: null, error: null }, // containing check — no match + { data: [], error: null }, // overlapping check — none (2017 vs 2026) + { data: [], error: null }, // no earlier period than 2017-07-28 + { data: { id: 'retro-first-year-id' }, error: null }, // insert + ]) + + const id = await ensureFiscalPeriod( + supabase as unknown as Supabase, + 'company-id', + '2017-07-28', + '2018-12-31', + ) + + expect(id).toBe('retro-first-year-id') + }) + it('reuses an existing period that contains the range (no validation needed)', async () => { const { supabase, enqueueMany } = createQueuedMockSupabase() enqueueMany([ diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts index 42e41a2e..f4f7e350 100644 --- a/lib/import/sie-import.ts +++ b/lib/import/sie-import.ts @@ -252,22 +252,28 @@ export async function ensureFiscalPeriod( // Pre-validate against the DB-side enforce_period_start_day trigger so the // user gets an actionable Swedish error instead of a raw Postgres message. - // Only the company's FIRST fiscal period may start mid-month (BFL 3 kap.); - // any subsequent period must start on day 1. The trigger queries the same - // table, so "another period exists" == the overlap check above missed it, - // i.e. this would be a non-contiguous second period. - const { count: existingCount } = await supabase - .from('fiscal_periods') - .select('id', { count: 'exact', head: true }) - .eq('company_id', companyId) - + // Per BFL 3 kap., only the company's chronologically FIRST fiscal year may + // start mid-month (förlängt första räkenskapsår). Any period that comes + // after an earlier one must start on day 1. We check "is there a period + // that starts earlier?" rather than "does any period exist?" so a user can + // retroactively import an old first fiscal year via SIE even after an + // onboarding-created period already exists later in time. const startParts = parseDateParts(startDate) const endParts = parseDateParts(endDate) - if ((existingCount ?? 0) > 0 && startParts.day !== 1) { - throw new Error( - `SIE-filens räkenskapsår börjar ${startDate} — endast företagets första räkenskapsår får börja mitt i månaden. Efterföljande räkenskapsår måste börja den 1:a i en månad (BFL 3 kap.). Kontrollera datumen i #RAR-raden eller importera filen innan du skapar fler perioder.` - ) + if (startParts.day !== 1) { + const { data: earlier } = await supabase + .from('fiscal_periods') + .select('id') + .eq('company_id', companyId) + .lt('period_start', startDate) + .limit(1) + + if (earlier && earlier.length > 0) { + throw new Error( + `SIE-filens räkenskapsår börjar ${startDate} — endast företagets kronologiskt första räkenskapsår får börja mitt i månaden. Efterföljande räkenskapsår måste börja den 1:a i en månad (BFL 3 kap.). Kontrollera datumen i #RAR-raden.` + ) + } } // Matches the fiscal_period_end_last_of_month CHECK constraint on prod; diff --git a/supabase/migrations/20260418120000_allow_retroactive_first_fiscal_year.sql b/supabase/migrations/20260418120000_allow_retroactive_first_fiscal_year.sql new file mode 100644 index 00000000..dd7c9816 --- /dev/null +++ b/supabase/migrations/20260418120000_allow_retroactive_first_fiscal_year.sql @@ -0,0 +1,38 @@ +-- Allow retroactive first fiscal year via SIE import. +-- +-- The previous trigger (20260416120000) rejected any non-first-of-month +-- period_start when *any other* fiscal period existed for the company. +-- That blocked a common flow: user onboards and gets a default period +-- (e.g. the current year, starting on day 1), then imports an SIE file +-- covering an older, förlängt första räkenskapsår that starts mid-month +-- (e.g. 2017-07-28 – 2018-12-31). +-- +-- Per BFL 3 kap., the forst-in-time (chronologically earliest) fiscal year +-- is the one that may be 6–18 months and start mid-month. That property is +-- about *when* the period starts relative to other periods, not about the +-- order rows were inserted. This migration changes the trigger to reflect +-- that: mid-month start is allowed iff no existing period starts earlier. + +CREATE OR REPLACE FUNCTION public.enforce_first_of_month_for_subsequent_periods() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF EXTRACT(DAY FROM NEW.period_start) = 1 THEN + RETURN NEW; + END IF; + + IF EXISTS ( + SELECT 1 FROM public.fiscal_periods + WHERE company_id = NEW.company_id + AND id IS DISTINCT FROM NEW.id + AND period_start < NEW.period_start + ) THEN + RAISE EXCEPTION 'Non-first fiscal period must start on the 1st of a month'; + END IF; + + RETURN NEW; +END; +$$; + +NOTIFY pgrst, 'reload schema';