e11f70b347
* refactor: optimize page loading and data fetching * fix: resolve recurring production runtime errors * feat: add MCP company and customer updates * fix: handle year-end tax adjustments * feat: harden annual report compliance * fix: expand invoice logo and font support * fix: sanitize API route error responses * fix: sanitize user-facing error messages * feat: persist onboarding and tax assessment notices * fix: reduce cloud backup audit churn * feat: refine invoice editor layout * fix: show saved tax adjustments in INK2 * fix: complete annual report API mappings * docs: record operational safeguards and decisions * fix: harden annual report review findings * fix: adjust column span for description based on VAT registration * New css class name
822 lines
32 KiB
TypeScript
822 lines
32 KiB
TypeScript
/**
|
|
* Static configuration of all Swedish tax deadlines (Skatteverket)
|
|
* Based on Skatteverket's official deadline schedule
|
|
*/
|
|
|
|
import type { TaxDeadlineType, EntityType, MomsPeriod, TaxFilingMethod } from '@/types'
|
|
import { isBankingDay } from './swedish-holidays'
|
|
|
|
// Condition function type for determining if a deadline applies
|
|
export type DeadlineCondition = (settings: CompanySettingsForDeadlines) => boolean
|
|
|
|
export interface TaxAssessmentNoticeForDeadline {
|
|
id: string
|
|
fiscalPeriodName: string
|
|
decisionType: 'final' | 'reassessment'
|
|
paymentDueDate: string
|
|
}
|
|
|
|
// Subset of company settings needed for deadline generation
|
|
export interface CompanySettingsForDeadlines {
|
|
entity_type: EntityType
|
|
moms_period: MomsPeriod | null
|
|
f_skatt: boolean
|
|
preliminary_tax_monthly: number | null
|
|
vat_registered: boolean
|
|
pays_salaries: boolean
|
|
// null = never attested; the generator falls back to pays_salaries so
|
|
// rows saved before the registration flag existed keep their deadlines.
|
|
employer_registered: boolean | null
|
|
employer_seasonal: 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
|
|
kontrolluppgifter_enabled: boolean
|
|
rot_rut_enabled: boolean
|
|
oss_enabled: boolean
|
|
ioss_enabled: boolean
|
|
intrastat_enabled: boolean
|
|
punktskatt_enabled: boolean
|
|
fyllnadsinbetalning_enabled: boolean
|
|
/**
|
|
* Derived, NOT a company_settings column: distinct years with paid ROT/RUT
|
|
* invoices. Populated by the generator from the invoices table (a begäran
|
|
* deadline for year Y only exists when Y actually has ROT/RUT payments,
|
|
* Lag 2009:194 8 §). Undefined in pure-settings contexts (backfill
|
|
* detection), where rot_rut_begaran rows are simply never expected.
|
|
*/
|
|
rot_rut_payment_years?: number[]
|
|
/** Derived from active tax_assessment_notices rows by the generator. */
|
|
tax_assessment_notices?: TaxAssessmentNoticeForDeadline[]
|
|
}
|
|
|
|
// Configuration for a single tax deadline type
|
|
export interface TaxDeadlineConfig {
|
|
type: TaxDeadlineType
|
|
titleTemplate: string
|
|
description: string
|
|
condition: DeadlineCondition
|
|
priority: 'critical' | 'important' | 'normal'
|
|
// Function to generate all instances for a year
|
|
generateDates: (year: number, settings: CompanySettingsForDeadlines) => DeadlineInstance[]
|
|
// Link to report type for navigation
|
|
linkedReportType: string | null
|
|
/**
|
|
* EU-law deadlines (OSS/IOSS) do not move to the next banking day: the
|
|
* last day of the month stands even on weekends and holidays. Also set
|
|
* for dates that are already computed as banking days (Intrastat).
|
|
*/
|
|
skipBankingDayAdjustment?: boolean
|
|
}
|
|
|
|
// A specific instance of a deadline
|
|
export interface DeadlineInstance {
|
|
day: number // Day of month
|
|
month: number // 0-indexed month
|
|
year: number
|
|
period: string // e.g., "2025-Q1", "2025-01", "2025"
|
|
periodLabel: string // Human-readable, e.g., "Q1 2025", "januari 2025"
|
|
taxAssessmentNoticeId?: string
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* day of the month after the reference month.
|
|
*/
|
|
function nthBankingDayOfMonth(year: number, month: number, n: number): number {
|
|
let count = 0
|
|
for (let day = 1; day <= 31; day++) {
|
|
const date = new Date(year, month, day)
|
|
if (date.getMonth() !== month) break
|
|
if (isBankingDay(date)) {
|
|
count++
|
|
if (count === n) return day
|
|
}
|
|
}
|
|
// A month always has more than 10 banking days; never reached.
|
|
return 28
|
|
}
|
|
|
|
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
|
|
*/
|
|
export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [
|
|
// Momsdeklaration (monthly)
|
|
{
|
|
type: 'moms_monthly',
|
|
titleTemplate: 'Momsdeklaration {periodLabel}',
|
|
description: 'Momsdeklaration för månadsredovisare',
|
|
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
|
|
},
|
|
},
|
|
|
|
// 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),
|
|
},
|
|
|
|
// Debiterad preliminärskatt (monthly payment). Gated on the debited amount,
|
|
// NOT on F-skatt approval: approval is a status with no recurring duty, and
|
|
// Skatteverket debits nothing below 2 400 kr/år (SFL 55 kap. 2 §). The
|
|
// monthly payment obligation exists only while an amount > 0 is debited
|
|
// (SFL 62 kap. 4-5 §§).
|
|
{
|
|
type: 'f_skatt',
|
|
titleTemplate: 'Betala preliminärskatt {periodLabel}',
|
|
description: 'Inbetalning av debiterad preliminärskatt',
|
|
condition: (s) => (s.preliminary_tax_monthly ?? 0) > 0,
|
|
priority: 'important',
|
|
linkedReportType: null,
|
|
generateDates: (year, settings) => {
|
|
// Small-company förfallodagar are the 12th, with the 17th in January
|
|
// and August; storföretag (VAT taxable base over SEK 40M) keep the
|
|
// 12th in August, January-only 17th (62 kap. 3-4 §§ SFL and
|
|
// Skatteverket's published storföretag calendar).
|
|
const storforetag = settings.vat_registered && settings.vat_taxable_base_over_40m
|
|
const instances: DeadlineInstance[] = []
|
|
for (let month = 0; month < 12; month++) {
|
|
instances.push({
|
|
day: month === 0 || (month === 7 && !storforetag) ? 17 : 12,
|
|
month,
|
|
year,
|
|
period: `${year}-${String(month + 1).padStart(2, '0')}`,
|
|
periodLabel: getMonthLabel(month, year),
|
|
})
|
|
}
|
|
return instances
|
|
},
|
|
},
|
|
|
|
// Arbetsgivardeklaration (monthly). A REGISTERED employer must file AGI
|
|
// every month, including nil months (SFL 26 kap. 3 §): the gate is
|
|
// registration, not whether salaries were paid, with pays_salaries as a
|
|
// fallback for settings saved before the registration flag existed.
|
|
// Säsongsregistrerade employers file only for months with payments plus a
|
|
// December nil declaration when nothing was paid all year, so they get
|
|
// only the December-period row; payment months are handled by the salary
|
|
// flow itself.
|
|
// 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 registrerade arbetsgivare',
|
|
condition: (s) => s.employer_registered ?? 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++) {
|
|
if (settings.employer_seasonal && month !== 11) continue
|
|
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.employer_registered ?? 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 && s.periodisk_sammanstallning_enabled,
|
|
priority: 'normal',
|
|
linkedReportType: null,
|
|
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),
|
|
}))
|
|
},
|
|
},
|
|
|
|
// OSS (unionsordningen): quarterly declaration for B2C distance sales
|
|
// above the EUR 10 000 threshold, filed in Skatteverket's OSS portal
|
|
// (ML 22 kap., Art. 369f VAT directive). Due the last day of the month
|
|
// after the quarter. EU-law deadline: it does NOT move to the next
|
|
// banking day; a Sunday 31st stands.
|
|
{
|
|
type: 'oss_quarterly',
|
|
titleTemplate: 'OSS-deklaration {periodLabel}',
|
|
description: 'OSS-deklaration (unionsordningen) för EU-försäljning till konsumenter',
|
|
condition: (s) => s.vat_registered && s.oss_enabled,
|
|
priority: 'important',
|
|
linkedReportType: null,
|
|
skipBankingDayAdjustment: true,
|
|
generateDates: (year) => [
|
|
{ day: 30, month: 3, year, period: `${year}-Q1`, periodLabel: `Q1 ${year}` },
|
|
{ day: 31, month: 6, year, period: `${year}-Q2`, periodLabel: `Q2 ${year}` },
|
|
{ day: 31, month: 9, year, period: `${year}-Q3`, periodLabel: `Q3 ${year}` },
|
|
{ day: 31, month: 0, year: year + 1, period: `${year}-Q4`, periodLabel: `Q4 ${year}` },
|
|
],
|
|
},
|
|
|
|
// IOSS (importordningen): monthly declaration for distance sales of
|
|
// imported low-value goods (Art. 369s VAT directive). Due the last day
|
|
// of the following month; same EU no-shift rule as OSS. Unlike OSS the
|
|
// scheme does not require Swedish VAT registration (Art. 369s applies to
|
|
// registered IOSS sellers regardless), so the opt-in flag stands alone.
|
|
{
|
|
type: 'ioss_monthly',
|
|
titleTemplate: 'IOSS-deklaration {periodLabel}',
|
|
description: 'IOSS-deklaration (importordningen) för distansförsäljning av importerade varor',
|
|
condition: (s) => s.ioss_enabled,
|
|
priority: 'important',
|
|
linkedReportType: null,
|
|
skipBankingDayAdjustment: true,
|
|
generateDates: (year) =>
|
|
Array.from({ length: 12 }, (_, month) => {
|
|
const deadlineYear = month === 11 ? year + 1 : year
|
|
const deadlineMonth = (month + 1) % 12
|
|
// Last day of the month after the reference month.
|
|
const day = new Date(deadlineYear, deadlineMonth + 1, 0).getDate()
|
|
return {
|
|
day,
|
|
month: deadlineMonth,
|
|
year: deadlineYear,
|
|
period: `${year}-${String(month + 1).padStart(2, '0')}`,
|
|
periodLabel: getMonthLabel(month, year),
|
|
}
|
|
}),
|
|
},
|
|
|
|
// Intrastat: SCB's monthly trade-in-goods report for companies above the
|
|
// arrival/dispatch thresholds. SCB publishes exact dates yearly; they
|
|
// follow the ~10th working day of the month after the reference month,
|
|
// which is what we compute. Already a banking day, so no adjustment.
|
|
{
|
|
type: 'intrastat_monthly',
|
|
titleTemplate: 'Intrastat {periodLabel}',
|
|
description: 'Intrastat-rapport till SCB för varuhandel inom EU',
|
|
condition: (s) => s.vat_registered && s.intrastat_enabled,
|
|
priority: 'normal',
|
|
linkedReportType: null,
|
|
skipBankingDayAdjustment: true,
|
|
generateDates: (year) =>
|
|
Array.from({ length: 12 }, (_, month) => {
|
|
const deadlineYear = month === 11 ? year + 1 : year
|
|
const deadlineMonth = (month + 1) % 12
|
|
return {
|
|
day: nthBankingDayOfMonth(deadlineYear, deadlineMonth, 10),
|
|
month: deadlineMonth,
|
|
year: deadlineYear,
|
|
period: `${year}-${String(month + 1).padStart(2, '0')}`,
|
|
periodLabel: getMonthLabel(month, year),
|
|
}
|
|
}),
|
|
},
|
|
|
|
// Punktskattedeklaration (monthly): excise duties follow the ordinary
|
|
// skattedeklaration schedule (SFL 26 kap.): the 12th of the following
|
|
// month (17th in January and August), the 26th for storföretag.
|
|
{
|
|
type: 'punktskatt_monthly',
|
|
titleTemplate: 'Punktskattedeklaration {periodLabel}',
|
|
description: 'Punktskattedeklaration för punktskattepliktiga företag',
|
|
condition: (s) => s.punktskatt_enabled,
|
|
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
|
|
},
|
|
},
|
|
|
|
// Fyllnadsinbetalning: extra preliminary tax payments that stop
|
|
// kostnadsränta on the coming kvarskatt (SFL 62 kap. 8 §, 65 kap.).
|
|
// Parts over 30 000 kr must be on skattekontot by the 12th of the second
|
|
// month after the beskattningsår ends (12 Feb for calendar years); the
|
|
// remainder by the 3rd of the fifth month (3 May). Both dates are
|
|
// generated since the app cannot know the kvarskatt amount; the labels
|
|
// say which part each date covers.
|
|
{
|
|
type: 'fyllnadsinbetalning',
|
|
titleTemplate: 'Fyllnadsinbetalning {periodLabel}',
|
|
description: 'Extra inbetalning av preliminärskatt för att undvika kostnadsränta',
|
|
condition: (s) => s.fyllnadsinbetalning_enabled,
|
|
priority: 'normal',
|
|
linkedReportType: null,
|
|
generateDates: (year, settings) => {
|
|
const fyEndMonth = settings.entity_type === 'enskild_firma'
|
|
? 12
|
|
: (settings.fiscal_year_start_month === 1 ? 12 : settings.fiscal_year_start_month - 1)
|
|
const results: DeadlineInstance[] = []
|
|
for (const fyEndYear of [year - 1, year]) {
|
|
const fyLabel = getFiscalYearLabel(fyEndMonth, fyEndYear)
|
|
// fyEndMonth is 1-indexed; (fyEndMonth - 1 + n) is the 0-indexed
|
|
// month n months after FY end, counted from fyEndYear's January.
|
|
// 12th of the second month after FY end (amounts over 30 000 kr):
|
|
// February for a calendar fiscal year.
|
|
const over = {
|
|
day: 12,
|
|
month: (fyEndMonth + 1) % 12,
|
|
year: fyEndYear + Math.floor((fyEndMonth + 1) / 12),
|
|
}
|
|
if (over.year === year) {
|
|
results.push({
|
|
...over,
|
|
period: `${fyLabel}-over30k`,
|
|
periodLabel: `belopp över 30 000 kr, beskattningsår ${fyLabel}`,
|
|
})
|
|
}
|
|
// 3rd of the fifth month after FY end (the remainder): May for a
|
|
// calendar fiscal year.
|
|
const rest = {
|
|
day: 3,
|
|
month: (fyEndMonth + 4) % 12,
|
|
year: fyEndYear + Math.floor((fyEndMonth + 4) / 12),
|
|
}
|
|
if (rest.year === year) {
|
|
results.push({
|
|
...rest,
|
|
period: `${fyLabel}-rest`,
|
|
periodLabel: `resterande belopp, beskattningsår ${fyLabel}`,
|
|
})
|
|
}
|
|
}
|
|
return results
|
|
},
|
|
},
|
|
|
|
// Kvarskatt: the payment date is copied exactly from the final tax notice
|
|
// or reassessment decision. It must not be estimated or moved to a banking
|
|
// day because Skatteverket has already determined the statutory due date.
|
|
{
|
|
type: 'kvarskatt',
|
|
titleTemplate: 'Kvarskatt {periodLabel}',
|
|
description: 'Kvarskatt enligt slutskattebesked eller omprövningsbeslut',
|
|
condition: (s) => (s.tax_assessment_notices?.length ?? 0) > 0,
|
|
priority: 'critical',
|
|
linkedReportType: null,
|
|
skipBankingDayAdjustment: true,
|
|
generateDates: (year, settings) => (settings.tax_assessment_notices ?? [])
|
|
.filter((notice) => Number(notice.paymentDueDate.slice(0, 4)) === year)
|
|
.map((notice) => ({
|
|
day: Number(notice.paymentDueDate.slice(8, 10)),
|
|
month: Number(notice.paymentDueDate.slice(5, 7)) - 1,
|
|
year,
|
|
period: `notice:${notice.id}`,
|
|
periodLabel: notice.decisionType === 'reassessment'
|
|
? `omprövning, ${notice.fiscalPeriodName}`
|
|
: `slutskattebesked, ${notice.fiscalPeriodName}`,
|
|
taxAssessmentNoticeId: notice.id,
|
|
})),
|
|
},
|
|
|
|
// Kontrolluppgifter (KU10/KU20/KU31): annual income statements to
|
|
// Skatteverket, due 31 January after the income year (SFL 24 kap. 1 §).
|
|
// KU31 (utdelning) is never covered by the monthly AGI, so a fåmansbolag
|
|
// paying utdelning must file it separately even when all salaries are
|
|
// AGI-reported. Opt-in: the user confirms the flag in tax settings, where
|
|
// a ledger-derived signal (2898 utdelning, 2393/2893 ägarlån) suggests it.
|
|
{
|
|
type: 'kontrolluppgifter',
|
|
titleTemplate: 'Kontrolluppgifter {periodLabel}',
|
|
description: 'Kontrolluppgifter (KU10/KU20/KU31) till Skatteverket',
|
|
condition: (s) => s.kontrolluppgifter_enabled,
|
|
priority: 'important',
|
|
linkedReportType: null,
|
|
generateDates: (year) => {
|
|
// Due 31 January for the previous income year (always calendar year:
|
|
// kontrolluppgifter follow the income year, not the räkenskapsår).
|
|
return [
|
|
{ day: 31, month: 0, year, period: `${year - 1}`, periodLabel: `${year - 1}` },
|
|
]
|
|
},
|
|
},
|
|
|
|
// ROT/RUT begäran om utbetalning: the payout request for deductions given
|
|
// during year Y must reach Skatteverket by 31 January of year Y+1
|
|
// (Lag 2009:194 8 §). Missing the date forfeits the payout on account
|
|
// 1513, so this is the one deadline where lateness costs the principal,
|
|
// not a fee. Keyed on PAYMENT years (buyer paid), never invoice dates:
|
|
// rows only exist for years present in rot_rut_payment_years.
|
|
{
|
|
type: 'rot_rut_begaran',
|
|
titleTemplate: 'ROT/RUT-begäran om utbetalning {periodLabel}',
|
|
description: 'Begäran om utbetalning för ROT/RUT-avdrag till Skatteverket',
|
|
condition: (s) => s.rot_rut_enabled,
|
|
priority: 'critical',
|
|
linkedReportType: null,
|
|
generateDates: (year, settings) => {
|
|
if (!(settings.rot_rut_payment_years ?? []).includes(year - 1)) {
|
|
return []
|
|
}
|
|
return [
|
|
{ day: 31, month: 0, year, period: `${year - 1}`, periodLabel: `${year - 1}` },
|
|
]
|
|
},
|
|
},
|
|
|
|
// Inkomstdeklaration (EF) - 2 maj
|
|
{
|
|
type: 'inkomstdeklaration_ef',
|
|
titleTemplate: 'Inkomstdeklaration + NE-bilaga {periodLabel}',
|
|
description: 'Inkomstdeklaration för enskild firma',
|
|
condition: (s) => s.entity_type === 'enskild_firma',
|
|
priority: 'critical',
|
|
linkedReportType: 'ne-declaration',
|
|
generateDates: (year) => {
|
|
// Due May 2nd for previous year's income
|
|
return [
|
|
{ day: 2, month: 4, year, period: `${year - 1}`, periodLabel: `${year - 1}` },
|
|
]
|
|
},
|
|
},
|
|
|
|
// Inkomstdeklaration (AB): digital filing deadlines per Skatteverket lookup table
|
|
{
|
|
type: 'inkomstdeklaration_ab',
|
|
titleTemplate: 'Inkomstdeklaration AB {periodLabel}',
|
|
description: 'Inkomstdeklaration för aktiebolag',
|
|
condition: (s) => s.entity_type === 'aktiebolag',
|
|
priority: 'critical',
|
|
linkedReportType: null,
|
|
generateDates: (year, settings) => {
|
|
// FY end month (1-indexed): e.g. start=1 → end=12, start=5 → end=4
|
|
const fyEndMonth = settings.fiscal_year_start_month === 1 ? 12 : settings.fiscal_year_start_month - 1
|
|
|
|
// Skatteverket digital filing deadline lookup:
|
|
// FY end Jan-Apr → Dec 1 same year as FY end
|
|
// FY end May-Jun → Jan 15 year after FY end
|
|
// FY end Jul-Aug → Apr 1 year after FY end
|
|
// FY end Sep-Dec → Aug 1 year after FY end
|
|
const getDeadline = (fyEndYear: number) => {
|
|
if (fyEndMonth >= 1 && fyEndMonth <= 4) {
|
|
return { day: 1, month: 11, year: fyEndYear } // Dec 1
|
|
} else if (fyEndMonth >= 5 && fyEndMonth <= 6) {
|
|
return { day: 15, month: 0, year: fyEndYear + 1 } // Jan 15
|
|
} else if (fyEndMonth >= 7 && fyEndMonth <= 8) {
|
|
return { day: 1, month: 3, year: fyEndYear + 1 } // Apr 1
|
|
} else {
|
|
return { day: 1, month: 7, year: fyEndYear + 1 } // Aug 1
|
|
}
|
|
}
|
|
|
|
// We need to find which FY ending produces a deadline in `year`.
|
|
// Try FY endings in year-1 and year (both could produce deadlines in `year`).
|
|
const results: DeadlineInstance[] = []
|
|
for (const fyEndYear of [year - 1, year]) {
|
|
const dl = getDeadline(fyEndYear)
|
|
if (dl.year === year) {
|
|
// Compute the FY start year
|
|
const fyStart = fyEndMonth === 12 ? fyEndYear : fyEndYear
|
|
const periodLabel = fyEndMonth === 12
|
|
? `${fyEndYear}`
|
|
: `${fyStart - 1}/${fyStart}`
|
|
const period = fyEndMonth === 12
|
|
? `${fyEndYear}`
|
|
: `${fyStart - 1}/${fyStart}`
|
|
results.push({
|
|
day: dl.day,
|
|
month: dl.month,
|
|
year: dl.year,
|
|
period,
|
|
periodLabel,
|
|
})
|
|
}
|
|
}
|
|
return results
|
|
},
|
|
},
|
|
|
|
// Årsredovisning (AB): 7 months after fiscal year end per ÅRL 8:3
|
|
{
|
|
type: 'arsredovisning',
|
|
titleTemplate: 'Årsredovisning till Bolagsverket {periodLabel}',
|
|
description: 'Årsredovisning för aktiebolag',
|
|
condition: (s) => s.entity_type === 'aktiebolag',
|
|
priority: 'critical',
|
|
linkedReportType: null,
|
|
generateDates: (year, settings) => {
|
|
// FY end month (1-indexed)
|
|
const fyEndMonth = settings.fiscal_year_start_month === 1 ? 12 : settings.fiscal_year_start_month - 1
|
|
|
|
// 7 months after FY end per ÅRL 8:3
|
|
// Deadline month (0-indexed): ((fyEndMonth - 1) + 7) % 12
|
|
// Last day of the deadline month
|
|
// Determine which year the deadline falls in
|
|
const _wrapsYear = fyEndMonth > 5 // Jun+ wraps into next year
|
|
// For calendar year (Dec end): deadline Jul 31 same year+1
|
|
// The FY ending in `year` produces a deadline:
|
|
const _fyEndYear = year - 1 // By default we show deadline for the FY that ended in year-1
|
|
// Simpler: compute from a concrete FY end date
|
|
// FY ends: fyEndMonth (1-indexed), last day, in some year.
|
|
// We want the deadline that falls in `year`.
|
|
|
|
// Try FY endings in year-1 and year
|
|
const results: DeadlineInstance[] = []
|
|
for (const endYr of [year - 1, year]) {
|
|
// Deadline: 7 months after last day of fyEndMonth in endYr
|
|
const dlMonth0 = ((fyEndMonth - 1) + 7) % 12
|
|
const dlYear = (fyEndMonth - 1) + 7 >= 12 ? endYr + 1 : endYr
|
|
if (dlYear === year) {
|
|
const lastDay = new Date(dlYear, dlMonth0 + 1, 0).getDate()
|
|
const periodLabel = fyEndMonth === 12
|
|
? `${endYr}`
|
|
: `${endYr - 1}/${endYr}`
|
|
const period = periodLabel
|
|
results.push({
|
|
day: lastDay,
|
|
month: dlMonth0,
|
|
year: dlYear,
|
|
period,
|
|
periodLabel,
|
|
})
|
|
}
|
|
}
|
|
return results
|
|
},
|
|
},
|
|
|
|
// Årsstämma (AB): within 6 months of FY end per ABL 7 kap. 10 §. Replaces
|
|
// the former non-statutory 'bokslut' milestone (3 months had no legal
|
|
// basis). The stämma gates the årsredovisning chain: the AR is presented
|
|
// and adopted there, and the Bolagsverket filing (arsredovisning row,
|
|
// 7 months) requires the adopted AR.
|
|
{
|
|
type: 'arsstamma',
|
|
titleTemplate: 'Årsstämma räkenskapsår {periodLabel}',
|
|
description: 'Årsstämma för aktiebolag (senast sex månader efter räkenskapsårets utgång)',
|
|
condition: (s) => s.entity_type === 'aktiebolag',
|
|
priority: 'important',
|
|
linkedReportType: null,
|
|
generateDates: (year, settings) => {
|
|
// FY end month (1-indexed)
|
|
const fyEndMonth = settings.fiscal_year_start_month === 1 ? 12 : settings.fiscal_year_start_month - 1
|
|
|
|
// Last day of (FY end month + 6). Swedish fiscal years always end on
|
|
// the last day of a calendar month (BFL 3 kap.), so this equals the
|
|
// statutory six-month limit.
|
|
const results: DeadlineInstance[] = []
|
|
for (const endYr of [year - 1, year]) {
|
|
const dlMonth0 = ((fyEndMonth - 1) + 6) % 12
|
|
const dlYear = (fyEndMonth - 1) + 6 >= 12 ? endYr + 1 : endYr
|
|
if (dlYear === year) {
|
|
const lastDay = new Date(dlYear, dlMonth0 + 1, 0).getDate()
|
|
const periodLabel = fyEndMonth === 12 ? `${endYr}` : `${endYr - 1}/${endYr}`
|
|
results.push({
|
|
day: lastDay,
|
|
month: dlMonth0,
|
|
year: dlYear,
|
|
period: periodLabel,
|
|
periodLabel,
|
|
})
|
|
}
|
|
}
|
|
return results
|
|
},
|
|
},
|
|
]
|
|
|
|
/**
|
|
* Helper to get month label in Swedish
|
|
*/
|
|
function getMonthLabel(month: number, year: number): string {
|
|
const months = [
|
|
'januari', 'februari', 'mars', 'april', 'maj', 'juni',
|
|
'juli', 'augusti', 'september', 'oktober', 'november', 'december'
|
|
]
|
|
return `${months[month]} ${year}`
|
|
}
|
|
|
|
/**
|
|
* Get all applicable deadline configs for given company settings
|
|
*/
|
|
export function getApplicableDeadlineConfigs(
|
|
settings: CompanySettingsForDeadlines
|
|
): TaxDeadlineConfig[] {
|
|
return TAX_DEADLINE_CONFIGS.filter((config) => config.condition(settings))
|
|
}
|
|
|
|
/**
|
|
* Map from tax deadline type to report URL generator
|
|
*/
|
|
export const REPORT_URLS: Record<string, (period: { year: number; quarter?: number; month?: number }) => string> = {
|
|
vat: (p) => {
|
|
if (p.quarter) {
|
|
return `/reports?tab=vat&year=${p.year}&period=${p.quarter}`
|
|
}
|
|
if (p.month) {
|
|
return `/reports?tab=vat&year=${p.year}&period=${p.month}`
|
|
}
|
|
return `/reports?tab=vat&year=${p.year}`
|
|
},
|
|
'ne-declaration': () => '/reports?tab=ne-declaration',
|
|
}
|
|
|
|
/**
|
|
* Get the report URL for a deadline
|
|
*/
|
|
export function getReportUrl(
|
|
linkedReportType: string | null,
|
|
linkedReportPeriod: Record<string, unknown> | null
|
|
): string | null {
|
|
if (!linkedReportType || !linkedReportPeriod) {
|
|
return null
|
|
}
|
|
|
|
const urlGenerator = REPORT_URLS[linkedReportType]
|
|
if (!urlGenerator) {
|
|
return null
|
|
}
|
|
|
|
return urlGenerator(linkedReportPeriod as { year: number; quarter?: number; month?: number })
|
|
}
|