Files
accounted/app/api/bookkeeping/fiscal-periods/[id]/route.ts
T
Jakob WennbergandClaude Opus 4.7 4a6c473cc4 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>
2026-05-14 14:31:01 +02:00

170 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { validateBody } from '@/lib/api/validate'
import { validatePeriodDuration, parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { z } from 'zod'
const UpdateFiscalPeriodSchema = z.object({
name: z.string().min(1).optional(),
period_start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Startdatum måste vara i format ÅÅÅÅ-MM-DD').optional(),
period_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Slutdatum måste vara i format ÅÅÅÅ-MM-DD').optional(),
})
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, UpdateFiscalPeriodSchema)
if (!validation.success) return validation.response
const body = validation.data
// Fetch the period
const { data: period, error: fetchError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !period) {
return NextResponse.json({ error: 'Räkenskapsår hittades inte' }, { status: 404 })
}
// Cannot edit locked or closed periods
if (period.locked_at) {
return NextResponse.json({ error: 'Kan inte ändra ett låst räkenskapsår' }, { status: 400 })
}
if (period.is_closed) {
return NextResponse.json({ error: 'Kan inte ändra ett stängt räkenskapsår' }, { status: 400 })
}
// If dates are being changed, check for existing journal entries
if (body.period_start || body.period_end) {
const { count: entryCount } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('fiscal_period_id', id)
.in('status', ['posted', 'reversed'])
if (entryCount && entryCount > 0) {
return NextResponse.json(
{ error: `Kan inte ändra datum: ${entryCount} bokförda verifikationer finns i perioden. Ta bort eller flytta dem först.` },
{ status: 400 }
)
}
const newStart = body.period_start || period.period_start
const newEnd = body.period_end || period.period_end
// 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 })
.eq('company_id', companyId)
.neq('id', id)
.lt('period_start', newStart)
const isFirstPeriod = !earlierCount || earlierCount === 0
// 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 })
}
// Check for overlapping periods (excluding this one)
const { data: overlapping } = await supabase
.from('fiscal_periods')
.select('id, name')
.eq('company_id', companyId)
.neq('id', id)
.lte('period_start', newEnd)
.gte('period_end', newStart)
.limit(1)
if (overlapping && overlapping.length > 0) {
return NextResponse.json(
{ error: `Överlappar med befintligt räkenskapsår: ${overlapping[0].name}` },
{ status: 409 }
)
}
}
// Build update object
const updates: Record<string, unknown> = {}
if (body.name) updates.name = body.name
if (body.period_start) updates.period_start = body.period_start
if (body.period_end) updates.period_end = body.period_end
if (Object.keys(updates).length === 0) {
return NextResponse.json({ data: period })
}
const { data: updated, error: updateError } = await supabase
.from('fiscal_periods')
.update(updates)
.eq('id', id)
.eq('company_id', companyId)
.select()
.single()
if (updateError) {
// Database trigger/constraint will catch invalid month boundaries
const msg = updateError.message
if (msg.includes('period_start') || msg.includes('period_end') || msg.includes('first of a month') || msg.includes('1st of a month')) {
return NextResponse.json(
{ error: 'Perioden måste sluta sista dagen i en månad. Efterföljande perioder måste börja den 1:a.' },
{ status: 400 }
)
}
return NextResponse.json({ error: msg }, { status: 500 })
}
return NextResponse.json({ data: updated })
}