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:
co-authored by
Claude Opus 4.7
parent
7a0214c053
commit
11621bb79f
@@ -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&B <Admin>')
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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>')
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user