feat: implement fiscal period date fields component and validation logic (#301)
* feat: implement fiscal period date fields component and validation logic * feat: update fiscal period validation and naming logic
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, type ReactNode } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { CalendarDays } from 'lucide-react'
|
||||
import {
|
||||
monthsBetween,
|
||||
parseDateParts,
|
||||
validatePeriodDuration,
|
||||
} from '@/lib/bookkeeping/validate-period-duration'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
const monthNames = [
|
||||
'januari', 'februari', 'mars', 'april', 'maj', 'juni',
|
||||
'juli', 'augusti', 'september', 'oktober', 'november', 'december',
|
||||
]
|
||||
|
||||
function formatSwedishDate(dateStr: string): string {
|
||||
const { year, month, day } = parseDateParts(dateStr)
|
||||
return `${day} ${monthNames[month - 1]} ${year}`
|
||||
}
|
||||
|
||||
function endsOnDec31(end: string): boolean {
|
||||
const e = parseDateParts(end)
|
||||
return e.month === 12 && e.day === 31
|
||||
}
|
||||
|
||||
/**
|
||||
* Map validatePeriodDuration's English messages to user-facing Swedish copy.
|
||||
*/
|
||||
function toSwedishError(msg: string): string {
|
||||
if (msg.includes('after period start')) return 'Slutdatum måste vara efter startdatum.'
|
||||
if (msg.includes('1st of a month')) return 'Startdatum måste vara den första i månaden.'
|
||||
if (msg.includes('last day of a month')) return 'Slutdatum måste vara den sista i månaden.'
|
||||
if (msg.includes('exceeds maximum 18 months')) return 'Räkenskapsåret får vara högst 18 månader (BFL 3 kap.).'
|
||||
if (msg.includes('at least 6 months')) return 'Första räkenskapsåret måste vara minst 6 månader (BFL 3 kap.).'
|
||||
return msg
|
||||
}
|
||||
|
||||
export interface FiscalPeriodValidation {
|
||||
/** User-facing Swedish error, or null if valid */
|
||||
error: string | null
|
||||
/** Integer month count, or null if inputs are incomplete/invalid */
|
||||
months: number | null
|
||||
/** True if inputs are complete enough to render the summary */
|
||||
canSummarise: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared validation for the first fiscal period — used by both onboarding Step 3
|
||||
* and the settings FiscalPeriodEditor. Returns Swedish error copy.
|
||||
*/
|
||||
export function validateFirstPeriod(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
entityType: EntityType | undefined
|
||||
): FiscalPeriodValidation {
|
||||
if (!startDate || !endDate) {
|
||||
return { error: null, months: null, canSummarise: false }
|
||||
}
|
||||
if (endDate <= startDate) {
|
||||
return {
|
||||
error: 'Slutdatum måste vara efter startdatum.',
|
||||
months: null,
|
||||
canSummarise: false,
|
||||
}
|
||||
}
|
||||
|
||||
const baseError = validatePeriodDuration(startDate, endDate, { isFirstPeriod: true })
|
||||
if (baseError) {
|
||||
return {
|
||||
error: toSwedishError(baseError),
|
||||
months: monthsBetween(startDate, endDate),
|
||||
canSummarise: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (entityType === 'enskild_firma' && !endsOnDec31(endDate)) {
|
||||
return {
|
||||
error: 'Enskild firma måste ha slutdatum 31 december (BFL 3 kap.).',
|
||||
months: monthsBetween(startDate, endDate),
|
||||
canSummarise: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error: null,
|
||||
months: monthsBetween(startDate, endDate),
|
||||
canSummarise: true,
|
||||
}
|
||||
}
|
||||
|
||||
interface FiscalPeriodDateFieldsProps {
|
||||
startDate: string
|
||||
onStartDateChange: (value: string) => void
|
||||
startHelpText?: string
|
||||
/**
|
||||
* Render the end-date control. Onboarding passes its AB end-month <Select>
|
||||
* + computed-option <Select>; settings passes a native <input type="date">.
|
||||
*/
|
||||
endDateSlot: ReactNode
|
||||
/** The raw end-date string (used for summary + validation). */
|
||||
endDate: string
|
||||
entityType: EntityType | undefined
|
||||
/** Label for the summary card. Defaults to "Ditt första räkenskapsår". */
|
||||
summaryTitle?: string
|
||||
/** Optional override for start date <Label> content. */
|
||||
startLabel?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared first-fiscal-period date entry: day-level start (native date input),
|
||||
* caller-provided end-date control, and a Swedish summary card with inline
|
||||
* validation errors (6–18 months, EF calendar-year rule, last-day-of-month).
|
||||
*
|
||||
* Used by onboarding Step 3 and the settings FiscalPeriodEditor so the two
|
||||
* screens stay in lockstep.
|
||||
*/
|
||||
export function FiscalPeriodDateFields({
|
||||
startDate,
|
||||
onStartDateChange,
|
||||
startHelpText = 'Första räkenskapsåret kan börja valfri dag.',
|
||||
endDateSlot,
|
||||
endDate,
|
||||
entityType,
|
||||
summaryTitle = 'Ditt första räkenskapsår',
|
||||
startLabel = 'Startdatum',
|
||||
}: FiscalPeriodDateFieldsProps) {
|
||||
const validation = useMemo(
|
||||
() => validateFirstPeriod(startDate, endDate, entityType),
|
||||
[startDate, endDate, entityType],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fiscal-period-start">{startLabel}</Label>
|
||||
<Input
|
||||
id="fiscal-period-start"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => onStartDateChange(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{startHelpText}</p>
|
||||
</div>
|
||||
|
||||
{endDateSlot}
|
||||
|
||||
{validation.canSummarise && (
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<CalendarDays className="h-4 w-4 text-primary" />
|
||||
{summaryTitle}
|
||||
</div>
|
||||
{startDate && endDate && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatSwedishDate(startDate)} – {formatSwedishDate(endDate)}
|
||||
</p>
|
||||
)}
|
||||
{validation.months !== null && (
|
||||
<p
|
||||
className={`text-xs ${validation.error ? 'text-destructive' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{validation.months} månader
|
||||
{validation.error ? ` — ${validation.error}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{validation.error && validation.months === null && (
|
||||
<p className="text-xs text-destructive">{validation.error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validation.error && !validation.canSummarise && (
|
||||
<p className="text-xs text-destructive">{validation.error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,11 +13,15 @@ import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { Loader2, ArrowRight, ArrowLeft, Check, CalendarDays } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { monthsBetween, parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
|
||||
import { parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
|
||||
import {
|
||||
DestructiveConfirmDialog,
|
||||
useDestructiveConfirm,
|
||||
} from '@/components/ui/destructive-confirm-dialog'
|
||||
import {
|
||||
FiscalPeriodDateFields,
|
||||
validateFirstPeriod,
|
||||
} from '@/components/bookkeeping/FiscalPeriodDateFields'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
const schema = z.object({
|
||||
@@ -71,11 +75,6 @@ const monthNames = [
|
||||
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
|
||||
]
|
||||
|
||||
function formatSwedishDate(dateStr: string): string {
|
||||
const { year, month, day } = parseDateParts(dateStr)
|
||||
return `${day} ${monthNames[month - 1].toLowerCase()} ${year}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last day of a given month (1-indexed).
|
||||
*/
|
||||
@@ -131,7 +130,7 @@ function getABFirstYearEndDates(
|
||||
// Try ending in the same year or next year
|
||||
for (const endYear of [startYear, startYear + 1, startYear + 2]) {
|
||||
const months = (endYear - startYear) * 12 + (endMonth - startMonth) + 1
|
||||
if (months >= 1 && months <= 18) {
|
||||
if (months >= 6 && months <= 18) {
|
||||
const day = lastDayOfMonth(endYear, endMonth)
|
||||
const endDate = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
options.push({
|
||||
@@ -176,26 +175,8 @@ export default function Step3TaxRegistration({
|
||||
|
||||
const isFirstYear = watch('is_first_fiscal_year')
|
||||
const firstYearStart = watch('first_year_start')
|
||||
const firstYearEnd = watch('first_year_end')
|
||||
const fiscalYearEndMonth = watch('fiscal_year_end_month')
|
||||
|
||||
// State for first-year start date selectors (day/month/year)
|
||||
const [startDay, setStartDay] = useState<number>(
|
||||
initialData.first_year_start
|
||||
? parseDateParts(initialData.first_year_start).day
|
||||
: 1
|
||||
)
|
||||
const [startMonth, setStartMonth] = useState<number>(
|
||||
initialData.first_year_start
|
||||
? parseDateParts(initialData.first_year_start).month
|
||||
: 0
|
||||
)
|
||||
const [startYear, setStartYear] = useState<number>(
|
||||
initialData.first_year_start
|
||||
? parseDateParts(initialData.first_year_start).year
|
||||
: 0
|
||||
)
|
||||
|
||||
// State for AB first-year end month selector
|
||||
const [abEndMonth, setAbEndMonth] = useState<number>(
|
||||
initialData.first_year_end
|
||||
@@ -226,6 +207,20 @@ export default function Step3TaxRegistration({
|
||||
let firstEnd: string | undefined
|
||||
|
||||
if (data.is_first_fiscal_year && data.first_year_start && data.first_year_end) {
|
||||
// Validate the 6–18 month BFL 3 kap. window + EF calendar-year rule
|
||||
const validation = validateFirstPeriod(
|
||||
data.first_year_start,
|
||||
data.first_year_end,
|
||||
entityType,
|
||||
)
|
||||
if (validation.error) {
|
||||
toast({
|
||||
title: 'Räkenskapsåret är inte giltigt',
|
||||
description: validation.error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
// Derive start month from end date
|
||||
const endMonth = parseDateParts(data.first_year_end).month
|
||||
fiscalYearStartMonth = endMonth === 12 ? 1 : endMonth + 1
|
||||
@@ -395,173 +390,94 @@ export default function Step3TaxRegistration({
|
||||
{/* First fiscal year options */}
|
||||
{isFirstYear && (
|
||||
<div className="space-y-4 rounded-lg bg-muted/50 p-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Startdatum</Label>
|
||||
<Controller
|
||||
name="first_year_start"
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
const currentYear = new Date().getFullYear()
|
||||
const years = Array.from({ length: 7 }, (_, i) => currentYear - 5 + i)
|
||||
|
||||
const updateField = (day: number, month: number, year: number) => {
|
||||
if (month && year) {
|
||||
const maxDay = lastDayOfMonth(year, month)
|
||||
const clampedDay = Math.min(day, maxDay)
|
||||
field.onChange(`${year}-${String(month).padStart(2, '0')}-${String(clampedDay).padStart(2, '0')}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleYearChange = (year: number) => {
|
||||
setStartYear(year)
|
||||
updateField(startDay, startMonth, year)
|
||||
}
|
||||
|
||||
const handleMonthChange = (month: number) => {
|
||||
setStartMonth(month)
|
||||
// Clamp day if needed when month changes
|
||||
if (startYear) {
|
||||
const maxDay = lastDayOfMonth(startYear, month)
|
||||
if (startDay > maxDay) setStartDay(maxDay)
|
||||
}
|
||||
updateField(startDay, month, startYear)
|
||||
}
|
||||
|
||||
const handleDayChange = (day: number) => {
|
||||
setStartDay(day)
|
||||
updateField(day, startMonth, startYear)
|
||||
}
|
||||
|
||||
const maxDays = startMonth && startYear
|
||||
? lastDayOfMonth(startYear, startMonth)
|
||||
: 31
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Select
|
||||
value={startYear ? startYear.toString() : ''}
|
||||
onValueChange={(v) => { if (v) handleYearChange(parseInt(v)) }}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="År" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{years.map((y) => (
|
||||
<SelectItem key={y} value={y.toString()}>{y}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={startMonth ? startMonth.toString() : ''}
|
||||
onValueChange={(v) => { if (v) handleMonthChange(parseInt(v)) }}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Månad" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthNames.map((name, i) => (
|
||||
<SelectItem key={i + 1} value={(i + 1).toString()}>{name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={startDay.toString()}
|
||||
onValueChange={(v) => { if (v) handleDayChange(parseInt(v)) }}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Dag" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: maxDays }, (_, i) => i + 1).map((d) => (
|
||||
<SelectItem key={d} value={d.toString()}>{d}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Datumet företaget registrerades. Första räkenskapsåret kan börja valfri dag.
|
||||
</p>
|
||||
{errors.first_year_start && (
|
||||
<p className="text-xs text-destructive">{errors.first_year_start.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AB: end month selector */}
|
||||
{!isEF && parsedStart && (
|
||||
<div className="space-y-2">
|
||||
<Label>Räkenskapsåret slutar (månad)</Label>
|
||||
<Select
|
||||
value={abEndMonth.toString()}
|
||||
onValueChange={(v) => { if (v) setAbEndMonth(parseInt(v)) }}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj månad" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthNames.map((name, i) => (
|
||||
<SelectItem key={i + 1} value={(i + 1).toString()}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End date selector (options depend on entity type + start) */}
|
||||
{parsedStart && firstYearEndOptions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Slutdatum</Label>
|
||||
<Controller
|
||||
name="first_year_start"
|
||||
control={control}
|
||||
render={({ field: startField }) => (
|
||||
<Controller
|
||||
name="first_year_end"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value || ''}
|
||||
onValueChange={(v) => { if (v) field.onChange(v) }}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj slutdatum" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{firstYearEndOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
render={({ field: endField }) => (
|
||||
<FiscalPeriodDateFields
|
||||
startDate={startField.value || ''}
|
||||
onStartDateChange={(v) => {
|
||||
startField.onChange(v)
|
||||
// Reset end when start changes — its valid options depend on start
|
||||
if (endField.value) endField.onChange('')
|
||||
}}
|
||||
startHelpText="Datumet företaget registrerades. Första räkenskapsåret kan börja valfri dag."
|
||||
endDate={endField.value || ''}
|
||||
entityType={entityType}
|
||||
endDateSlot={
|
||||
<>
|
||||
{/* AB: end month selector */}
|
||||
{!isEF && parsedStart && (
|
||||
<div className="space-y-2">
|
||||
<Label>Räkenskapsåret slutar (månad)</Label>
|
||||
<Select
|
||||
value={abEndMonth.toString()}
|
||||
onValueChange={(v) => {
|
||||
if (v) {
|
||||
setAbEndMonth(parseInt(v))
|
||||
if (endField.value) endField.onChange('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj månad" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthNames.map((name, i) => (
|
||||
<SelectItem key={i + 1} value={(i + 1).toString()}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End date selector (options depend on entity type + start) */}
|
||||
{parsedStart && firstYearEndOptions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Slutdatum</Label>
|
||||
<Select
|
||||
value={endField.value || ''}
|
||||
onValueChange={(v) => { if (v) endField.onChange(v) }}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj slutdatum" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{firstYearEndOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.first_year_end && (
|
||||
<p className="text-xs text-destructive">{errors.first_year_end.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsedStart && firstYearEndOptions.length === 0 && (
|
||||
<p className="text-sm text-destructive">
|
||||
Ingen giltig slutperiod hittades. Kontrollera startdatumet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{errors.first_year_start && (
|
||||
<p className="text-xs text-destructive">{errors.first_year_start.message}</p>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.first_year_end && (
|
||||
<p className="text-xs text-destructive">{errors.first_year_end.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsedStart && firstYearEndOptions.length === 0 && (
|
||||
<p className="text-sm text-destructive">
|
||||
Ingen giltig slutperiod hittades. Kontrollera startdatumet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{firstYearStart && firstYearEnd && (
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<CalendarDays className="h-4 w-4 text-primary" />
|
||||
Ditt första räkenskapsår
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatSwedishDate(firstYearStart)} – {formatSwedishDate(firstYearEnd)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{monthsBetween(firstYearStart, firstYearEnd)} månader
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -10,8 +10,12 @@ import {
|
||||
DestructiveConfirmDialog,
|
||||
useDestructiveConfirm,
|
||||
} from '@/components/ui/destructive-confirm-dialog'
|
||||
import { Loader2, CalendarDays, Info, Lock } from 'lucide-react'
|
||||
import { monthsBetween, parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
|
||||
import { Loader2, Info, Lock } from 'lucide-react'
|
||||
import { parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
|
||||
import {
|
||||
FiscalPeriodDateFields,
|
||||
validateFirstPeriod,
|
||||
} from '@/components/bookkeeping/FiscalPeriodDateFields'
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
|
||||
function formatSwedishDate(dateStr: string): string {
|
||||
@@ -91,17 +95,11 @@ export function FiscalPeriodEditor() {
|
||||
}
|
||||
}, [company])
|
||||
|
||||
const durationMonths = useMemo(() => {
|
||||
if (!startDate || !endDate || endDate <= startDate) return null
|
||||
return monthsBetween(startDate, endDate)
|
||||
}, [startDate, endDate])
|
||||
|
||||
const efCalendarYearInvalid = useMemo(() => {
|
||||
if (!isEF || !startDate || !endDate) return false
|
||||
return !isCalendarYear({ period_start: startDate, period_end: endDate })
|
||||
}, [isEF, startDate, endDate])
|
||||
|
||||
const exceedsMaxDuration = durationMonths !== null && durationMonths > 18
|
||||
const validation = validateFirstPeriod(
|
||||
startDate,
|
||||
endDate,
|
||||
company?.entity_type,
|
||||
)
|
||||
|
||||
const isBlocked =
|
||||
!!period && (period.locked_at || period.is_closed || (postedCount ?? 0) > 0)
|
||||
@@ -126,7 +124,12 @@ export function FiscalPeriodEditor() {
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const newName = `Räkenskapsår ${parseDateParts(endDate).year}`
|
||||
const startYear = parseDateParts(startDate).year
|
||||
const endYear = parseDateParts(endDate).year
|
||||
const newName =
|
||||
startYear === endYear
|
||||
? `Räkenskapsår ${startYear}`
|
||||
: `Räkenskapsår ${startYear}/${endYear}`
|
||||
const res = await fetch(`/api/bookkeeping/fiscal-periods/${period.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -201,55 +204,27 @@ export function FiscalPeriodEditor() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fp_start">Startdatum</Label>
|
||||
<Input
|
||||
id="fp_start"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Första räkenskapsåret kan börja valfri dag.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fp_end">Slutdatum</Label>
|
||||
<Input
|
||||
id="fp_end"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Måste vara sista dagen i en månad.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{startDate && endDate && endDate > startDate && (
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4 space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<CalendarDays className="h-4 w-4 text-primary" />
|
||||
Föreslaget räkenskapsår
|
||||
<FiscalPeriodDateFields
|
||||
startDate={startDate}
|
||||
onStartDateChange={setStartDate}
|
||||
endDate={endDate}
|
||||
entityType={company?.entity_type}
|
||||
summaryTitle="Föreslaget räkenskapsår"
|
||||
endDateSlot={
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fp_end">Slutdatum</Label>
|
||||
<Input
|
||||
id="fp_end"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Måste vara sista dagen i en månad.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatSwedishDate(startDate)} – {formatSwedishDate(endDate)}
|
||||
</p>
|
||||
{durationMonths !== null && (
|
||||
<p className={`text-xs ${exceedsMaxDuration ? 'text-destructive' : 'text-muted-foreground'}`}>
|
||||
{durationMonths} månader
|
||||
{exceedsMaxDuration && ' — över 18 månader är inte tillåtet (BFL 3 kap.)'}
|
||||
</p>
|
||||
)}
|
||||
{efCalendarYearInvalid && (
|
||||
<p className="text-xs text-destructive">
|
||||
Enskild firma måste använda kalenderår (1 januari – 31 december).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
@@ -268,9 +243,7 @@ export function FiscalPeriodEditor() {
|
||||
isSaving ||
|
||||
!startDate ||
|
||||
!endDate ||
|
||||
endDate <= startDate ||
|
||||
exceedsMaxDuration ||
|
||||
efCalendarYearInvalid
|
||||
validation.error !== null
|
||||
}
|
||||
>
|
||||
{isSaving ? (
|
||||
|
||||
@@ -64,12 +64,12 @@ describe('validatePeriodDuration', () => {
|
||||
expect(validatePeriodDuration('2025-03-25', '2025-12-31', { isFirstPeriod: true })).toBeNull()
|
||||
})
|
||||
|
||||
it('allows mid-month start for first period (October)', () => {
|
||||
expect(validatePeriodDuration('2025-10-15', '2025-12-31', { isFirstPeriod: true })).toBeNull()
|
||||
it('allows mid-month start for first period (July)', () => {
|
||||
expect(validatePeriodDuration('2025-07-15', '2025-12-31', { isFirstPeriod: true })).toBeNull()
|
||||
})
|
||||
|
||||
it('still allows day-1 start for first period', () => {
|
||||
expect(validatePeriodDuration('2025-10-01', '2025-12-31', { isFirstPeriod: true })).toBeNull()
|
||||
it('still allows day-1 start for first period (6 months)', () => {
|
||||
expect(validatePeriodDuration('2025-07-01', '2025-12-31', { isFirstPeriod: true })).toBeNull()
|
||||
})
|
||||
|
||||
it('enforces end-of-month even for first period', () => {
|
||||
@@ -84,6 +84,26 @@ describe('validatePeriodDuration', () => {
|
||||
expect(result).toContain('18 months')
|
||||
})
|
||||
|
||||
it('enforces 6-month minimum for first period (2 months)', () => {
|
||||
expect(validatePeriodDuration('2026-03-25', '2026-05-31', { isFirstPeriod: true })).toBe(
|
||||
'First fiscal period must be at least 6 months (BFL 3 kap.)'
|
||||
)
|
||||
})
|
||||
|
||||
it('enforces 6-month minimum for first period (5 months)', () => {
|
||||
expect(validatePeriodDuration('2026-08-01', '2026-12-31', { isFirstPeriod: true })).toBe(
|
||||
'First fiscal period must be at least 6 months (BFL 3 kap.)'
|
||||
)
|
||||
})
|
||||
|
||||
it('allows exactly 6 months for first period', () => {
|
||||
expect(validatePeriodDuration('2026-07-01', '2026-12-31', { isFirstPeriod: true })).toBeNull()
|
||||
})
|
||||
|
||||
it('realistic customer case: 2026-03-25 to 2027-02-28', () => {
|
||||
expect(validatePeriodDuration('2026-03-25', '2027-02-28', { isFirstPeriod: true })).toBeNull()
|
||||
})
|
||||
|
||||
it('returns error when end is not last day of month', () => {
|
||||
expect(validatePeriodDuration('2025-01-01', '2025-12-15')).toBe(
|
||||
'Period end must be the last day of a month'
|
||||
|
||||
@@ -62,5 +62,10 @@ export function validatePeriodDuration(start: string, end: string, options?: Val
|
||||
return `Period duration ${months} months exceeds maximum 18 months (BFL 3 kap.)`
|
||||
}
|
||||
|
||||
// First fiscal period must be at least 6 months per BFL 3 kap.
|
||||
if (options?.isFirstPeriod && months < 6) {
|
||||
return `First fiscal period must be at least 6 months (BFL 3 kap.)`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ensureFiscalPeriod,
|
||||
importVouchers,
|
||||
computeVoucherNumberRanges,
|
||||
linkOpeningBalanceEntryToPeriod,
|
||||
} from '../sie-import'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import type { ParsedSIEFile, AccountMapping } from '../types'
|
||||
@@ -421,6 +422,84 @@ describe('ensureFiscalPeriod validation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('linkOpeningBalanceEntryToPeriod', () => {
|
||||
// Regression: SIE import created the opening-balance entry but never wrote
|
||||
// its ID back to fiscal_periods. Without the link, getOpeningBalances falls
|
||||
// through to summing all prior journal lines, which inflates balance-sheet
|
||||
// accounts across multi-year imports (each year's IB double-counted against
|
||||
// the prior year's UB).
|
||||
type Supabase = Parameters<typeof linkOpeningBalanceEntryToPeriod>[0]
|
||||
|
||||
it('writes opening_balance_entry_id and opening_balances_set to the fiscal period', async () => {
|
||||
const updates: Array<{ payload: Record<string, unknown>; filters: Record<string, unknown> }> = []
|
||||
|
||||
const supabase = {
|
||||
from: (table: string) => {
|
||||
if (table !== 'fiscal_periods') {
|
||||
throw new Error(`Unexpected table: ${table}`)
|
||||
}
|
||||
let pendingPayload: Record<string, unknown> = {}
|
||||
const filters: Record<string, unknown> = {}
|
||||
const chain = {
|
||||
update: (payload: Record<string, unknown>) => {
|
||||
pendingPayload = payload
|
||||
return chain
|
||||
},
|
||||
eq: (col: string, val: unknown) => {
|
||||
filters[col] = val
|
||||
return chain
|
||||
},
|
||||
then: (resolve: (v: unknown) => void) => {
|
||||
updates.push({ payload: pendingPayload, filters: { ...filters } })
|
||||
resolve({ data: null, error: null })
|
||||
},
|
||||
}
|
||||
return chain
|
||||
},
|
||||
}
|
||||
|
||||
await linkOpeningBalanceEntryToPeriod(
|
||||
supabase as unknown as Supabase,
|
||||
'company-1',
|
||||
'period-1',
|
||||
'ob-entry-1',
|
||||
)
|
||||
|
||||
expect(updates).toHaveLength(1)
|
||||
expect(updates[0].payload).toEqual({
|
||||
opening_balance_entry_id: 'ob-entry-1',
|
||||
opening_balances_set: true,
|
||||
})
|
||||
expect(updates[0].filters).toEqual({
|
||||
id: 'period-1',
|
||||
company_id: 'company-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws a descriptive error when the update fails', async () => {
|
||||
const supabase = {
|
||||
from: () => {
|
||||
const chain = {
|
||||
update: () => chain,
|
||||
eq: () => chain,
|
||||
then: (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: null, error: { message: 'permission denied' } }),
|
||||
}
|
||||
return chain
|
||||
},
|
||||
}
|
||||
|
||||
await expect(
|
||||
linkOpeningBalanceEntryToPeriod(
|
||||
supabase as unknown as Supabase,
|
||||
'company-1',
|
||||
'period-1',
|
||||
'ob-entry-1',
|
||||
),
|
||||
).rejects.toThrow(/Failed to link opening balance entry.*permission denied/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isBalanceSheetAccount', () => {
|
||||
it('returns true for class 1 (assets)', () => {
|
||||
expect(isBalanceSheetAccount('1510')).toBe(true)
|
||||
|
||||
@@ -207,6 +207,7 @@ async function cleanupStaleImportRecords(
|
||||
.eq('company_id', companyId)
|
||||
.eq('file_hash', fileHash)
|
||||
.in('status', ['pending', 'failed'])
|
||||
.lt('created_at', oneHourAgo)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -458,6 +459,36 @@ async function createOpeningBalanceEntry(
|
||||
return entry.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Link an opening-balance journal entry to its fiscal period so balance-sheet
|
||||
* reports use the explicit IB path in getOpeningBalances() (reads only that
|
||||
* entry's lines for IB) instead of falling through to summing all prior
|
||||
* journal lines — which inflates multi-year imports, because each year's IB
|
||||
* is double-counted against the prior year's UB.
|
||||
*
|
||||
* Mirrors the pattern used by the Excel-based OB import at
|
||||
* app/api/import/opening-balance/execute/route.ts:224-231.
|
||||
*/
|
||||
export async function linkOpeningBalanceEntryToPeriod(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
fiscalPeriodId: string,
|
||||
openingBalanceEntryId: string
|
||||
): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.update({
|
||||
opening_balance_entry_id: openingBalanceEntryId,
|
||||
opening_balances_set: true,
|
||||
})
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to link opening balance entry to fiscal period: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create journal entries from vouchers using batch insert for performance.
|
||||
*
|
||||
@@ -1636,6 +1667,13 @@ export async function executeSIEImport(
|
||||
if (result.openingBalanceEntryId) {
|
||||
result.journalEntriesCreated++
|
||||
result.journalEntryIds.push(result.openingBalanceEntryId)
|
||||
|
||||
await linkOpeningBalanceEntryToPeriod(
|
||||
supabase,
|
||||
companyId,
|
||||
result.fiscalPeriodId,
|
||||
result.openingBalanceEntryId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Backfill fiscal_periods.opening_balance_entry_id for periods where a SIE
|
||||
-- import created an opening-balance journal entry but never linked it back to
|
||||
-- the period. Without the link, getOpeningBalances falls through to summing
|
||||
-- all prior journal-entry lines before period_start, which double-counts each
|
||||
-- year's IB against the prior year's UB and inflates balance-sheet accounts
|
||||
-- in proportion to the number of years imported.
|
||||
--
|
||||
-- Safe to re-run: only periods with NULL opening_balance_entry_id are updated.
|
||||
-- The enforce_opening_balance_immutability trigger (migration 019) only fires
|
||||
-- when OLD.opening_balance_entry_id IS NOT NULL, so this UPDATE is permitted.
|
||||
--
|
||||
-- Periods with more than one posted opening-balance entry (should not happen
|
||||
-- because checkDuplicatePeriodImport rejects overlapping imports, but a failed
|
||||
-- partial import could theoretically produce this) are intentionally skipped
|
||||
-- so the correct entry can be chosen manually rather than linked arbitrarily.
|
||||
|
||||
UPDATE public.fiscal_periods fp
|
||||
SET
|
||||
opening_balance_entry_id = (
|
||||
SELECT je.id
|
||||
FROM public.journal_entries je
|
||||
WHERE je.fiscal_period_id = fp.id
|
||||
AND je.company_id = fp.company_id
|
||||
AND je.source_type = 'opening_balance'
|
||||
AND je.status = 'posted'
|
||||
LIMIT 1
|
||||
),
|
||||
opening_balances_set = true
|
||||
WHERE fp.opening_balance_entry_id IS NULL
|
||||
AND (
|
||||
SELECT COUNT(*) FROM public.journal_entries je
|
||||
WHERE je.fiscal_period_id = fp.id
|
||||
AND je.company_id = fp.company_id
|
||||
AND je.source_type = 'opening_balance'
|
||||
AND je.status = 'posted'
|
||||
) = 1;
|
||||
Reference in New Issue
Block a user