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:
co-authored by
Claude Opus 4.7
parent
1f89a71962
commit
4a6c473cc4
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user