fix(bookkeeping): allow EF förlängt räkenskapsår on first-period edit + hide Recapt widget (#481)

* fix(bookkeeping): allow EF förlängt räkenskapsår on first-period edit + hide Recapt widget

PATCH /api/bookkeeping/fiscal-periods/[id] rejected enskild firma first
fiscal periods extended into the next calendar year (e.g. 2020-10-04 →
2021-12-31, 15 mån) even though BFL 3 kap. permits up to 18 months when
the EF starts after 1 juli. Validator and DB trigger already supported
this; only the API check ignored isFirstPeriod. Move the isFirstPeriod
calculation above the EF rule and split it into "end must be 31 dec
(always)" + "start must be 1 jan (only when not first period)". Brings
the API into agreement with the frontend's validateFirstPeriod logic.

Also mount RecaptHideWidget in the root layout, which calls
window.recapt('feedback', { widget: 'hide' }) once the SDK is ready.
The floating bubble no longer appears in the bottom-right; Recapt's
identify and programmatic feedback APIs continue to work unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(bookkeeping): address PR-481 review — standard guards, shared helpers, 18-month cap regression

- Use shared createMockRequest / createMockRouteParams from tests/helpers.ts
- Add 401 (unauthenticated), 400 (malformed body), 404 (unknown period)
- Rename "mid-month startdatum" case to "not 1 januari" (request sends 2026-02-01, a month boundary, not mid-month — the rule rejects any non-Jan-1)
- Add defense-in-depth case proving validatePeriodDuration rejects a 24-month EF first period (BFL 3 kap. 18-month cap), since the new EF end-date guard runs before duration validation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(bookkeeping): lock in EF subsequent-period end-date guard

The start-must-be-1-jan + end-must-be-31-dec guards together force EF
subsequent periods to a 12-month span. validatePeriodDuration's 18-month
universal cap doesn't enforce this on its own — only the route's per-EF
guards do. Add a regression test so a future refactor of the EF block
can't silently allow a 13-month subsequent period (e.g. 2026-01-01 →
2027-01-31), addressing PR-481 swedish-compliance review finding #1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-14 14:31:01 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 1f89a71962
commit 4a6c473cc4
4 changed files with 313 additions and 20 deletions
@@ -0,0 +1,242 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockRequest, createMockRouteParams } from '@/tests/helpers'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { createClient } from '@/lib/supabase/server'
import { PATCH } from '../route'
function patchRequest(body: unknown): Request {
return createMockRequest('/api/bookkeeping/fiscal-periods/period-1', {
method: 'PATCH',
body,
})
}
/**
* Mocks the chain of supabase calls the PATCH route makes:
* 1. from('fiscal_periods').select('*').eq.eq.single() → existing period (null = 404)
* 2. from('journal_entries').select(count, head).eq.eq.in() → posted entry count
* 3. from('fiscal_periods').select(count, head).eq.neq.lt() → earlier-period count
* 4. from('companies').select('entity_type').eq.single() → entity type
* 5. from('fiscal_periods').select('id, name').eq.neq.lte.gte.limit() → overlap
* 6. from('fiscal_periods').update().eq.eq.select.single() → updated row
*/
function buildMockSupabase(options: {
user?: { id: string } | null
period?: { id: string; period_start: string; period_end: string; locked_at: string | null; is_closed: boolean } | null
entityType?: 'aktiebolag' | 'enskild_firma'
postedEntryCount?: number
earlierPeriodCount?: number
overlapping?: Array<{ id: string; name: string }>
}) {
const {
user = { id: 'user-1' },
period = { id: 'p1', period_start: '2020-01-01', period_end: '2020-12-31', locked_at: null, is_closed: false },
entityType = 'enskild_firma',
postedEntryCount = 0,
earlierPeriodCount = 0,
overlapping = [],
} = options
let fiscalPeriodsCall = 0
const supabase = {
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user } }),
},
from: vi.fn().mockImplementation((table: string) => {
if (table === 'journal_entries') {
return {
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
in: vi.fn().mockResolvedValue({ count: postedEntryCount, error: null }),
}),
}),
}),
}
}
if (table === 'companies') {
return {
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({ data: { entity_type: entityType }, error: null }),
}),
}),
}
}
if (table === 'fiscal_periods') {
fiscalPeriodsCall++
const callNum = fiscalPeriodsCall
return {
select: vi.fn().mockImplementation((_sel: string, opts?: { count?: string; head?: boolean }) => {
if (callNum === 1) {
return {
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: period,
error: period ? null : { message: 'not found' },
}),
}),
}),
}
}
if (opts?.head) {
return {
eq: vi.fn().mockReturnValue({
neq: vi.fn().mockReturnValue({
lt: vi.fn().mockResolvedValue({ count: earlierPeriodCount, error: null }),
}),
}),
}
}
return {
eq: vi.fn().mockReturnValue({
neq: vi.fn().mockReturnValue({
lte: vi.fn().mockReturnValue({
gte: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue({ data: overlapping, error: null }),
}),
}),
}),
}),
}
}),
update: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({ data: period, error: null }),
}),
}),
}),
}),
}
}
return {}
}),
}
;(createClient as ReturnType<typeof vi.fn>).mockResolvedValue(supabase)
return supabase
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('PATCH /api/bookkeeping/fiscal-periods/[id]', () => {
describe('standard guards', () => {
it('returns 401 when not authenticated', async () => {
buildMockSupabase({ user: null })
const res = await PATCH(patchRequest({ period_end: '2020-12-31' }), createMockRouteParams({ id: 'p1' }))
expect(res.status).toBe(401)
})
it('returns 400 when body is malformed', async () => {
buildMockSupabase({})
const res = await PATCH(patchRequest({ period_start: 'not-a-date' }), createMockRouteParams({ id: 'p1' }))
expect(res.status).toBe(400)
})
it('returns 404 when period does not exist', async () => {
buildMockSupabase({ period: null })
const res = await PATCH(patchRequest({ period_end: '2020-12-31' }), createMockRouteParams({ id: 'missing' }))
expect(res.status).toBe(404)
})
})
describe('enskild firma — BFL 3 kap.', () => {
it('allows förlängt räkenskapsår (15 mån, 4 okt 2020 → 31 dec 2021) on the first period', async () => {
buildMockSupabase({ earlierPeriodCount: 0 })
const res = await PATCH(
patchRequest({ period_start: '2020-10-04', period_end: '2021-12-31' }),
createMockRouteParams({ id: 'p1' }),
)
expect(res.status).toBe(200)
})
it('rejects EF first period when slutdatum is not 31 december', async () => {
buildMockSupabase({ earlierPeriodCount: 0 })
const res = await PATCH(
patchRequest({ period_start: '2020-10-04', period_end: '2021-11-30' }),
createMockRouteParams({ id: 'p1' }),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/31 december/)
})
it('rejects EF subsequent period when startdatum is not 1 januari', async () => {
buildMockSupabase({
period: { id: 'p2', period_start: '2026-01-01', period_end: '2026-12-31', locked_at: null, is_closed: false },
earlierPeriodCount: 1,
})
const res = await PATCH(
patchRequest({ period_start: '2026-02-01', period_end: '2026-12-31' }),
createMockRouteParams({ id: 'p2' }),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/kalenderår/)
})
it('accepts EF subsequent period running 1 jan – 31 dec', async () => {
buildMockSupabase({
period: { id: 'p2', period_start: '2026-01-01', period_end: '2026-12-31', locked_at: null, is_closed: false },
earlierPeriodCount: 1,
})
const res = await PATCH(
patchRequest({ period_start: '2026-01-01', period_end: '2026-12-31' }),
createMockRouteParams({ id: 'p2' }),
)
expect(res.status).toBe(200)
})
// Locks in the implicit "EF subsequent period is always exactly 12 months"
// rule. The start-must-be-1-jan + end-must-be-31-dec guards together force
// a 12-month span; this test catches a future refactor that loosens either.
it('rejects EF subsequent period when slutdatum is not 31 december', async () => {
buildMockSupabase({
period: { id: 'p2', period_start: '2026-01-01', period_end: '2026-12-31', locked_at: null, is_closed: false },
earlierPeriodCount: 1,
})
const res = await PATCH(
patchRequest({ period_start: '2026-01-01', period_end: '2027-01-31' }),
createMockRouteParams({ id: 'p2' }),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/31 december/)
})
// Defense-in-depth: the EF end-date guard runs before validatePeriodDuration.
// A 24-month first period (2020-01-01 → 2021-12-31) ends on 31 dec, so the EF
// guard passes — duration validation must catch it as BFL 3 kap. caps the
// first period at 18 months.
it('rejects a 24-month first period via the duration cap', async () => {
buildMockSupabase({ earlierPeriodCount: 0 })
const res = await PATCH(
patchRequest({ period_start: '2020-01-01', period_end: '2021-12-31' }),
createMockRouteParams({ id: 'p1' }),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/18 months/)
})
})
})
@@ -72,25 +72,9 @@ export async function PATCH(
const newStart = body.period_start || period.period_start
const newEnd = body.period_end || period.period_end
// Enskild firma must use calendar year per BFL 3 kap.
const { data: companyRow } = await supabase
.from('companies')
.select('entity_type')
.eq('id', companyId)
.single()
if (companyRow?.entity_type === 'enskild_firma') {
const s = parseDateParts(newStart)
const e = parseDateParts(newEnd)
if (s.month !== 1 || s.day !== 1 || e.month !== 12 || e.day !== 31) {
return NextResponse.json(
{ error: 'Enskild firma måste använda kalenderår (1 januari – 31 december) enligt BFL 3 kap.' },
{ status: 400 }
)
}
}
// First period for this company may start on any day (BFL 3 kap.)
// First period for this company may start on any day (BFL 3 kap.).
// EF's first period may also extend to 31 dec next year (förlängt
// räkenskapsår, max 18 months) when the company started after 1 juli.
const { count: earlierCount } = await supabase
.from('fiscal_periods')
.select('id', { count: 'exact', head: true })
@@ -100,7 +84,34 @@ export async function PATCH(
const isFirstPeriod = !earlierCount || earlierCount === 0
// Validate period duration (max 18 months per BFL 3 kap.)
// Enskild firma must end on 31 december (BFL 3 kap.). Subsequent periods
// must also start on 1 januari. The first period may start any day.
const { data: companyRow } = await supabase
.from('companies')
.select('entity_type')
.eq('id', companyId)
.single()
if (companyRow?.entity_type === 'enskild_firma') {
const e = parseDateParts(newEnd)
if (e.month !== 12 || e.day !== 31) {
return NextResponse.json(
{ error: 'Enskild firma måste ha slutdatum 31 december enligt BFL 3 kap.' },
{ status: 400 }
)
}
if (!isFirstPeriod) {
const s = parseDateParts(newStart)
if (s.month !== 1 || s.day !== 1) {
return NextResponse.json(
{ error: 'Enskild firma måste använda kalenderår (1 januari – 31 december) enligt BFL 3 kap.' },
{ status: 400 }
)
}
}
}
// Validate period duration (max 18 months for first period, 12 for subsequent, per BFL 3 kap.)
const durationError = validatePeriodDuration(newStart, newEnd, { isFirstPeriod })
if (durationError) {
return NextResponse.json({ error: durationError }, { status: 400 })
+2
View File
@@ -4,6 +4,7 @@ import { Hedvig_Letters_Serif } from "next/font/google";
import Script from "next/script";
import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@/components/theme-provider";
import { RecaptHideWidget } from "@/components/RecaptHideWidget";
import { ensureInitialized } from "@/lib/init";
import { getBranding } from "@/lib/branding/service";
import "./globals.css";
@@ -83,6 +84,7 @@ export default function RootLayout({
>
{children}
<Toaster />
<RecaptHideWidget />
</ThemeProvider>
<Script src="/sw-register.js" strategy="afterInteractive" />
</body>
+38
View File
@@ -0,0 +1,38 @@
'use client'
import { useEffect } from 'react'
/**
* Hides Recapt's floating feedback bubble while keeping the SDK active so
* `window.recapt('identify', ...)` and programmatic `window.recapt('feedback',
* { message })` calls continue to work. Mounted globally in the root layout.
*/
export function RecaptHideWidget() {
useEffect(() => {
let attempts = 0
const maxAttempts = 50
const hide = (): boolean => {
if (typeof window.recapt !== 'function') return false
try {
window.recapt('feedback', { widget: 'hide' })
} catch {
// best-effort
}
return true
}
if (hide()) return
const interval = setInterval(() => {
attempts++
if (hide() || attempts >= maxAttempts) {
clearInterval(interval)
}
}, 100)
return () => clearInterval(interval)
}, [])
return null
}