fix: allow retroactive first fiscal year via SIE import (#265)
The enforce_period_start_day trigger and its sie-import pre-validation rejected any non-first-of-month period_start whenever *any other* fiscal period existed for the company. That blocked a real user flow: after onboarding creates a default period (e.g. current year, day 1), importing an SIE for an older förlängt första räkenskapsår (e.g. 2017-07-28 – 2018-12-31) failed with "Non-first fiscal period must start on the 1st of a month". Per BFL 3 kap., the chronologically first fiscal year is the one that may be 6–18 months and start mid-month — which is a property of *when* the period starts relative to others, not of insert order. The trigger and pre-validation now allow mid-month start iff no existing period starts earlier. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
c8a5f044c3
commit
8ed943198f
@@ -323,12 +323,12 @@ describe('ensureFiscalPeriod validation', () => {
|
||||
// actionable Swedish error instead of a raw Postgres message.
|
||||
type Supabase = Parameters<typeof ensureFiscalPeriod>[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([
|
||||
|
||||
+19
-13
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user