From cc18e9d5302d335bc3d5e3a12c461eccaca6ca34 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 3 Sep 2026 18:41:14 +0200 Subject: [PATCH] =?UTF-8?q?fix(bookkeeping):=20let=20a=20backfilled=20firs?= =?UTF-8?q?t=20r=C3=A4kenskaps=C3=A5r=20start=20mid-month=20(#2237)=20(#22?= =?UTF-8?q?42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(bookkeeping): let a backfilled first räkenskapsår start mid-month (#2237) POST /api/bookkeeping/fiscal-periods decided "first period" as "no period exists at all", so a company that imported 2024+ from Fortnox and then created its actual first year by hand (2022-07-22, the registration date) was refused with the 1st-of-month error, while the DB trigger enforce_first_of_month_for_subsequent_periods would have accepted the row. The route now mirrors the trigger: first = no existing period starts earlier. The 1st-of-month rule (BFL 3 kap. 1 §) keeps binding subsequent years, and its message now says which years it binds and why instead of only refusing. Tests: prepend with a mid-month start passes; a mid-month start for a non-earliest period is still a 400 that names the rule. Closes #2237 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VnConrmMCxJRQ5kfiPPWyy * chore: carry the DECISIONS.md line for this PR in #2247 instead (append-only log conflicts on every merge) --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- .../fiscal-periods/__tests__/route.test.ts | 32 +++++++++++++++++++ app/api/bookkeeping/fiscal-periods/route.ts | 11 ++++++- .../validate-period-duration.test.ts | 10 +++--- lib/bookkeeping/validate-period-duration.ts | 6 ++-- 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts index 4821aff3..346e91fd 100644 --- a/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts +++ b/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts @@ -369,6 +369,38 @@ describe('POST /api/bookkeeping/fiscal-periods', () => { expect(body.warnings).toBeUndefined() }) + // Regression (issue #2237): a company that imported 2024+ from Fortnox and + // then backfilled its FIRST räkenskapsår by hand was refused because the + // first year started mid-month (2022-07-22, the registration date). The + // 1st-of-month rule (BFL 3 kap. 1 §) binds subsequent years only; "first" + // means no existing period starts earlier, exactly as the DB trigger + // enforce_first_of_month_for_subsequent_periods defines it. + it('allows a mid-month start when the new period becomes the earliest (first räkenskapsår backfilled after an import)', async () => { + buildMockSupabase({ + allPeriods: [ + { id: 'p2024', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false }, + { id: 'p2025', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false }, + ], + overlapping: [], + }) + const req = createMockRequest({ name: '2022/2023', period_start: '2022-07-22', period_end: '2023-12-31' }) + const res = await POST(req) + expect(res.status).toBe(200) + }) + + it('still rejects a mid-month start for a period that is not the earliest', async () => { + buildMockSupabase({ + allPeriods: [{ id: 'p2024', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false }], + }) + const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-15', period_end: '2025-12-31' }) + const res = await POST(req) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toMatch(/1st of a month/) + // The refusal explains the rule instead of only saying no. + expect(body.error).toMatch(/first fiscal year may start mid-month/) + }) + // Regression (2026-06-16): a company with FY 2024 + FY 2026 but no // FY 2025 could not create the missing year: the old code only allowed // chaining before the earliest or after the latest period. A period that diff --git a/app/api/bookkeeping/fiscal-periods/route.ts b/app/api/bookkeeping/fiscal-periods/route.ts index a05df91a..192e892b 100644 --- a/app/api/bookkeeping/fiscal-periods/route.ts +++ b/app/api/bookkeeping/fiscal-periods/route.ts @@ -49,7 +49,16 @@ export const POST = withRouteContext( .eq('company_id', companyId) .order('period_start', { ascending: true }) - const isFirstPeriod = !allPeriods || allPeriods.length === 0 + // "First" räkenskapsår = no existing period starts earlier, NOT "no period + // exists at all". Mirrors the enforce_first_of_month_for_subsequent_periods + // trigger: a mid-month start is legal for the company's first year (BFL 3 + // kap. 3 §, it begins the day bokföringsskyldigheten inträder) and only + // subsequent years must start on the 1st (BFL 3 kap. 1 §). A company that + // imported 2024+ from Fortnox and now backfills its first year from + // 2022-07-22 is creating exactly that first year; the old + // `allPeriods.length === 0` test refused it with the 1st-of-month error + // while the trigger would have accepted the row (issue #2237). + const isFirstPeriod = !(allPeriods ?? []).some((p) => p.period_start < body.period_start) // Validate period duration (max 18 months per BFL 3 kap.) const durationError = validatePeriodDuration(body.period_start, body.period_end, { isFirstPeriod }) diff --git a/lib/bookkeeping/__tests__/validate-period-duration.test.ts b/lib/bookkeeping/__tests__/validate-period-duration.test.ts index c3c34956..77f79352 100644 --- a/lib/bookkeeping/__tests__/validate-period-duration.test.ts +++ b/lib/bookkeeping/__tests__/validate-period-duration.test.ts @@ -49,13 +49,15 @@ describe('validatePeriodDuration', () => { }) 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' - ) + const result = validatePeriodDuration('2025-01-15', '2025-12-31') + expect(result).toContain('Period start must be the 1st of a month') + // Says what IS allowed and why, not only "no" (issue #2237). + expect(result).toContain('first fiscal year may start mid-month') + expect(result).toContain('BFL 3 kap.') }) it('returns error when start is not 1st of month (isFirstPeriod: false)', () => { - expect(validatePeriodDuration('2025-03-25', '2025-12-31', { isFirstPeriod: false })).toBe( + expect(validatePeriodDuration('2025-03-25', '2025-12-31', { isFirstPeriod: false })).toContain( 'Period start must be the 1st of a month' ) }) diff --git a/lib/bookkeeping/validate-period-duration.ts b/lib/bookkeeping/validate-period-duration.ts index 7ac0da3a..f9904bb6 100644 --- a/lib/bookkeeping/validate-period-duration.ts +++ b/lib/bookkeeping/validate-period-duration.ts @@ -47,9 +47,11 @@ export function validatePeriodDuration(start: string, end: string, options?: Val return 'Period end must be after period start' } - // start must be 1st of month: unless this is the first fiscal period (BFL 3 kap.) + // start must be 1st of month: unless this is the first fiscal period (BFL 3 + // kap. 1 § for subsequent years, 3 kap. 3 § for the first). Say why and what + // is allowed, not only "no" (issue #2237). if (startParts.day !== 1 && !options?.isFirstPeriod) { - return 'Period start must be the 1st of a month' + return "Period start must be the 1st of a month: only the company's first fiscal year may start mid-month (BFL 3 kap. 1 and 3 §§)" } // end must be last day of month