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 <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>
This commit is contained in:
Jakob Wennberg
2026-07-17 15:48:25 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 97907a5a5c
commit 3c0bf3f584
21 changed files with 237 additions and 40 deletions
+2
View File
@@ -209,3 +209,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+1
View File
@@ -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),
+1 -1
View File
@@ -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]),
+1
View File
@@ -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)
+1
View File
@@ -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')
+37 -4
View File
@@ -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)
})
})
+42 -1
View File
@@ -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' })
+3 -1
View File
@@ -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') {
+1
View File
@@ -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)
@@ -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
+1 -1
View File
@@ -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',
+3
View File
@@ -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 })
+21 -1
View File
@@ -11,6 +11,7 @@ function makeSettings(overrides: Partial<CompanySettingsForDeadlines> = {}): 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,
+30 -1
View File
@@ -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<string>, isCompleted = false) {
function rowsFor(
companyId: string,
keys: Set<string>,
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)
+16 -6
View File
@@ -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')}`,
+28 -21
View File
@@ -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<string>()
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<UpcomingDeadlineCompanyRow>(({ 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)
+1
View File
@@ -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
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
@@ -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';
+4 -1
View File
@@ -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<string, unknown> | null
@@ -2340,7 +2343,7 @@ export const TAX_DEADLINE_TYPE_LABELS: Record<TaxDeadlineType, 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',