* 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>
170 lines
5.9 KiB
TypeScript
170 lines
5.9 KiB
TypeScript
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 })
|
||
}
|