91e2c1705a
Per-line VAT rates: - Add generatePerRateLines() to group invoice items by vat_rate with separate revenue + VAT lines per rate group (invoice-entries.ts) - Add getAvailableVatRates() and getVatTreatmentForRate() (vat-rules.ts) - PDF template shows per-line VAT column and per-rate totals for mixed-rate invoices - Invoice create/review UI supports per-line rate selection - Types: add vat_rate/vat_amount to InvoiceItem, vat_rate to CreateInvoiceItemInput Invoice document types (proforma, delivery note): - Add InvoiceDocumentType, document_type and converted_from_id to Invoice type - PDF hides prices for delivery notes, adds proforma notice - Email templates support all document types - mark-paid skips journal entries for non-invoice document types - Migration 031: invoice_document_type Accounting method support: - Add AccountingMethod type (accrual/cash) - Migration 032: add_accounting_method column to company_settings VAT declaration rewrite: - Rewrite to read directly from general ledger (26xx/3xxx account lines) instead of aggregating invoices/transactions/receipts - ACCOUNT_RUTA mapping drives momsdeklaration boxes from GL balances Bank reconciliation: - Transaction ingest now pre-fetches unlinked GL lines and attempts auto-reconciliation during import - Add transaction.reconciled event type - Add ReconciliationMethod type and reconciliation_method on Transaction - Migration 030: bank_reconciliation - New reconciliation engine, API routes, and BankReconciliationView component Pagination (fetchAllRows): - New lib/supabase/fetch-all.ts overcomes PostgREST 1000-row limit - Adopted in all report generators, SIE/SRU export, account list APIs Fiscal period validation: - New validate-period-duration.ts enforces max 18 months per BFL 3 kap. - Applied in period-service.ts and fiscal-periods API Account mapper simplification: - Remove Levenshtein/fuzzy matching, use exact account number match only Swedbank parser improvements: - Support abbreviated headers (Clnr, Bokfdag, Radnr) - Use Referens column as counterparty Chart of accounts management: - Add DELETE endpoint with system account and usage protection - PUT uses partial updates - New AccountCombobox, AddAccountDialog, EditAccountDialog, ChartOfAccountsManager Tax deadline corrections: - Rewrite inkomstdeklaration_ab using Skatteverket lookup table - Rewrite arsredovisning deadline to 7 months after FY end per ÅRL 8:3 Onboarding first fiscal year: - Add first fiscal year toggle with date pickers and 18-month validation UI terminology: - Change "okategoriserad/kategorisera" to "obokförd/bokföra" throughout Report column fix: - Fix start_date/end_date to period_start/period_end in report queries Supplier invoice input: - CreateSupplierInvoiceItemInput uses amount field (legacy quantity/unit_price kept) Misc: - SIE import uses upsert for idempotent account creation - account-descriptions.ts falls back to BAS reference data - Add invoice_default_notes to CompanySettings - Update CLAUDE.md to reflect current project state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
369 lines
13 KiB
TypeScript
369 lines
13 KiB
TypeScript
/**
|
||
* Static configuration of all Swedish tax deadlines (Skatteverket)
|
||
* Based on Skatteverket's official deadline schedule
|
||
*/
|
||
|
||
import type { TaxDeadlineType, EntityType, MomsPeriod } from '@/types'
|
||
|
||
// Condition function type for determining if a deadline applies
|
||
export type DeadlineCondition = (settings: CompanySettingsForDeadlines) => boolean
|
||
|
||
// Subset of company settings needed for deadline generation
|
||
export interface CompanySettingsForDeadlines {
|
||
entity_type: EntityType
|
||
moms_period: MomsPeriod | null
|
||
f_skatt: boolean
|
||
vat_registered: boolean
|
||
pays_salaries: boolean
|
||
fiscal_year_start_month: number // 1-12
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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"
|
||
}
|
||
|
||
/**
|
||
* 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) => {
|
||
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, AB with employees)
|
||
{
|
||
type: 'arbetsgivardeklaration',
|
||
titleTemplate: 'Arbetsgivardeklaration {periodLabel}',
|
||
description: 'Arbetsgivardeklaration för aktiebolag med anställda',
|
||
condition: (s) => s.entity_type === 'aktiebolag' && s.pays_salaries,
|
||
priority: 'important',
|
||
linkedReportType: null,
|
||
generateDates: (year) => {
|
||
const instances: DeadlineInstance[] = []
|
||
// Due on the 12th of the following month
|
||
for (let month = 0; month < 12; month++) {
|
||
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
|
||
},
|
||
},
|
||
|
||
// Periodisk sammanställning (quarterly, 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
|
||
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}` },
|
||
]
|
||
},
|
||
},
|
||
|
||
// 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 fyStartYear = fyEndMonth === 12 ? fyEndYear : fyEndYear - (fyEndMonth < settings.fiscal_year_start_month ? 0 : 0)
|
||
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
|
||
const deadlineMonth0 = ((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
|
||
const deadlineYear = wrapsYear ? fyEndYear + 1 + 1 : fyEndYear + 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
|
||
},
|
||
},
|
||
|
||
// Bokslut (AB) - 31 mars for calendar year
|
||
{
|
||
type: 'bokslut',
|
||
titleTemplate: 'Bokslut räkenskapsår {periodLabel}',
|
||
description: 'Bokslut för aktiebolag',
|
||
condition: (s) => s.entity_type === 'aktiebolag',
|
||
priority: 'important',
|
||
linkedReportType: null,
|
||
generateDates: (year, settings) => {
|
||
// For calendar year, December 31 is fiscal year end, deadline March 31
|
||
if (settings.fiscal_year_start_month === 1) {
|
||
return [
|
||
{ day: 31, month: 2, year, period: `${year - 1}`, periodLabel: `${year - 1}` }, // March
|
||
]
|
||
}
|
||
// For non-calendar fiscal years, 3 months after year end
|
||
const fiscalYearEnd = settings.fiscal_year_start_month - 1
|
||
const deadlineMonth = (fiscalYearEnd + 3) % 12
|
||
const deadlineYear = deadlineMonth < fiscalYearEnd ? year + 1 : year
|
||
return [
|
||
{ day: 31, month: deadlineMonth, year: deadlineYear, period: `${year - 1}/${year}`, periodLabel: `${year - 1}/${year}` },
|
||
]
|
||
},
|
||
},
|
||
]
|
||
|
||
/**
|
||
* 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 })
|
||
}
|