Fix/build (#1041)
* 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:
co-authored by
Claude Fable 5
parent
1443235cec
commit
a5e37d3510
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user