diff --git a/DECISIONS.md b/DECISIONS.md index 925003cf..067b9b22 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -211,3 +211,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-17] Articles default sort is article_number (numeric-aware via Intl.Collator numeric, unnumbered last, name tiebreak) in both the register and the invoice editor picker, replacing name order (issue #1053): users number articles precisely to control listing order, matching Fortnox convention. [2026-07-17] F-skatt deadline gate = preliminary_tax_monthly > 0 instead of a new column: the field was already collected at onboarding and in tax settings but never consumed; f_skatt boolean stays as approval status (drives invoice text, no recurring duty per SFL). The migration also declares the prod-only orphan column so migration-built installs stop failing tax-settings saves. [2026-07-17] System-deadline delete = soft dismiss (dismissed_at) rather than a mute endpoint or hard delete: hard deletes were silently resurrected by the nightly backfill cron; dismissed rows satisfy the generator/backfill like completed rows. +[2026-07-17] AGI deadline gate = employer_registered (nullable, pays_salaries fallback) with migration backfill from salary_runs: a registered employer owes monthly AGI incl. nil months (SFL 26 kap. 3 §); companies running payroll in-app are treated as employers (SFL 7 kap. 1 § obliges registration), erring toward a dismissible reminder over a missed statutory filing. Seasonal employers get only the December-period row. +[2026-07-17] AGI XML generation no longer completes the arbetsgivardeklaration deadline: SFL 26 kap. deems the duty met only when the declaration reaches Skatteverket; kvittens reconcile remains the confirming path. diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index bb27f765..a12377e9 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -125,6 +125,11 @@ export const PUT = withRouteContext( if (body.vat_has_eu_trade === false) { body.periodisk_sammanstallning_enabled = false } + // Seasonal registration is a mode of being a registered employer; an + // unregistered company cannot be sasongsregistrerad. + if (body.employer_registered === false) { + body.employer_seasonal = false + } // Validate: VAT-registered must have VAT number (ML 11 kap. 8§) and moms period (SFL 26 kap.) const effectiveVatRegistered = body.vat_registered ?? oldSettings?.vat_registered diff --git a/components/settings/TaxSettingsForm.tsx b/components/settings/TaxSettingsForm.tsx index 1a31a7f9..9c778c66 100644 --- a/components/settings/TaxSettingsForm.tsx +++ b/components/settings/TaxSettingsForm.tsx @@ -17,6 +17,12 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) { const [vatRegistered, setVatRegistered] = useState(settings.vat_registered ?? false) const [fSkatt, setFSkatt] = useState(settings.f_skatt ?? true) const [paysSalaries, setPaysSalaries] = useState(settings.pays_salaries ?? false) + // Fall back to pays_salaries for rows saved before the registration flag + // existed; saving attests the shown value. + const [employerRegistered, setEmployerRegistered] = useState( + settings.employer_registered ?? settings.pays_salaries ?? false, + ) + const [employerSeasonal, setEmployerSeasonal] = useState(settings.employer_seasonal ?? false) const [momsPeriod, setMomsPeriod] = useState(settings.moms_period || '') const [vatTaxableBaseOver40m, setVatTaxableBaseOver40m] = useState( settings.vat_taxable_base_over_40m ?? false, @@ -340,7 +346,12 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) { setPaysSalaries(v === true)} + onCheckedChange={(v) => { + const checked = v === true + setPaysSalaries(checked) + // Paying out salary obliges employer registration (SFL 7 kap. 1 §). + if (checked) setEmployerRegistered(true) + }} />
@@ -350,6 +361,54 @@ export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {

+ +
+ { + const checked = v === true + setEmployerRegistered(checked) + if (!checked) setEmployerSeasonal(false) + }} + /> + +
+ +

+ {t('employer_registered_help')} +

+
+
+ + {employerRegistered && ( +
+ setEmployerSeasonal(v === true)} + /> + +
+ +

+ {t('employer_seasonal_help')} +

+
+
+ )} {/* Preliminary tax */} diff --git a/components/settings/sections/TaxSettingsContent.tsx b/components/settings/sections/TaxSettingsContent.tsx index b1f05404..06d9d715 100644 --- a/components/settings/sections/TaxSettingsContent.tsx +++ b/components/settings/sections/TaxSettingsContent.tsx @@ -48,6 +48,7 @@ export function TaxSettingsContent() { 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 = { f_skatt: formData.get('f_skatt') === 'true', @@ -80,6 +81,10 @@ export function TaxSettingsContent() { 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 { diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 22238c5c..9e2d4087 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -1467,6 +1467,8 @@ export const UpdateSettingsSchema = z.object({ tax_contact_email: z.string().email().nullable().optional().or(z.literal('')), fiscal_year_start_month: z.number().int().min(1).max(12).optional(), preliminary_tax_monthly: z.number().nullable().optional(), + employer_registered: z.boolean().nullable().optional(), + employer_seasonal: z.boolean().optional(), bank_name: z.string().max(100, 'Banknamn får vara max 100 tecken').optional(), clearing_number: z.string().regex(/^\d{4,5}$/, 'Clearingnummer måste vara 4-5 siffror').optional().or(z.literal('')), account_number: z.string().regex(/^\d{6,12}$/, 'Kontonummer måste vara 6-12 siffror').optional().or(z.literal('')), diff --git a/lib/salary/agi/generate-declaration.ts b/lib/salary/agi/generate-declaration.ts index c60b573d..34fb568c 100644 --- a/lib/salary/agi/generate-declaration.ts +++ b/lib/salary/agi/generate-declaration.ts @@ -31,7 +31,6 @@ import { } from './xml-generator' import type { AGIEmployeeData, AGICompanyData, AGITotals } from './xml-generator' import { eventBus } from '@/lib/events' -import { completeTaxDeadline } from '@/lib/deadlines/complete-tax-deadline' import type { Logger } from '@/lib/logger' // Strict runtime validation of the joined salary_run_employees row. Without @@ -659,17 +658,13 @@ export async function generateAgiDeclaration( opLog.warn('agi.generated emit failed', err as Error) } - // 11. Auto-complete the arbetsgivardeklaration deadline for this period - // (Skatteförfarandelagen: AGI generation satisfies the filing - // obligation). - const period = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` - await completeTaxDeadline( - supabase, - companyId, - ['arbetsgivardeklaration'], - period, - 'submitted' - ) + // NOTE: generating the XML deliberately does NOT complete the + // arbetsgivardeklaration deadline. SFL 26 kap. deems the obligation + // satisfied only when the declaration has come in to Skatteverket; the + // Skatteverket extension confirms the deadline on kvittens receipt + // (agi-kvittens-reconcile), and manual filers tick it off themselves. + // Completing here made a generated-but-never-filed AGI silently sail + // past its statutory date. opLog.info('AGI declaration generated', { requestId, diff --git a/lib/tax/__tests__/deadline-config.test.ts b/lib/tax/__tests__/deadline-config.test.ts index 2cadf3e6..151a105a 100644 --- a/lib/tax/__tests__/deadline-config.test.ts +++ b/lib/tax/__tests__/deadline-config.test.ts @@ -14,6 +14,8 @@ function makeSettings(overrides: Partial = {}): Com preliminary_tax_monthly: 5000, vat_registered: true, pays_salaries: false, + employer_registered: null, + employer_seasonal: false, fiscal_year_start_month: 1, vat_taxable_base_over_40m: false, vat_has_eu_trade: false, @@ -102,6 +104,27 @@ describe('monthly tax and employer deadlines', () => { expect(dates[7].day).toBe(12) }) + it('gates AGI on employer registration with pays_salaries as legacy fallback', () => { + const config = getConfig('arbetsgivardeklaration') + // Never attested: fall back to pays_salaries. + expect(config.condition(makeSettings({ pays_salaries: true }))).toBe(true) + expect(config.condition(makeSettings({ pays_salaries: false }))).toBe(false) + // Attested registration wins over pays_salaries in both directions: a + // registered employer must file monthly even with zero salaries. + expect(config.condition(makeSettings({ employer_registered: true }))).toBe(true) + expect(config.condition(makeSettings({ employer_registered: false, pays_salaries: true }))).toBe(false) + }) + + it('generates only the December-period AGI row for seasonal employers', () => { + const dates = getConfig('arbetsgivardeklaration').generateDates(2026, makeSettings({ + employer_registered: true, + employer_seasonal: true, + })) + expect(dates).toHaveLength(1) + // December period, declared 17 January the following year. + expect(dates[0]).toMatchObject({ day: 17, month: 0, year: 2027, period: '2026-12' }) + }) + it('uses the 26th for AGI when the VAT taxable base is above SEK 40 million', () => { const dates = getConfig('arbetsgivardeklaration').generateDates(2026, makeSettings({ pays_salaries: true, @@ -137,6 +160,16 @@ describe('storföretag tax payment deadline', () => { pays_salaries: true, vat_taxable_base_over_40m: true, }))).toBe(true) + // Same registration gate as AGI: attested registration wins. + expect(config.condition(makeSettings({ + employer_registered: true, + vat_taxable_base_over_40m: true, + }))).toBe(true) + expect(config.condition(makeSettings({ + employer_registered: false, + pays_salaries: true, + vat_taxable_base_over_40m: true, + }))).toBe(false) }) it('is due the 12th of the following month, the 17th in January', () => { diff --git a/lib/tax/__tests__/deadline-generator.test.ts b/lib/tax/__tests__/deadline-generator.test.ts index b5a15f86..d8992631 100644 --- a/lib/tax/__tests__/deadline-generator.test.ts +++ b/lib/tax/__tests__/deadline-generator.test.ts @@ -15,6 +15,8 @@ const SETTINGS: CompanySettingsForDeadlines = { preliminary_tax_monthly: 5000, vat_registered: true, pays_salaries: true, + employer_registered: null, + employer_seasonal: false, fiscal_year_start_month: 1, vat_taxable_base_over_40m: false, vat_has_eu_trade: false, diff --git a/lib/tax/deadline-config.ts b/lib/tax/deadline-config.ts index 36c1200f..e1206ecf 100644 --- a/lib/tax/deadline-config.ts +++ b/lib/tax/deadline-config.ts @@ -16,6 +16,10 @@ export interface CompanySettingsForDeadlines { preliminary_tax_monthly: number | null vat_registered: boolean pays_salaries: boolean + // null = never attested; the generator falls back to pays_salaries so + // rows saved before the registration flag existed keep their deadlines. + employer_registered: boolean | null + employer_seasonal: boolean fiscal_year_start_month: number // 1-12 vat_taxable_base_over_40m: boolean vat_has_eu_trade: boolean @@ -211,8 +215,14 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [ }, }, - // Arbetsgivardeklaration (monthly, any employer with employees: AB or EF) - // Per Skatteförfarandelagen: every employer paying salary must file AGI monthly. + // Arbetsgivardeklaration (monthly). A REGISTERED employer must file AGI + // every month, including nil months (SFL 26 kap. 3 §): the gate is + // registration, not whether salaries were paid, with pays_salaries as a + // fallback for settings saved before the registration flag existed. + // Säsongsregistrerade employers file only for months with payments plus a + // December nil declaration when nothing was paid all year, so they get + // only the December-period row; payment months are handled by the salary + // flow itself. // The filing day is keyed to the VAT taxable base, not a separate employer // measure (SFL 26 kap.): above SEK 40M the whole skattedeklaration (AGI and // VAT) is due the 26th of the following month; otherwise the 12th (17th in @@ -221,14 +231,15 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [ { type: 'arbetsgivardeklaration', titleTemplate: 'Arbetsgivardeklaration {periodLabel}', - description: 'Arbetsgivardeklaration för arbetsgivare med anställda', - condition: (s) => s.pays_salaries, + description: 'Arbetsgivardeklaration för registrerade arbetsgivare', + condition: (s) => s.employer_registered ?? s.pays_salaries, priority: 'important', linkedReportType: null, generateDates: (year, settings) => { const storforetag = settings.vat_registered && settings.vat_taxable_base_over_40m const instances: DeadlineInstance[] = [] for (let month = 0; month < 12; month++) { + if (settings.employer_seasonal && month !== 11) continue const deadlineMonth = (month + 1) % 12 const deadlineYear = month === 11 ? year + 1 : year const day = storforetag @@ -255,7 +266,8 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [ type: 'skatteinbetalning', titleTemplate: 'Betala skatt och arbetsgivaravgifter {periodLabel}', description: 'Inbetalning av avdragen skatt och arbetsgivaravgifter för företag med beskattningsunderlag över 40 miljoner kronor', - condition: (s) => s.pays_salaries && s.vat_registered && s.vat_taxable_base_over_40m, + condition: (s) => + (s.employer_registered ?? s.pays_salaries) && s.vat_registered && s.vat_taxable_base_over_40m, priority: 'important', linkedReportType: null, generateDates: (year) => { diff --git a/lib/tax/deadline-generator.ts b/lib/tax/deadline-generator.ts index 38ef8261..6c72f56b 100644 --- a/lib/tax/deadline-generator.ts +++ b/lib/tax/deadline-generator.ts @@ -25,6 +25,8 @@ export const TAX_RELEVANT_FIELDS = [ 'preliminary_tax_monthly', 'vat_registered', 'pays_salaries', + 'employer_registered', + 'employer_seasonal', 'fiscal_year_start_month', 'vat_taxable_base_over_40m', 'vat_has_eu_trade', @@ -35,7 +37,7 @@ export const TAX_RELEVANT_FIELDS = [ ] as const export const DEADLINE_SETTINGS_SELECT = - 'company_id, entity_type, moms_period, f_skatt, preliminary_tax_monthly, vat_registered, pays_salaries, fiscal_year_start_month, vat_taxable_base_over_40m, vat_has_eu_trade, vat_filing_method, periodisk_sammanstallning_enabled, periodisk_sammanstallning_period, periodisk_sammanstallning_filing_method' as const + 'company_id, entity_type, moms_period, f_skatt, preliminary_tax_monthly, vat_registered, pays_salaries, employer_registered, employer_seasonal, fiscal_year_start_month, vat_taxable_base_over_40m, vat_has_eu_trade, vat_filing_method, periodisk_sammanstallning_enabled, periodisk_sammanstallning_period, periodisk_sammanstallning_filing_method' as const /** * Check if any tax-relevant fields changed @@ -70,6 +72,8 @@ export function toDeadlineSettings( preliminary_tax_monthly: settings.preliminary_tax_monthly ?? null, vat_registered: settings.vat_registered ?? false, pays_salaries: settings.pays_salaries ?? false, + employer_registered: settings.employer_registered ?? null, + employer_seasonal: settings.employer_seasonal ?? false, fiscal_year_start_month: settings.fiscal_year_start_month ?? 1, vat_taxable_base_over_40m: settings.vat_taxable_base_over_40m ?? false, vat_has_eu_trade: settings.vat_has_eu_trade ?? false, diff --git a/messages/en.json b/messages/en.json index 9cf547d2..2b35fa54 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1777,7 +1777,11 @@ "month_nov": "November", "month_dec": "December", "pays_salaries_label": "Pays salaries", - "pays_salaries_help": "Affects which tax deadlines are shown (employer declarations, etc.).", + "pays_salaries_help": "Controls the salary module in the menu. Paying out salary requires employer registration with Skatteverket.", + "employer_registered_label": "Registered as employer", + "employer_registered_help": "Registered employers must file an employer declaration (AGI) every month, including months without salary payments (nil declaration). Controls AGI deadlines.", + "employer_seasonal_label": "Seasonal employer (säsongsregistrerad)", + "employer_seasonal_help": "Files the employer declaration only for months with salary payments, plus December if no salary was paid during the year.", "preliminary_tax_heading": "Preliminary tax", "preliminary_tax_monthly_label": "Monthly preliminary tax (F-skatt)", "preliminary_tax_monthly_help": "Monthly amount per Skatteverket's debited preliminary tax decision. Payment reminders are only created when an amount is set. Leave empty if no preliminary tax is debited." diff --git a/messages/sv.json b/messages/sv.json index 6dfd302f..e58d073a 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1777,7 +1777,11 @@ "month_nov": "November", "month_dec": "December", "pays_salaries_label": "Betalar löner", - "pays_salaries_help": "Påverkar vilka skattedeadlines som visas (arbetsgivardeklaration m.m.).", + "pays_salaries_help": "Styr lönemodulen i menyn. Att betala ut lön kräver arbetsgivarregistrering hos Skatteverket.", + "employer_registered_label": "Registrerad som arbetsgivare", + "employer_registered_help": "Registrerade arbetsgivare ska lämna arbetsgivardeklaration varje månad, även månader utan löneutbetalning (nolldeklaration). Styr AGI-deadlines.", + "employer_seasonal_label": "Säsongsregistrerad arbetsgivare", + "employer_seasonal_help": "Lämnar arbetsgivardeklaration bara för månader med löneutbetalning, samt för december om ingen lön betalats under året.", "preliminary_tax_heading": "Preliminärskatt", "preliminary_tax_monthly_label": "Månatlig preliminärskatt (F-skatt)", "preliminary_tax_monthly_help": "Månadsbelopp enligt Skatteverkets beslut om debiterad preliminärskatt. Betalningspåminnelser skapas bara när ett belopp är angivet. Lämna tomt om ingen preliminärskatt är debiterad." diff --git a/supabase/migrations/20260717151000_agi_employer_registration_gate.sql b/supabase/migrations/20260717151000_agi_employer_registration_gate.sql new file mode 100644 index 00000000..facc11cd --- /dev/null +++ b/supabase/migrations/20260717151000_agi_employer_registration_gate.sql @@ -0,0 +1,32 @@ +-- AGI deadline gating: employer registration, not salary payments (issue #1028). +-- +-- A company registered as arbetsgivare must file an arbetsgivardeklaration +-- every month, including months with no salaries (nil declaration), per +-- SFL 26 kap. 3 §. Only sasongsregistrerade employers are exempt for nil +-- months (and still owe a December nil declaration when nothing was paid all +-- year). "Pays salaries" was the wrong predicate: companies actively running +-- payroll with the flag off received no AGI reminders (each missed monthly +-- filing risks a forseningsavgift), while flagged-but-inactive companies were +-- over-reminded. + +ALTER TABLE public.company_settings + ADD COLUMN IF NOT EXISTS employer_registered boolean, + ADD COLUMN IF NOT EXISTS employer_seasonal boolean NOT NULL DEFAULT false; + +-- Companies that attested paying salaries are employers. +UPDATE public.company_settings +SET employer_registered = true +WHERE pays_salaries = true + AND employer_registered IS DISTINCT FROM true; + +-- Companies with payroll runs in the app are employers regardless of the old +-- flag: paying out salary obliges registration (SFL 7 kap. 1 §) and monthly +-- AGI (SFL 26 kap. 2 §). A wrong reminder is dismissible; a missed statutory +-- filing costs money. +UPDATE public.company_settings cs +SET employer_registered = true +FROM (SELECT DISTINCT company_id FROM public.salary_runs) sr +WHERE sr.company_id = cs.company_id + AND cs.employer_registered IS DISTINCT FROM true; + +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index 251bb45c..00877271 100644 --- a/types/index.ts +++ b/types/index.ts @@ -222,6 +222,9 @@ export interface CompanySettings { // Tax registration pays_salaries: boolean + // null = never attested; deadline generation falls back to pays_salaries. + employer_registered?: boolean | null + employer_seasonal?: boolean f_skatt: boolean vat_registered: boolean vat_number: string | null