From 3c0bf3f58421d5b0d9ec56224692d520494c7208 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:48:25 +0200 Subject: [PATCH] feat(deadlines): gate F-skatt reminders on debited preliminary tax + durable dismissal (#1057) * 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 * 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 shape. Co-Authored-By: Claude Fable 5 * 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 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 2 + app/(dashboard)/deadlines/page.tsx | 1 + app/(dashboard)/page.tsx | 2 +- app/(dashboard)/salary/page.tsx | 1 + app/api/calendar/feed/[token]/route.ts | 1 + .../deadlines/[id]/__tests__/route.test.ts | 41 ++++++++++++++-- app/api/deadlines/[id]/route.ts | 43 +++++++++++++++- app/api/deadlines/route.ts | 4 +- components/deadlines/DeadlineForm.tsx | 1 + .../extensions/general/CalendarWorkspace.tsx | 1 + lib/calendar/ics-generator.ts | 2 +- lib/deadlines/status-engine.ts | 3 ++ lib/tax/__tests__/deadline-config.test.ts | 22 ++++++++- lib/tax/__tests__/deadline-generator.test.ts | 31 +++++++++++- lib/tax/deadline-config.ts | 22 ++++++--- lib/tax/deadline-generator.ts | 49 +++++++++++-------- lib/worklist/categories.ts | 1 + messages/en.json | 2 +- messages/sv.json | 2 +- ...skatt_deadline_amount_gate_and_dismiss.sql | 41 ++++++++++++++++ types/index.ts | 5 +- 21 files changed, 237 insertions(+), 40 deletions(-) create mode 100644 supabase/migrations/20260717150000_fskatt_deadline_amount_gate_and_dismiss.sql diff --git a/DECISIONS.md b/DECISIONS.md index 058be807..925003cf 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -209,3 +209,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-17] getActiveCompanyId now throws CompanyContextError('resolution_failed') on query failure instead of returning null (issue #1053): null was indistinguishable from "no companies" and every caller redirects that state to /onboarding, so a transient DB failure showed onboarded users the wizard. Chose throw-at-the-source over a degraded-flag return so all redirect sites are fixed at once; withRouteContext already try/catches the call. The Edge middleware copy keeps a degraded flag instead (middleware cannot throw usefully) and fails open. [2026-07-17] Amount-less invoice rows (quantity 0 and unit price 0) render as text rows on PDF/detail/review via shared isTextLikeLine() instead of printing "0 / 0,00 SEK / 0,00 SEK" (issue #1053): users write free-text lines through the article picker's "Egen rad (fri text)" product row, not only the dedicated textrad button. Display-only; booking and validation semantics untouched. [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. diff --git a/app/(dashboard)/deadlines/page.tsx b/app/(dashboard)/deadlines/page.tsx index fdecac54..23b5eb98 100644 --- a/app/(dashboard)/deadlines/page.tsx +++ b/app/(dashboard)/deadlines/page.tsx @@ -46,6 +46,7 @@ export default function DeadlinesPage() { .from('deadlines') .select('*, customer:customers(name)') .eq('company_id', companyId) + .is('dismissed_at', null) .order('due_date', { ascending: true }) .order('id', { ascending: true }) .range(from, to), diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index 30536fb3..d2634d46 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -87,7 +87,7 @@ export default async function DashboardPage() { .gte('journal_entry.entry_date', startOfYearStr), supabase.from('invoices').select('total, total_sek, vat_amount, vat_amount_sek, status, ore_rounding').eq('company_id', companyId).in('status', ['sent', 'overdue']).is('credited_invoice_id', null), supabase.from('bank_connections').select('id, accounts_data, status, consent_expires, bank_name').eq('company_id', companyId).eq('status', 'active'), - supabase.from('deadlines').select('*, customer:customers(id, name)').eq('company_id', companyId).eq('is_completed', false) + supabase.from('deadlines').select('*, customer:customers(id, name)').eq('company_id', companyId).eq('is_completed', false).is('dismissed_at', null) .or(`due_date.lt.${today},due_date.lte.${nextWeek}`).order('due_date', { ascending: true }), supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'), supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).is('journal_entry_id', null).eq('is_ignored', false).is('is_business', null).lt('date', new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]), diff --git a/app/(dashboard)/salary/page.tsx b/app/(dashboard)/salary/page.tsx index 80fbd225..f687e039 100644 --- a/app/(dashboard)/salary/page.tsx +++ b/app/(dashboard)/salary/page.tsx @@ -149,6 +149,7 @@ export default function SalaryPage() { .eq('company_id', company.id) .eq('tax_deadline_type', 'arbetsgivardeklaration') .eq('is_completed', false) + .is('dismissed_at', null) .gte('due_date', today) .order('due_date') .limit(1) diff --git a/app/api/calendar/feed/[token]/route.ts b/app/api/calendar/feed/[token]/route.ts index cde71f76..d703277d 100644 --- a/app/api/calendar/feed/[token]/route.ts +++ b/app/api/calendar/feed/[token]/route.ts @@ -105,6 +105,7 @@ export async function GET( .from('deadlines') .select('*') .eq('company_id', feed.company_id) + .is('dismissed_at', null) .gte('due_date', startStr) .lte('due_date', endStr) .order('due_date') diff --git a/app/api/deadlines/[id]/__tests__/route.test.ts b/app/api/deadlines/[id]/__tests__/route.test.ts index 91c39265..ad84f5d5 100644 --- a/app/api/deadlines/[id]/__tests__/route.test.ts +++ b/app/api/deadlines/[id]/__tests__/route.test.ts @@ -105,19 +105,52 @@ describe('DELETE /api/deadlines/[id]', () => { }) it('returns 404 instead of phantom success when no row matches', async () => { - auth(createCapturingSupabase([{ count: 0 }])) + // First result: the source lookup finds nothing. + auth(createCapturingSupabase([{ data: null }])) const { status } = await parseJsonResponse( await DELETE(createMockRequest('/x', { method: 'DELETE' }), idParams) ) expect(status).toBe(404) }) - it('deletes the deadline', async () => { - auth(createCapturingSupabase([{ count: 1 }])) - const { status, body } = await parseJsonResponse<{ success: boolean }>( + it('hard-deletes a user-created deadline', async () => { + auth(createCapturingSupabase([ + { data: { id: 'deadline-1', source: 'user' } }, + { count: 1 }, + ])) + const { status, body } = await parseJsonResponse<{ success: boolean; dismissed?: boolean }>( await DELETE(createMockRequest('/x', { method: 'DELETE' }), idParams) ) expect(status).toBe(200) expect(body.success).toBe(true) + expect(body.dismissed).toBeUndefined() + }) + + it('dismisses a system deadline instead of deleting it', async () => { + // A hard-deleted system row is recreated by the nightly backfill cron; + // the route must soft-dismiss so the opt-out is durable. + auth(createCapturingSupabase([ + { data: { id: 'deadline-1', source: 'system' } }, + { data: [{ id: 'deadline-1' }] }, + ])) + const { status, body } = await parseJsonResponse<{ success: boolean; dismissed?: boolean }>( + await DELETE(createMockRequest('/x', { method: 'DELETE' }), idParams) + ) + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.dismissed).toBe(true) + }) + + it('returns 404 when the system row vanished before the dismissal landed', async () => { + // Concurrent regeneration can delete the row between lookup and update; + // a phantom "dismissed" success would persist nothing. + auth(createCapturingSupabase([ + { data: { id: 'deadline-1', source: 'system' } }, + { data: [] }, + ])) + const { status } = await parseJsonResponse( + await DELETE(createMockRequest('/x', { method: 'DELETE' }), idParams) + ) + expect(status).toBe(404) }) }) diff --git a/app/api/deadlines/[id]/route.ts b/app/api/deadlines/[id]/route.ts index f5720af9..b53afee3 100644 --- a/app/api/deadlines/[id]/route.ts +++ b/app/api/deadlines/[id]/route.ts @@ -90,7 +90,13 @@ export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>( /** * DELETE /api/deadlines/[id] - * Delete a deadline + * Delete a user deadline, or durably dismiss a system-generated one. + * + * System rows are soft-dismissed instead of hard-deleted: the nightly + * backfill cron treats a missing upcoming system row as a repair case and + * recreates it within 24 hours, so a hard delete silently undoes itself. + * A dismissed row stays in the table (hidden from every surface) and + * satisfies the generator and backfill the same way a completed row does. */ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( 'deadline.delete', @@ -98,6 +104,41 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( const { id } = await params const { supabase, companyId } = ctx + const { data: existing, error: fetchError } = await supabase + .from('deadlines') + .select('id, source') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle() + + if (fetchError) { + return NextResponse.json({ error: fetchError.message }, { status: 500 }) + } + if (!existing) { + return NextResponse.json({ error: 'Deadline not found' }, { status: 404 }) + } + + if (existing.source === 'system') { + const { data: dismissedRows, error: dismissError } = await supabase + .from('deadlines') + .update({ dismissed_at: new Date().toISOString() }) + .eq('id', id) + .eq('company_id', companyId) + .eq('source', 'system') + .select('id') + + if (dismissError) { + return NextResponse.json({ error: dismissError.message }, { status: 500 }) + } + // The row can vanish between lookup and update (generator cleanup + // during a concurrent regeneration); report 404 rather than a + // phantom success that persisted nothing. + if (!dismissedRows || dismissedRows.length === 0) { + return NextResponse.json({ error: 'Deadline not found' }, { status: 404 }) + } + return NextResponse.json({ success: true, dismissed: true }) + } + const { error, count } = await supabase .from('deadlines') .delete({ count: 'exact' }) diff --git a/app/api/deadlines/route.ts b/app/api/deadlines/route.ts index e2f70d6a..5d7cb84d 100644 --- a/app/api/deadlines/route.ts +++ b/app/api/deadlines/route.ts @@ -22,11 +22,13 @@ export const GET = withRouteContext('deadline.list', async (request, ctx) => { const from = searchParams.get('from') const to = searchParams.get('to') - // Build query + // Build query. Dismissed system deadlines are an explicit opt-out and + // never listed. let query = supabase .from('deadlines') .select('*, customer:customers(id, name)') .eq('company_id', companyId) + .is('dismissed_at', null) // Apply filters if (status === 'pending') { diff --git a/components/deadlines/DeadlineForm.tsx b/components/deadlines/DeadlineForm.tsx index 0646be12..dbfa76cf 100644 --- a/components/deadlines/DeadlineForm.tsx +++ b/components/deadlines/DeadlineForm.tsx @@ -119,6 +119,7 @@ export function DeadlineForm({ status_changed_at: new Date().toISOString(), linked_report_type: null, linked_report_period: null, + dismissed_at: null, }) } finally { setIsLoading(false) diff --git a/components/extensions/general/CalendarWorkspace.tsx b/components/extensions/general/CalendarWorkspace.tsx index dd701013..56654470 100644 --- a/components/extensions/general/CalendarWorkspace.tsx +++ b/components/extensions/general/CalendarWorkspace.tsx @@ -29,6 +29,7 @@ export default function CalendarWorkspace({ userId }: WorkspaceComponentProps) { const { data: deadlinesData, error: deadlinesError } = await supabase .from('deadlines') .select('*, customer:customers(name)') + .is('dismissed_at', null) .order('due_date', { ascending: true }) if (deadlinesError) throw deadlinesError diff --git a/lib/calendar/ics-generator.ts b/lib/calendar/ics-generator.ts index 87773f81..0a8d7333 100644 --- a/lib/calendar/ics-generator.ts +++ b/lib/calendar/ics-generator.ts @@ -176,7 +176,7 @@ function getSwedishTaxTypeLabel(type: string): string { moms_monthly: 'Momsdeklaration (månad)', moms_quarterly: 'Momsdeklaration (kvartal)', moms_yearly: 'Momsdeklaration (år)', - f_skatt: 'F-skatt', + f_skatt: 'Preliminärskatt (F-skatt)', arbetsgivardeklaration: 'Arbetsgivardeklaration', skatteinbetalning: 'Skatteinbetalning (storföretag)', inkomstdeklaration_ef: 'Inkomstdeklaration EF', diff --git a/lib/deadlines/status-engine.ts b/lib/deadlines/status-engine.ts index fe6c8f5f..46108483 100644 --- a/lib/deadlines/status-engine.ts +++ b/lib/deadlines/status-engine.ts @@ -84,6 +84,7 @@ export async function updateDeadlineStatuses( }) .lt('due_date', todayStr) .eq('is_completed', false) + .is('dismissed_at', null) .in('status', ['upcoming', 'action_needed']) .select('id') @@ -105,6 +106,7 @@ export async function updateDeadlineStatuses( .lte('due_date', thresholdStr) .eq('status', 'upcoming') .eq('is_completed', false) + .is('dismissed_at', null) .select('id') if (actionNeededError) { @@ -186,6 +188,7 @@ export async function getDeadlinesNeedingAttention( .select('id, title, due_date, tax_deadline_type, status') .eq('company_id', companyId) .eq('is_completed', false) + .is('dismissed_at', null) .in('status', ['action_needed', 'overdue']) .order('due_date', { ascending: true }) diff --git a/lib/tax/__tests__/deadline-config.test.ts b/lib/tax/__tests__/deadline-config.test.ts index 589cf094..2cadf3e6 100644 --- a/lib/tax/__tests__/deadline-config.test.ts +++ b/lib/tax/__tests__/deadline-config.test.ts @@ -11,6 +11,7 @@ function makeSettings(overrides: Partial = {}): Com entity_type: 'aktiebolag', moms_period: 'quarterly', f_skatt: true, + preliminary_tax_monthly: 5000, vat_registered: true, pays_salaries: false, fiscal_year_start_month: 1, @@ -75,13 +76,32 @@ describe('VAT filing deadlines', () => { }) describe('monthly tax and employer deadlines', () => { - it('uses the 12th for F-tax except January and August', () => { + it('generates preliminary tax deadlines only when an amount is debited', () => { + const config = getConfig('f_skatt') + // F-skatt approval alone carries no payment obligation. + expect(config.condition(makeSettings({ preliminary_tax_monthly: null }))).toBe(false) + expect(config.condition(makeSettings({ preliminary_tax_monthly: 0 }))).toBe(false) + expect(config.condition(makeSettings({ preliminary_tax_monthly: 2500 }))).toBe(true) + // The debited amount governs even without F-skatt approval (SA-skatt). + expect(config.condition(makeSettings({ f_skatt: false, preliminary_tax_monthly: 2500 }))).toBe(true) + }) + + it('uses the 12th for preliminary tax except January and August', () => { const dates = getConfig('f_skatt').generateDates(2026, makeSettings()) expect(dates[0].day).toBe(17) expect(dates[1].day).toBe(12) expect(dates[7].day).toBe(17) }) + it('keeps the 12th in August for storföretag preliminary tax (January-only 17th)', () => { + const dates = getConfig('f_skatt').generateDates(2026, makeSettings({ + moms_period: 'monthly', + vat_taxable_base_over_40m: true, + })) + expect(dates[0].day).toBe(17) + expect(dates[7].day).toBe(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, diff --git a/lib/tax/__tests__/deadline-generator.test.ts b/lib/tax/__tests__/deadline-generator.test.ts index 45d75f2c..b5a15f86 100644 --- a/lib/tax/__tests__/deadline-generator.test.ts +++ b/lib/tax/__tests__/deadline-generator.test.ts @@ -12,6 +12,7 @@ const SETTINGS: CompanySettingsForDeadlines = { entity_type: 'aktiebolag', moms_period: 'monthly', f_skatt: true, + preliminary_tax_monthly: 5000, vat_registered: true, pays_salaries: true, fiscal_year_start_month: 1, @@ -59,6 +60,8 @@ function makeRecordingSupabase(opts: { return chain }) chain.eq = vi.fn(self) + chain.or = vi.fn(self) + chain.is = vi.fn(self) chain.gte = vi.fn(self) chain.lte = vi.fn(self) chain.not = vi.fn((...args: unknown[]) => { @@ -156,7 +159,12 @@ describe('findSettingsMissingUpcomingDeadlines', () => { const fromDate = new Date(2030, 0, 1) const years = [2030] - function rowsFor(companyId: string, keys: Set, isCompleted = false) { + function rowsFor( + companyId: string, + keys: Set, + isCompleted = false, + dismissedAt: string | null = null, + ) { return Array.from(keys, (key, index) => { const [taxDeadlineType, taxPeriod, dueDate] = key.split(':') return { @@ -166,6 +174,7 @@ describe('findSettingsMissingUpcomingDeadlines', () => { tax_period: taxPeriod, due_date: dueDate, is_completed: isCompleted, + dismissed_at: dismissedAt, } }) } @@ -238,6 +247,26 @@ describe('findSettingsMissingUpcomingDeadlines', () => { )).toEqual([]) }) + it('treats a dismissed obligation as satisfied even with a superseded due date', () => { + // A dismissed row is an explicit opt-out. The generator never replaces + // dismissed rows, so flagging one by date would resurrect the obligation + // the user opted out of on every cron run. + const settings = [{ company_id: 'company-1', ...SETTINGS }] + const dismissedStaleRows = rowsFor( + 'company-1', + getExpectedUpcomingDeadlineKeys(SETTINGS, years, fromDate), + false, + '2029-06-01T00:00:00Z', + ).map((row) => ({ ...row, due_date: '2029-01-15' })) + + expect(findSettingsMissingUpcomingDeadlines( + settings, + dismissedStaleRows, + years, + fromDate, + )).toEqual([]) + }) + it('still repairs missing pending obligations when other obligations are completed', () => { const settings = [{ company_id: 'company-1', ...SETTINGS }] const expectedKeys = getExpectedUpcomingDeadlineKeys(SETTINGS, years, fromDate) diff --git a/lib/tax/deadline-config.ts b/lib/tax/deadline-config.ts index 8146d63e..36c1200f 100644 --- a/lib/tax/deadline-config.ts +++ b/lib/tax/deadline-config.ts @@ -13,6 +13,7 @@ export interface CompanySettingsForDeadlines { entity_type: EntityType moms_period: MomsPeriod | null f_skatt: boolean + preliminary_tax_monthly: number | null vat_registered: boolean pays_salaries: boolean fiscal_year_start_month: number // 1-12 @@ -178,19 +179,28 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [ generateDates: (year, settings) => generateAnnualVatDates(year, settings), }, - // F-skatt (monthly) + // Debiterad preliminärskatt (monthly payment). Gated on the debited amount, + // NOT on F-skatt approval: approval is a status with no recurring duty, and + // Skatteverket debits nothing below 2 400 kr/år (SFL 55 kap. 2 §). The + // monthly payment obligation exists only while an amount > 0 is debited + // (SFL 62 kap. 4-5 §§). { type: 'f_skatt', - titleTemplate: 'F-skatt {periodLabel}', - description: 'Inbetalning av preliminär skatt', - condition: (s) => s.f_skatt, + titleTemplate: 'Betala preliminärskatt {periodLabel}', + description: 'Inbetalning av debiterad preliminärskatt', + condition: (s) => (s.preliminary_tax_monthly ?? 0) > 0, priority: 'important', linkedReportType: null, - generateDates: (year) => { + generateDates: (year, settings) => { + // Small-company förfallodagar are the 12th, with the 17th in January + // and August; storföretag (VAT taxable base over SEK 40M) keep the + // 12th in August, January-only 17th (62 kap. 3-4 §§ SFL and + // Skatteverket's published storföretag calendar). + const storforetag = settings.vat_registered && settings.vat_taxable_base_over_40m const instances: DeadlineInstance[] = [] for (let month = 0; month < 12; month++) { instances.push({ - day: month === 0 || month === 7 ? 17 : 12, + day: month === 0 || (month === 7 && !storforetag) ? 17 : 12, month, year, period: `${year}-${String(month + 1).padStart(2, '0')}`, diff --git a/lib/tax/deadline-generator.ts b/lib/tax/deadline-generator.ts index 9c9e5cd4..38ef8261 100644 --- a/lib/tax/deadline-generator.ts +++ b/lib/tax/deadline-generator.ts @@ -22,6 +22,7 @@ export const TAX_RELEVANT_FIELDS = [ 'entity_type', 'moms_period', 'f_skatt', + 'preliminary_tax_monthly', 'vat_registered', 'pays_salaries', 'fiscal_year_start_month', @@ -34,7 +35,7 @@ export const TAX_RELEVANT_FIELDS = [ ] as const export const DEADLINE_SETTINGS_SELECT = - 'company_id, entity_type, moms_period, f_skatt, 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, 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 @@ -66,6 +67,7 @@ export function toDeadlineSettings( entity_type: settings.entity_type, moms_period: settings.moms_period ?? null, f_skatt: settings.f_skatt ?? true, + preliminary_tax_monthly: settings.preliminary_tax_monthly ?? null, vat_registered: settings.vat_registered ?? false, pays_salaries: settings.pays_salaries ?? false, fiscal_year_start_month: settings.fiscal_year_start_month ?? 1, @@ -129,28 +131,29 @@ export async function generateTaxDeadlinesForUser( const todayIso = formatDateISO(today) const endDate = `${Math.max(...years) + 1}-12-31` - // Completed deadlines represent real filing progress. Preserve them and do - // not create a second pending row for the same obligation. The window starts - // a year before the earliest generated year, NOT today: a completed row can - // carry a superseded due date that already passed while the current - // statutory date is still ahead, and filtering on today would resurrect a - // pending row for an obligation the user already filed. + // Completed deadlines represent real filing progress and dismissed + // deadlines represent an explicit opt-out; preserve both and do not create + // a second pending row for the same obligation. The window starts a year + // before the earliest generated year, NOT today: a completed row can carry + // a superseded due date that already passed while the current statutory + // date is still ahead, and filtering on today would resurrect a pending + // row for an obligation the user already filed. const completedFloor = `${Math.min(...years) - 1}-01-01` - const { data: completedRows, error: completedRowsError } = await supabase + const { data: preservedRows, error: preservedRowsError } = await supabase .from('deadlines') .select('tax_deadline_type, tax_period') .eq('company_id', companyId) .eq('source', 'system') - .eq('is_completed', true) + .or('is_completed.eq.true,dismissed_at.not.is.null') .gte('due_date', completedFloor) - if (completedRowsError) { - log.error('Error fetching completed deadlines:', completedRowsError) - throw completedRowsError + if (preservedRowsError) { + log.error('Error fetching completed/dismissed deadlines:', preservedRowsError) + throw preservedRowsError } const completedKeys = new Set( - (completedRows ?? []).map( + (preservedRows ?? []).map( (row: { tax_deadline_type: string | null; tax_period: string | null }) => `${row.tax_deadline_type}:${row.tax_period}`, ), @@ -259,13 +262,16 @@ export async function generateTaxDeadlinesForUser( } // Delete the superseded system-generated deadlines for these years, - // excluding the rows just inserted. + // excluding the rows just inserted. Dismissed rows survive: deleting one + // would erase the opt-out and let the next regeneration recreate the + // obligation as a fresh pending row. let deleteQuery = supabase .from('deadlines') .delete() .eq('company_id', companyId) .eq('source', 'system') .eq('is_completed', false) + .is('dismissed_at', null) .gte('due_date', todayIso) .lte('due_date', endDate) @@ -345,6 +351,7 @@ interface UpcomingDeadlineCompanyRow { tax_period: string | null due_date: string | null is_completed: boolean | null + dismissed_at: string | null } // The due date is part of the identity: rows created by older schedule logic @@ -358,11 +365,11 @@ function deadlineIdentity( return `${type}:${period}:${dueDate}` } -// Completed rows use the looser type:period identity (no due date): a filed -// obligation is satisfied even when its stored date comes from a superseded -// schedule, and the generator never replaces completed rows, so flagging them -// by date would make the repair loop re-run for the same company every day -// without ever converging. +// Completed and dismissed rows use the looser type:period identity (no due +// date): a filed or opted-out obligation is satisfied even when its stored +// date comes from a superseded schedule, and the generator never replaces +// either kind, so flagging them by date would make the repair loop re-run +// for the same company every day without ever converging. function completedIdentity(type: string | null, period: string | null): string { return `${type}:${period}` } @@ -410,7 +417,7 @@ export function findSettingsMissingUpcomingDeadlines( keys.add(deadlineIdentity(row.tax_deadline_type, row.tax_period, row.due_date)) actualKeysByCompany.set(row.company_id, keys) - if (row.is_completed) { + if (row.is_completed || row.dismissed_at) { const completed = completedKeysByCompany.get(row.company_id) ?? new Set() completed.add(completedIdentity(row.tax_deadline_type, row.tax_period)) completedKeysByCompany.set(row.company_id, completed) @@ -501,7 +508,7 @@ export async function backfillMissingTaxDeadlines( fetchAllRows(({ from, to }) => supabase .from('deadlines') - .select('id, company_id, tax_deadline_type, tax_period, due_date, is_completed') + .select('id, company_id, tax_deadline_type, tax_period, due_date, is_completed, dismissed_at') .eq('source', 'system') .eq('deadline_type', 'tax') .gte('due_date', pastFloor) diff --git a/lib/worklist/categories.ts b/lib/worklist/categories.ts index f0179e80..3122c3ca 100644 --- a/lib/worklist/categories.ts +++ b/lib/worklist/categories.ts @@ -225,6 +225,7 @@ export async function countDeadlinesNeedingAction( .select('id', { count: 'exact', head: true }) .eq('company_id', companyId) .eq('is_completed', false) + .is('dismissed_at', null) .in('status', ['action_needed', 'overdue']) if (error) return logAndZero('deadline_action', companyId, error) return count ?? 0 diff --git a/messages/en.json b/messages/en.json index 0e100ac9..9cf547d2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1780,7 +1780,7 @@ "pays_salaries_help": "Affects which tax deadlines are shown (employer declarations, etc.).", "preliminary_tax_heading": "Preliminary tax", "preliminary_tax_monthly_label": "Monthly preliminary tax (F-skatt)", - "preliminary_tax_monthly_help": "Amount in SEK paid each month." + "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." }, "settings_backup_download": { "create_backup_title": "Create backup", diff --git a/messages/sv.json b/messages/sv.json index 7ad4a2ee..6dfd302f 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1780,7 +1780,7 @@ "pays_salaries_help": "Påverkar vilka skattedeadlines som visas (arbetsgivardeklaration m.m.).", "preliminary_tax_heading": "Preliminärskatt", "preliminary_tax_monthly_label": "Månatlig preliminärskatt (F-skatt)", - "preliminary_tax_monthly_help": "Belopp i SEK som betalas varje månad." + "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." }, "settings_backup_download": { "create_backup_title": "Skapa backup", diff --git a/supabase/migrations/20260717150000_fskatt_deadline_amount_gate_and_dismiss.sql b/supabase/migrations/20260717150000_fskatt_deadline_amount_gate_and_dismiss.sql new file mode 100644 index 00000000..eb78d373 --- /dev/null +++ b/supabase/migrations/20260717150000_fskatt_deadline_amount_gate_and_dismiss.sql @@ -0,0 +1,41 @@ +-- F-skatt deadline re-gating (issue #1028) + durable deadline dismissal. +-- +-- Approval for F-skatt carries no recurring obligation: the recurring duty is +-- PAYMENT of debiterad preliminarskatt, and only when Skatteverket has debited +-- an amount > 0 kr (below 2 400 kr nothing is debited at all, SFL 55 kap. 2 §). +-- The deadline generator previously gated on the f_skatt approval flag, whose +-- column default is true, so nearly every company received 12 payment +-- reminders per year for a tax that may not be debited. The generator now +-- gates on preliminary_tax_monthly > 0 instead. + +-- 1) Schema alignment: preliminary_tax_monthly has existed on the hosted +-- database since before migration discipline, but no migration ever +-- declared it. Installs built purely from migrations (self-hosted, local, +-- preview branches) lack the column and fail every tax-settings save that +-- includes the field. No-op on hosted. +ALTER TABLE public.company_settings + ADD COLUMN IF NOT EXISTS preliminary_tax_monthly numeric; + +-- 2) Durable dismissal for system-generated deadlines. Hard-deleting a system +-- row never worked: the nightly backfill cron treats the missing row as a +-- repair case and recreates it within 24 hours. A dismissed row stays in +-- the table, is hidden from every surface, and satisfies the generator and +-- backfill the same way a completed row does. +ALTER TABLE public.deadlines + ADD COLUMN IF NOT EXISTS dismissed_at timestamptz; + +-- 3) Prune upcoming F-skatt payment reminders for companies with no debited +-- preliminary tax on record. Completed rows are kept (filing history). +DELETE FROM public.deadlines d +WHERE d.source = 'system' + AND d.tax_deadline_type = 'f_skatt' + AND d.is_completed = false + AND d.due_date >= current_date + AND NOT EXISTS ( + SELECT 1 + FROM public.company_settings cs + WHERE cs.company_id = d.company_id + AND coalesce(cs.preliminary_tax_monthly, 0) > 0 + ); + +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index 39ce0555..251bb45c 100644 --- a/types/index.ts +++ b/types/index.ts @@ -2215,6 +2215,9 @@ export interface Deadline { reminder_offsets: number[] | null status: DeadlineStatus status_changed_at: string + // Durable opt-out for system deadlines: hidden everywhere, never + // recreated by the generator or the backfill cron. + dismissed_at: string | null linked_report_type: string | null linked_report_period: Record | null @@ -2340,7 +2343,7 @@ export const TAX_DEADLINE_TYPE_LABELS: Record = { moms_monthly: 'Momsdeklaration (månad)', moms_quarterly: 'Momsdeklaration (kvartal)', moms_yearly: 'Momsdeklaration (år)', - f_skatt: 'F-skatt', + f_skatt: 'Preliminärskatt (F-skatt)', arbetsgivardeklaration: 'Arbetsgivardeklaration', skatteinbetalning: 'Skatteinbetalning (storföretag)', inkomstdeklaration_ef: 'Inkomstdeklaration EF',