* 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:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user