From 4a6c473cc4d44eb1a697738eae9404fd3076ee2c Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 14 May 2026 14:31:01 +0200 Subject: [PATCH] =?UTF-8?q?fix(bookkeeping):=20allow=20EF=20f=C3=B6rl?= =?UTF-8?q?=C3=A4ngt=20r=C3=A4kenskaps=C3=A5r=20on=20first-period=20edit?= =?UTF-8?q?=20+=20hide=20Recapt=20widget=20(#481)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../[id]/__tests__/route.test.ts | 242 ++++++++++++++++++ .../bookkeeping/fiscal-periods/[id]/route.ts | 51 ++-- app/layout.tsx | 2 + components/RecaptHideWidget.tsx | 38 +++ 4 files changed, 313 insertions(+), 20 deletions(-) create mode 100644 app/api/bookkeeping/fiscal-periods/[id]/__tests__/route.test.ts create mode 100644 components/RecaptHideWidget.tsx diff --git a/app/api/bookkeeping/fiscal-periods/[id]/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/__tests__/route.test.ts new file mode 100644 index 00000000..ac7ab7e7 --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/[id]/__tests__/route.test.ts @@ -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).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/) + }) + }) +}) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/route.ts index e4ab97a1..f24fca05 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/route.ts @@ -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 }) diff --git a/app/layout.tsx b/app/layout.tsx index 5d0ea977..dfbc1eec 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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} +