Files
accounted/lib/tax/deadline-config.ts
T
Jakob Wennberg 91e2c1705a feat: per-line VAT, invoice document types, ledger-based VAT declaration, bank reconciliation, and pagination
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>
2026-02-21 14:57:15 +01:00

369 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 JanApr → Dec 1 same year as FY end
// FY end MayJun → Jan 15 year after FY end
// FY end JulAug → Apr 1 year after FY end
// FY end SepDec → 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 })
}