fix(mcp): use canonical VAT deadlines (#1679)

* fix(mcp): use canonical VAT deadlines

* fix(mcp): handle incomplete VAT settings

* fix(mcp): block unknown VAT deadlines

* fix(mcp): tighten VAT tool description

* fix(mcp): fail closed on missing entity type

* ci: retry timed-out preview

* fix(vat): scope annual filing method requirement
This commit is contained in:
Mattsson
2026-08-18 17:22:52 +02:00
committed by GitHub
parent d0640e0968
commit 7d56e0ec01
13 changed files with 724 additions and 145 deletions
+111 -42
View File
@@ -83,6 +83,21 @@ export interface DeadlineInstance {
taxAssessmentNoticeId?: string
}
export interface VatDeadlineCalculationSettings {
vat_taxable_base_over_40m: boolean
entity_type?: EntityType | null
fiscal_year_start_month?: number | null
vat_has_eu_trade?: boolean | null
vat_filing_method?: TaxFilingMethod | null
}
interface AnnualVatDeadlineSettings {
entity_type: 'aktiebolag' | 'enskild_firma'
fiscal_year_start_month: number
vat_has_eu_trade: boolean
vat_filing_method?: TaxFilingMethod | null
}
/**
* Day-of-month of the nth Swedish banking day in a month (1-based n).
* Used for Intrastat, whose SCB reporting dates follow the ~10th working
@@ -111,7 +126,7 @@ function getFiscalYearLabel(fiscalYearEndMonth: number, fiscalYearEndYear: numbe
function getAnnualVatDeadline(
fiscalYearEndMonth: number,
fiscalYearEndYear: number,
settings: CompanySettingsForDeadlines,
settings: AnnualVatDeadlineSettings,
): { 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
@@ -146,25 +161,100 @@ function getAnnualVatDeadline(
return { day: paper ? 12 : 17, month: paper ? 6 : 7, year: fiscalYearEndYear + 1 }
}
/**
* Resolve the raw statutory VAT filing date for one reporting period.
* Banking-day adjustment is deliberately left to callers, matching the other
* deadline configs whose generated instances are adjusted by the generator.
*/
export function getVatDeadlineForPeriod(
periodType: 'monthly' | 'quarterly' | 'yearly',
year: number,
period: number,
settings: VatDeadlineCalculationSettings,
): DeadlineInstance | null {
if (periodType === 'monthly') {
if (period < 1 || period > 12) return null
const month = period - 1
const monthOffset = settings.vat_taxable_base_over_40m ? 1 : 2
const deadlineMonth = (month + monthOffset) % 12
const deadlineYear = year + Math.floor((month + monthOffset) / 12)
const day = settings.vat_taxable_base_over_40m
? 26
: (deadlineMonth === 0 || deadlineMonth === 7 ? 17 : 12)
return {
day,
month: deadlineMonth,
year: deadlineYear,
period: `${year}-${String(period).padStart(2, '0')}`,
periodLabel: getMonthLabel(month, year),
}
}
if (periodType === 'quarterly') {
if (period < 1 || period > 4) return null
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}` },
][period - 1]
}
if (period !== 1) return null
if (settings.entity_type !== 'aktiebolag' && settings.entity_type !== 'enskild_firma') {
return null
}
if (typeof settings.vat_has_eu_trade !== 'boolean') return null
if (
settings.entity_type === 'aktiebolag'
&& settings.vat_has_eu_trade === false
&& settings.vat_filing_method !== 'electronic'
&& settings.vat_filing_method !== 'paper'
) {
return null
}
const configuredFiscalYearStartMonth = settings.fiscal_year_start_month != null
&& settings.fiscal_year_start_month >= 1
&& settings.fiscal_year_start_month <= 12
? settings.fiscal_year_start_month
: null
if (settings.entity_type === 'aktiebolag' && configuredFiscalYearStartMonth === null) {
return null
}
const fiscalYearStartMonth = settings.entity_type === 'enskild_firma'
? 1
: configuredFiscalYearStartMonth!
const fiscalYearEndMonth = settings.entity_type === 'enskild_firma'
? 12
: (fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1)
const deadline = getAnnualVatDeadline(fiscalYearEndMonth, year, {
entity_type: settings.entity_type,
fiscal_year_start_month: fiscalYearStartMonth,
vat_has_eu_trade: settings.vat_has_eu_trade,
vat_filing_method: settings.vat_filing_method,
})
const fiscalYearLabel = getFiscalYearLabel(fiscalYearEndMonth, year)
return {
...deadline,
period: fiscalYearLabel,
periodLabel: fiscalYearLabel,
}
}
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,
})
const instance = getVatDeadlineForPeriod('yearly', fiscalYearEndYear, 1, settings)
if (instance?.year === deadlineYear) results.push(instance)
}
return results
@@ -182,27 +272,10 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [
condition: (s) => s.vat_registered && s.moms_period === 'monthly',
priority: 'important',
linkedReportType: 'vat',
generateDates: (year, settings) => {
const instances: DeadlineInstance[] = []
for (let month = 0; month < 12; month++) {
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,
year: deadlineYear,
period: `${year}-${String(month + 1).padStart(2, '0')}`,
periodLabel: getMonthLabel(month, year),
})
}
return instances
},
generateDates: (year, settings) => Array.from(
{ length: 12 },
(_, month) => getVatDeadlineForPeriod('monthly', year, month + 1, settings)!,
),
},
// Momsdeklaration (quarterly)
@@ -213,14 +286,10 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [
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}` },
]
},
generateDates: (year, settings) => Array.from(
{ length: 4 },
(_, quarter) => getVatDeadlineForPeriod('quarterly', year, quarter + 1, settings)!,
),
},
// Momsdeklaration (yearly)
+12
View File
@@ -47,6 +47,18 @@ describe('mostRecentEndedVatPeriod', () => {
})
})
it('keeps the M-2 period through an adjusted banking-day deadline', () => {
// 17 January 2026 is Saturday, so the deadline moves to Monday 19th.
expect(mostRecentEndedVatPeriod('monthly', new Date(2026, 0, 19))).toEqual({
year: 2025,
period: 11,
})
expect(mostRecentEndedVatPeriod('monthly', new Date(2026, 0, 20))).toEqual({
year: 2025,
period: 12,
})
})
it('over-40M filers (month M due the 26th of M+1) always get M-1', () => {
expect(
mostRecentEndedVatPeriod('monthly', new Date(2026, 7, 6), { over40m: true }),
+27 -13
View File
@@ -14,12 +14,22 @@
* most recently ended month the due one year-round.
*/
import { getVatDeadlineForPeriod } from '@/lib/tax/deadline-config'
import { adjustDeadlineToNextBankingDay } from '@/lib/tax/swedish-holidays'
export interface VatPeriodDefault {
year: number
/** 1-12 for monthly, 1-4 for quarterly. */
period: number
}
/** Return the monthly VAT period a fixed number of months before a month. */
function previousMonth(year: number, month: number, monthsBack: number): VatPeriodDefault {
const date = new Date(year, month - 1 - monthsBack, 1)
return { year: date.getFullYear(), period: date.getMonth() + 1 }
}
/** Select the latest ended VAT period that is currently relevant for filing. */
export function mostRecentEndedVatPeriod(
periodType: 'monthly' | 'quarterly',
today: Date = new Date(),
@@ -29,19 +39,23 @@ export function mostRecentEndedVatPeriod(
const month = today.getMonth() + 1
if (periodType === 'monthly') {
let m = month - 1
let y = year
if (!opts.over40m) {
// Deadline day of the CURRENT month for the M-2 declaration: 17 in
// January and August, else 12 (deadline-config huvudregel).
const deadlineDay = month === 1 || month === 8 ? 17 : 12
if (today.getDate() <= deadlineDay) m -= 1
}
while (m < 1) {
m += 12
y -= 1
}
return { year: y, period: m }
const mostRecentEnded = previousMonth(year, month, 1)
if (opts.over40m) return mostRecentEnded
const periodDueThisMonth = previousMonth(year, month, 2)
const deadline = getVatDeadlineForPeriod(
'monthly',
periodDueThisMonth.year,
periodDueThisMonth.period,
{ vat_taxable_base_over_40m: false },
)
const adjustedDeadline = deadline
? adjustDeadlineToNextBankingDay(new Date(deadline.year, deadline.month, deadline.day))
: null
const currentDate = new Date(year, month - 1, today.getDate())
return adjustedDeadline && currentDate <= adjustedDeadline
? periodDueThisMonth
: mostRecentEnded
}
const quarter = Math.ceil(month / 3)