* fix(bookkeeping): harden correction account changes

* feat(tax): enhance tax deadline generation with new settings and filing methods

- Added new company settings: tax_turnover_over_40m, vat_has_eu_trade, vat_filing_method, periodisk_sammanstallning_enabled, and periodisk_sammanstallning_filing_method.
- Updated deadline generation logic to accommodate new settings affecting VAT and employer declaration deadlines.
- Implemented tests for new functionality, ensuring that completed obligations are preserved and not replaced by new pending rows.
- Introduced a cron job to backfill missing tax deadlines for companies with settings but no upcoming deadlines.
- Updated API routes for generating tax deadlines and handling cron jobs.
- Modified database schema to include new columns for tax filing profiles and constraints for filing methods.

* fix(invoices): record credit note reconciliation guard

* fix(tax): correct automatic deadline settings

* fix(tax): key AGI deadline to VAT taxable base and add storforetag payment deadline

The 26th filing day for the skattedeklaration (AGI and VAT together) hinges
on one statutory measure, a VAT taxable base above SEK 40 million (SFL 26
kap.), not a separate employer turnover. Drop employer_turnover_over_40m and
derive the AGI schedule from vat_registered plus vat_taxable_base_over_40m,
so a non-VAT-reporting employer is never shown the 26th when its binding
date is the 12th.

Also:
- add a skatteinbetalning deadline row (12th, 17 January) for storforetag,
  whose deducted tax and employer contributions are due before the 26th
  filing date
- normalize legally incoherent over-40m flag combinations to the earlier
  small-company schedule in a follow-up migration
- replace hardcoded 27 December dates with the banking-day adjustment
- extend the 40m help text to cover the SKV-decided early filing election
  and the payment-still-on-the-12th rule
- document the regeneration race repaired by the daily backfill cron

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(migrations): add AGI and VAT filing logic with employer column removal

* feat(settings): implement VAT registration logic and update related flags; enhance deadline handling

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-17 00:52:57 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 1443235cec
commit a5e37d3510
32 changed files with 1978 additions and 259 deletions
+14
View File
@@ -1358,6 +1358,20 @@ describe('UpdateSettingsSchema', () => {
expect(result.success).toBe(true)
})
it('validates tax deadline filing-profile fields', () => {
expect(UpdateSettingsSchema.safeParse({
vat_taxable_base_over_40m: true,
vat_has_eu_trade: true,
vat_filing_method: 'electronic',
periodisk_sammanstallning_enabled: true,
periodisk_sammanstallning_filing_method: 'paper',
}).success).toBe(true)
expect(UpdateSettingsSchema.safeParse({ vat_filing_method: 'fax' }).success).toBe(false)
expect(UpdateSettingsSchema.safeParse({
periodisk_sammanstallning_filing_method: 'fax',
}).success).toBe(false)
})
it('rejects invalid email', () => {
const result = UpdateSettingsSchema.safeParse({ email: 'not-email' })
expect(result.success).toBe(false)
+7
View File
@@ -244,6 +244,7 @@ export const TaxDeadlineTypeSchema = z.enum([
'moms_yearly',
'f_skatt',
'arbetsgivardeklaration',
'skatteinbetalning',
'inkomstdeklaration_ef',
'inkomstdeklaration_ab',
'arsredovisning',
@@ -256,6 +257,7 @@ export const DeadlineSourceSchema = z.enum(['system', 'user'])
export const MomsPeriodSchema = z.enum(['monthly', 'quarterly', 'yearly'])
export const PsPeriodTypeSchema = z.enum(['monthly', 'quarterly'])
export const TaxFilingMethodSchema = z.enum(['electronic', 'paper'])
export const DocumentUploadSourceSchema = z.enum([
'camera', 'file_upload', 'email', 'e_invoice', 'scan', 'api', 'system',
@@ -1442,7 +1444,12 @@ export const UpdateSettingsSchema = z.object({
.nullable()
.optional(),
moms_period: MomsPeriodSchema.nullable().optional(),
vat_taxable_base_over_40m: z.boolean().optional(),
vat_has_eu_trade: z.boolean().optional(),
vat_filing_method: TaxFilingMethodSchema.optional(),
periodisk_sammanstallning_enabled: z.boolean().optional(),
periodisk_sammanstallning_period: PsPeriodTypeSchema.optional(),
periodisk_sammanstallning_filing_method: TaxFilingMethodSchema.optional(),
tax_contact_name: z.string().max(200).nullable().optional(),
tax_contact_phone: z.string().max(40).nullable().optional(),
tax_contact_email: z.string().email().nullable().optional().or(z.literal('')),
@@ -0,0 +1,134 @@
import { describe, expect, it } from 'vitest'
import {
changeCorrectionLineAccount,
getSelectableCorrectionCatalog,
} from '../correction-line-account'
const accounts = [
{
account_number: '2393',
account_name: 'Lån från närstående personer, långfristig del',
},
{
account_number: '2893',
account_name: 'Skulder till närstående personer, kortfristig del',
},
]
describe('changeCorrectionLineAccount', () => {
it('replaces a stale account-derived description when the account changes', () => {
const result = changeCorrectionLineAccount(
{
account_number: '2393',
line_description: 'Lån från närstående personer, långfristig del',
debit_amount: '',
credit_amount: '1000',
},
'2893',
accounts,
)
expect(result).toEqual({
account_number: '2893',
line_description: 'Skulder till närstående personer, kortfristig del',
debit_amount: '',
credit_amount: '1000',
})
})
it('preserves a user-authored line description', () => {
const result = changeCorrectionLineAccount(
{
account_number: '2393',
line_description: 'Lån enligt avtal 2026-07-01',
},
'2893',
accounts,
)
expect(result.line_description).toBe('Lån enligt avtal 2026-07-01')
})
it('uses the BAS catalogue when the new account is not active', () => {
const result = changeCorrectionLineAccount(
{
account_number: '2393',
line_description: 'Lån från närstående personer, långfristig del',
},
'2893',
accounts,
)
expect(result.line_description).toBe('Skulder till närstående personer, kortfristig del')
})
it('recognizes an account-derived description from a deactivated custom account', () => {
const result = changeCorrectionLineAccount(
{
account_number: '2997',
line_description: 'Avräkning projektägare',
},
'2893',
[
{ account_number: '2997', account_name: 'Avräkning projektägare' },
accounts[1],
],
)
expect(result.line_description).toBe('Skulder till närstående personer, kortfristig del')
})
it('prefers the company account name over a duplicate BAS catalogue name', () => {
const result = changeCorrectionLineAccount(
{
account_number: '2393',
line_description: 'Lån från närstående personer, långfristig del',
},
'2893',
[
accounts[0],
{ account_number: '2893', account_name: 'Avräkning ägare' },
accounts[1],
],
)
expect(result.line_description).toBe('Avräkning ägare')
})
it('fills a blank description from the selected account', () => {
const result = changeCorrectionLineAccount(
{ account_number: '2393', line_description: '' },
'2893',
accounts,
)
expect(result.line_description).toBe('Skulder till närstående personer, kortfristig del')
})
it('clears a derived description when the new account has no known name', () => {
const result = changeCorrectionLineAccount(
{
account_number: '2393',
line_description: 'Lån från närstående personer, långfristig del',
},
'9999',
accounts,
)
expect(result.line_description).toBe('')
})
})
describe('getSelectableCorrectionCatalog', () => {
it('excludes deliberately deactivated company accounts from catalogue choices', () => {
const result = getSelectableCorrectionCatalog(
[
{ account_number: '2393', account_name: 'Långfristigt lån', is_active: true },
{ account_number: '2893', account_name: 'Kortfristigt lån', is_active: false },
],
accounts,
)
expect(result).toEqual([accounts[0]])
})
})
@@ -0,0 +1,67 @@
import { foldText } from './account-search'
interface NamedAccount {
account_number: string
account_name: string
}
interface CompanyAccount extends NamedAccount {
is_active: boolean
}
interface CorrectionLineAccountFields {
account_number: string
line_description: string
}
function findAccountName(accountNumber: string, accounts: NamedAccount[]): string | undefined {
return accounts.find((account) => account.account_number === accountNumber)?.account_name
}
function namesMatch(left: string, right: string): boolean {
return foldText(left.trim()) === foldText(right.trim())
}
/**
* Keep deliberately deactivated company accounts out of catalogue-backed
* picker results. The correction service will seed missing BAS accounts, but
* it intentionally refuses to reactivate accounts the company disabled.
*/
export function getSelectableCorrectionCatalog<T extends NamedAccount>(
companyAccounts: CompanyAccount[],
catalog: T[],
): T[] {
const inactiveNumbers = new Set(
companyAccounts
.filter((account) => !account.is_active)
.map((account) => account.account_number),
)
return catalog.filter((account) => !inactiveNumbers.has(account.account_number))
}
/**
* Change the account on a correction line without carrying a stale account
* name into the posted replacement entry. Descriptions that do not match the
* previous account name are user-authored voucher text and stay untouched.
*/
export function changeCorrectionLineAccount<T extends CorrectionLineAccountFields>(
line: T,
nextAccountNumber: string,
accounts: NamedAccount[],
): T {
const currentDescription = line.line_description.trim()
const previousNames = accounts
.filter((account) => account.account_number === line.account_number)
.map((account) => account.account_name)
const descriptionIsAccountName = currentDescription.length === 0
|| previousNames.some((name) => namesMatch(currentDescription, name))
return {
...line,
account_number: nextAccountNumber,
line_description: descriptionIsAccountName
? findAccountName(nextAccountNumber, accounts) ?? ''
: line.line_description,
}
}
+1
View File
@@ -178,6 +178,7 @@ function getSwedishTaxTypeLabel(type: string): string {
moms_yearly: 'Momsdeklaration (år)',
f_skatt: 'F-skatt',
arbetsgivardeklaration: 'Arbetsgivardeklaration',
skatteinbetalning: 'Skatteinbetalning (storföretag)',
inkomstdeklaration_ef: 'Inkomstdeklaration EF',
inkomstdeklaration_ab: 'Inkomstdeklaration AB',
arsredovisning: 'Årsredovisning',
+44
View File
@@ -8,6 +8,15 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
}))
const deadlineMocks = vi.hoisted(() => ({
regenerate: vi.fn().mockResolvedValue({ created: 1, deleted: 0 }),
}))
vi.mock('@/lib/tax/deadline-generator', () => ({
regenerateTaxDeadlinesForUser: deadlineMocks.regenerate,
toDeadlineSettings: vi.fn((settings: Record<string, unknown>) => settings),
}))
// Keep the real CompanyContextError so instanceof checks in switchCompany
// see the same class the tests throw.
vi.mock('@/lib/company/context', async (importOriginal) => ({
@@ -83,6 +92,7 @@ function buildSupabase(opts: {
beforeEach(() => {
vi.clearAllMocks()
deadlineMocks.regenerate.mockResolvedValue({ created: 1, deleted: 0 })
})
describe('switchCompany', () => {
@@ -256,6 +266,40 @@ describe('createCompanyFromOnboarding: TIC snapshot persistence', () => {
const payload = snapshotUpdate!.args[0] as Record<string, unknown>
expect(payload.tic_snapshot).toEqual(ticLookup)
expect(payload.tic_snapshot_fetched_at).toBeDefined()
expect(deadlineMocks.regenerate).toHaveBeenCalledWith(
supabase,
'new-company-id',
expect.objectContaining({ entity_type: 'aktiebolag' }),
)
})
it('rolls back company creation when automatic deadlines cannot be created', async () => {
const { supabase, calls } = buildSupabase({
user: { id: 'user-1' },
rpcResults: {
create_company_with_owner: { data: 'new-company-id' },
seed_chart_of_accounts: { data: null },
},
})
mockCreateClient.mockResolvedValue(supabase as never)
deadlineMocks.regenerate.mockRejectedValueOnce(new Error('deadline insert failed'))
const result = await createCompanyFromOnboarding({
teamId: 'team-1',
settings: {
entity_type: 'aktiebolag',
company_name: 'Acme AB',
},
fiscalPeriod: {
startDate: '2026-01-01',
endDate: '2026-12-31',
name: 'Räkenskapsår 2026',
},
})
expect(result).toEqual({ error: 'Kunde inte skapa skattedeadlines. Försök igen.' })
expect(calls).toContainEqual(expect.objectContaining({ table: 'companies', method: 'delete' }))
expect(mockSetActiveCompany).not.toHaveBeenCalled()
})
it('skips the snapshot update when no ticLookup is supplied (manual signup)', async () => {
+40 -7
View File
@@ -5,6 +5,11 @@ import { setActiveCompany, CompanyContextError } from '@/lib/company/context'
import { revalidatePath } from 'next/cache'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import { normalizeVatNumber, isValidSwedishVatNumber, deriveSwedishVatNumber } from '@/lib/vat/vat-number'
import {
regenerateTaxDeadlinesForUser,
toDeadlineSettings,
} from '@/lib/tax/deadline-generator'
import type { CompanySettingsForDeadlines } from '@/lib/tax/deadline-config'
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
/**
@@ -122,14 +127,28 @@ async function createCompanyFromOnboardingImpl(params: {
return { error: 'Kunde inte skapa företag. Försök igen.' }
}
// Helper: roll back the company if a subsequent step fails. Deletes in FK order.
// Helper: roll back the company if a subsequent step fails. Deletes in FK
// order. Each delete is error-checked so a failed cleanup leaves a trace
// instead of silently stranding partial company data behind a generic
// "try again" message.
const rollback = async (reason: string, err: unknown) => {
console.error(`[createCompanyFromOnboarding] rolling back ${newCompanyId}: ${reason}`, err)
await supabase.from('company_settings').delete().eq('company_id', newCompanyId)
await supabase.from('fiscal_periods').delete().eq('company_id', newCompanyId)
await supabase.from('chart_of_accounts').delete().eq('company_id', newCompanyId)
await supabase.from('company_members').delete().eq('company_id', newCompanyId)
await supabase.from('companies').delete().eq('id', newCompanyId)
const deletions: Array<[table: string, run: () => PromiseLike<{ error: unknown }>]> = [
['company_settings', () => supabase.from('company_settings').delete().eq('company_id', newCompanyId)],
['fiscal_periods', () => supabase.from('fiscal_periods').delete().eq('company_id', newCompanyId)],
['chart_of_accounts', () => supabase.from('chart_of_accounts').delete().eq('company_id', newCompanyId)],
['company_members', () => supabase.from('company_members').delete().eq('company_id', newCompanyId)],
['companies', () => supabase.from('companies').delete().eq('id', newCompanyId)],
]
for (const [table, run] of deletions) {
const { error: deleteError } = await run()
if (deleteError) {
console.error(
`[createCompanyFromOnboarding] rollback delete failed for ${table} (company ${newCompanyId})`,
deleteError,
)
}
}
}
// Mirror the normalized org_number onto the companies row so future
@@ -237,7 +256,21 @@ async function createCompanyFromOnboardingImpl(params: {
return { error: 'Kunde inte skapa räkenskapsår. Försök igen.' }
}
// 5. Set as active company
// 5. Create the automatic tax deadlines while the onboarding data is still
// available. Treat this as part of company creation so a new company never
// starts in the broken state where valid settings exist without deadlines.
try {
await regenerateTaxDeadlinesForUser(
supabase,
newCompanyId,
toDeadlineSettings(settingsToSave as Partial<CompanySettingsForDeadlines>),
)
} catch (deadlineError) {
await rollback('tax deadline generation failed', deadlineError)
return { error: 'Kunde inte skapa skattedeadlines. Försök igen.' }
}
// 6. Set as active company
try {
await setActiveCompany(supabase, user.id, newCompanyId)
} catch (err) {
+141
View File
@@ -14,10 +14,151 @@ function makeSettings(overrides: Partial<CompanySettingsForDeadlines> = {}): Com
vat_registered: true,
pays_salaries: false,
fiscal_year_start_month: 1,
vat_taxable_base_over_40m: false,
vat_has_eu_trade: false,
vat_filing_method: 'electronic',
periodisk_sammanstallning_enabled: false,
periodisk_sammanstallning_period: 'monthly',
periodisk_sammanstallning_filing_method: 'electronic',
...overrides,
}
}
describe('VAT filing deadlines', () => {
it('uses the second following month for monthly filers at or below SEK 40 million', () => {
const dates = getConfig('moms_monthly').generateDates(2026, makeSettings({ moms_period: 'monthly' }))
expect(dates[0]).toMatchObject({ day: 12, month: 2, year: 2026, period: '2026-01' })
expect(dates[10]).toMatchObject({ day: 17, month: 0, year: 2027, period: '2026-11' })
})
it('uses the following month for monthly filers above SEK 40 million', () => {
const dates = getConfig('moms_monthly').generateDates(2026, makeSettings({
moms_period: 'monthly',
vat_taxable_base_over_40m: true,
}))
expect(dates[0]).toMatchObject({ day: 26, month: 1, year: 2026, period: '2026-01' })
// Raw date stays the 26th even in December; the banking-day adjustment
// in the generator moves annandag jul to Skatteverket's published 27th.
expect(dates[10]).toMatchObject({ day: 26, month: 11, year: 2026, period: '2026-11' })
})
it('uses May, August, November and February for quarterly VAT', () => {
const dates = getConfig('moms_quarterly').generateDates(2026, makeSettings())
expect(dates.map(({ day, month, year }) => ({ day, month, year }))).toEqual([
{ day: 12, month: 4, year: 2026 },
{ day: 17, month: 7, year: 2026 },
{ day: 12, month: 10, year: 2026 },
{ day: 12, month: 1, year: 2027 },
])
})
it('uses the entity, EU-trade and filing-method rules for yearly VAT', () => {
const config = getConfig('moms_yearly')
expect(config.generateDates(2027, makeSettings({
entity_type: 'enskild_firma',
moms_period: 'yearly',
}))[0]).toMatchObject({ day: 12, month: 4, year: 2027, period: '2026' })
expect(config.generateDates(2027, makeSettings({
entity_type: 'enskild_firma',
moms_period: 'yearly',
vat_has_eu_trade: true,
}))[0]).toMatchObject({ day: 26, month: 1, year: 2027, period: '2026' })
expect(config.generateDates(2027, makeSettings({
moms_period: 'yearly',
vat_filing_method: 'paper',
}))[0]).toMatchObject({ day: 12, month: 6, year: 2027, period: '2026' })
})
})
describe('monthly tax and employer deadlines', () => {
it('uses the 12th for F-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('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,
vat_taxable_base_over_40m: true,
}))
expect(dates.every((date) => date.day === 26)).toBe(true)
})
it('keeps the 12th for AGI when the employer does not report VAT', () => {
const dates = getConfig('arbetsgivardeklaration').generateDates(2026, makeSettings({
pays_salaries: true,
vat_registered: false,
vat_taxable_base_over_40m: true,
}))
expect(dates[1].day).toBe(12)
expect(dates[11].day).toBe(17) // December salaries are declared 17 January
})
})
describe('storföretag tax payment deadline', () => {
const config = getConfig('skatteinbetalning')
it('applies only to employers reporting VAT above SEK 40 million', () => {
expect(config.condition(makeSettings({ pays_salaries: true }))).toBe(false)
expect(config.condition(makeSettings({ vat_taxable_base_over_40m: true }))).toBe(false)
expect(config.condition(makeSettings({
pays_salaries: true,
vat_taxable_base_over_40m: true,
vat_registered: false,
}))).toBe(false)
expect(config.condition(makeSettings({
pays_salaries: true,
vat_taxable_base_over_40m: true,
}))).toBe(true)
})
it('is due the 12th of the following month, the 17th in January', () => {
const dates = config.generateDates(2026, makeSettings({
pays_salaries: true,
vat_taxable_base_over_40m: true,
}))
expect(dates).toHaveLength(12)
expect(dates[0]).toMatchObject({ day: 12, month: 1, year: 2026, period: '2026-01' })
expect(dates[6]).toMatchObject({ day: 12, month: 7, year: 2026, period: '2026-07' })
expect(dates[11]).toMatchObject({ day: 17, month: 0, year: 2027, period: '2026-12' })
})
})
describe('periodic EU sales list deadlines', () => {
const config = getConfig('periodisk_sammanstallning')
it('is only applicable when explicitly enabled', () => {
expect(config.condition(makeSettings())).toBe(false)
expect(config.condition(makeSettings({ periodisk_sammanstallning_enabled: true }))).toBe(true)
})
it('uses the 25th monthly for electronic filing', () => {
const dates = config.generateDates(2026, makeSettings({
periodisk_sammanstallning_enabled: true,
}))
expect(dates).toHaveLength(12)
expect(dates[0]).toMatchObject({ day: 25, month: 1, year: 2026, period: '2026-01' })
})
it('uses the 20th quarterly for paper filing', () => {
const dates = config.generateDates(2026, makeSettings({
periodisk_sammanstallning_enabled: true,
periodisk_sammanstallning_period: 'quarterly',
periodisk_sammanstallning_filing_method: 'paper',
}))
expect(dates).toHaveLength(4)
expect(dates[0]).toMatchObject({ day: 20, month: 3, year: 2026, period: '2026-Q1' })
})
})
describe('inkomstdeklaration_ab: digital filing deadlines', () => {
const config = getConfig('inkomstdeklaration_ab')
+147 -3
View File
@@ -1,6 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateTaxDeadlinesForUser, shouldRegenerateTaxDeadlines } from '../deadline-generator'
import {
findSettingsMissingUpcomingDeadlines,
generateTaxDeadlinesForUser,
getExpectedUpcomingDeadlineKeys,
shouldRegenerateTaxDeadlines,
} from '../deadline-generator'
import type { CompanySettingsForDeadlines } from '../deadline-config'
const SETTINGS: CompanySettingsForDeadlines = {
@@ -10,6 +15,12 @@ const SETTINGS: CompanySettingsForDeadlines = {
vat_registered: true,
pays_salaries: true,
fiscal_year_start_month: 1,
vat_taxable_base_over_40m: false,
vat_has_eu_trade: false,
vat_filing_method: 'electronic',
periodisk_sammanstallning_enabled: false,
periodisk_sammanstallning_period: 'monthly',
periodisk_sammanstallning_filing_method: 'electronic',
}
// Future year so generated dates are never skipped as "in the past".
@@ -20,12 +31,16 @@ const FUTURE_YEAR = new Date().getFullYear() + 1
* insert payload, so the tests can assert the insert-first/delete-after
* ordering that prevents regeneration failures from wiping deadlines.
*/
function makeRecordingSupabase(opts: { insertError?: { code: string; message: string } } = {}) {
function makeRecordingSupabase(opts: {
insertError?: { code: string; message: string }
completedRows?: Array<{ tax_deadline_type: string; tax_period: string }>
} = {}) {
const calls: string[] = []
let insertPayload: Array<Record<string, unknown>> | null = null
const from = vi.fn(() => {
const chain: Record<string, ReturnType<typeof vi.fn>> = {}
let isDelete = false
const self = () => chain
chain.insert = vi.fn((rows: Array<Record<string, unknown>>) => {
calls.push('insert')
@@ -40,6 +55,7 @@ function makeRecordingSupabase(opts: { insertError?: { code: string; message: st
})
chain.delete = vi.fn(() => {
calls.push('delete')
isDelete = true
return chain
})
chain.eq = vi.fn(self)
@@ -49,7 +65,16 @@ function makeRecordingSupabase(opts: { insertError?: { code: string; message: st
calls.push(`not(${String(args[2]).slice(0, 20)}…)`)
return chain
})
chain.select = vi.fn(async () => ({ data: [{ id: 'old-1' }, { id: 'old-2' }], error: null }))
chain.select = vi.fn(() => {
if (isDelete) {
return Promise.resolve({ data: [{ id: 'old-1' }, { id: 'old-2' }], error: null })
}
return chain
})
chain.then = vi.fn((resolve: (value: unknown) => unknown) => Promise.resolve({
data: opts.completedRows ?? [],
error: null,
}).then(resolve))
return chain
})
@@ -111,6 +136,125 @@ describe('generateTaxDeadlinesForUser', () => {
expect(calls).toContain('insert')
expect(calls).not.toContain('delete')
})
it('does not replace a completed future obligation with a new pending row', async () => {
const completedPeriod = `${FUTURE_YEAR}-01`
const { supabase, getInsertPayload } = makeRecordingSupabase({
completedRows: [{ tax_deadline_type: 'f_skatt', tax_period: completedPeriod }],
})
await generateTaxDeadlinesForUser(supabase, 'company-1', SETTINGS, [FUTURE_YEAR])
expect(getInsertPayload()).not.toContainEqual(expect.objectContaining({
tax_deadline_type: 'f_skatt',
tax_period: completedPeriod,
}))
})
})
describe('findSettingsMissingUpcomingDeadlines', () => {
const fromDate = new Date(2030, 0, 1)
const years = [2030]
function rowsFor(companyId: string, keys: Set<string>, isCompleted = false) {
return Array.from(keys, (key, index) => {
const [taxDeadlineType, taxPeriod, dueDate] = key.split(':')
return {
id: `${companyId}-${index}`,
company_id: companyId,
tax_deadline_type: taxDeadlineType,
tax_period: taxPeriod,
due_date: dueDate,
is_completed: isCompleted,
}
})
}
it('returns only companies missing at least one expected obligation', () => {
const settings = [
{ company_id: 'company-1', ...SETTINGS },
{ company_id: 'company-2', ...SETTINGS },
]
const completeRows = rowsFor(
'company-1',
getExpectedUpcomingDeadlineKeys(SETTINGS, years, fromDate),
)
expect(findSettingsMissingUpcomingDeadlines(
settings,
completeRows,
years,
fromDate,
)).toEqual([settings[1]])
})
it('repairs a company that has F-tax deadlines but is missing VAT deadlines', () => {
const settings = [{ company_id: 'company-1', ...SETTINGS }]
const expectedKeys = getExpectedUpcomingDeadlineKeys(SETTINGS, years, fromDate)
const fTaxRows = rowsFor(
'company-1',
new Set(Array.from(expectedKeys).filter((key) => key.startsWith('f_skatt:'))),
)
expect(findSettingsMissingUpcomingDeadlines(
settings,
fTaxRows,
years,
fromDate,
)).toEqual(settings)
})
it('repairs a company whose rows carry dates from a superseded schedule', () => {
const settings = [{ company_id: 'company-1', ...SETTINGS }]
const staleRows = rowsFor(
'company-1',
getExpectedUpcomingDeadlineKeys(SETTINGS, years, fromDate),
).map((row) => ({ ...row, due_date: '2030-12-31' }))
expect(findSettingsMissingUpcomingDeadlines(
settings,
staleRows,
years,
fromDate,
)).toEqual(settings)
})
it('treats a completed obligation as satisfied even with a superseded due date', () => {
// A filed (is_completed) row keeps its old statutory date. The generator
// never replaces completed rows, so flagging it by date would make the
// repair loop re-run for this company every day without converging.
const settings = [{ company_id: 'company-1', ...SETTINGS }]
const completedStaleRows = rowsFor(
'company-1',
getExpectedUpcomingDeadlineKeys(SETTINGS, years, fromDate),
true,
).map((row) => ({ ...row, due_date: '2029-01-15' }))
expect(findSettingsMissingUpcomingDeadlines(
settings,
completedStaleRows,
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)
// Only the F-tax obligations exist (completed); everything else is missing.
const completedFTaxRows = rowsFor(
'company-1',
new Set(Array.from(expectedKeys).filter((key) => key.startsWith('f_skatt:'))),
true,
)
expect(findSettingsMissingUpcomingDeadlines(
settings,
completedFTaxRows,
years,
fromDate,
)).toEqual(settings)
})
})
describe('shouldRegenerateTaxDeadlines', () => {
+224 -93
View File
@@ -3,7 +3,7 @@
* Based on Skatteverket's official deadline schedule
*/
import type { TaxDeadlineType, EntityType, MomsPeriod } from '@/types'
import type { TaxDeadlineType, EntityType, MomsPeriod, TaxFilingMethod } from '@/types'
// Condition function type for determining if a deadline applies
export type DeadlineCondition = (settings: CompanySettingsForDeadlines) => boolean
@@ -16,6 +16,12 @@ export interface CompanySettingsForDeadlines {
vat_registered: boolean
pays_salaries: boolean
fiscal_year_start_month: number // 1-12
vat_taxable_base_over_40m: boolean
vat_has_eu_trade: boolean
vat_filing_method: TaxFilingMethod
periodisk_sammanstallning_enabled: boolean
periodisk_sammanstallning_period: 'monthly' | 'quarterly'
periodisk_sammanstallning_filing_method: TaxFilingMethod
}
// Configuration for a single tax deadline type
@@ -40,6 +46,74 @@ export interface DeadlineInstance {
periodLabel: string // Human-readable, e.g., "Q1 2025", "januari 2025"
}
function getFiscalYearLabel(fiscalYearEndMonth: number, fiscalYearEndYear: number): string {
return fiscalYearEndMonth === 12
? `${fiscalYearEndYear}`
: `${fiscalYearEndYear - 1}/${fiscalYearEndYear}`
}
function getAnnualVatDeadline(
fiscalYearEndMonth: number,
fiscalYearEndYear: number,
settings: CompanySettingsForDeadlines,
): { day: number; month: number; year: number } {
// Enskild firma (calendar year only, BFL 3 kap.): without EU trade the
// annual momsdeklaration follows the income tax return (12 May); with EU
// trade it is due 26 February (26 kap. 33-33a §§ SFL, Skatteverket's
// published helårsmoms schedule).
if (settings.entity_type === 'enskild_firma') {
return settings.vat_has_eu_trade
? { day: 26, month: 1, year: fiscalYearEndYear + 1 }
: { day: 12, month: 4, year: fiscalYearEndYear + 1 }
}
if (settings.vat_has_eu_trade) {
const month = (fiscalYearEndMonth + 1) % 12
const year = fiscalYearEndYear + (fiscalYearEndMonth >= 11 ? 1 : 0)
// A 26 December due date lands on annandag jul; the banking-day
// adjustment moves it to Skatteverket's published 27th (or later).
return { day: 26, month, year }
}
const paper = settings.vat_filing_method === 'paper'
if (fiscalYearEndMonth <= 4) {
return { day: 12, month: paper ? 10 : 11, year: fiscalYearEndYear }
}
if (fiscalYearEndMonth <= 6) {
return paper
? { day: 27, month: 11, year: fiscalYearEndYear }
: { day: 17, month: 0, year: fiscalYearEndYear + 1 }
}
if (fiscalYearEndMonth <= 8) {
return { day: 12, month: paper ? 2 : 3, year: fiscalYearEndYear + 1 }
}
return { day: paper ? 12 : 17, month: paper ? 6 : 7, year: fiscalYearEndYear + 1 }
}
function generateAnnualVatDates(
deadlineYear: number,
settings: CompanySettingsForDeadlines,
): DeadlineInstance[] {
const fiscalYearEndMonth = settings.entity_type === 'enskild_firma'
? 12
: (settings.fiscal_year_start_month === 1 ? 12 : settings.fiscal_year_start_month - 1)
const results: DeadlineInstance[] = []
for (const fiscalYearEndYear of [deadlineYear - 1, deadlineYear]) {
const deadline = getAnnualVatDeadline(fiscalYearEndMonth, fiscalYearEndYear, settings)
if (deadline.year !== deadlineYear) continue
const period = getFiscalYearLabel(fiscalYearEndMonth, fiscalYearEndYear)
results.push({
...deadline,
period,
periodLabel: period,
})
}
return results
}
/**
* All tax deadline configurations
*/
@@ -52,90 +126,17 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [
condition: (s) => s.vat_registered && s.moms_period === 'monthly',
priority: 'important',
linkedReportType: 'vat',
generateDates: (year) => {
generateDates: (year, settings) => {
const instances: DeadlineInstance[] = []
// Due on the 12th of the following month
for (let month = 0; month < 12; month++) {
// Deadline for month X is on 12th of month X+1
const deadlineMonth = (month + 1) % 12
const deadlineYear = month === 11 ? year + 1 : year
instances.push({
day: 12,
month: deadlineMonth,
year: deadlineYear,
period: `${year}-${String(month + 1).padStart(2, '0')}`,
periodLabel: getMonthLabel(month, year),
})
}
return instances
},
},
// Momsdeklaration (quarterly) - e-tjänst deadline (26:e)
{
type: 'moms_quarterly',
titleTemplate: 'Momsdeklaration {periodLabel}',
description: 'Momsdeklaration för kvartalsredovisare (e-tjänst)',
condition: (s) => s.vat_registered && s.moms_period === 'quarterly',
priority: 'important',
linkedReportType: 'vat',
generateDates: (year) => {
// Q1 (Jan-Mar) -> 26 april
// Q2 (Apr-Jun) -> 26 juli
// Q3 (Jul-Sep) -> 26 oktober
// Q4 (Oct-Dec) -> 26 januari next year
return [
{ day: 26, month: 3, year, period: `${year}-Q1`, periodLabel: `Q1 ${year}` }, // April
{ day: 26, month: 6, year, period: `${year}-Q2`, periodLabel: `Q2 ${year}` }, // July
{ day: 26, month: 9, year, period: `${year}-Q3`, periodLabel: `Q3 ${year}` }, // October
{ day: 26, month: 0, year: year + 1, period: `${year}-Q4`, periodLabel: `Q4 ${year}` }, // January next year
]
},
},
// F-skatt (monthly)
{
type: 'f_skatt',
titleTemplate: 'F-skatt {periodLabel}',
description: 'Inbetalning av preliminär skatt',
condition: (s) => s.f_skatt,
priority: 'important',
linkedReportType: null,
generateDates: (year) => {
const instances: DeadlineInstance[] = []
// Due on the 17th of each month
for (let month = 0; month < 12; month++) {
instances.push({
day: 17,
month,
year,
period: `${year}-${String(month + 1).padStart(2, '0')}`,
periodLabel: getMonthLabel(month, year),
})
}
return instances
},
},
// Arbetsgivardeklaration (monthly, any employer with employees: AB or EF)
// Per Skatteförfarandelagen: every employer paying salary must file AGI monthly.
// Deadline: 12th of following month (17th in Jan/Aug for turnover ≤40 MSEK per agi-filing.md)
{
type: 'arbetsgivardeklaration',
titleTemplate: 'Arbetsgivardeklaration {periodLabel}',
description: 'Arbetsgivardeklaration för arbetsgivare med anställda',
condition: (s) => s.pays_salaries,
priority: 'important',
linkedReportType: null,
generateDates: (year) => {
const instances: DeadlineInstance[] = []
// Due on the 12th of the following month
// Exception: January (for Dec) and August (for Jul) = 17th for ≤40 MSEK turnover
for (let month = 0; month < 12; month++) {
const deadlineMonth = (month + 1) % 12
const deadlineYear = month === 11 ? year + 1 : year
// Jan (deadlineMonth=0) and Aug (deadlineMonth=7) get 17th
const day = (deadlineMonth === 0 || deadlineMonth === 7) ? 17 : 12
const monthOffset = settings.vat_taxable_base_over_40m ? 1 : 2
const deadlineMonth = (month + monthOffset) % 12
const deadlineYear = year + Math.floor((month + monthOffset) / 12)
// Above SEK 40M the 26th applies year-round; 26 December is annandag
// jul, and the banking-day adjustment yields Skatteverket's 27th.
const day = settings.vat_taxable_base_over_40m
? 26
: (deadlineMonth === 0 || deadlineMonth === 7 ? 17 : 12)
instances.push({
day,
month: deadlineMonth,
@@ -148,22 +149,152 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [
},
},
// Periodisk sammanställning (quarterly, EU sales)
// Momsdeklaration (quarterly)
{
type: 'moms_quarterly',
titleTemplate: 'Momsdeklaration {periodLabel}',
description: 'Momsdeklaration för kvartalsredovisare',
condition: (s) => s.vat_registered && s.moms_period === 'quarterly',
priority: 'important',
linkedReportType: 'vat',
generateDates: (year) => {
return [
{ day: 12, month: 4, year, period: `${year}-Q1`, periodLabel: `Q1 ${year}` },
{ day: 17, month: 7, year, period: `${year}-Q2`, periodLabel: `Q2 ${year}` },
{ day: 12, month: 10, year, period: `${year}-Q3`, periodLabel: `Q3 ${year}` },
{ day: 12, month: 1, year: year + 1, period: `${year}-Q4`, periodLabel: `Q4 ${year}` },
]
},
},
// Momsdeklaration (yearly)
{
type: 'moms_yearly',
titleTemplate: 'Momsdeklaration {periodLabel}',
description: 'Momsdeklaration för årsredovisare',
condition: (s) => s.vat_registered && s.moms_period === 'yearly',
priority: 'important',
linkedReportType: 'vat',
generateDates: (year, settings) => generateAnnualVatDates(year, settings),
},
// F-skatt (monthly)
{
type: 'f_skatt',
titleTemplate: 'F-skatt {periodLabel}',
description: 'Inbetalning av preliminär skatt',
condition: (s) => s.f_skatt,
priority: 'important',
linkedReportType: null,
generateDates: (year) => {
const instances: DeadlineInstance[] = []
for (let month = 0; month < 12; month++) {
instances.push({
day: month === 0 || month === 7 ? 17 : 12,
month,
year,
period: `${year}-${String(month + 1).padStart(2, '0')}`,
periodLabel: getMonthLabel(month, year),
})
}
return instances
},
},
// Arbetsgivardeklaration (monthly, any employer with employees: AB or EF)
// Per Skatteförfarandelagen: every employer paying salary must file AGI monthly.
// The filing day is keyed to the VAT taxable base, not a separate employer
// measure (SFL 26 kap.): above SEK 40M the whole skattedeklaration (AGI and
// VAT) is due the 26th of the following month; otherwise the 12th (17th in
// January and August). Employers without VAT reporting follow the same
// 12th/17th small-company schedule.
{
type: 'arbetsgivardeklaration',
titleTemplate: 'Arbetsgivardeklaration {periodLabel}',
description: 'Arbetsgivardeklaration för arbetsgivare med anställda',
condition: (s) => s.pays_salaries,
priority: 'important',
linkedReportType: null,
generateDates: (year, settings) => {
const storforetag = settings.vat_registered && settings.vat_taxable_base_over_40m
const instances: DeadlineInstance[] = []
for (let month = 0; month < 12; month++) {
const deadlineMonth = (month + 1) % 12
const deadlineYear = month === 11 ? year + 1 : year
const day = storforetag
? 26
: (deadlineMonth === 0 || deadlineMonth === 7 ? 17 : 12)
instances.push({
day,
month: deadlineMonth,
year: deadlineYear,
period: `${year}-${String(month + 1).padStart(2, '0')}`,
periodLabel: getMonthLabel(month, year),
})
}
return instances
},
},
// Skatteinbetalning (storföretag): companies above the SEK 40M VAT taxable
// base file the skattedeklaration on the 26th but must still have deducted
// tax and employer contributions paid into skattekontot by the 12th (17th
// in January). Without this row the 26th filing date hides a payment
// deadline two weeks earlier.
{
type: 'skatteinbetalning',
titleTemplate: 'Betala skatt och arbetsgivaravgifter {periodLabel}',
description: 'Inbetalning av avdragen skatt och arbetsgivaravgifter för företag med beskattningsunderlag över 40 miljoner kronor',
condition: (s) => s.pays_salaries && s.vat_registered && s.vat_taxable_base_over_40m,
priority: 'important',
linkedReportType: null,
generateDates: (year) => {
const instances: DeadlineInstance[] = []
for (let month = 0; month < 12; month++) {
const deadlineMonth = (month + 1) % 12
const deadlineYear = month === 11 ? year + 1 : year
instances.push({
// Deliberately January-only: the 17 August exception applies to the
// small-company (below SEK 40M) schedule. Storföretag payment dates
// are the 12th every month except January (62 kap. 3 § SFL and
// Skatteverket's published storföretag calendar).
day: deadlineMonth === 0 ? 17 : 12,
month: deadlineMonth,
year: deadlineYear,
period: `${year}-${String(month + 1).padStart(2, '0')}`,
periodLabel: getMonthLabel(month, year),
})
}
return instances
},
},
// Periodisk sammanställning (EU sales)
{
type: 'periodisk_sammanstallning',
titleTemplate: 'Periodisk sammanställning {periodLabel}',
description: 'Periodisk sammanställning för EU-försäljning',
condition: (s) => s.vat_registered, // Simplified - in reality depends on EU sales
condition: (s) => s.vat_registered && s.periodisk_sammanstallning_enabled,
priority: 'normal',
linkedReportType: null,
generateDates: (year) => {
// Q1 -> 20 april, Q2 -> 20 juli, Q3 -> 20 oktober, Q4 -> 20 januari
return [
{ day: 20, month: 3, year, period: `${year}-Q1`, periodLabel: `Q1 ${year}` },
{ day: 20, month: 6, year, period: `${year}-Q2`, periodLabel: `Q2 ${year}` },
{ day: 20, month: 9, year, period: `${year}-Q3`, periodLabel: `Q3 ${year}` },
{ day: 20, month: 0, year: year + 1, period: `${year}-Q4`, periodLabel: `Q4 ${year}` },
]
generateDates: (year, settings) => {
const day = settings.periodisk_sammanstallning_filing_method === 'paper' ? 20 : 25
if (settings.periodisk_sammanstallning_period === 'quarterly') {
return [
{ day, month: 3, year, period: `${year}-Q1`, periodLabel: `Q1 ${year}` },
{ day, month: 6, year, period: `${year}-Q2`, periodLabel: `Q2 ${year}` },
{ day, month: 9, year, period: `${year}-Q3`, periodLabel: `Q3 ${year}` },
{ day, month: 0, year: year + 1, period: `${year}-Q4`, periodLabel: `Q4 ${year}` },
]
}
return Array.from({ length: 12 }, (_, month) => ({
day,
month: (month + 1) % 12,
year: month === 11 ? year + 1 : year,
period: `${year}-${String(month + 1).padStart(2, '0')}`,
periodLabel: getMonthLabel(month, year),
}))
},
},
+267 -37
View File
@@ -25,8 +25,17 @@ export const TAX_RELEVANT_FIELDS = [
'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
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
/**
* Check if any tax-relevant fields changed
*/
@@ -42,6 +51,34 @@ export function didTaxFieldsChange(
return false
}
export function hasTaxRelevantFields(body: Record<string, unknown>): boolean {
return TAX_RELEVANT_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(body, field))
}
export function toDeadlineSettings(
settings: Partial<CompanySettingsForDeadlines>,
): 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,
vat_registered: settings.vat_registered ?? false,
pays_salaries: settings.pays_salaries ?? 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',
}
}
/**
* Decide whether a settings save should (re)generate tax deadlines.
*
@@ -87,8 +124,37 @@ export async function generateTaxDeadlinesForUser(
// Get applicable deadline configs based on settings
const applicableConfigs = getApplicableDeadlineConfigs(settings)
const startDate = `${Math.min(...years)}-01-01`
const endDate = `${Math.max(...years)}-12-31`
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. 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.
const completedFloor = `${Math.min(...years) - 1}-01-01`
const { data: completedRows, error: completedRowsError } = await supabase
.from('deadlines')
.select('tax_deadline_type, tax_period')
.eq('company_id', companyId)
.eq('source', 'system')
.eq('is_completed', true)
.gte('due_date', completedFloor)
if (completedRowsError) {
log.error('Error fetching completed deadlines:', completedRowsError)
throw completedRowsError
}
const completedKeys = new Set(
(completedRows ?? []).map(
(row: { tax_deadline_type: string | null; tax_period: string | null }) =>
`${row.tax_deadline_type}:${row.tax_period}`,
),
)
// Generate new deadlines
const deadlines: Array<{
@@ -121,12 +187,15 @@ export async function generateTaxDeadlinesForUser(
const dueDate = formatDateISO(adjustedDate)
// Skip if the deadline is in the past
const today = new Date()
today.setHours(0, 0, 0, 0)
if (adjustedDate < today) {
continue
}
const deadlineKey = `${config.type}:${instance.period}`
if (completedKeys.has(deadlineKey)) {
continue
}
// Determine initial status based on days until deadline
const daysUntil = Math.ceil((adjustedDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
const status: DeadlineStatus = daysUntil <= 14 ? 'action_needed' : 'upcoming'
@@ -157,15 +226,29 @@ export async function generateTaxDeadlinesForUser(
}
}
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
// 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 (deadlines.length > 0) {
if (uniqueDeadlines.length > 0) {
const { data: insertedData, error: insertError } = await supabase
.from('deadlines')
.insert(deadlines)
.insert(uniqueDeadlines)
.select('id')
if (insertError) {
@@ -182,7 +265,8 @@ export async function generateTaxDeadlinesForUser(
.delete()
.eq('company_id', companyId)
.eq('source', 'system')
.gte('due_date', startDate)
.eq('is_completed', false)
.gte('due_date', todayIso)
.lte('due_date', endDate)
if (newIds.length > 0) {
@@ -197,7 +281,7 @@ export async function generateTaxDeadlinesForUser(
}
return {
created: deadlines.length,
created: uniqueDeadlines.length,
deleted: deletedData?.length || 0,
}
}
@@ -250,32 +334,133 @@ export async function regenerateTaxDeadlinesForUser(
return generateTaxDeadlinesForUser(supabase, companyId, newSettings, [currentYear, currentYear + 1])
}
interface DeadlineSettingsRow extends Partial<CompanySettingsForDeadlines> {
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
}
// 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 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.
function completedIdentity(type: string | null, period: string | null): string {
return `${type}:${period}`
}
export function getExpectedUpcomingDeadlineKeys(
settings: CompanySettingsForDeadlines,
years: number[] = [],
fromDate: Date = new Date(),
): Set<string> {
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<string>()
for (const config of getApplicableDeadlineConfigs(settings)) {
for (const year of years) {
for (const instance of config.generateDates(year, settings)) {
const adjustedDate = adjustDeadlineToNextBankingDay(
new Date(instance.year, instance.month, instance.day),
)
if (adjustedDate >= today) {
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<string, Set<string>>()
const completedKeysByCompany = new Map<string, Set<string>>()
for (const row of upcomingDeadlineRows) {
const keys = actualKeysByCompany.get(row.company_id) ?? new Set<string>()
keys.add(deadlineIdentity(row.tax_deadline_type, row.tax_period, row.due_date))
actualKeysByCompany.set(row.company_id, keys)
if (row.is_completed) {
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)
}
}
return settingsRows.filter((settings) => {
try {
const expectedKeys = getExpectedUpcomingDeadlineKeys(
toDeadlineSettings(settings),
years,
fromDate,
)
const actualKeys = actualKeysByCompany.get(settings.company_id) ?? new Set<string>()
const completedKeys = completedKeysByCompany.get(settings.company_id) ?? new Set<string>()
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<DeadlineSettingsRow[]> {
return fetchAllRows<DeadlineSettingsRow>(({ 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 (called by annual cron job)
* 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()
// Fetch all companies with company settings. Paginate: PostgREST silently
// caps a plain .select() at 1000 rows, which would leave companies beyond the
// cap without next-year deadlines every January.
const allSettings = await fetchAllRows<{
company_id: string
entity_type: CompanySettingsForDeadlines['entity_type']
moms_period: CompanySettingsForDeadlines['moms_period']
f_skatt: boolean
vat_registered: boolean
pays_salaries: boolean | null
fiscal_year_start_month: number
}>(({ from, to }) =>
supabase
.from('company_settings')
.select('company_id, entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month')
.order('company_id', { ascending: true })
.range(from, to)
)
const allSettings = await fetchAllDeadlineSettings(supabase)
let usersProcessed = 0
let totalCreated = 0
@@ -285,14 +470,7 @@ export async function generateNewYearDeadlines(
const result = await generateTaxDeadlinesForUser(
supabase,
settings.company_id,
{
entity_type: settings.entity_type,
moms_period: settings.moms_period,
f_skatt: settings.f_skatt,
vat_registered: settings.vat_registered,
pays_salaries: settings.pays_salaries ?? false,
fiscal_year_start_month: settings.fiscal_year_start_month,
},
toDeadlineSettings(settings),
[newYear, newYear + 1]
)
usersProcessed++
@@ -304,3 +482,55 @@ export async function generateNewYearDeadlines(
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 [allSettings, upcomingDeadlineRows] = await Promise.all([
fetchAllDeadlineSettings(supabase),
fetchAllRows<UpcomingDeadlineCompanyRow>(({ from, to }) =>
supabase
.from('deadlines')
.select('id, company_id, tax_deadline_type, tax_period, due_date, is_completed')
.eq('source', 'system')
.eq('deadline_type', 'tax')
.gte('due_date', pastFloor)
.order('id', { ascending: true })
.range(from, to),
),
])
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,
}
}