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:
co-authored by
Claude Fable 5
parent
97907a5a5c
commit
3c0bf3f584
@@ -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',
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')}`,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user