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:
+111
-42
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user