Feat/skv integration full (#284)

* feat: add script to import Skatteverket monthly tax tables as fallback TypeScript module

- Implemented a new script `import-tax-tables.ts` to parse fixed-width TXT tax tables from Skatteverket (SKV 434).
- The script generates a TypeScript module for emergency fallback when the Skatteverket open-data API is unavailable.
- Supports command-line argument for specifying the year and handles parsing of B-rows only.
- Outputs a structured TypeScript file containing tax data for specified years.

* feat: gate salary module behind dev-only flag

Temporarily disable the Lön module in production while the feature is
being completed. Sidebar entries ("Löner", "Anställda") still render but
are not clickable and show a "Kommer snart" badge. Middleware redirects
/salary* to / and returns 404 on /api/salary/* so the feature can't be
reached by direct URL. All gates check NODE_ENV === 'development' so
local dev keeps full access for continued development.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: refactor bank file import wizard to streamline column mapping and enhance CSV handling

* fix: bump migration timestamp to avoid collision with logos_bucket

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: enhance AGI generation and salary entry calculations with improved status checks and error handling

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-04-20 21:03:23 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 7a0214c053
commit 11621bb79f
23 changed files with 16227 additions and 340 deletions
+185 -59
View File
@@ -1,5 +1,9 @@
import { describe, it, expect, vi } from 'vitest'
import { generateAGIXml, buildIndividuppgifterSnapshot } from '../agi/xml-generator'
import {
generateAGIXml,
buildIndividuppgifterSnapshot,
AGIIncompleteDataError,
} from '../agi/xml-generator'
import type { AGICompanyData, AGIEmployeeData, AGITotals } from '../agi/xml-generator'
// Mock personnummer decryption
@@ -28,8 +32,6 @@ const employees: AGIEmployeeData[] = [
grossSalary: 40000,
taxWithheld: 12000,
avgifterBasis: 40000,
sickDays: 3,
vabDays: 2,
},
{
personnummer: 'emp2_encrypted',
@@ -44,110 +46,236 @@ const employees: AGIEmployeeData[] = [
const totals: AGITotals = {
totalTax: 22500,
totalAvgifterBasis: 80000,
totalAvgifterAmount: 24075.5,
avgifterByCategory: {
standard: { basis: 75000, amount: 23565 },
reduced65plus: { basis: 5000, amount: 510.50 },
reduced65plus: { basis: 5000, amount: 510.5 },
},
}
describe('generateAGIXml', () => {
it('generates valid XML with correct root element', () => {
describe('generateAGIXml — root structure', () => {
it('starts with XML declaration', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>')
expect(xml).toContain('<Skatteverket')
})
it('uses the Skatteverket AGI namespace (schema 1.1)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('xmlns="http://xmls.skatteverket.se/se/skatteverket/da/instans/schema/1.1"')
// Declares the komponent namespace for shared building blocks (Avsandare etc.)
expect(xml).toContain('xmlns:gem="http://xmls.skatteverket.se/se/skatteverket/da/komponent/schema/1.1"')
// Reject the old bogus namespace
expect(xml).not.toContain('infoForBeskworksgiv')
expect(xml).not.toContain('/ai/instans/')
})
it('sets omrade="Arbetsgivardeklaration" on the root element', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Skatteverket omrade="Arbetsgivardeklaration"')
})
it('closes the Skatteverket element', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('</Skatteverket>')
})
})
describe('generateAGIXml — Avsandare', () => {
it('includes program name "gnubok"', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Programnamn>gnubok</Programnamn>')
expect(xml).toContain('<gem:Programnamn>gnubok</gem:Programnamn>')
})
it('includes correct period', () => {
it('emits Organisationsnummer in IDENTITET format (16 + 10-digit AB orgnr)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Period>202604</Period>')
})
it('includes org number without dash', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('5561234567')
expect(xml).toContain('<gem:Organisationsnummer>165561234567</gem:Organisationsnummer>')
expect(xml).not.toContain('556123-4567')
})
it('includes huvuduppgift with total tax (Ruta 001)', () => {
it('includes technical contact (name, phone, email)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<AvdragenSkatt faltkod="001">22500</AvdragenSkatt>')
expect(xml).toContain('<gem:Namn>Anna Admin</gem:Namn>')
expect(xml).toContain('<gem:Telefon>0701234567</gem:Telefon>')
expect(xml).toContain('<gem:Epostadress>anna@test.se</gem:Epostadress>')
})
it('includes avgifter categories (Ruta 060, 061)', () => {
it('emits Avsandare in the komponent namespace (gem: prefix)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('faltkod="060"')
expect(xml).toContain('faltkod="061"')
expect(xml).toContain('<gem:Avsandare>')
expect(xml).toContain('</gem:Avsandare>')
})
})
describe('generateAGIXml — Blankettgemensamt', () => {
it('includes AgRegistreradId for the employer in IDENTITET format', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:AgRegistreradId>165561234567</gem:AgRegistreradId>')
})
it('decrypts personnummer for FK215', () => {
it('emits Blankettgemensamt in the komponent namespace', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Personnummer faltkod="215">199001011234</Personnummer>')
expect(xml).toContain('<Personnummer faltkod="215">198506159876</Personnummer>')
expect(xml).toContain('<gem:Blankettgemensamt>')
expect(xml).toContain('</gem:Blankettgemensamt>')
})
})
describe('generateAGIXml — Huvuduppgift (HU)', () => {
it('includes AgRegistreradId with FK201 inside HU (IDENTITET format)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:AgRegistreradId faltkod="201">165561234567</gem:AgRegistreradId>')
})
it('includes consistent FK570 specifikationsnummer', () => {
it('includes RedovisningsPeriod with FK006 inside HU', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Specifikationsnummer faltkod="570">1</Specifikationsnummer>')
expect(xml).toContain('<Specifikationsnummer faltkod="570">2</Specifikationsnummer>')
expect(xml).toContain('<gem:RedovisningsPeriod faltkod="006">202604</gem:RedovisningsPeriod>')
})
it('includes gross salary (Ruta 011) per employee', () => {
it('emits total tax as SummaSkatteavdr FK497 (not AvdragenSkatt FK001)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<KontantBruttoloen faltkod="011">40000</KontantBruttoloen>')
expect(xml).toContain('<KontantBruttoloen faltkod="011">35000</KontantBruttoloen>')
expect(xml).toContain('<gem:SummaSkatteavdr faltkod="497">22500</gem:SummaSkatteavdr>')
// Legacy incorrect HU element must not appear
expect(xml).not.toMatch(/<gem:HU>[\s\S]*<AvdragenSkatt[\s\S]*<\/gem:HU>/)
})
it('includes tax withheld (Ruta 001) per employee', () => {
it('emits total employer contributions as SummaArbAvgSlf FK487', () => {
const xml = generateAGIXml(company, employees, totals)
// Both HU and IU have AvdragenSkatt
const matches = xml.match(/AvdragenSkatt/g)
expect(matches!.length).toBeGreaterThanOrEqual(3) // 1 HU + 2 IU
expect(xml).toContain('<gem:SummaArbAvgSlf faltkod="487">24076</gem:SummaArbAvgSlf>')
})
it('includes benefit values (Ruta 012 for car)', () => {
it('does NOT emit FK060/061/062 — those field codes do not exist in HU', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<FormanBil faltkod="012">5000</FormanBil>')
// Look for those faltkoder inside the HU section
const huMatch = xml.match(/<gem:HU>[\s\S]*?<\/gem:HU>/)
expect(huMatch).not.toBeNull()
const hu = huMatch![0]
expect(hu).not.toContain('faltkod="060"')
expect(hu).not.toContain('faltkod="061"')
expect(hu).not.toContain('faltkod="062"')
})
it('includes absence fields FK821-FK823', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<SjukfranvaroDagar faltkod="821">3</SjukfranvaroDagar>')
expect(xml).toContain('<VabDagar faltkod="822">2</VabDagar>')
it('emits TotalSjuklonekostnad FK499 when sjuklön cost is reported', () => {
const withSjuklon = { ...totals, totalSjuklonekostnad: 4200 }
const xml = generateAGIXml(company, employees, withSjuklon)
expect(xml).toContain('<gem:TotalSjuklonekostnad faltkod="499">4200</gem:TotalSjuklonekostnad>')
})
it('omits zero/undefined fields', () => {
it('omits TotalSjuklonekostnad when zero or undefined', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).not.toContain('TotalSjuklonekostnad')
const zero = { ...totals, totalSjuklonekostnad: 0 }
expect(generateAGIXml(company, employees, zero)).not.toContain('TotalSjuklonekostnad')
})
})
describe('generateAGIXml — Individuppgift (IU)', () => {
it('uses BetalningsmottagarId FK215 (not Personnummer) for the payment recipient', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:BetalningsmottagarId faltkod="215">199001011234</gem:BetalningsmottagarId>')
expect(xml).toContain('<gem:BetalningsmottagarId faltkod="215">198506159876</gem:BetalningsmottagarId>')
expect(xml).not.toContain('<Personnummer faltkod="215">')
})
it('wraps BetalningsmottagarId in BetalningsmottagareIUGROUP → BetalningsmottagareIDChoice', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:BetalningsmottagareIUGROUP>')
expect(xml).toContain('<gem:BetalningsmottagareIDChoice>')
expect(xml).toContain('</gem:BetalningsmottagareIDChoice>')
expect(xml).toContain('</gem:BetalningsmottagareIUGROUP>')
// Ensure correct nesting order (IUGROUP contains IDChoice which contains the id)
expect(xml).toMatch(/<gem:BetalningsmottagareIUGROUP>\s*<gem:BetalningsmottagareIDChoice>\s*<gem:BetalningsmottagarId/)
})
it('wraps AgRegistreradId in ArbetsgivareIUGROUP inside IU', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:ArbetsgivareIUGROUP>')
expect(xml).toContain('</gem:ArbetsgivareIUGROUP>')
})
it('preserves Specifikationsnummer FK570 per employee', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:Specifikationsnummer faltkod="570">1</gem:Specifikationsnummer>')
expect(xml).toContain('<gem:Specifikationsnummer faltkod="570">2</gem:Specifikationsnummer>')
})
it('uses KontantErsattningUlagAG FK011 (not KontantBruttoloen) for gross salary', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:KontantErsattningUlagAG faltkod="011">40000</gem:KontantErsattningUlagAG>')
expect(xml).toContain('<gem:KontantErsattningUlagAG faltkod="011">35000</gem:KontantErsattningUlagAG>')
expect(xml).not.toContain('KontantBruttoloen')
})
it('uses AvdrPrelSkatt FK001 (not AvdragenSkatt) for withheld tax in IU', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:AvdrPrelSkatt faltkod="001">12000</gem:AvdrPrelSkatt>')
expect(xml).toContain('<gem:AvdrPrelSkatt faltkod="001">10500</gem:AvdrPrelSkatt>')
})
it('includes AgRegistreradId and RedovisningsPeriod in every IU', () => {
const xml = generateAGIXml(company, employees, totals)
// 1 HU + 2 IU = 3 occurrences each
const agRegMatches = xml.match(/AgRegistreradId faltkod="201"/g)
const periodMatches = xml.match(/RedovisningsPeriod faltkod="006"/g)
expect(agRegMatches?.length).toBe(3)
expect(periodMatches?.length).toBe(3)
})
it('maps benefit_car to SkatteplBilformanUlagAG FK013 (not FormanBil FK012)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:SkatteplBilformanUlagAG faltkod="013">5000</gem:SkatteplBilformanUlagAG>')
expect(xml).not.toContain('FormanBil')
})
it('omits empty/zero fields', () => {
const xml = generateAGIXml(company, employees, totals)
// Employee 1 has no car benefit
// Employee 2 has no sick days
// Check that we don't emit empty tags
const lines = xml.split('\n')
for (const line of lines) {
if (line.includes('faltkod')) {
expect(line).not.toContain('>0</')
expect(line).not.toMatch(/>0<\//)
}
}
})
it('marks corrections with Rattelse flag', () => {
const xml = generateAGIXml(company, employees, totals, true)
expect(xml).toContain('<Rattelse>J</Rattelse>')
})
it('does not include Rattelse flag for initial filing', () => {
const xml = generateAGIXml(company, employees, totals, false)
expect(xml).not.toContain('Rattelse')
})
it('escapes XML special characters in company name', () => {
const specialCompany = { ...company, companyName: 'Test & <Co>' }
it('escapes XML special characters in contact info', () => {
const specialCompany = { ...company, contactName: 'A&B <Admin>' }
const xml = generateAGIXml(specialCompany, employees, totals)
expect(xml).not.toContain('Test & <Co>')
expect(xml).not.toContain('A&B <Admin>')
expect(xml).toContain('A&amp;B &lt;Admin&gt;')
})
})
describe('generateAGIXml — fail-fast on missing data', () => {
it('throws AGIIncompleteDataError when org number is missing', () => {
const bad = { ...company, orgNumber: '' }
expect(() => generateAGIXml(bad, employees, totals)).toThrow(AGIIncompleteDataError)
expect(() => generateAGIXml(bad, employees, totals)).toThrow(/organisationsnummer/)
})
it('throws when org number has too few digits', () => {
const bad = { ...company, orgNumber: '12345' }
expect(() => generateAGIXml(bad, employees, totals)).toThrow(AGIIncompleteDataError)
})
it('throws when contact phone is missing', () => {
const bad = { ...company, contactPhone: '' }
expect(() => generateAGIXml(bad, employees, totals)).toThrow(/telefon/)
})
it('throws when contact email is missing', () => {
const bad = { ...company, contactEmail: '' }
expect(() => generateAGIXml(bad, employees, totals)).toThrow(/e-post/)
})
it('lists all missing fields on the error', () => {
const bad = { ...company, orgNumber: '', contactPhone: '', contactEmail: '' }
try {
generateAGIXml(bad, employees, totals)
throw new Error('should have thrown')
} catch (err) {
expect(err).toBeInstanceOf(AGIIncompleteDataError)
expect((err as AGIIncompleteDataError).missingFields).toEqual(
expect.arrayContaining(['organisationsnummer', 'telefon', 'e-post'])
)
}
})
})
@@ -170,7 +298,5 @@ describe('buildIndividuppgifterSnapshot', () => {
expect(snapshot[0]).toHaveProperty('ruta011', 40000)
expect(snapshot[0]).toHaveProperty('ruta001', 12000)
expect(snapshot[0]).toHaveProperty('ruta020', 40000)
expect(snapshot[0]).toHaveProperty('fk821', 3)
expect(snapshot[0]).toHaveProperty('fk822', 2)
})
})
+88 -4
View File
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest'
import { lookupTaxAmount, calculateJamkningTax, calculateSidoinkomstTax } from '../tax-tables'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import {
lookupTaxAmount,
calculateJamkningTax,
calculateSidoinkomstTax,
fetchTaxTableRates,
clearTaxTableCache,
TaxTableUnavailableError,
} from '../tax-tables'
import type { TaxTableRate } from '../tax-tables'
const sampleRates: TaxTableRate[] = [
@@ -29,8 +36,9 @@ describe('lookupTaxAmount', () => {
expect(lookupTaxAmount(33, 1, 100000, sampleRates)).toBe(16800)
})
it('falls back to 30% when table not found', () => {
expect(lookupTaxAmount(99, 1, 40000, sampleRates)).toBe(12000)
it('throws TaxTableUnavailableError when no matching rates exist (no silent 30% fallback)', () => {
expect(() => lookupTaxAmount(99, 1, 40000, sampleRates)).toThrow(TaxTableUnavailableError)
expect(() => lookupTaxAmount(99, 1, 40000, sampleRates)).toThrow(/table 99/)
})
it('filters by correct column', () => {
@@ -61,3 +69,79 @@ describe('calculateSidoinkomstTax', () => {
expect(calculateSidoinkomstTax(33333)).toBe(9999.90)
})
})
describe('fetchTaxTableRates fallback behavior', () => {
const originalFetch = globalThis.fetch
beforeEach(() => {
clearTaxTableCache()
})
afterEach(() => {
globalThis.fetch = originalFetch
clearTaxTableCache()
})
it("falls back to local data when the Skatteverket API fails for a supported year", async () => {
// Mock fetch to simulate API outage
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network down'))
const result = await fetchTaxTableRates(2026, 33, 1)
expect(result.source).toBe('fallback')
expect(result.rates.length).toBeGreaterThan(0)
// Every rate should match the requested table/column/year
for (const r of result.rates) {
expect(r.tableYear).toBe(2026)
expect(r.tableNumber).toBe(33)
expect(r.columnNumber).toBe(1)
}
})
it('marks source as api when the API returns data', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
resultCount: 1,
results: [
{
'år': '2026',
'tabellnr': '33',
'inkomst fr.o.m.': '20001',
'inkomst t.o.m.': '20100',
'kolumn 1': '2800',
'kolumn 2': '0',
'kolumn 3': '2500',
'kolumn 4': '100',
'kolumn 5': '2800',
'kolumn 6': '3000',
},
],
}),
} as Response)
const result = await fetchTaxTableRates(2026, 33, 1)
expect(result.source).toBe('api')
expect(result.rates[0].taxAmount).toBe(2800)
})
it('throws TaxTableUnavailableError when API fails and year has no fallback', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network down'))
await expect(fetchTaxTableRates(2020, 33, 1)).rejects.toBeInstanceOf(TaxTableUnavailableError)
await expect(fetchTaxTableRates(2020, 33, 1)).rejects.toThrow(/2020/)
})
it('caches the fallback result so repeat calls do not re-trigger the failed API', async () => {
const fetchSpy = vi.fn().mockRejectedValue(new Error('network down'))
globalThis.fetch = fetchSpy
await fetchTaxTableRates(2026, 33, 1)
await fetchTaxTableRates(2026, 33, 1)
// Only one API attempt despite two calls — second hit the cache
expect(fetchSpy).toHaveBeenCalledTimes(1)
})
})
+255 -117
View File
@@ -1,33 +1,59 @@
import { decryptPersonnummer } from '../personnummer'
/**
* AGI XML generator — Arbetsgivardeklaration per Skatteverket Teknisk beskrivning.
* AGI XML generator — Arbetsgivardeklaration på individnivå.
*
* Generates the XML content for filing employer declarations.
* The XML is stored in agi_declarations.xml_content for 7-year retention.
* Produces XML conforming to Skatteverket's schema:
* http://xmls.skatteverket.se/se/skatteverket/da/instans/schema/1.1
*
* The XML can be uploaded on Skatteverket's AGI e-tjänst. For programmatic
* submission use the JSON API flow via the skatteverket extension instead.
*
* Sources verified against Skatteverket's schema + technical description
* (SKV 269, teknisk beskrivning 1.1.16):
* - Root: <Skatteverket omrade="Arbetsgivardeklaration">
* - HU totals: SummaSkatteavdr (497), SummaArbAvgSlf (487), TotalSjuklonekostnad (499)
* - IU identity: BetalningsmottagarId (215), Specifikationsnummer (570)
* - IU amounts: KontantErsattningUlagAG (011), AvdrPrelSkatt (001)
* - Every HU and IU must include AgRegistreradId (201) + RedovisningsPeriod (006)
*
* CRITICAL: FK570 (specifikationsnummer) must stay consistent per employee.
* Corrections are detected by Skatteverket matching the same FK570.
*
* NOT HANDLED HERE (future work):
* - <Franvarouppgift>: separate top-level section for parental leave events
* (FK821 FranvaroDatum, FK823 FranvaroTyp={TILLFALLIG_FORALDRAPENNING|
* FORALDRAPENNING}, etc.). Requires per-event date records, not a simple
* day count. Per-employee sick days are NOT reported via AGI at all —
* they go to Försäkringskassan separately.
*/
const INSTANS_NS = 'http://xmls.skatteverket.se/se/skatteverket/da/instans/schema/1.1'
const KOMPONENT_NS = 'http://xmls.skatteverket.se/se/skatteverket/da/komponent/schema/1.1'
export interface AGIEmployeeData {
personnummer: string // Encrypted — will be decrypted for XML
specificationNumber: number // FK570 — MUST stay consistent
grossSalary: number // Ruta 011
taxWithheld: number // Ruta 001
avgifterBasis: number // Ruta 020
fSkattPayment?: number // Ruta 131 (F-skatt holders)
// Benefits by type
benefitCar?: number // Ruta 012
benefitFuel?: number // Ruta 013
benefitHousing?: number // Ruta 014
benefitMeals?: number // Ruta 015
benefitOther?: number // Ruta 019
// Absence (from 2025)
sickDays?: number // FK821
vabDays?: number // FK822
parentalDays?: number // FK823
personnummer: string // Encrypted — decrypted for XML
specificationNumber: number // FK570 — MUST stay consistent per employee
grossSalary: number // FK011 KontantErsattningUlagAG
taxWithheld: number // FK001 AvdrPrelSkatt
avgifterBasis: number // Retained for backwards compat; equals grossSalary for standard cases. Not emitted separately (FK011 already captures basis).
fSkattPayment?: number // FK131 KontantErsattningEjUlagSA
benefitCar?: number // FK013 SkatteplBilformanUlagAG
benefitFuel?: number // FK018 DrivmVidBilformanUlagAG
benefitHousing?: number // FK043 BostadsformanEjSmahusUlagAG (non-småhus default)
benefitOther?: number // FK012 SkatteplOvrigaFormanerUlagAG
/** @deprecated Meal benefit element name not verified against schema; kept for snapshot compatibility only (not emitted). */
benefitMeals?: number
/** @deprecated Per-employee sick days are not reported via AGI (goes to Försäkringskassan separately). Kept for snapshot compatibility. */
sickDays?: number
/** @deprecated VAB is reported via top-level <Franvarouppgift> as per-event records, not as an IU day count. Kept for snapshot compatibility. */
vabDays?: number
/** @deprecated Parental leave is reported via top-level <Franvarouppgift> as per-event records, not as an IU day count. Kept for snapshot compatibility. */
parentalDays?: number
}
export interface AGICompanyData {
orgNumber: string // NNNNNN-NNNN format
orgNumber: string // 10 digits after stripping dashes
companyName: string
periodYear: number
periodMonth: number
@@ -37,8 +63,15 @@ export interface AGICompanyData {
}
export interface AGITotals {
totalTax: number // Ruta 001 (huvuduppgift)
totalAvgifterBasis: number // Ruta 020 (huvuduppgift)
totalTax: number // FK497 SummaSkatteavdr
totalAvgifterBasis: number // retained for compat (sum of IU underlag)
totalAvgifterAmount: number // FK487 SummaArbAvgSlf (sum of calculated avgifter across categories)
/**
* FK499 TotalSjuklonekostnad — company's total sjuklön cost for the period
* (sum of sjuklön paid days 2–14 across all employees). Required per 2025+ rules.
* Day 1 is karens (unpaid); day 15+ is Försäkringskassan, not employer.
*/
totalSjuklonekostnad?: number
avgifterByCategory: {
standard?: { basis: number; amount: number }
reduced65plus?: { basis: number; amount: number }
@@ -46,153 +79,258 @@ export interface AGITotals {
}
}
/**
* Thrown when required AGI data is missing. Caller should surface the message
* to the user so they can fill in the missing field (org number, contact info).
*/
export class AGIIncompleteDataError extends Error {
constructor(message: string, public readonly missingFields: string[]) {
super(message)
this.name = 'AGIIncompleteDataError'
}
}
function assertRequiredCompanyData(company: AGICompanyData): void {
const missing: string[] = []
const orgNumberDigits = (company.orgNumber || '').replace(/\D/g, '')
// Skatteverket's IDENTITET type requires either 10 digits (AB orgnr, we prefix
// with "16") or 12 digits (personnummer for enskild firma). Any other length
// is a data-entry error that we cannot silently fix.
if (orgNumberDigits.length !== 10 && orgNumberDigits.length !== 12) missing.push('organisationsnummer')
if (!company.contactName.trim()) missing.push('kontaktperson (namn)')
if (!company.contactPhone.trim()) missing.push('telefon')
if (!company.contactEmail.trim()) missing.push('e-post')
if (missing.length > 0) {
throw new AGIIncompleteDataError(
`AGI kan inte genereras — följande uppgifter saknas: ${missing.join(', ')}. ` +
'Fyll i dem under Inställningar → Företag och Inställningar → Lön.',
missing
)
}
}
/**
* Skatteverket's IDENTITET pattern (from the AGI XSD). Accepts:
* - 12-digit personnummer YYYYMMDDXXXX (real dates 19xx/20xx, incl. leap days
* and samordningsnummer where day = actual_day + 60)
* - 12-digit AB/organisationsnummer: literal "16" + 10-digit orgnr, where the
* 3rd digit (first of the 10-digit orgnr) is 1-3, 5, 6, 7, 8 or 9 (NOT 4,
* and with specific restrictions) and the 5th is 2-9.
*
* Mirrored here so we can fail fast with a user-friendly message instead of
* emitting XML that Skatteverket's validator will reject cryptically.
*/
const IDENTITET_PATTERN = /^(((19|20)[0-9][0-9])((((01|03|05|07|08|10|12)(6[1-9]|7[0-9]|8[0-9]|9[0-1]))|((04|06|09|11)(6[1-9]|7[0-9]|8[0-9]|90))|((02)(6[1-9]|7[0-9]|8[0-8])))|00[6-9][0-9]|[0-9][0-9]60)|(((19|20)(04|08|12|16|20|24|28|32|36|40|44|48|52|56|60|64|68|72|76|80|84|88|92|96)(0289))|(20000289)))(00[1-9]|0[1-9][0-9]|[1-9][0-9][0-9])[0-9]|16(1[0-9]|2[0-9]|3[0-9]|5[0-9]|6[0-4]|66|68|7[0-9]|8[0-9]|9[0-9])[2-9]\d{7}|((((19|20)[0-9][0-9])(((01|03|05|07|08|10|12)(0[1-9]|1[0-9]|2[0-9]|3[0-1]))|((04|06|09|11)(0[1-9]|1[0-9]|2[0-9]|30))|((02)(0[1-9]|1[0-9]|2[0-8]))))|(((19|20)(04|08|12|16|20|24|28|32|36|40|44|48|52|56|60|64|68|72|76|80|84|88|92|96)(0229))|(20000229)))(00[1-9]|0[1-9][0-9]|[1-9][0-9][0-9])[0-9]$/
/**
* Normalize an org number or personnummer to Skatteverket's 12-character
* IDENTITET format, required by the AGI schema for Avsandare/Organisationsnummer,
* AgRegistreradId, and Arendeagare.
*
* - 10-digit orgnr (AB e.g. 5561234567) → prefixed with "16" → 165561234567
* - 12-digit personnummer (EF e.g. 196904206942) → used as-is
*
* Throws AGIIncompleteDataError if the resulting value cannot match the
* IDENTITET pattern — this catches bogus test data (e.g. "420694-2069") before
* the file reaches Skatteverket.
*/
function toIdentitet(raw: string): string {
const digits = raw.replace(/\D/g, '')
let candidate: string
if (digits.length === 12) candidate = digits
else if (digits.length === 10) candidate = `16${digits}`
else {
throw new AGIIncompleteDataError(
`Ogiltigt organisations-/personnummer (${digits.length} siffror). ` +
'Ange ett giltigt svenskt organisationsnummer (10 siffror, t.ex. 556123-4567) ' +
'eller fullständigt personnummer (12 siffror, YYYYMMDD-XXXX) under Inställningar → Företag.',
['organisationsnummer']
)
}
if (!IDENTITET_PATTERN.test(candidate)) {
throw new AGIIncompleteDataError(
`Ogiltigt organisationsnummer "${raw}" — värdet är inte ett svenskt organisationsnummer eller personnummer enligt Skatteverkets format. ` +
'Kontrollera värdet under Inställningar → Företag. För AB ska det vara 10 siffror (t.ex. 556123-4567). ' +
'För enskild firma ska det vara ett fullständigt 12-siffrigt personnummer (YYYYMMDD-XXXX).',
['organisationsnummer']
)
}
return candidate
}
/**
* Generate AGI XML for a period.
*
* CRITICAL: FK570 (specifikationsnummer) must stay consistent per employee.
* Using a different number creates a new record instead of correcting.
* Throws AGIIncompleteDataError if required fields (orgNumber, contact info)
* are missing — we never emit partial XML that Skatteverket would reject.
*/
export function generateAGIXml(
company: AGICompanyData,
employees: AGIEmployeeData[],
totals: AGITotals,
isCorrection: boolean = false
_isCorrection: boolean = false
): string {
assertRequiredCompanyData(company)
const orgIdentitet = toIdentitet(company.orgNumber)
const period = `${company.periodYear}${String(company.periodMonth).padStart(2, '0')}`
const createdAt = new Date().toISOString().replace(/\.\d+Z$/, '')
const lines: string[] = []
lines.push('<?xml version="1.0" encoding="UTF-8"?>')
lines.push('<Skatteverket xmlns="http://xmls.skatteverket.se/se/skatteverket/ai/instans/infoForBeskworksgiv662/1.0"')
lines.push(' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">')
lines.push(' <Avsandare>')
lines.push(` <Programnamn>gnubok</Programnamn>`)
lines.push(` <Organisationsnummer>${escapeXml(company.orgNumber.replace('-', ''))}</Organisationsnummer>`)
lines.push(' <TekniskKontaktperson>')
lines.push(` <Namn>${escapeXml(company.contactName)}</Namn>`)
lines.push(` <Telefon>${escapeXml(company.contactPhone)}</Telefon>`)
lines.push(` <Epostadress>${escapeXml(company.contactEmail)}</Epostadress>`)
lines.push(' </TekniskKontaktperson>')
lines.push(' </Avsandare>')
lines.push(
`<Skatteverket omrade="Arbetsgivardeklaration" xmlns="${INSTANS_NS}" xmlns:gem="${KOMPONENT_NS}">`
)
lines.push(' <Blankettgemensamt>')
lines.push(` <Arbetsgivare>`)
lines.push(` <AgRegistreradId>${escapeXml(company.orgNumber.replace('-', ''))}</AgRegistreradId>`)
lines.push(` </Arbetsgivare>`)
lines.push(' </Blankettgemensamt>')
// ── Avsandare (komponent namespace) ──────────────────────────
lines.push(' <gem:Avsandare>')
lines.push(' <gem:Programnamn>gnubok</gem:Programnamn>')
lines.push(` <gem:Organisationsnummer>${orgIdentitet}</gem:Organisationsnummer>`)
lines.push(' <gem:TekniskKontaktperson>')
lines.push(` <gem:Namn>${escapeXml(company.contactName)}</gem:Namn>`)
lines.push(` <gem:Telefon>${escapeXml(company.contactPhone)}</gem:Telefon>`)
lines.push(` <gem:Epostadress>${escapeXml(company.contactEmail)}</gem:Epostadress>`)
lines.push(' </gem:TekniskKontaktperson>')
lines.push(` <gem:Skapad>${createdAt}</gem:Skapad>`)
lines.push(' </gem:Avsandare>')
// Huvuduppgift (employer totals)
lines.push(' <Blankett>')
lines.push(' <Arendeinformation>')
lines.push(` <Arendeagare>${escapeXml(company.orgNumber.replace('-', ''))}</Arendeagare>`)
lines.push(` <Period>${period}</Period>`)
if (isCorrection) {
lines.push(' <Rattelse>J</Rattelse>')
}
lines.push(' </Arendeinformation>')
lines.push(' <Blankettinnehall>')
lines.push(' <HU>')
// ── Blankettgemensamt (komponent namespace) ──────────────────
lines.push(' <gem:Blankettgemensamt>')
lines.push(' <gem:Arbetsgivare>')
lines.push(` <gem:AgRegistreradId>${orgIdentitet}</gem:AgRegistreradId>`)
lines.push(' <gem:Kontaktperson>')
lines.push(` <gem:Namn>${escapeXml(company.contactName)}</gem:Namn>`)
lines.push(` <gem:Telefon>${escapeXml(company.contactPhone)}</gem:Telefon>`)
lines.push(` <gem:Epostadress>${escapeXml(company.contactEmail)}</gem:Epostadress>`)
lines.push(' </gem:Kontaktperson>')
lines.push(' </gem:Arbetsgivare>')
lines.push(' </gem:Blankettgemensamt>')
// Ruta 001: Total skatteavdrag
// ── Blankett: Huvuduppgift (komponent namespace) ─────────────
lines.push(' <gem:Blankett>')
lines.push(' <gem:Arendeinformation>')
lines.push(` <gem:Arendeagare>${orgIdentitet}</gem:Arendeagare>`)
lines.push(` <gem:Period>${period}</gem:Period>`)
lines.push(' </gem:Arendeinformation>')
lines.push(' <gem:Blankettinnehall>')
// HU/IU substitute for the abstract gem:Uppgift element in the komponent
// namespace (substitution group head). Use the concrete element directly —
// gem:Uppgift itself is abstract and cannot appear in an instance document.
lines.push(' <gem:HU>')
// AgRegistreradId is wrapped in ArbetsgivareHUGROUP, all payload elements
// live in the komponent namespace (gem: prefix).
lines.push(' <gem:ArbetsgivareHUGROUP>')
lines.push(` <gem:AgRegistreradId faltkod="201">${orgIdentitet}</gem:AgRegistreradId>`)
lines.push(' </gem:ArbetsgivareHUGROUP>')
lines.push(` <gem:RedovisningsPeriod faltkod="006">${period}</gem:RedovisningsPeriod>`)
// FK497 — Summa skatteavdrag (total from all IU)
if (totals.totalTax > 0) {
lines.push(` <AvdragenSkatt faltkod="001">${formatAmount(totals.totalTax)}</AvdragenSkatt>`)
lines.push(` <gem:SummaSkatteavdr faltkod="497">${formatAmount(totals.totalTax)}</gem:SummaSkatteavdr>`)
}
// Ruta 020: Total avgifter basis
if (totals.totalAvgifterBasis > 0) {
lines.push(` <SummaArbAvg>${formatAmount(totals.totalAvgifterBasis)}</SummaArbAvg>`)
// FK487 — Summa arbetsgivaravgifter och SLF (calculated total, NOT basis)
if (totals.totalAvgifterAmount > 0) {
lines.push(` <gem:SummaArbAvgSlf faltkod="487">${formatAmount(totals.totalAvgifterAmount)}</gem:SummaArbAvgSlf>`)
}
// Avgifter by category
if (totals.avgifterByCategory.standard) {
lines.push(` <AvgUnderlagStandardRate faltkod="060">${formatAmount(totals.avgifterByCategory.standard.basis)}</AvgUnderlagStandardRate>`)
}
if (totals.avgifterByCategory.reduced65plus) {
lines.push(` <AvgUnderlagAlderspension faltkod="061">${formatAmount(totals.avgifterByCategory.reduced65plus.basis)}</AvgUnderlagAlderspension>`)
}
if (totals.avgifterByCategory.youth) {
lines.push(` <AvgUnderlagUngdom faltkod="062">${formatAmount(totals.avgifterByCategory.youth.basis)}</AvgUnderlagUngdom>`)
// FK499 — Total sjuklönekostnad (legal requirement from 2025 when > 0)
if (totals.totalSjuklonekostnad && totals.totalSjuklonekostnad > 0) {
lines.push(` <gem:TotalSjuklonekostnad faltkod="499">${formatAmount(totals.totalSjuklonekostnad)}</gem:TotalSjuklonekostnad>`)
}
lines.push(' </HU>')
lines.push(' </Blankettinnehall>')
lines.push(' </Blankett>')
lines.push(' </gem:HU>')
lines.push(' </gem:Blankettinnehall>')
lines.push(' </gem:Blankett>')
// Individuppgifter (per employee)
// ── Blankett: Individuppgift (one per employee) ──────────────
for (const emp of employees) {
lines.push(' <Blankett>')
lines.push(' <Arendeinformation>')
lines.push(` <Arendeagare>${escapeXml(company.orgNumber.replace('-', ''))}</Arendeagare>`)
lines.push(` <Period>${period}</Period>`)
if (isCorrection) {
lines.push(' <Rattelse>J</Rattelse>')
}
lines.push(' </Arendeinformation>')
lines.push(' <Blankettinnehall>')
lines.push(' <IU>')
// FK215: Personnummer (CRITICAL: must be decrypted for AGI)
let pnr: string
try {
pnr = decryptPersonnummer(emp.personnummer)
} catch {
throw new Error(`Kunde inte dekryptera personnummer för anställd med FK570=${emp.specificationNumber}. AGI kan inte genereras utan giltigt personnummer.`)
throw new Error(
`Kunde inte dekryptera personnummer för anställd med FK570=${emp.specificationNumber}. ` +
'AGI kan inte genereras utan giltigt personnummer.'
)
}
lines.push(` <Personnummer faltkod="215">${pnr}</Personnummer>`)
// FK570: Specifikationsnummer (MUST stay consistent)
lines.push(` <Specifikationsnummer faltkod="570">${emp.specificationNumber}</Specifikationsnummer>`)
lines.push(' <gem:Blankett>')
lines.push(' <gem:Arendeinformation>')
lines.push(` <gem:Arendeagare>${orgIdentitet}</gem:Arendeagare>`)
lines.push(` <gem:Period>${period}</gem:Period>`)
lines.push(' </gem:Arendeinformation>')
lines.push(' <gem:Blankettinnehall>')
lines.push(' <gem:IU>')
// Identity groups wrap AgRegistreradId and BetalningsmottagarId in IU.
lines.push(' <gem:ArbetsgivareIUGROUP>')
lines.push(` <gem:AgRegistreradId faltkod="201">${orgIdentitet}</gem:AgRegistreradId>`)
lines.push(' </gem:ArbetsgivareIUGROUP>')
// BetalningsmottagarId must be inside BetalningsmottagareIDChoice (an
// xs:choice allowing BetalningsmottagarId | Fodelsetid | AnnatId).
lines.push(' <gem:BetalningsmottagareIUGROUP>')
lines.push(' <gem:BetalningsmottagareIDChoice>')
lines.push(` <gem:BetalningsmottagarId faltkod="215">${pnr}</gem:BetalningsmottagarId>`)
lines.push(' </gem:BetalningsmottagareIDChoice>')
lines.push(' </gem:BetalningsmottagareIUGROUP>')
lines.push(` <gem:RedovisningsPeriod faltkod="006">${period}</gem:RedovisningsPeriod>`)
lines.push(` <gem:Specifikationsnummer faltkod="570">${emp.specificationNumber}</gem:Specifikationsnummer>`)
// Ruta 011: Gross salary
// FK011 — Kontant ersättning, underlag arbetsgivaravgifter (= gross salary)
if (emp.grossSalary > 0) {
lines.push(` <KontantBruttoloen faltkod="011">${formatAmount(emp.grossSalary)}</KontantBruttoloen>`)
lines.push(` <gem:KontantErsattningUlagAG faltkod="011">${formatAmount(emp.grossSalary)}</gem:KontantErsattningUlagAG>`)
}
// Ruta 001: Tax withheld
// FK001 — Avdragen preliminärskatt
if (emp.taxWithheld > 0) {
lines.push(` <AvdragenSkatt faltkod="001">${formatAmount(emp.taxWithheld)}</AvdragenSkatt>`)
lines.push(` <gem:AvdrPrelSkatt faltkod="001">${formatAmount(emp.taxWithheld)}</gem:AvdrPrelSkatt>`)
}
// Benefits
// FK013 — Bilförmån (skattepliktig, underlag AG)
if (emp.benefitCar && emp.benefitCar > 0) {
lines.push(` <FormanBil faltkod="012">${formatAmount(emp.benefitCar)}</FormanBil>`)
lines.push(` <gem:SkatteplBilformanUlagAG faltkod="013">${formatAmount(emp.benefitCar)}</gem:SkatteplBilformanUlagAG>`)
}
// FK018 — Drivmedel vid bilförmån
if (emp.benefitFuel && emp.benefitFuel > 0) {
lines.push(` <FormanDrivmedel faltkod="013">${formatAmount(emp.benefitFuel)}</FormanDrivmedel>`)
lines.push(` <gem:DrivmVidBilformanUlagAG faltkod="018">${formatAmount(emp.benefitFuel)}</gem:DrivmVidBilformanUlagAG>`)
}
// FK043 — Bostadsförmån (ej småhus). TODO: for single-family home use
// BostadsformanSmahusUlagAG (FK041); currently defaults to non-småhus.
if (emp.benefitHousing && emp.benefitHousing > 0) {
lines.push(` <FormanBostad faltkod="014">${formatAmount(emp.benefitHousing)}</FormanBostad>`)
}
if (emp.benefitMeals && emp.benefitMeals > 0) {
lines.push(` <FormanKost faltkod="015">${formatAmount(emp.benefitMeals)}</FormanKost>`)
lines.push(` <gem:BostadsformanEjSmahusUlagAG faltkod="043">${formatAmount(emp.benefitHousing)}</gem:BostadsformanEjSmahusUlagAG>`)
}
// FK012 — Övriga skattepliktiga förmåner
if (emp.benefitOther && emp.benefitOther > 0) {
lines.push(` <FormanOvrigt faltkod="019">${formatAmount(emp.benefitOther)}</FormanOvrigt>`)
lines.push(` <gem:SkatteplOvrigaFormanerUlagAG faltkod="012">${formatAmount(emp.benefitOther)}</gem:SkatteplOvrigaFormanerUlagAG>`)
}
// Ruta 020: Avgifter basis
if (emp.avgifterBasis > 0) {
lines.push(` <UnderlagArbAvg faltkod="020">${formatAmount(emp.avgifterBasis)}</UnderlagArbAvg>`)
}
// Meal benefit: element name not verified in the component schema yet.
// Intentionally omitted until we have an authoritative mapping.
void emp.benefitMeals
// Ruta 131: F-skatt payments
// FK131 — Ersättning till mottagare med F-skattsedel (ej underlag SA)
if (emp.fSkattPayment && emp.fSkattPayment > 0) {
lines.push(` <ErsattningFSkatt faltkod="131">${formatAmount(emp.fSkattPayment)}</ErsattningFSkatt>`)
lines.push(` <gem:KontantErsattningEjUlagSA faltkod="131">${formatAmount(emp.fSkattPayment)}</gem:KontantErsattningEjUlagSA>`)
}
// Absence fields (from 2025)
if (emp.sickDays && emp.sickDays > 0) {
lines.push(` <SjukfranvaroDagar faltkod="821">${Math.round(emp.sickDays)}</SjukfranvaroDagar>`)
}
if (emp.vabDays && emp.vabDays > 0) {
lines.push(` <VabDagar faltkod="822">${Math.round(emp.vabDays)}</VabDagar>`)
}
if (emp.parentalDays && emp.parentalDays > 0) {
lines.push(` <ForaldraledigDagar faltkod="823">${Math.round(emp.parentalDays)}</ForaldraledigDagar>`)
}
// Sjuk/VAB/föräldra-dagar flows elsewhere:
// - Per-employee sick days are reported to Försäkringskassan, not AGI.
// The company-level total goes in HU as TotalSjuklonekostnad (FK499).
// - VAB and parental leave are reported via the top-level
// <Franvarouppgift> section (FK820-827) as per-event date records,
// not as per-IU day counts. Not implemented in this generator yet.
void emp.sickDays
void emp.vabDays
void emp.parentalDays
lines.push(' </IU>')
lines.push(' </Blankettinnehall>')
lines.push(' </Blankett>')
lines.push(' </gem:IU>')
lines.push(' </gem:Blankettinnehall>')
lines.push(' </gem:Blankett>')
}
lines.push('</Skatteverket>')
+77 -48
View File
@@ -58,7 +58,8 @@ export interface CalculationStep {
label: string
formula: string
input: Record<string, number | string>
output: number
/** Numeric result for the step. `null` for context-only rows (e.g. avgiftskategori) that describe a rule, not a calculation. */
output: number | null
}
export interface SalaryCalculationResult {
@@ -88,13 +89,36 @@ export interface AvgifterCalculation {
}
// ============================================================
// Rounding helper
// Rounding / formatting helpers
// ============================================================
function r(x: number): number {
return Math.round(x * 100) / 100
}
/**
* Format a rate (0.2081) as a Swedish percentage string ("20,81 %").
* Strips trailing zeros, uses Swedish comma as decimal separator, and rounds
* to avoid JS floating-point noise like "20.810000000000002".
*/
function fmtPct(decimal: number, decimals = 2): string {
const pct = decimal * 100
const rounded = Math.round(pct * 10 ** decimals) / 10 ** decimals
const str = rounded
.toFixed(decimals)
.replace(/\.?0+$/, '')
.replace('.', ',')
return `${str} %`
}
/**
* Format an integer amount with Swedish thousand-separators and "kr" suffix,
* for embedding inside formula descriptions ("25 000 kr").
*/
function fmtKr(amount: number): string {
return `${Math.round(amount).toLocaleString('sv-SE')} kr`
}
// ============================================================
// Main calculation
// ============================================================
@@ -126,7 +150,7 @@ export function calculateSalary(
baseSalary = r(input.monthlySalary * (input.employmentDegree / 100))
steps.push({
label: 'Grundlön',
formula: 'monthly_salary × (employment_degree / 100)',
formula: 'månadslön × (sysselsättningsgrad / 100)',
input: { monthly_salary: input.monthlySalary, employment_degree: input.employmentDegree },
output: baseSalary,
})
@@ -136,7 +160,7 @@ export function calculateSalary(
baseSalary = r(rate * hours)
steps.push({
label: 'Grundlön (timavlönad)',
formula: 'hourly_rate × hours_worked',
formula: 'timlön × arbetade timmar',
input: { hourly_rate: rate, hours_worked: hours },
output: baseSalary,
})
@@ -150,7 +174,7 @@ export function calculateSalary(
if (totalAdditions > 0) {
steps.push({
label: 'Tillägg (övertid, bonus, provision)',
formula: 'sum(additions)',
formula: 'summa tillägg',
input: { count: additions.length },
output: totalAdditions,
})
@@ -164,7 +188,7 @@ export function calculateSalary(
if (totalAbsence !== 0) {
steps.push({
label: 'Frånvaro (sjuk, VAB, semester, föräldraledig)',
formula: 'sum(absence_items)',
formula: 'summa frånvaroposter',
input: { count: absenceItems.length },
output: totalAbsence,
})
@@ -176,7 +200,7 @@ export function calculateSalary(
if (totalGrossDeductions > 0) {
steps.push({
label: 'Bruttolöneavdrag',
formula: 'sum(gross_deductions)',
formula: 'summa bruttoavdrag',
input: { count: grossDeductionItems.length },
output: -totalGrossDeductions,
})
@@ -186,7 +210,7 @@ export function calculateSalary(
const grossSalary = r(baseSalary + totalAdditions + totalAbsence - totalGrossDeductions)
steps.push({
label: 'Bruttolön',
formula: 'base + additions + absence - gross_deductions',
formula: 'grundlön + tillägg + frånvaro − bruttoavdrag',
input: { base: baseSalary, additions: totalAdditions, absence: totalAbsence, gross_deductions: totalGrossDeductions },
output: grossSalary,
})
@@ -199,7 +223,7 @@ export function calculateSalary(
if (totalBenefits > 0) {
steps.push({
label: 'Förmånsvärden',
formula: 'sum(benefit_values)',
formula: 'summa förmåner',
input: { count: benefitItems.length },
output: totalBenefits,
})
@@ -208,7 +232,7 @@ export function calculateSalary(
const taxableIncome = r(grossSalary + totalBenefits)
steps.push({
label: 'Skattegrundande inkomst',
formula: 'gross_salary + benefit_values',
formula: 'bruttolön + förmåner',
input: { gross_salary: grossSalary, benefit_values: totalBenefits },
output: taxableIncome,
})
@@ -222,7 +246,7 @@ export function calculateSalary(
taxWithheld = 0
steps.push({
label: 'Skatteavdrag (F-skatt)',
formula: '0 (F-skattsedel, inget avdrag)',
formula: 'F-skattsedel — inget skatteavdrag görs',
input: {},
output: 0,
})
@@ -231,7 +255,7 @@ export function calculateSalary(
taxWithheld = r(taxableIncome * 0.30)
steps.push({
label: 'Skatteavdrag (ej verifierad)',
formula: 'taxable_income × 30%',
formula: 'skattegrundande inkomst × 30 %',
input: { taxable_income: taxableIncome },
output: taxWithheld,
})
@@ -239,8 +263,8 @@ export function calculateSalary(
// Sidoinkomst: flat 30%
taxWithheld = calculateSidoinkomstTax(taxableIncome)
steps.push({
label: 'Skatteavdrag (sidoinkomst 30%)',
formula: 'taxable_income × 30%',
label: 'Skatteavdrag (sidoinkomst 30 %)',
formula: 'skattegrundande inkomst × 30 %',
input: { taxable_income: taxableIncome },
output: taxWithheld,
})
@@ -248,8 +272,8 @@ export function calculateSalary(
// Jämkning
taxWithheld = calculateJamkningTax(taxableIncome, input.jamkningPercentage)
steps.push({
label: `Skatteavdrag (jämkning ${input.jamkningPercentage}%)`,
formula: 'taxable_income × jamkning_percentage / 100',
label: `Skatteavdrag (jämkning ${input.jamkningPercentage} %)`,
formula: `skattegrundande inkomst × ${input.jamkningPercentage} %`,
input: { taxable_income: taxableIncome, jamkning_percentage: input.jamkningPercentage },
output: taxWithheld,
})
@@ -258,7 +282,7 @@ export function calculateSalary(
taxWithheld = lookupTaxAmount(input.taxTableNumber, input.taxColumn, taxableIncome, taxRates)
steps.push({
label: `Skatteavdrag (tabell ${input.taxTableNumber}, kolumn ${input.taxColumn})`,
formula: `lookup(table=${input.taxTableNumber}, column=${input.taxColumn}, income=${Math.round(taxableIncome)})`,
formula: `skattetabell ${input.taxTableNumber}, kolumn ${input.taxColumn}, inkomst ${fmtKr(taxableIncome)}`,
input: { table: input.taxTableNumber, column: input.taxColumn, taxable_income: taxableIncome },
output: taxWithheld,
})
@@ -266,8 +290,8 @@ export function calculateSalary(
// Fallback: flat 30%
taxWithheld = r(taxableIncome * 0.30)
steps.push({
label: 'Skatteavdrag (30% schablon)',
formula: 'taxable_income × 30%',
label: 'Skatteavdrag (30 % schablon)',
formula: 'skattegrundande inkomst × 30 %',
input: { taxable_income: taxableIncome },
output: taxWithheld,
})
@@ -280,7 +304,7 @@ export function calculateSalary(
const netSalary = r(grossSalary - taxWithheld - totalNetDeductions)
steps.push({
label: 'Nettolön',
formula: 'gross - tax - net_deductions',
formula: 'bruttolön − skatt − nettoavdrag',
input: { gross: grossSalary, tax: taxWithheld, net_deductions: totalNetDeductions },
output: netSalary,
})
@@ -299,7 +323,7 @@ export function calculateSalary(
steps.push(...avgifterCalc.steps)
steps.push({
label: 'Arbetsgivaravgifter (ungdomsrabatt med tak)',
formula: `${config.avgifterYouthSalaryCap} × ${avgifterCalc.rate} + ${r(avgifterBasis - config.avgifterYouthSalaryCap)} × ${config.avgifterTotal}`,
formula: `${fmtKr(config.avgifterYouthSalaryCap)} × ${fmtPct(avgifterCalc.rate)} + ${fmtKr(avgifterBasis - config.avgifterYouthSalaryCap)} × ${fmtPct(config.avgifterTotal)}`,
input: { cap: config.avgifterYouthSalaryCap, reduced: reducedPart, standard: standardPart },
output: avgifterAmount,
})
@@ -310,7 +334,7 @@ export function calculateSalary(
steps.push(...avgifterCalc.steps)
steps.push({
label: 'Arbetsgivaravgifter (växa-stöd med tak)',
formula: `${config.avgifterVaxaStodCap} × ${avgifterCalc.rate} + ${r(avgifterBasis - config.avgifterVaxaStodCap)} × ${config.avgifterTotal}`,
formula: `${fmtKr(config.avgifterVaxaStodCap)} × ${fmtPct(avgifterCalc.rate)} + ${fmtKr(avgifterBasis - config.avgifterVaxaStodCap)} × ${fmtPct(config.avgifterTotal)}`,
input: { cap: config.avgifterVaxaStodCap, reduced: reducedPart, standard: standardPart },
output: avgifterAmount,
})
@@ -319,7 +343,7 @@ export function calculateSalary(
steps.push(...avgifterCalc.steps)
steps.push({
label: 'Arbetsgivaravgifter',
formula: 'avgifter_basis × rate',
formula: `avgiftsunderlag × ${fmtPct(avgifterCalc.rate)}`,
input: { avgifter_basis: avgifterBasis, rate: avgifterCalc.rate },
output: avgifterAmount,
})
@@ -335,8 +359,8 @@ export function calculateSalary(
const rate = input.vacationDaysPerYear >= 30 ? 0.144 : 0.12
vacationAccrual = r(vacationBasis * rate)
steps.push({
label: `Semesteravsättning (procentregeln ${rate * 100}%)`,
formula: 'vacation_basis × rate',
label: `Semesteravsättning (procentregeln ${fmtPct(rate)})`,
formula: `semesterunderlag × ${fmtPct(rate)}`,
input: { vacation_basis: vacationBasis, rate },
output: vacationAccrual,
})
@@ -350,8 +374,8 @@ export function calculateSalary(
const tillagg = r(dailyRate * input.semestertillaggRate * input.vacationDaysPerYear)
vacationAccrual = tillagg
steps.push({
label: `Semesteravsättning (sammalöneregeln, tillägg ${(input.semestertillaggRate * 100).toFixed(2)}%)`,
formula: 'daily_rate × semestertillagg_rate × vacation_days',
label: `Semesteravsättning (sammalöneregeln, tillägg ${fmtPct(input.semestertillaggRate)})`,
formula: `dagslön × ${fmtPct(input.semestertillaggRate)} × semesterdagar`,
input: { daily_rate: dailyRate, semestertillagg_rate: input.semestertillaggRate, vacation_days: input.vacationDaysPerYear },
output: vacationAccrual,
})
@@ -361,7 +385,7 @@ export function calculateSalary(
const vacationAccrualAvgifter = r(vacationAccrual * avgifterCalc.rate)
steps.push({
label: 'Arbetsgivaravgifter på semesteravsättning',
formula: 'vacation_accrual × avgifter_rate',
formula: `semesteravsättning × ${fmtPct(avgifterCalc.rate)}`,
input: { vacation_accrual: vacationAccrual, avgifter_rate: avgifterCalc.rate },
output: vacationAccrualAvgifter,
})
@@ -369,7 +393,7 @@ export function calculateSalary(
const totalEmployerCost = r(grossSalary + avgifterAmount + vacationAccrual + vacationAccrualAvgifter)
steps.push({
label: 'Total arbetsgivarkostnad',
formula: 'gross + avgifter + vacation_accrual + vacation_avgifter',
formula: 'bruttolön + avgifter + semesteravsättning + avgifter på semester',
input: { gross: grossSalary, avgifter: avgifterAmount, vacation_accrual: vacationAccrual, vacation_avgifter: vacationAccrualAvgifter },
output: totalEmployerCost,
})
@@ -418,7 +442,12 @@ export function calculateAvgifterRate(
amount: 0,
basis: 0,
category: 'standard',
steps: [{ label: 'Avgiftskategori', formula: 'standard (personnummer ej dekrypterbart)', input: {}, output: config.avgifterTotal }],
steps: [{
label: 'Avgiftskategori',
formula: `Standard ${fmtPct(config.avgifterTotal)} (personnummer kunde inte dekrypteras)`,
input: {},
output: null,
}],
}
}
@@ -429,9 +458,9 @@ export function calculateAvgifterRate(
if (birthYear <= 1937) {
steps.push({
label: 'Avgiftskategori',
formula: 'Född ≤1937: 0%',
formula: 'Född 1937 eller tidigare — inga arbetsgivaravgifter',
input: { birth_year: birthYear },
output: 0,
output: null,
})
return { rate: 0, amount: 0, basis: 0, category: 'exempt', steps }
}
@@ -440,9 +469,9 @@ export function calculateAvgifterRate(
if (ageAtYearStart >= config.reducedAvgiftAge) {
steps.push({
label: 'Avgiftskategori',
formula: `Ålder ${ageAtYearStart} ≥ ${config.reducedAvgiftAge}: reducerad (${config.avgifterReduced65plus * 100}%)`,
formula: `Ålder ${ageAtYearStart} år: reducerad avgift ${fmtPct(config.avgifterReduced65plus)} (endast ålderspensionsavgift)`,
input: { age: ageAtYearStart, threshold: config.reducedAvgiftAge },
output: config.avgifterReduced65plus,
output: null,
})
return { rate: config.avgifterReduced65plus, amount: 0, basis: 0, category: 'reduced_65plus', steps }
}
@@ -453,9 +482,9 @@ export function calculateAvgifterRate(
if (payDate >= input.vaxaStodStart && payDate <= input.vaxaStodEnd && config.avgifterVaxaStodRate !== null) {
steps.push({
label: 'Avgiftskategori',
formula: `Växa-stöd: ${(config.avgifterVaxaStodRate ?? 0) * 100}% på första ${config.avgifterVaxaStodCap} SEK`,
formula: `Växa-stöd ${fmtPct(config.avgifterVaxaStodRate ?? 0)} på första ${fmtKr(config.avgifterVaxaStodCap ?? 0)}`,
input: { vaxa_cap: config.avgifterVaxaStodCap ?? 0 },
output: config.avgifterVaxaStodRate ?? 0,
output: null,
})
return { rate: config.avgifterVaxaStodRate ?? config.avgifterTotal, amount: 0, basis: 0, category: 'vaxa_stod', steps }
}
@@ -470,9 +499,9 @@ export function calculateAvgifterRate(
if (isYouthPeriod) {
steps.push({
label: 'Avgiftskategori',
formula: `Ungdomsrabatt (${ageAtYearStart} år): ${config.avgifterYouthRate * 100}% på första ${config.avgifterYouthSalaryCap} SEK`,
formula: `Ungdomsrabatt (${ageAtYearStart} år): ${fmtPct(config.avgifterYouthRate)} på första ${fmtKr(config.avgifterYouthSalaryCap ?? 0)}`,
input: { age: ageAtYearStart, cap: config.avgifterYouthSalaryCap ?? 0 },
output: config.avgifterYouthRate,
output: null,
})
return { rate: config.avgifterYouthRate, amount: 0, basis: 0, category: 'youth', steps }
}
@@ -481,9 +510,9 @@ export function calculateAvgifterRate(
// Standard rate
steps.push({
label: 'Avgiftskategori',
formula: `Standard: ${config.avgifterTotal * 100}%`,
formula: `Standard ${fmtPct(config.avgifterTotal)}`,
input: { age: ageAtYearStart },
output: config.avgifterTotal,
output: null,
})
return { rate: config.avgifterTotal, amount: 0, basis: 0, category: 'standard', steps }
}
@@ -517,7 +546,7 @@ export function calculateSjuklon(
const karensavdrag = calculateKarensavdrag(monthlySalary, config)
steps.push({
label: 'Karensavdrag',
formula: '20% × (monthly × 12/52 × 80%)',
formula: `20 % × (månadslön × 12/52 × ${fmtPct(config.sjuklonRate)})`,
input: { monthly_salary: monthlySalary },
output: karensavdrag,
})
@@ -526,8 +555,8 @@ export function calculateSjuklon(
const sjuklonDays = Math.min(Math.max(sickDays - 1, 0), 13)
const sjuklon = r(dailyRate * config.sjuklonRate * sjuklonDays)
steps.push({
label: 'Sjuklön dag 2-14',
formula: 'daily_rate × 80% × (sick_days - 1)',
label: 'Sjuklön dag 2–14',
formula: `dagslön × ${fmtPct(config.sjuklonRate)} × (sjukdagar − 1)`,
input: { daily_rate: dailyRate, sjuklon_rate: config.sjuklonRate, days: sjuklonDays },
output: sjuklon,
})
@@ -537,7 +566,7 @@ export function calculateSjuklon(
const totalDeduction = r(-(fullPayForPeriod - sjuklon + karensavdrag))
steps.push({
label: 'Netto sjukavdrag',
formula: '-(full_pay - sjuklon + karensavdrag)',
formula: '−(full lön − sjuklön + karensavdrag)',
input: { full_pay: fullPayForPeriod, sjuklon, karensavdrag },
output: totalDeduction,
})
@@ -561,8 +590,8 @@ export function calculateVacationAccrual(params: {
const rate = params.vacationDaysPerYear >= 30 ? 0.144 : 0.12
const accrual = r(params.vacationBasis * rate)
steps.push({
label: `Semesteravsättning (procentregeln ${rate * 100}%)`,
formula: 'vacation_basis × rate',
label: `Semesteravsättning (procentregeln ${fmtPct(rate)})`,
formula: `semesterunderlag × ${fmtPct(rate)}`,
input: { vacation_basis: params.vacationBasis, rate },
output: accrual,
})
@@ -571,8 +600,8 @@ export function calculateVacationAccrual(params: {
const dailyRate = r(params.monthlySalary / 21)
const accrual = r(dailyRate * params.semestertillaggRate * params.vacationDaysPerYear)
steps.push({
label: `Semesteravsättning (sammalöneregeln ${params.semestertillaggRate * 100}%)`,
formula: 'daily_rate × semestertillagg_rate × vacation_days',
label: `Semesteravsättning (sammalöneregeln ${fmtPct(params.semestertillaggRate)})`,
formula: `dagslön × ${fmtPct(params.semestertillaggRate)} × semesterdagar`,
input: { daily_rate: dailyRate, rate: params.semestertillaggRate, days: params.vacationDaysPerYear },
output: accrual,
})
+98 -3
View File
@@ -1,4 +1,5 @@
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { createLogger } from '@/lib/logger'
import { SALARY_ACCOUNTS, getLineItemAccount } from './account-mapping'
import type { SupabaseClient } from '@supabase/supabase-js'
@@ -77,6 +78,8 @@ export async function createSalaryRunEntries(
const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
const desc = `Lön ${periodLabel}`
await ensureSalaryAccountsExist(supabase, companyId, userId, run)
// ─── Entry 1: Salary (brutto, skatt, netto) ───
const salaryEntry = await createSalaryEntry(
supabase, companyId, userId, run, fiscalPeriodId, desc
@@ -140,18 +143,25 @@ async function createSalaryEntry(
// Förmånsvärden (benefits) are excluded — they affect the tax base but
// have no cash flow and should not appear as expense lines in the journal.
const BENEFIT_TYPES = ['benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_other']
let lineItemTotal = 0
for (const li of emp.line_items) {
if (li.is_net_deduction || li.is_gross_deduction) continue
if (BENEFIT_TYPES.includes(li.item_type)) continue // No cash flow for förmånsvärden
const account = li.account_number || getLineItemAccount(li.item_type as never, emp.employment_type)
const current = expenseByAccount.get(account) || 0
expenseByAccount.set(account, current + li.amount)
lineItemTotal += li.amount
}
// If no specific line items resolved, use gross salary on default account
if (emp.line_items.length === 0) {
// Ensure the debit side always equals gross_salary (minus gross deductions,
// which the credit side doesn't book either). If line items don't cover the
// full gross amount, book the remainder to the default salary account so the
// entry balances. Without this, an employee with overtime line items but no
// base-salary line item would fail the check_journal_entry_balance() trigger.
const baseRemainder = Math.round((emp.gross_salary - lineItemTotal) * 100) / 100
if (baseRemainder !== 0) {
const current = expenseByAccount.get(salaryAccount) || 0
expenseByAccount.set(salaryAccount, current + emp.gross_salary)
expenseByAccount.set(salaryAccount, current + baseRemainder)
}
}
@@ -410,6 +420,91 @@ function getEmployeeSalaryAccount(employmentType: string): string {
}
}
/**
* Ensure every BAS account referenced by the salary run exists in
* chart_of_accounts. Users who seeded the minimal chart via
* seed_chart_of_accounts will be missing many 7xxx/29xx accounts — we
* auto-create them from BAS reference data on first salary booking.
*/
async function ensureSalaryAccountsExist(
supabase: SupabaseClient,
companyId: string,
userId: string,
run: SalaryRunData
): Promise<void> {
const needed = new Set<string>()
for (const account of Object.values(SALARY_ACCOUNTS)) needed.add(account)
for (const emp of run.employees) {
needed.add(getEmployeeSalaryAccount(emp.employment_type))
for (const li of emp.line_items) {
const account = li.account_number || getLineItemAccount(li.item_type as never, emp.employment_type)
if (account) needed.add(account)
}
}
if (needed.size === 0) return
const { data: existing, error } = await supabase
.from('chart_of_accounts')
.select('account_number')
.eq('company_id', companyId)
.in('account_number', [...needed])
if (error) {
throw new Error(`Kunde inte läsa kontoplanen: ${error.message}`)
}
const existingSet = new Set((existing || []).map(a => a.account_number))
const missing = [...needed].filter(num => !existingSet.has(num))
if (missing.length === 0) return
const inserts = missing.map(accountNumber => {
const basRef = getBASReference(accountNumber)
if (basRef) {
return {
user_id: userId,
company_id: companyId,
account_number: accountNumber,
account_name: basRef.account_name,
account_class: basRef.account_class,
account_group: basRef.account_group,
account_type: basRef.account_type,
normal_balance: basRef.normal_balance,
sru_code: basRef.sru_code,
k2_excluded: basRef.k2_excluded,
plan_type: 'full_bas',
is_active: true,
is_system_account: false,
}
}
// Fallback — shouldn't happen for salary accounts, but keeps us safe.
const classNum = parseInt(accountNumber.charAt(0), 10)
const group = accountNumber.substring(0, 2)
return {
user_id: userId,
company_id: companyId,
account_number: accountNumber,
account_name: `Konto ${accountNumber}`,
account_class: classNum,
account_group: group,
account_type: classNum >= 4 ? 'expense' : classNum === 2 ? 'liability' : 'asset',
normal_balance: classNum <= 1 || classNum >= 4 ? 'debit' : 'credit',
plan_type: 'full_bas',
is_active: true,
is_system_account: false,
}
})
const { error: insertError } = await supabase.from('chart_of_accounts').insert(inserts)
if (insertError && !insertError.message.includes('duplicate')) {
throw new Error(`Kunde inte skapa saknade konton: ${insertError.message}`)
}
log.info(`Auto-created ${missing.length} missing salary accounts: ${missing.join(', ')}`)
}
function accountLabel(account: string): string {
const labels: Record<string, string> = {
'7210': 'Löner tjänstemän',
File diff suppressed because it is too large Load Diff
+131 -24
View File
@@ -1,25 +1,32 @@
/**
* Tax table lookup via Skatteverket's open data API.
*
* Uses the free, public EntryScape rowstore API — no authentication required.
* Endpoints:
* Primary source: Skatteverket EntryScape rowstore API (no authentication).
* - Tax tables: https://skatteverket.entryscape.net/rowstore/dataset/88320397-5c32-4c16-ae79-d36d95b17b95
* - Kommun rates: https://skatteverket.entryscape.net/rowstore/dataset/c67b320b-ffee-4876-b073-dd9236cd2a99
*
* Emergency fallback: lib/salary/tax-tables-fallback.ts (generated from
* Skatteverket's published TXT file — used only if the API is unreachable).
*
* Per Skatteförfarandelagen: Tax withholding must use the correct table/column
* for each employee based on their folkbokföringskommun.
* for each employee based on their folkbokföringskommun. No silent percentage
* fallback is used — if neither the API nor the local fallback can serve the
* requested data, the lookup throws. This prevents silent under-withholding.
*
* Results are cached in-memory per salary run calculation to avoid redundant
* API calls (one call fetches all brackets for a table/column combination).
*/
import { createLogger } from '@/lib/logger'
import { FALLBACK_TAX_TABLES, FALLBACK_TAX_TABLE_YEARS } from './tax-tables-fallback'
const log = createLogger('tax-tables')
const TAX_TABLE_API = 'https://skatteverket.entryscape.net/rowstore/dataset/88320397-5c32-4c16-ae79-d36d95b17b95'
const KOMMUN_RATES_API = 'https://skatteverket.entryscape.net/rowstore/dataset/c67b320b-ffee-4876-b073-dd9236cd2a99'
export type TaxTableSource = 'api' | 'fallback'
export interface TaxTableRate {
tableYear: number
tableNumber: number
@@ -29,28 +36,58 @@ export interface TaxTableRate {
taxAmount: number
}
export interface TaxTableRatesResult {
rates: TaxTableRate[]
source: TaxTableSource
}
// In-memory cache: "year-table-column" → rates
const rateCache = new Map<string, { rates: TaxTableRate[]; fetchedAt: number }>()
interface CachedEntry {
rates: TaxTableRate[]
source: TaxTableSource
fetchedAt: number
}
const rateCache = new Map<string, CachedEntry>()
const CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour
/**
* Thrown when tax table data is unavailable from both the API and local fallback.
* Payroll calculation must fail loudly rather than silently under-withhold.
*/
export class TaxTableUnavailableError extends Error {
constructor(
message: string,
public readonly context: { year: number; tableNumber: number; column: number }
) {
super(message)
this.name = 'TaxTableUnavailableError'
}
}
/**
* Look up tax amount for a given monthly income using Skatteverket's API.
*
* Fetches the matching bracket from the API (or cache) and returns the
* tax amount in SEK for the given income.
* Returns the tax amount in SEK and the data source used.
*/
export async function lookupTaxFromApi(
tableNumber: number,
column: number,
monthlyIncome: number,
year: number = new Date().getFullYear()
): Promise<number> {
const rates = await fetchTaxTableRates(year, tableNumber, column)
return lookupTaxAmount(tableNumber, column, monthlyIncome, rates)
): Promise<{ taxAmount: number; source: TaxTableSource }> {
const { rates, source } = await fetchTaxTableRates(year, tableNumber, column)
return {
taxAmount: lookupTaxAmount(tableNumber, column, monthlyIncome, rates),
source,
}
}
/**
* Look up the tax amount from pre-loaded rates (pure function, no API call).
*
* Throws TaxTableUnavailableError if no rates match the requested table/column.
* Callers must ensure rates have been loaded via fetchTaxTableRates first —
* we deliberately avoid a silent percentage fallback because silent wrong
* withholding is worse than loud failure.
*/
export function lookupTaxAmount(
tableNumber: number,
@@ -65,8 +102,11 @@ export function lookupTaxAmount(
)
if (matchingRates.length === 0) {
// Fallback: 30% flat rate if table not found
return Math.round(roundedIncome * 0.30 * 100) / 100
throw new TaxTableUnavailableError(
`No tax table rates found for table ${tableNumber}, column ${column}. ` +
`Ensure fetchTaxTableRates succeeded before calling lookupTaxAmount.`,
{ year: rates[0]?.tableYear ?? 0, tableNumber, column }
)
}
matchingRates.sort((a, b) => a.incomeFrom - b.incomeFrom)
@@ -77,7 +117,8 @@ export function lookupTaxAmount(
}
}
// Above all brackets — use last bracket
// Above all brackets — use last bracket (matches Skatteverket's published behavior
// where the top B-row applies until %-rows take over; we only load B-rows)
const lastRate = matchingRates[matchingRates.length - 1]
if (roundedIncome > lastRate.incomeTo) {
return lastRate.taxAmount
@@ -86,20 +127,52 @@ export function lookupTaxAmount(
return 0
}
/**
* Build TaxTableRate[] for a given year/table/column from the bundled fallback data.
* Returns null when the requested year/table is not present in the fallback module.
*/
function getFallbackRates(
year: number,
tableNumber: number,
column: number
): TaxTableRate[] | null {
const yearTables = FALLBACK_TAX_TABLES[year]
if (!yearTables) return null
const rows = yearTables[tableNumber]
if (!rows) return null
if (column < 1 || column > 6) return null
// Columns 1-6 map to tuple indices 2-7 ([incomeFrom, incomeTo, col1..col6])
const colIndex = column + 1
return rows.map(row => ({
tableYear: year,
tableNumber,
columnNumber: column,
incomeFrom: row[0],
incomeTo: row[1] || 9999999,
taxAmount: row[colIndex] as number,
}))
}
/**
* Fetch tax table rates from Skatteverket's open data API.
* Returns all brackets for a specific year/table/column combination.
*
* On API failure, falls back to bundled Skatteverket TXT data (see
* tax-tables-fallback.ts). If neither source has the requested year,
* throws TaxTableUnavailableError so payroll calculation fails loudly.
*
* Results are cached in-memory for 1 hour.
*/
export async function fetchTaxTableRates(
year: number,
tableNumber: number,
column: number
): Promise<TaxTableRate[]> {
): Promise<TaxTableRatesResult> {
const cacheKey = `${year}-${tableNumber}-${column}`
const cached = rateCache.get(cacheKey)
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
return cached.rates
return { rates: cached.rates, source: cached.source }
}
try {
@@ -164,6 +237,12 @@ export async function fetchTaxTableRates(
}
}
if (allResults.length === 0) {
// API responded but has no rows for this year/table — treat like failure
// so the fallback path runs below.
throw new Error(`Skatteverket API returned no rows for table ${tableNumber} year ${year}`)
}
// Parse results — each row has all 6 columns, we extract the requested one
const columnKey = `kolumn ${column}` as keyof typeof allResults[0]
const rates: TaxTableRate[] = allResults.map(r => ({
@@ -175,28 +254,51 @@ export async function fetchTaxTableRates(
taxAmount: parseInt(r[columnKey] as string) || 0,
}))
// Cache the results
rateCache.set(cacheKey, { rates, fetchedAt: Date.now() })
rateCache.set(cacheKey, { rates, source: 'api', fetchedAt: Date.now() })
log.info(`Fetched ${rates.length} tax brackets for table ${tableNumber} col ${column} (${year})`)
return rates
return { rates, source: 'api' }
} catch (err) {
log.warn(`Failed to fetch tax table from API: ${err instanceof Error ? err.message : 'unknown'}. Falling back to 30%.`)
return []
const apiErr = err instanceof Error ? err.message : 'unknown'
const fallbackRates = getFallbackRates(year, tableNumber, column)
if (fallbackRates) {
log.warn(
`Skatteverket API unavailable (${apiErr}) — using bundled fallback for table ${tableNumber} col ${column} (${year})`
)
rateCache.set(cacheKey, {
rates: fallbackRates,
source: 'fallback',
fetchedAt: Date.now(),
})
return { rates: fallbackRates, source: 'fallback' }
}
const supportedYears = Array.from(FALLBACK_TAX_TABLE_YEARS).join(', ') || 'none'
throw new TaxTableUnavailableError(
`Kunde inte hämta skattetabell ${tableNumber} kolumn ${column} för ${year} från Skatteverket ` +
`(${apiErr}). Ingen lokal reservdata finns för året ${year} (reservdata finns för: ${supportedYears}).`,
{ year, tableNumber, column }
)
}
}
/**
* Fetch all tax table rates for a year (all tables/columns for a salary run).
* Used by the calculate route for bulk lookups.
*
* Returns { rates, source } where source is 'api' if every table came from the
* API, 'fallback' if every table came from the local fallback, and 'mixed' if
* some came from each (indicates partial API outage).
*/
export async function fetchAllTaxTableRatesForRun(
year: number,
tableNumbers: number[],
columns: number[]
): Promise<TaxTableRate[]> {
): Promise<{ rates: TaxTableRate[]; source: TaxTableSource | 'mixed' }> {
const allRates: TaxTableRate[] = []
const uniquePairs = new Set<string>()
let sawApi = false
let sawFallback = false
for (const table of tableNumbers) {
for (const col of columns) {
@@ -204,12 +306,17 @@ export async function fetchAllTaxTableRatesForRun(
if (uniquePairs.has(key)) continue
uniquePairs.add(key)
const rates = await fetchTaxTableRates(year, table, col)
const { rates, source } = await fetchTaxTableRates(year, table, col)
allRates.push(...rates)
if (source === 'api') sawApi = true
else if (source === 'fallback') sawFallback = true
}
}
return allRates
const source: TaxTableSource | 'mixed' =
sawApi && sawFallback ? 'mixed' : sawFallback ? 'fallback' : 'api'
return { rates: allRates, source }
}
/**
+12
View File
@@ -42,6 +42,18 @@ export async function updateSession(request: NextRequest) {
// Get the pathname
const pathname = request.nextUrl.pathname
// Salary feature is temporarily disabled in production — block page and API
// routes. Sidebar links render as "Kommer snart" and direct URL access is
// blocked. Local dev (NODE_ENV === 'development') keeps everything enabled
// so we can continue building the feature.
const SALARY_DISABLED = process.env.NODE_ENV !== 'development'
if (SALARY_DISABLED && (pathname === '/salary' || pathname.startsWith('/salary/') || pathname.startsWith('/api/salary/'))) {
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: 'Lön är inte tillgängligt ännu.' }, { status: 404 })
}
return NextResponse.redirect(new URL('/', request.url))
}
// If the refresh token is stale/invalid, clear the session cookies
// so the browser stops sending them on every request.
// Skip on auth routes — the callback needs PKCE cookies intact.