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:
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user