/** * Deadline generator - creates tax deadlines based on company settings */ import { SupabaseClient } from '@supabase/supabase-js' import { createLogger } from '@/lib/logger' import { fetchAllRows } from '@/lib/supabase/fetch-all' import type { TaxDeadlineType, DeadlineStatus } from '@/types' const log = createLogger('deadline-generator') import { getApplicableDeadlineConfigs, type CompanySettingsForDeadlines, type DeadlineInstance, type TaxAssessmentNoticeForDeadline, } from './deadline-config' import { adjustDeadlineToNextBankingDay } from './swedish-holidays' /** * Rolling generation horizons. Recurring skattekonto obligations (monthly * and quarterly filings) only generate ~6 months ahead: nobody acts on a * moms deadline 14 months out, and the rows just bury the near-term list. * Annual obligations keep 12 months so year-end planning still gets * warning. The daily backfill cron rolls the window forward: a row is * created once its due date enters the horizon. * * The same cutoff MUST apply in generateTaxDeadlinesForUser and * getExpectedUpcomingDeadlineKeys: if detection expected a row the * generator refuses to create, the nightly cron would regenerate (and * status-reset) the company every day forever. */ export const RECURRING_HORIZON_DAYS = 183 export const ANNUAL_HORIZON_DAYS = 365 /** Types on the recurring horizon; anything not listed defaults to annual. */ const RECURRING_HORIZON_TYPES = new Set([ 'moms_monthly', 'moms_quarterly', 'f_skatt', 'arbetsgivardeklaration', 'skatteinbetalning', 'periodisk_sammanstallning', 'oss_quarterly', 'ioss_monthly', 'intrastat_monthly', 'punktskatt_monthly', ]) function horizonEndFor(type: TaxDeadlineType, today: Date): Date { const days = RECURRING_HORIZON_TYPES.has(type) ? RECURRING_HORIZON_DAYS : ANNUAL_HORIZON_DAYS const end = new Date(today) end.setDate(end.getDate() + days) return end } /** * Fields in company_settings that affect tax deadline generation */ export const TAX_RELEVANT_FIELDS = [ 'entity_type', 'moms_period', 'f_skatt', 'preliminary_tax_monthly', 'vat_registered', 'pays_salaries', 'employer_registered', 'employer_seasonal', 'fiscal_year_start_month', 'vat_taxable_base_over_40m', 'vat_has_eu_trade', 'vat_filing_method', 'periodisk_sammanstallning_enabled', 'periodisk_sammanstallning_period', 'periodisk_sammanstallning_filing_method', 'kontrolluppgifter_enabled', 'rot_rut_enabled', 'oss_enabled', 'ioss_enabled', 'intrastat_enabled', 'punktskatt_enabled', 'fyllnadsinbetalning_enabled', ] as const export const DEADLINE_SETTINGS_SELECT = 'company_id, entity_type, moms_period, f_skatt, preliminary_tax_monthly, vat_registered, pays_salaries, employer_registered, employer_seasonal, fiscal_year_start_month, vat_taxable_base_over_40m, vat_has_eu_trade, vat_filing_method, periodisk_sammanstallning_enabled, periodisk_sammanstallning_period, periodisk_sammanstallning_filing_method, kontrolluppgifter_enabled, rot_rut_enabled, oss_enabled, ioss_enabled, intrastat_enabled, punktskatt_enabled, fyllnadsinbetalning_enabled' as const /** * Check if any tax-relevant fields changed */ export function didTaxFieldsChange( oldSettings: Partial, newSettings: Partial ): boolean { for (const field of TAX_RELEVANT_FIELDS) { if (oldSettings[field] !== newSettings[field]) { return true } } return false } export function hasTaxRelevantFields(body: Record): boolean { return TAX_RELEVANT_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(body, field)) } export function toDeadlineSettings( settings: Partial, ): CompanySettingsForDeadlines { if (settings.entity_type !== 'aktiebolag' && settings.entity_type !== 'enskild_firma') { throw new Error('Company entity type is required to generate tax deadlines') } return { 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, employer_registered: settings.employer_registered ?? null, employer_seasonal: settings.employer_seasonal ?? false, fiscal_year_start_month: settings.fiscal_year_start_month ?? 1, vat_taxable_base_over_40m: settings.vat_taxable_base_over_40m ?? false, vat_has_eu_trade: settings.vat_has_eu_trade ?? false, vat_filing_method: settings.vat_filing_method ?? 'electronic', periodisk_sammanstallning_enabled: settings.periodisk_sammanstallning_enabled ?? false, periodisk_sammanstallning_period: settings.periodisk_sammanstallning_period ?? 'monthly', periodisk_sammanstallning_filing_method: settings.periodisk_sammanstallning_filing_method ?? 'electronic', kontrolluppgifter_enabled: settings.kontrolluppgifter_enabled ?? false, rot_rut_enabled: settings.rot_rut_enabled ?? false, rot_rut_payment_years: settings.rot_rut_payment_years, oss_enabled: settings.oss_enabled ?? false, ioss_enabled: settings.ioss_enabled ?? false, intrastat_enabled: settings.intrastat_enabled ?? false, punktskatt_enabled: settings.punktskatt_enabled ?? false, fyllnadsinbetalning_enabled: settings.fyllnadsinbetalning_enabled ?? false, tax_assessment_notices: settings.tax_assessment_notices, } } interface TaxAssessmentNoticeRow { id: string company_id: string decision_type: 'final' | 'reassessment' payment_due_date: string fiscal_periods: { name: string } | Array<{ name: string }> | null } async function fetchActiveTaxAssessmentNotices( supabase: SupabaseClient, companyId?: string, ): Promise { return fetchAllRows(({ from, to }) => { let query = supabase .from('tax_assessment_notices') .select('id, company_id, decision_type, payment_due_date, fiscal_periods(name)') .is('archived_at', null) .order('id', { ascending: true }) .range(from, to) if (companyId) query = query.eq('company_id', companyId) return query }) } function toDeadlineNotice(row: TaxAssessmentNoticeRow): TaxAssessmentNoticeForDeadline { const fiscalPeriod = Array.isArray(row.fiscal_periods) ? row.fiscal_periods[0] : row.fiscal_periods return { id: row.id, fiscalPeriodName: fiscalPeriod?.name ?? '', decisionType: row.decision_type, paymentDueDate: row.payment_due_date, } } async function hydrateTaxAssessmentNotices( supabase: SupabaseClient, settingsRows: DeadlineSettingsRow[], ): Promise { const notices = await fetchActiveTaxAssessmentNotices(supabase) const byCompany = new Map() for (const notice of notices) { const current = byCompany.get(notice.company_id) ?? [] current.push(toDeadlineNotice(notice)) byCompany.set(notice.company_id, current) } return settingsRows.map((settings) => ({ ...settings, tax_assessment_notices: byCompany.get(settings.company_id) ?? [], })) } /** * Decide whether a settings save should (re)generate tax deadlines. * * Regenerate when a tax-relevant field changed OR when the company has no * system-generated deadlines yet. The second case is the common one: tax * settings are filled at onboarding, so a later save with no tax-field change * used to skip generation entirely and the deadlines page stayed empty even * though the settings were "filled in". Backfilling an empty set is safe: there * is no existing status/progress to clobber. */ export function shouldRegenerateTaxDeadlines( taxFieldsChanged: boolean, existingSystemDeadlineCount: number ): boolean { return taxFieldsChanged || existingSystemDeadlineCount === 0 } /** * Format date to YYYY-MM-DD */ function formatDateISO(date: Date): string { const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') return `${year}-${month}-${day}` } /** * Generate all tax deadlines for a user based on their company settings */ export async function generateTaxDeadlinesForUser( supabase: SupabaseClient, companyId: string, settings: CompanySettingsForDeadlines, years: number[] = [] ): Promise<{ created: number; deleted: number }> { if (settings.tax_assessment_notices === undefined) { const notices = await fetchActiveTaxAssessmentNotices(supabase, companyId) settings = { ...settings, tax_assessment_notices: notices.map(toDeadlineNotice), } } // Recurring deadlines use the current rolling window. Explicit tax notices // also include their own due-date years so a newly entered overdue notice is // represented instead of disappearing only because its exact date has passed. if (years.length === 0) { const currentYear = new Date().getFullYear() const noticeYears = (settings.tax_assessment_notices ?? []) .map((notice) => Number(notice.paymentDueDate.slice(0, 4))) .filter(Number.isInteger) years = Array.from(new Set([currentYear, currentYear + 1, ...noticeYears])) } // The ROT/RUT begäran deadline is data-dependent: a row for year Y only // exists when Y has paid ROT/RUT invoices (Lag 2009:194 8 §, payment // dates). Resolve the payment years here, in the one place that inserts // and deletes rows, so every generation path agrees. Callers that pass // pure settings (backfill detection) leave the field undefined and simply // never expect rot_rut_begaran rows. if (settings.rot_rut_enabled && settings.rot_rut_payment_years === undefined) { const rotRutRows = await fetchAllRows<{ paid_at: string | null }>(({ from, to }) => supabase .from('invoices') .select('paid_at') .eq('company_id', companyId) .gt('deduction_total', 0) .not('paid_at', 'is', null) .order('id', { ascending: true }) .range(from, to), ) settings = { ...settings, rot_rut_payment_years: Array.from( new Set( rotRutRows .filter((row) => row.paid_at) .map((row) => Number(String(row.paid_at).slice(0, 4))), ), ), } } // Get applicable deadline configs based on settings const applicableConfigs = getApplicableDeadlineConfigs(settings) const today = new Date() today.setHours(0, 0, 0, 0) const todayIso = formatDateISO(today) const endDate = `${Math.max(...years) + 1}-12-31` // 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: preservedRows, error: preservedRowsError } = await supabase .from('deadlines') .select('tax_deadline_type, tax_period') .eq('company_id', companyId) .eq('source', 'system') .or('is_completed.eq.true,dismissed_at.not.is.null') .gte('due_date', completedFloor) if (preservedRowsError) { log.error('Error fetching completed/dismissed deadlines:', preservedRowsError) throw preservedRowsError } const completedKeys = new Set( (preservedRows ?? []).map( (row: { tax_deadline_type: string | null; tax_period: string | null }) => `${row.tax_deadline_type}:${row.tax_period}`, ), ) // Manual progress survives regeneration: a replacement row for the same // obligation keeps its in_progress status instead of resetting to // upcoming. This matters with the rolling horizon, where the backfill // cron regenerates a company every time a new row enters the window. const { data: inProgressRows, error: inProgressError } = await supabase .from('deadlines') .select('tax_deadline_type, tax_period, status') .eq('company_id', companyId) .eq('source', 'system') .eq('is_completed', false) .is('dismissed_at', null) .eq('status', 'in_progress') if (inProgressError) { log.error('Error fetching in-progress deadlines:', inProgressError) throw inProgressError } const inProgressKeys = new Set( (inProgressRows ?? []) .filter((row: { status?: string }) => row.status === 'in_progress') .map( (row: { tax_deadline_type: string | null; tax_period: string | null }) => `${row.tax_deadline_type}:${row.tax_period}`, ), ) // Generate new deadlines const deadlines: Array<{ company_id: string title: string due_date: string deadline_type: 'tax' priority: 'critical' | 'important' | 'normal' is_completed: boolean source: 'system' status: DeadlineStatus tax_deadline_type: TaxDeadlineType tax_period: string linked_report_type: string | null linked_report_period: Record | null reminder_offsets: number[] is_auto_generated: boolean tax_assessment_notice_id: string | null }> = [] for (const config of applicableConfigs) { const horizonEnd = horizonEndFor(config.type, today) for (const year of years) { const instances = config.generateDates(year, settings) for (const instance of instances) { // Create the raw deadline date const rawDate = new Date(instance.year, instance.month, instance.day) // Adjust for banking days (skip weekends and holidays). EU-law // deadlines (OSS/IOSS) opt out: their dates stand on weekends. const adjustedDate = config.skipBankingDayAdjustment ? rawDate : adjustDeadlineToNextBankingDay(rawDate) const dueDate = formatDateISO(adjustedDate) // Skip if the deadline is in the past if (adjustedDate < today && !instance.taxAssessmentNoticeId) { continue } // Skip rows beyond the rolling horizon; the daily backfill creates // them once they come into view. if (adjustedDate > horizonEnd) { continue } const deadlineKey = `${config.type}:${instance.period}` if (completedKeys.has(deadlineKey)) { continue } // Determine initial status based on days until deadline, keeping a // manually set in_progress from the row being replaced. const daysUntil = Math.ceil((adjustedDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)) const status: DeadlineStatus = inProgressKeys.has(deadlineKey) ? 'in_progress' : daysUntil <= 14 ? 'action_needed' : 'upcoming' // Generate title from template const title = config.titleTemplate.replace('{periodLabel}', instance.periodLabel) // Create linked report period data const linkedReportPeriod = createLinkedReportPeriod(instance, config.type) deadlines.push({ company_id: companyId, title, due_date: dueDate, deadline_type: 'tax', priority: config.priority, is_completed: false, source: 'system', status, tax_deadline_type: config.type, tax_period: instance.period, linked_report_type: config.linkedReportType, linked_report_period: linkedReportPeriod, reminder_offsets: [14, 7, 1, 0], is_auto_generated: true, tax_assessment_notice_id: instance.taxAssessmentNoticeId ?? null, }) } } } const uniqueDeadlines = Array.from( new Map( deadlines.map((deadline) => [ `${deadline.tax_deadline_type}:${deadline.tax_period}`, deadline, ]), ).values(), ) // Insert the replacement rows BEFORE deleting the old set. A failed insert // then leaves the previous deadlines intact: the old delete-first order // meant any insert failure (like the 23502 user_id regression) wiped the // company's tax deadlines without replacing them. // // Not concurrency-safe: two overlapping regenerations (settings save racing // the cron backfill) can each delete the other's freshly inserted rows and // leave the company with fewer rows than expected. Accepted: the daily // backfill cron detects the missing keys and repairs on its next run. let newIds: string[] = [] if (uniqueDeadlines.length > 0) { const { data: insertedData, error: insertError } = await supabase .from('deadlines') .insert(uniqueDeadlines) .select('id') if (insertError) { log.error('Error inserting deadlines:', insertError) throw insertError } newIds = (insertedData ?? []).map((d: { id: string }) => d.id) } // Delete the superseded system-generated deadlines for these years, // 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) if (newIds.length > 0) { deleteQuery = deleteQuery.not('id', 'in', `(${newIds.join(',')})`) } const { data: deletedData, error: deleteError } = await deleteQuery.select('id') if (deleteError) { log.error('Error deleting existing deadlines:', deleteError) throw deleteError } return { created: uniqueDeadlines.length, deleted: deletedData?.length || 0, } } /** * Create linked report period object for navigation */ function createLinkedReportPeriod( instance: DeadlineInstance, _type: TaxDeadlineType ): Record | null { const period = instance.period // Parse the period string if (period.includes('-Q')) { // Quarterly: "2025-Q1" const [year, quarter] = period.split('-Q') return { year: parseInt(year), quarter: parseInt(quarter) } } if (period.includes('-') && period.length === 7) { // Monthly: "2025-01" const [year, month] = period.split('-') return { year: parseInt(year), month: parseInt(month) } } if (period.includes('/')) { // Fiscal year: "2024/2025" const [startYear, endYear] = period.split('/') return { startYear: parseInt(startYear), endYear: parseInt(endYear) } } // Annual: "2025" if (/^\d{4}$/.test(period)) { return { year: parseInt(period) } } return null } /** * Regenerate tax deadlines for a user after settings change */ export async function regenerateTaxDeadlinesForUser( supabase: SupabaseClient, companyId: string, newSettings: CompanySettingsForDeadlines ): Promise<{ created: number; deleted: number }> { const currentYear = new Date().getFullYear() return generateTaxDeadlinesForUser(supabase, companyId, newSettings, [currentYear, currentYear + 1]) } interface DeadlineSettingsRow extends Partial { company_id: string } interface UpcomingDeadlineCompanyRow { id: string company_id: string tax_deadline_type: string | null 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 // keep their type and period but carry a superseded statutory date, and the // repair loop must treat those as missing so they get regenerated. function deadlineIdentity( type: string | null, period: string | null, dueDate: string | null, ): string { return `${type}:${period}:${dueDate}` } // 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}` } export function getExpectedUpcomingDeadlineKeys( settings: CompanySettingsForDeadlines, years: number[] = [], fromDate: Date = new Date(), ): Set { if (years.length === 0) { const currentYear = fromDate.getFullYear() years = [currentYear, currentYear + 1] } const today = new Date(fromDate) today.setHours(0, 0, 0, 0) const keys = new Set() for (const config of getApplicableDeadlineConfigs(settings)) { const horizonEnd = horizonEndFor(config.type, today) for (const year of years) { for (const instance of config.generateDates(year, settings)) { const rawDate = new Date(instance.year, instance.month, instance.day) const adjustedDate = config.skipBankingDayAdjustment ? rawDate : adjustDeadlineToNextBankingDay(rawDate) // Same window as the generator: past rows and rows beyond the // rolling horizon are never expected. if (adjustedDate >= today && adjustedDate <= horizonEnd) { keys.add(deadlineIdentity(config.type, instance.period, formatDateISO(adjustedDate))) } } } } return keys } export function findSettingsMissingUpcomingDeadlines( settingsRows: DeadlineSettingsRow[], upcomingDeadlineRows: UpcomingDeadlineCompanyRow[], years: number[] = [], fromDate: Date = new Date(), ): DeadlineSettingsRow[] { const actualKeysByCompany = new Map>() const completedKeysByCompany = new Map>() for (const row of upcomingDeadlineRows) { const keys = actualKeysByCompany.get(row.company_id) ?? new Set() keys.add(deadlineIdentity(row.tax_deadline_type, row.tax_period, row.due_date)) actualKeysByCompany.set(row.company_id, keys) 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) } } return settingsRows.filter((settings) => { try { const expectedKeys = getExpectedUpcomingDeadlineKeys( toDeadlineSettings(settings), years, fromDate, ) const actualKeys = actualKeysByCompany.get(settings.company_id) ?? new Set() const completedKeys = completedKeysByCompany.get(settings.company_id) ?? new Set() return Array.from(expectedKeys).some((key) => { if (actualKeys.has(key)) return false // key is `${type}:${period}:${dueDate}`; strip the date to compare // against the completed set (periods never contain a colon). const typeAndPeriod = key.slice(0, key.lastIndexOf(':')) return !completedKeys.has(typeAndPeriod) }) } catch { // Include malformed settings so the repair loop logs the company-specific // generation error without aborting recovery for every other company. return true } }) } // Paginate: PostgREST silently caps a plain .select() at 1000 rows, which // would leave companies beyond the cap without deadlines. async function fetchAllDeadlineSettings(supabase: SupabaseClient): Promise { return fetchAllRows(({ from, to }) => supabase .from('company_settings') .select(DEADLINE_SETTINGS_SELECT) .order('company_id', { ascending: true }) .range(from, to), ) } /** * Generate tax deadlines for the new year for every company. */ export async function generateNewYearDeadlines( supabase: SupabaseClient ): Promise<{ usersProcessed: number; totalCreated: number }> { const newYear = new Date().getFullYear() const allSettings = await hydrateTaxAssessmentNotices( supabase, await fetchAllDeadlineSettings(supabase), ) let usersProcessed = 0 let totalCreated = 0 for (const settings of allSettings) { try { const result = await generateTaxDeadlinesForUser( supabase, settings.company_id, toDeadlineSettings(settings), [newYear, newYear + 1] ) usersProcessed++ totalCreated += result.created } catch (err) { log.error(`Error generating deadlines for company ${settings.company_id}:`, err) } } return { usersProcessed, totalCreated } } /** * Repair companies whose upcoming system tax deadlines are missing or carry * dates from superseded schedule logic. */ export async function backfillMissingTaxDeadlines( supabase: SupabaseClient, ): Promise<{ companiesScanned: number; companiesRepaired: number; totalCreated: number }> { // Window starts a year back, not today: completed rows with a superseded // (already passed) due date must still count as satisfied, otherwise the // repair loop flags the company forever while the generator (correctly) // refuses to recreate a filed obligation. Matches the generator's own // completed-row floor. const pastFloor = `${new Date().getFullYear() - 1}-01-01` const [rawSettings, upcomingDeadlineRows] = await Promise.all([ fetchAllDeadlineSettings(supabase), fetchAllRows(({ from, to }) => supabase .from('deadlines') .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) .order('id', { ascending: true }) .range(from, to), ), ]) const allSettings = await hydrateTaxAssessmentNotices(supabase, rawSettings) const missingSettings = findSettingsMissingUpcomingDeadlines(allSettings, upcomingDeadlineRows) let companiesRepaired = 0 let totalCreated = 0 for (const settings of missingSettings) { try { const result = await regenerateTaxDeadlinesForUser( supabase, settings.company_id, toDeadlineSettings(settings), ) companiesRepaired++ totalCreated += result.created } catch (err) { log.error(`Error repairing deadlines for company ${settings.company_id}:`, err) } } return { companiesScanned: allSettings.length, companiesRepaired, totalCreated, } }