fix(bookkeeping): let a backfilled first räkenskapsår start mid-month (#2237) (#2242)

* 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-03 18:41:14 +02:00
committed by GitHub
parent d69de86b71
commit cc18e9d530
4 changed files with 52 additions and 7 deletions
@@ -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
+10 -1
View File
@@ -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 })
@@ -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'
)
})
+4 -2
View File
@@ -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