Onboarding updates (#54)

* Removed månatlig skatt from onboarding flow

* fix: convert DB null to undefined for moms_period in onboarding step 3

The moms_period field used a type cast (`as MomsPeriod | undefined`) instead
of null coalescing (`?? undefined`), causing Zod validation to reject the
DB-default null value since `.optional()` only accepts undefined.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add VAT accounting step to onboarding process

* feat: replace accounting method select with checkboxes for better UX

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-03-18 16:10:17 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent cdb40e7af3
commit 08e1757798
3 changed files with 319 additions and 202 deletions
+10 -11
View File
@@ -14,14 +14,14 @@ import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
import Step1EntityType from '@/components/onboarding/Step1EntityType'
import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails'
import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
import Step4PreliminaryTax from '@/components/onboarding/Step4PreliminaryTax'
import Step4VatAccounting from '@/components/onboarding/Step4VatAccounting'
import Step5ConnectBank from '@/components/onboarding/Step6ConnectBank'
const STEP_INFO = [
{ title: 'Välkommen', subtitle: 'Välj din företagsform för att komma igång.', label: 'Företagsform' },
{ title: 'Ditt företag', subtitle: 'Uppgifterna visas på fakturor och dokument.', label: 'Uppgifter' },
{ title: 'Skatt & bokföring', subtitle: 'F-skatt, räkenskapsår och momsregistrering.', label: 'Skatt' },
{ title: 'Preliminärskatt', subtitle: 'Frivilligt — hjälper dig hålla koll på skatten.', label: 'F-skatt' },
{ title: 'F-skatt & räkenskapsår', subtitle: 'Ange din skatteregistrering och räkenskapsår.', label: 'Skatt' },
{ title: 'Moms & bokföring', subtitle: 'Momsregistrering och bokföringsmetod.', label: 'Moms' },
{ title: 'Bankuppgifter', subtitle: 'Dessa visas på dina fakturor.', label: 'Bank' },
]
@@ -471,13 +471,8 @@ function OnboardingPageContent() {
initialData={{
f_skatt: settings.f_skatt ?? undefined,
fiscal_year_start_month: settings.fiscal_year_start_month ?? undefined,
vat_registered: settings.vat_registered ?? undefined,
vat_number: settings.vat_number ?? undefined,
moms_period: settings.moms_period as MomsPeriod | undefined,
accounting_method: (settings.accounting_method as 'accrual' | 'cash') ?? undefined,
}}
entityType={settings.entity_type as EntityType}
orgNumber={settings.org_number ?? undefined}
onNext={(data) => handleNext(data)}
onBack={handleBack}
isSaving={isSaving}
@@ -485,13 +480,17 @@ function OnboardingPageContent() {
)}
{currentStep === 4 && (
<Step4PreliminaryTax
<Step4VatAccounting
initialData={{
preliminary_tax_monthly: settings.preliminary_tax_monthly ?? undefined,
vat_registered: settings.vat_registered ?? undefined,
vat_number: settings.vat_number ?? undefined,
moms_period: (settings.moms_period as MomsPeriod | null) ?? undefined,
accounting_method: (settings.accounting_method as 'accrual' | 'cash') ?? undefined,
}}
entityType={settings.entity_type as EntityType}
orgNumber={settings.org_number ?? undefined}
onNext={(data) => handleNext(data)}
onBack={handleBack}
onSkip={handleSkip}
isSaving={isSaving}
/>
)}
+5 -191
View File
@@ -1,21 +1,20 @@
'use client'
import { useState, useMemo, useEffect } from 'react'
import { useState, useMemo } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { Loader2, ArrowRight, ArrowLeft, Check, CalendarDays, Info } from 'lucide-react'
import { Loader2, ArrowRight, ArrowLeft, Check, CalendarDays } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import { monthsBetween } from '@/lib/bookkeeping/validate-period-duration'
import type { MomsPeriod, EntityType } from '@/types'
import type { EntityType } from '@/types'
const schema = z.object({
f_skatt: z.boolean(),
@@ -25,11 +24,6 @@ const schema = z.object({
first_year_end: z.string().optional(),
// Ongoing year field (conditional)
fiscal_year_end_month: z.number().min(1).max(12).optional(),
// Existing fields
vat_registered: z.boolean(),
vat_number: z.string().optional(),
moms_period: z.enum(['monthly', 'quarterly', 'yearly']).optional(),
accounting_method: z.enum(['accrual', 'cash']),
}).superRefine((data, ctx) => {
if (data.is_first_fiscal_year) {
if (!data.first_year_start) {
@@ -47,13 +41,6 @@ const schema = z.object({
})
}
}
if (data.vat_registered && !data.moms_period) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Välj momsredovisningsperiod.',
path: ['moms_period'],
})
}
})
type FormData = z.infer<typeof schema>
@@ -65,16 +52,11 @@ interface Step3Output {
is_first_fiscal_year: boolean
first_year_start?: string
first_year_end?: string
vat_registered: boolean
vat_number?: string
moms_period?: MomsPeriod
accounting_method: 'accrual' | 'cash'
}
interface Step3Props {
initialData: Partial<Step3Output>
entityType?: EntityType
orgNumber?: string
onNext: (data: Step3Output) => void
onBack: () => void
isSaving: boolean
@@ -164,7 +146,6 @@ function getABFirstYearEndDates(
export default function Step3TaxRegistration({
initialData,
entityType,
orgNumber,
onNext,
onBack,
isSaving,
@@ -173,11 +154,9 @@ export default function Step3TaxRegistration({
const { toast } = useToast()
const {
register,
handleSubmit,
watch,
control,
setValue,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
@@ -190,30 +169,13 @@ export default function Step3TaxRegistration({
fiscal_year_end_month: initialData.fiscal_year_start_month
? (initialData.fiscal_year_start_month === 1 ? 12 : initialData.fiscal_year_start_month - 1)
: 12,
vat_registered: initialData.vat_registered ?? false,
vat_number: initialData.vat_number || '',
moms_period: initialData.moms_period,
accounting_method: initialData.accounting_method ?? 'accrual',
},
})
const vatRegistered = watch('vat_registered')
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')
const accountingMethod = watch('accounting_method')
// Auto-fill VAT number when vat_registered toggles on
const vatNumber = watch('vat_number')
useEffect(() => {
if (vatRegistered && !vatNumber && orgNumber) {
const cleaned = orgNumber.replace(/[-\s]/g, '')
if (cleaned.length >= 10) {
setValue('vat_number', `SE${cleaned}01`)
}
}
}, [vatRegistered, vatNumber, orgNumber, setValue])
// State for first-year start date selectors (month/year)
const [startMonth, setStartMonth] = useState<number>(
@@ -278,10 +240,6 @@ export default function Step3TaxRegistration({
is_first_fiscal_year: data.is_first_fiscal_year,
...(firstStart && { first_year_start: firstStart }),
...(firstEnd && { first_year_end: firstEnd }),
vat_registered: data.vat_registered,
vat_number: data.vat_registered ? data.vat_number : undefined,
moms_period: data.vat_registered ? data.moms_period : undefined,
accounting_method: data.accounting_method,
}
onNext(output)
@@ -611,161 +569,17 @@ export default function Step3TaxRegistration({
)}
</div>
{/* VAT section */}
<div className="pt-4 border-t space-y-4">
<h3 className="font-medium flex items-center gap-2">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Behöver jag momsregistrera mig?</p>
<p>Ja, om din omsättning överstiger 120 000 kr per år. Med moms lägger du på 25% extra på dina fakturor, men får också dra av moms på dina inköp.</p>
<p className="text-xs text-muted-foreground">Om din omsättning överstiger 120 000 kr per år behöver du momsregistrera dig.</p>
</div>
}
side="right"
>
<span>Momsregistrering</span>
</InfoTooltip>
</h3>
<div className="flex items-start space-x-3">
<Controller
name="vat_registered"
control={control}
render={({ field }) => (
<Checkbox
id="vat_registered"
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
<div className="space-y-1">
<Label htmlFor="vat_registered" className="cursor-pointer">
Jag är momsregistrerad
</Label>
<p className="text-sm text-muted-foreground">
Obligatoriskt om din omsättning överstiger 120 000 kr per år.
</p>
</div>
</div>
{vatRegistered && (
<div className="space-y-4 pl-0 sm:pl-7">
<div className="space-y-2">
<Label htmlFor="vat_number">Momsregistreringsnummer</Label>
<Input
id="vat_number"
placeholder="SE123456789001"
{...register('vat_number')}
/>
<p className="text-xs text-muted-foreground">
Format: SE + organisationsnummer + 01
</p>
</div>
<div className="space-y-2">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Hur ofta rapporterar du moms?</p>
<p>Välj den period som anges på Verksamt eller i ditt beslut från Skatteverket.</p>
<ul className="text-xs text-muted-foreground space-y-1">
<li>Under 1 miljon/år = Kan välja årsredovisning</li>
<li>1-40 miljoner = Kvartal</li>
<li>Över 40 miljoner = Månad</li>
</ul>
</div>
}
side="right"
>
<Label>Momsredovisningsperiod</Label>
</InfoTooltip>
<Controller
name="moms_period"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={(v) => { if (v) field.onChange(v) }}
>
<SelectTrigger>
<SelectValue placeholder="Välj period" />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Månad</SelectItem>
<SelectItem value="quarterly">Kvartal</SelectItem>
<SelectItem value="yearly">År</SelectItem>
</SelectContent>
</Select>
)}
/>
<p className="text-xs text-muted-foreground">
Välj den period som anges i ditt beslut från Skatteverket. Vanligtvis kvartal eller år.
</p>
</div>
</div>
)}
</div>
{/* Accounting method */}
<div className="pt-4 border-t space-y-4">
<div className="space-y-2">
<InfoTooltip
content="Faktureringsmetoden bokför intäkter och kostnader när fakturan skickas/mottas. Kontantmetoden bokför vid betalning."
side="right"
>
<Label>Bokföringsmetod</Label>
</InfoTooltip>
<Controller
name="accounting_method"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={(v) => { if (v) field.onChange(v) }}
>
<SelectTrigger>
<SelectValue placeholder="Välj metod" />
</SelectTrigger>
<SelectContent>
<SelectItem value="accrual">Faktureringsmetoden</SelectItem>
<SelectItem value="cash">Kontantmetoden</SelectItem>
</SelectContent>
</Select>
)}
/>
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Info className="h-4 w-4 text-muted-foreground" />
{accountingMethod === 'accrual' ? 'Faktureringsmetoden' : 'Kontantmetoden'}
</div>
<p className="text-sm text-muted-foreground">
{accountingMethod === 'accrual'
? 'Intäkter och kostnader bokförs när fakturan skickas eller tas emot, oavsett när betalningen sker. Detta ger en mer rättvisande bild av verksamhetens ekonomi.'
: 'Intäkter och kostnader bokförs först när betalningen faktiskt sker. Enklare att hantera men ger en mindre exakt bild av verksamhetens ekonomi vid varje given tidpunkt.'}
</p>
{entityType === 'aktiebolag' && (
<p className="text-xs text-warning-foreground bg-warning/10 rounded px-2 py-1">
Aktiebolag med omsättning över 3 MSEK per år måste använda faktureringsmetoden.
</p>
)}
</div>
</div>
</div>
<div className="flex flex-col-reverse sm:flex-row sm:justify-between gap-3 pt-4">
<div className="flex justify-between pt-4">
<Button
type="button"
variant="ghost"
onClick={onBack}
disabled={isSaving}
className="w-full sm:w-auto"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button type="submit" disabled={isSaving} className="w-full sm:w-auto">
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -0,0 +1,304 @@
'use client'
import { useEffect } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { Loader2, ArrowRight, ArrowLeft, Info } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import type { MomsPeriod, EntityType } from '@/types'
const schema = z.object({
vat_registered: z.boolean(),
vat_number: z.string().optional(),
moms_period: z.enum(['monthly', 'quarterly', 'yearly']).optional(),
accounting_method: z.enum(['accrual', 'cash']),
}).superRefine((data, ctx) => {
if (data.vat_registered && !data.moms_period) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Välj momsredovisningsperiod.',
path: ['moms_period'],
})
}
})
type FormData = z.infer<typeof schema>
interface Step4Output {
vat_registered: boolean
vat_number?: string
moms_period?: MomsPeriod
accounting_method: 'accrual' | 'cash'
}
interface Step4Props {
initialData: Partial<Step4Output>
entityType?: EntityType
orgNumber?: string
onNext: (data: Step4Output) => void
onBack: () => void
isSaving: boolean
}
export default function Step4VatAccounting({
initialData,
entityType,
orgNumber,
onNext,
onBack,
isSaving,
}: Step4Props) {
const { toast } = useToast()
const {
register,
handleSubmit,
watch,
control,
setValue,
formState: {},
} = useForm<FormData>({
resolver: zodResolver(schema),
mode: 'onTouched',
defaultValues: {
vat_registered: initialData.vat_registered ?? false,
vat_number: initialData.vat_number || '',
moms_period: initialData.moms_period,
accounting_method: initialData.accounting_method ?? 'accrual',
},
})
const vatRegistered = watch('vat_registered')
const vatNumber = watch('vat_number')
const accountingMethod = watch('accounting_method')
// Auto-fill VAT number when vat_registered toggles on
useEffect(() => {
if (vatRegistered && !vatNumber && orgNumber) {
const cleaned = orgNumber.replace(/[-\s]/g, '')
if (cleaned.length >= 10) {
setValue('vat_number', `SE${cleaned}01`)
}
}
}, [vatRegistered, vatNumber, orgNumber, setValue])
const onSubmit = (data: FormData) => {
const output: Step4Output = {
vat_registered: data.vat_registered,
vat_number: data.vat_registered ? data.vat_number : undefined,
moms_period: data.vat_registered ? data.moms_period : undefined,
accounting_method: data.accounting_method,
}
onNext(output)
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Moms och bokföringsmetod</CardTitle>
<CardDescription>
Ange din momsregistrering och välj bokföringsmetod.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit, (errs) => {
const fields = Object.keys(errs).join(', ')
console.error('[onboarding] step 4 validation failed:', fields, errs)
fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'step 4 validation failed', extra: { fields } }) }).catch(() => {})
const firstError = Object.values(errs)[0]
const message = firstError?.message || 'Kontrollera att alla fält är korrekt ifyllda.'
toast({ title: 'Saknade uppgifter', description: String(message), variant: 'destructive' })
})} className="space-y-6">
{/* VAT section */}
<div className="space-y-4">
<h3 className="font-medium flex items-center gap-2">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Behöver jag momsregistrera mig?</p>
<p>Ja, om din omsättning överstiger 120 000 kr per år. Med moms lägger du på 25% extra på dina fakturor, men får också dra av moms på dina inköp.</p>
<p className="text-xs text-muted-foreground">Om din omsättning överstiger 120 000 kr per år behöver du momsregistrera dig.</p>
</div>
}
side="right"
>
<span>Momsregistrering</span>
</InfoTooltip>
</h3>
<div className="flex items-start space-x-3">
<Controller
name="vat_registered"
control={control}
render={({ field }) => (
<Checkbox
id="vat_registered"
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
<div className="space-y-1">
<Label htmlFor="vat_registered" className="cursor-pointer">
Jag är momsregistrerad
</Label>
<p className="text-sm text-muted-foreground">
Obligatoriskt om din omsättning överstiger 120 000 kr per år.
</p>
</div>
</div>
{vatRegistered && (
<div className="space-y-4 pl-0 sm:pl-7">
<div className="space-y-2">
<Label htmlFor="vat_number">Momsregistreringsnummer</Label>
<Input
id="vat_number"
placeholder="SE123456789001"
{...register('vat_number')}
/>
<p className="text-xs text-muted-foreground">
Format: SE + organisationsnummer + 01
</p>
</div>
<div className="space-y-2">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Hur ofta rapporterar du moms?</p>
<p>Välj den period som anges på Verksamt eller i ditt beslut från Skatteverket.</p>
<ul className="text-xs text-muted-foreground space-y-1">
<li>Under 1 miljon/år = Kan välja årsredovisning</li>
<li>1-40 miljoner = Kvartal</li>
<li>Över 40 miljoner = Månad</li>
</ul>
</div>
}
side="right"
>
<Label>Momsredovisningsperiod</Label>
</InfoTooltip>
<Controller
name="moms_period"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={(v) => { if (v) field.onChange(v) }}
>
<SelectTrigger>
<SelectValue placeholder="Välj period" />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Månad</SelectItem>
<SelectItem value="quarterly">Kvartal</SelectItem>
<SelectItem value="yearly">År</SelectItem>
</SelectContent>
</Select>
)}
/>
<p className="text-xs text-muted-foreground">
Välj den period som anges i ditt beslut från Skatteverket. Vanligtvis kvartal eller år.
</p>
</div>
</div>
)}
</div>
{/* Accounting method */}
<div className="pt-4 border-t space-y-4">
<div className="space-y-2">
<InfoTooltip
content="Faktureringsmetoden bokför intäkter och kostnader när fakturan skickas/mottas. Kontantmetoden bokför vid betalning."
side="right"
>
<Label>Bokföringsmetod</Label>
</InfoTooltip>
<Controller
name="accounting_method"
control={control}
render={({ field }) => (
<div className="space-y-3">
<div className="flex items-start space-x-3">
<Checkbox
id="method_accrual"
checked={field.value === 'accrual'}
onCheckedChange={(checked) => { if (checked) field.onChange('accrual') }}
/>
<Label htmlFor="method_accrual" className="cursor-pointer">
Faktureringsmetoden
</Label>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="method_cash"
checked={field.value === 'cash'}
onCheckedChange={(checked) => { if (checked) field.onChange('cash') }}
/>
<Label htmlFor="method_cash" className="cursor-pointer">
Kontantmetoden
</Label>
</div>
</div>
)}
/>
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Info className="h-4 w-4 text-muted-foreground" />
{accountingMethod === 'accrual' ? 'Faktureringsmetoden' : 'Kontantmetoden'}
</div>
<p className="text-sm text-muted-foreground">
{accountingMethod === 'accrual'
? 'Intäkter och kostnader bokförs när fakturan skickas eller tas emot, oavsett när betalningen sker. Detta ger en mer rättvisande bild av verksamhetens ekonomi.'
: 'Intäkter och kostnader bokförs först när betalningen faktiskt sker. Enklare att hantera men ger en mindre exakt bild av verksamhetens ekonomi vid varje given tidpunkt.'}
</p>
{entityType === 'aktiebolag' && (
<p className="text-xs text-amber-800 dark:text-amber-200 bg-warning/10 rounded px-2 py-1">
Aktiebolag med omsättning över 3 MSEK per år måste använda faktureringsmetoden.
</p>
)}
</div>
</div>
</div>
<div className="flex justify-between pt-4">
<Button
type="button"
variant="ghost"
onClick={onBack}
disabled={isSaving}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
<>
Fortsätt
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}