Files
accounted/components/settings/sections/TaxSettingsContent.tsx
T
Jakob WennbergandClaude Fable 5 05b954ac1d feat(deadlines): årsstämma replaces bokslut + moms_yearly auto-complete + EU-sales suggestion (#1059)
* feat(deadlines): gate F-skatt reminders on debited preliminary tax, add durable dismissal

The f_skatt deadline was gated on the F-skatt approval flag (DB default
true), giving nearly every company 12 monthly payment reminders for a tax
Skatteverket may not have debited at all (64% of all system deadline rows,
one lifetime completion). Approval carries no recurring obligation; the
monthly duty is payment of debiterad preliminarskatt and exists only while
the debited amount is > 0 (SFL 62 kap. 4-5 par., 55 kap. 2 par.).

- Gate the f_skatt deadline on preliminary_tax_monthly > 0 (field already
  collected at onboarding, previously unread) and retitle it as a payment.
- Storforetag keep the 12th in August (January-only 17th, 62 kap. 3 par.).
- Declare the prod-only preliminary_tax_monthly column in a migration so
  installs built purely from migrations stop failing tax-settings saves.
- Add deadlines.dismissed_at: DELETE on a system deadline now soft-dismisses
  it durably (hard deletes were resurrected by the nightly backfill within
  24h); generator, backfill, and every read surface respect it.
- Prune upcoming f_skatt rows for companies with no debited amount.

Closes part of #1028.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(deadlines): gate AGI on employer registration, stop completing AGI deadline at XML generation

The arbetsgivardeklaration deadline was gated on pays_salaries, which is
wrong in both directions: a registered employer must file AGI every month
including nil months (SFL 26 kap. 3 par.), and companies actively running
payroll with the flag off got no AGI reminders at all (each missed monthly
filing risks a forseningsavgift).

- New company_settings.employer_registered (nullable, no default) gates
  AGI and the storforetag skatteinbetalning row; pays_salaries remains a
  fallback for rows saved before the flag existed and keeps its UI meaning.
- Migration backfills employer_registered=true from pays_salaries=true and
  from actual payroll activity (salary_runs).
- New employer_seasonal flag: sasongsregistrerade file only for payment
  months plus a December nil declaration, so only the December-period row
  is generated.
- Settings UI: registration + seasonal checkboxes (sv/en strings).
- AGI XML generation no longer auto-completes the deadline as submitted:
  SFL 26 kap. deems the obligation satisfied only when the declaration has
  come in to Skatteverket. The Skatteverket extension's kvittens reconcile
  remains the confirming path; manual filers tick the deadline themselves.

Part of #1028.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(deadlines): statutory arsstamma replaces bokslut, moms_yearly auto-complete, EU-sales suggestion

- Replace the non-statutory 'bokslut' deadline (3 months after FY end, no
  legal basis, off-by-one month math for broken FYs) with the statutory
  arsstamma deadline: within 6 months of FY end per ABL 7 kap. 10 par.,
  the corporate act that gates the arsredovisning filing chain. Migration
  deletes pending bokslut rows; the backfill cron generates arsstamma rows.
- Complete moms_yearly on Skatteverket submission/kvittens: the yearly
  branch previously returned null with a stale comment claiming annual VAT
  has no deadline type, leaving yearly filers with an eternally open row.
  The fiscal-year tax_period label is derived from company settings.
- Add /api/settings/eu-trade-signal + a tax-settings callout: companies
  with booked EU sales (3108/3308/3107, last 15 months) but EU-trade/PS
  flags off are prompted to confirm the periodisk sammanstallning
  obligation (SFL 35 kap., 1 250 kr late fee per report). Suggestion only,
  never auto-enables.

Part of #1028.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(deadlines): include dismissed_at in DeadlineForm payload

The Deadline type gained the required dismissed_at field; the form's
submit payload literal must carry it for the Omit<Deadline, ...> shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(deadlines): make system-deadline dismissal atomic

Constrain the dismiss update to source='system' and verify a row was
actually updated: a concurrent regeneration can delete the row between
lookup and update, and the route must not report a phantom success.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:02:49 +02:00

135 lines
6.0 KiB
TypeScript

'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useSearchParams, useRouter } from 'next/navigation'
import { TaxSettingsForm } from '@/components/settings/TaxSettingsForm'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { SkatteverketConnectPanel } from '@/components/settings/SkatteverketConnectPanel'
import { useSettings } from '@/components/settings/useSettings'
import { useToast } from '@/components/ui/use-toast'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import type { CompanySettings } from '@/types'
export function TaxSettingsContent() {
const { settings, isLoading, updateSettings, refetch } = useSettings()
const t = useTranslations('settings_skatteverket')
const searchParams = useSearchParams()
const router = useRouter()
const { toast } = useToast()
const hasSkatteverketExtension = ENABLED_EXTENSION_IDS.has('skatteverket')
// Derived EU-sales signal: postings on 3108/3308/3107 imply a periodisk
// sammanställning obligation the opt-in flags may not reflect. Suggestion
// only; the user confirms via the ordinary checkboxes.
const [euSalesDetected, setEuSalesDetected] = useState(false)
useEffect(() => {
let cancelled = false
fetch('/api/settings/eu-trade-signal')
.then((res) => (res.ok ? res.json() : null))
.then((json) => {
if (!cancelled && json?.data?.has_eu_sales) setEuSalesDetected(true)
})
.catch(() => {
// Best-effort signal: a failed fetch just hides the suggestion.
})
return () => {
cancelled = true
}
}, [])
// Skatteverket OAuth callback: the connect flow returns to /settings/tax with
// a status query param (returnTo set in SkatteverketConnectPanel).
useEffect(() => {
const connected = searchParams.get('skv_connected')
const error = searchParams.get('skv_error')
if (connected === 'true') {
toast({ title: t('connected_title'), description: t('connected_description') })
router.replace('/settings/tax')
} else if (error) {
let msg: string
try {
msg = decodeURIComponent(error)
} catch {
msg = error
}
toast({ title: t('connect_failed_title'), description: msg, variant: 'destructive' })
router.replace('/settings/tax')
}
}, [searchParams, router, toast, t])
if (isLoading) return <SettingsLoadingSkeleton />
if (!settings) return <SettingsLoadError onRetry={refetch} />
function handleSave(formData: FormData) {
const vatRegistered = formData.get('vat_registered') === 'true'
const paysSalaries = formData.get('pays_salaries') === 'true'
const employerRegistered = formData.get('employer_registered') === 'true'
const updates: Record<string, unknown> = {
f_skatt: formData.get('f_skatt') === 'true',
vat_registered: vatRegistered,
vat_number: vatRegistered ? ((formData.get('vat_number') as string) || null) : null,
moms_period: vatRegistered ? ((formData.get('moms_period') as string) || null) : null,
vat_taxable_base_over_40m:
vatRegistered && formData.get('vat_taxable_base_over_40m') === 'true',
vat_has_eu_trade: vatRegistered && formData.get('vat_has_eu_trade') === 'true',
// The filing-method and PS selects are conditionally rendered: when a
// control is unmounted its FormData key is absent, and defaulting would
// silently overwrite the saved preference. Fall back to the stored
// value first, then the default.
vat_filing_method:
(formData.get('vat_filing_method') as string) ||
settings?.vat_filing_method ||
'electronic',
periodisk_sammanstallning_enabled:
vatRegistered && formData.get('periodisk_sammanstallning_enabled') === 'true',
periodisk_sammanstallning_period:
(formData.get('periodisk_sammanstallning_period') as string) ||
settings?.periodisk_sammanstallning_period ||
'monthly',
periodisk_sammanstallning_filing_method:
(formData.get('periodisk_sammanstallning_filing_method') as string) ||
settings?.periodisk_sammanstallning_filing_method ||
'electronic',
tax_contact_name: (formData.get('tax_contact_name') as string) || null,
tax_contact_phone: (formData.get('tax_contact_phone') as string) || null,
tax_contact_email: (formData.get('tax_contact_email') as string) || null,
fiscal_year_start_month: parseInt(formData.get('fiscal_year_start_month') as string) || 1,
pays_salaries: paysSalaries,
employer_registered: employerRegistered,
// The seasonal checkbox is unmounted when not registered; absence
// means false rather than "keep stored value".
employer_seasonal: employerRegistered && formData.get('employer_seasonal') === 'true',
preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null,
}
return {
updates,
onSuccess: (data: Record<string, unknown>) => {
updateSettings(data as Partial<CompanySettings>)
},
}
}
// Sandbox companies don't connect to the real Skatteverket: hide the panel,
// matching the old Skatteverket tab's visibility gate. Read straight off the
// already-loaded settings row (no separate query needed).
const showSkatteverket = hasSkatteverketExtension && !settings.is_sandbox
return (
<div className="space-y-8">
{/* Connection panel first: the skattekonto and momsdeklaration pages
send users here specifically to (re)connect — below the long tax
form it sat out of view. */}
{showSkatteverket && <SkatteverketConnectPanel />}
<SettingsFormWrapper onSave={handleSave} className="space-y-0">
<TaxSettingsForm settings={settings} euSalesDetected={euSalesDetected} />
</SettingsFormWrapper>
</div>
)
}