fix(vat): momsdeklaration defaults respect the configured cadence and persist manual changes (#1998)

* fix(vat): momsdeklaration defaults respect the configured cadence and persist manual changes

The period picker re-seeded from scratch on every visit: an arsmoms user
whose moms_period was never set landed on a silently guessed quarterly
declaration (companies without a company_settings row bypassed every
gate), and a manually chosen cadence evaporated on the next visit.

- Gate the view when no company_settings row exists, matching the
  existing "registered but no period" gate: a declaration for the wrong
  period type is a compliance hazard, not a convenience.
- Persist the manually chosen cadence per company (localStorage,
  FyPicker pattern) and restore it while moms_period is unchanged; the
  concrete period still re-seeds to the most recently ended one, and a
  changed setting discards the stored cadence.
- Extract the seeding decision into lib/vat/period-selection.ts with
  unit tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012pQn9kC742B9R7Ggi8wdn9

* fix(vat): drop cadence persistence; the moms_period re-seed is the control

Skeptic review refuted the persistence half of the previous commit twice:
the render-phase localStorage restore diverged from SSR (hydration error
on every visit once a cadence was stored), and restoring a manually
chosen cadence that deviates from moms_period kept the filing pipeline
open on the wrong period type across visits, with no downstream path
validating period type against the setting.

The redovisningsperiod has exactly one lawful value per company, so the
mount-time re-seed from company_settings.moms_period is the self-healing
control, not a bug. The settings-row gate and the extracted, tested
seeding resolver stay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012pQn9kC742B9R7Ggi8wdn9

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-27 19:09:47 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 4f6ecad549
commit fca57dc470
4 changed files with 147 additions and 17 deletions
+40 -17
View File
@@ -19,6 +19,7 @@ import { SegmentedControl } from '@/components/ui/segmented-control'
import { EmptyState } from '@/components/ui/empty-state'
import { FyPicker } from '@/components/common/FyPicker'
import { mostRecentEndedVatPeriod } from '@/lib/vat/period-defaults'
import { resolveInitialVatPeriodSelection } from '@/lib/vat/period-selection'
import { ContextPicker } from '@/components/common/ContextPicker'
import { cn, formatDate } from '@/lib/utils'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -1517,8 +1518,12 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) {
// Company settings drive both the momsregistrerad gate and the default
// periodicity (moms_period in Inställningar). Applied once per company the
// first time its settings settle — as a render-phase adjustment, not an
// effect. A later manual change to the picker is preserved, and a company
// switch re-applies the new company's setting. `useCompanySettings` only
// effect. A later manual change to the picker is preserved for the session,
// and a company switch re-applies the new company's setting. The cadence is
// deliberately NOT persisted across visits: the redovisningsperiod is fixed
// by the company's Skatteverket registration, so this mount-time re-seed is
// the control that self-heals an in-session detour to the wrong period
// type (see lib/vat/period-selection.ts). `useCompanySettings` only
// refetches when the active company changes, so this never clobbers a
// manual selection mid-session.
const { settings, isLoading: settingsLoading, refetch: refetchSettings } = useCompanySettings()
@@ -1526,25 +1531,24 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) {
const companyKey = settingsLoading ? null : (settings?.company_id ?? 'none')
if (companyKey !== null && appliedCompany !== companyKey) {
setAppliedCompany(companyKey)
const configured = settings?.moms_period ?? 'quarterly'
setPeriodType(configured)
if (configured === 'monthly' || configured === 'quarterly') {
// Default to the period whose declaration is actually open: the current
// one can never be filed, so seeding it forced a step-back click on
// every filing visit (and a year-boundary trap in January).
const ended = mostRecentEndedVatPeriod(configured, new Date(), {
over40m: settings?.vat_taxable_base_over_40m === true,
})
setYear(ended.year)
setPeriod(ended.period)
} else {
setPeriod(1)
}
const initial = resolveInitialVatPeriodSelection({
momsPeriod: settings?.moms_period ?? null,
over40m: settings?.vat_taxable_base_over_40m === true,
})
setPeriodType(initial.periodType)
setYear(initial.year)
setPeriod(initial.period)
}
// Settings row present and the company answered "not VAT-registered" —
// the declaration is meaningless, so the whole view is gated below.
const notVatRegistered = !settingsLoading && settings !== null && !settings.vat_registered
// No company_settings row at all (company created outside onboarding):
// VAT registration AND periodicity are both unknown. This used to fall
// through every gate and render a silently guessed quarterly declaration;
// the wrong period type for an årsmoms company is a compliance hazard, so
// it now gates like the other unknowns.
const settingsRowMissing = !settingsLoading && settings === null
// Registered but never picked a redovisningsperiod (rare — onboarding
// requires it, but companies created outside that flow can miss it).
const momsPeriodMissing = settings?.vat_registered === true && !settings.moms_period
@@ -1586,7 +1590,11 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) {
// gated, or no redovisningsperiod configured); any change to it triggers a
// refetch and stale responses are discarded.
const fetchKey =
periodType === null || notVatRegistered || momsPeriodMissing || awaitingFiscalPeriod
periodType === null ||
notVatRegistered ||
settingsRowMissing ||
momsPeriodMissing ||
awaitingFiscalPeriod
? null
: `${periodType}:${year}:${period}:${isYearly ? fiscalPeriodId : ''}:${retryKey}`
@@ -1797,6 +1805,21 @@ export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) {
)
}
if (settingsRowMissing) {
return (
<div className="space-y-8">
{bareHeader}
<EmptyState
icon={Percent}
title="Skatteinställningar saknas"
description="Momsdeklarationen bygger på företagets skatteinställningar, men inga är angivna ännu. Ange momsregistrering och redovisningsperiod (månad, kvartal eller helår) i inställningarna, så visas deklarationen för rätt period."
actionLabel="Öppna skatteinställningar"
actionHref="/settings/tax"
/>
</div>
)
}
if (notVatRegistered) {
return (
<div className="space-y-8">