Salary module improvements (#250)
* feat: implement salary module with personnummer encryption, salary entries, tax tables, and AGI tracking - Added personnummer encryption and decryption functions for secure storage. - Created salary entries handling for journal entries including gross salary, tax withholding, and employer contributions. - Implemented tax table lookup functionality for calculating tax amounts based on monthly income. - Developed SQL migration for salary module including tables for payroll configuration, tax rates, employees, salary runs, salary run employees, salary line items, and AGI declarations. - Established row-level security policies for all new tables to ensure company-scoped access. * feat: add salary calculation modules for 2026 - Implemented engångsskatt calculation for one-time payments with tax brackets. - Added löneväxling functionality for salary sacrifice to pension, including employer savings and warnings. - Created pain.001 generator for salary batch payments in compliance with Swedish banking standards. - Developed PDF template for payslips, including detailed breakdowns and employer costs. - Generated seed data for Swedish tax tables for 2026, including SQL insert statements. - Implemented traktamente calculations for per diem and mileage allowances, adhering to Skatteverket regulations. - Added seed script for populating tax tables in the database. * feat: Update meal reduction percentages in traktamente calculation fix: Remove obsolete seed script for 2026 tax tables feat: Extend SalaryRunStatus type to include 'corrected' status feat: Implement KU10 XML generation endpoint for annual employee income statements feat: Add endpoint for creating corrections to booked salary runs feat: Implement endpoint for sending payslip PDFs to employees feat: Create KU10 XML generator for annual reporting feat: Add salary transaction matcher for auto-linking bank transactions to salary entries chore: Add database migration for salary correction support * feat: replace select elements with custom Select component for employment and salary types * feat: enhance salary calculations with pension entry and avgifter category support * feat: enhance employee management with salary type, tax status, and validation improvements * feat: Implement AGI submission flow to Skatteverket - Added AGI submission route to handle the submission process. - Created AGI client for interacting with Skatteverket's API. - Introduced AGI mappers to convert salary run data into the required AGI JSON payload format. - Enhanced API client to support custom base URLs for Skatteverket API requests. - Added types for AGI submission payload and validation results. - Implemented tests for AGI mappers to ensure correct payload structure and data handling. * feat: enhance salary module with Skatteverket integration and update dashboard navigation * Update app/api/salary/runs/[id]/agi/submit/route.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update app/api/salary/runs/[id]/approve/route.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * feat: integrate write permission check and remove Skatteverket extension --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
greptile-apps[bot]
parent
24466c6e94
commit
bb0db7a588
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { buildAGIPayload } from '../lib/agi-mappers'
|
||||
import type { AGIEmployeeData, AGITotals } from '@/lib/salary/agi/xml-generator'
|
||||
|
||||
// Mock personnummer decryption
|
||||
vi.mock('@/lib/salary/personnummer', () => ({
|
||||
decryptPersonnummer: vi.fn((encrypted: string) => {
|
||||
// Simulate decryption: in tests, we use plaintext personnummer
|
||||
if (encrypted === 'INVALID') throw new Error('Decryption failed')
|
||||
return encrypted
|
||||
}),
|
||||
}))
|
||||
|
||||
function makeEmployee(overrides: Partial<AGIEmployeeData> = {}): AGIEmployeeData {
|
||||
return {
|
||||
personnummer: '199001011234',
|
||||
specificationNumber: 1,
|
||||
grossSalary: 35000,
|
||||
taxWithheld: 8000,
|
||||
avgifterBasis: 35000,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeTotals(overrides: Partial<AGITotals> = {}): AGITotals {
|
||||
return {
|
||||
totalTax: 8000,
|
||||
totalAvgifterBasis: 35000,
|
||||
avgifterByCategory: {
|
||||
standard: { basis: 35000, amount: 10997 },
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildAGIPayload', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('builds payload with correct structure', () => {
|
||||
const result = buildAGIPayload([makeEmployee()], makeTotals())
|
||||
|
||||
expect(result).toMatchObject({
|
||||
rattelse: false,
|
||||
huvuduppgift: {
|
||||
avdragenSkatt: 8000,
|
||||
summaArbetsgivaravgifterUnderlag: 35000,
|
||||
avgifterUnderlagStandard: 35000,
|
||||
},
|
||||
individuppgifter: [
|
||||
{
|
||||
personnummer: '199001011234',
|
||||
specifikationsnummer: 1,
|
||||
kontantBruttoloen: 35000,
|
||||
avdragenSkatt: 8000,
|
||||
underlagArbetsgivaravgifter: 35000,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('sets rattelse flag for corrections', () => {
|
||||
const result = buildAGIPayload([makeEmployee()], makeTotals(), true)
|
||||
expect(result.rattelse).toBe(true)
|
||||
})
|
||||
|
||||
it('omits zero-value fields from individuppgift', () => {
|
||||
const emp = makeEmployee({
|
||||
benefitCar: 0,
|
||||
benefitMeals: undefined,
|
||||
sickDays: 0,
|
||||
})
|
||||
const result = buildAGIPayload([emp], makeTotals())
|
||||
const ind = result.individuppgifter[0]
|
||||
|
||||
expect(ind.formanBil).toBeUndefined()
|
||||
expect(ind.formanKost).toBeUndefined()
|
||||
expect(ind.sjukfranvaroDagar).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes benefit values when present', () => {
|
||||
const emp = makeEmployee({
|
||||
benefitCar: 4500,
|
||||
benefitHousing: 3000,
|
||||
benefitMeals: 1800,
|
||||
benefitOther: 500,
|
||||
})
|
||||
const result = buildAGIPayload([emp], makeTotals())
|
||||
const ind = result.individuppgifter[0]
|
||||
|
||||
expect(ind.formanBil).toBe(4500)
|
||||
expect(ind.formanBostad).toBe(3000)
|
||||
expect(ind.formanKost).toBe(1800)
|
||||
expect(ind.formanOvrigt).toBe(500)
|
||||
})
|
||||
|
||||
it('includes absence fields when present', () => {
|
||||
const emp = makeEmployee({
|
||||
sickDays: 3,
|
||||
vabDays: 2,
|
||||
parentalDays: 5,
|
||||
})
|
||||
const result = buildAGIPayload([emp], makeTotals())
|
||||
const ind = result.individuppgifter[0]
|
||||
|
||||
expect(ind.sjukfranvaroDagar).toBe(3)
|
||||
expect(ind.vabDagar).toBe(2)
|
||||
expect(ind.foraldraledigDagar).toBe(5)
|
||||
})
|
||||
|
||||
it('includes F-skatt payment field', () => {
|
||||
const emp = makeEmployee({ fSkattPayment: 50000 })
|
||||
const result = buildAGIPayload([emp], makeTotals())
|
||||
|
||||
expect(result.individuppgifter[0].ersattningFSkatt).toBe(50000)
|
||||
})
|
||||
|
||||
it('rounds all amounts to whole kronor', () => {
|
||||
const emp = makeEmployee({
|
||||
grossSalary: 35000.75,
|
||||
taxWithheld: 8000.49,
|
||||
avgifterBasis: 35000.5,
|
||||
})
|
||||
const result = buildAGIPayload([emp], makeTotals())
|
||||
const ind = result.individuppgifter[0]
|
||||
|
||||
expect(ind.kontantBruttoloen).toBe(35001)
|
||||
expect(ind.avdragenSkatt).toBe(8000)
|
||||
expect(ind.underlagArbetsgivaravgifter).toBe(35001)
|
||||
})
|
||||
|
||||
it('handles multiple avgifter categories', () => {
|
||||
const totals = makeTotals({
|
||||
avgifterByCategory: {
|
||||
standard: { basis: 70000, amount: 21994 },
|
||||
reduced65plus: { basis: 30000, amount: 3063 },
|
||||
youth: { basis: 25000, amount: 5203 },
|
||||
},
|
||||
})
|
||||
const result = buildAGIPayload([makeEmployee()], totals)
|
||||
const hu = result.huvuduppgift
|
||||
|
||||
expect(hu.avgifterUnderlagStandard).toBe(70000)
|
||||
expect(hu.avgifterUnderlagAlderspension).toBe(30000)
|
||||
expect(hu.avgifterUnderlagUngdom).toBe(25000)
|
||||
})
|
||||
|
||||
it('handles multiple employees', () => {
|
||||
const employees = [
|
||||
makeEmployee({ specificationNumber: 1, grossSalary: 35000 }),
|
||||
makeEmployee({ specificationNumber: 2, personnummer: '199512152345', grossSalary: 28000 }),
|
||||
]
|
||||
const result = buildAGIPayload(employees, makeTotals({ totalTax: 15000, totalAvgifterBasis: 63000 }))
|
||||
|
||||
expect(result.individuppgifter).toHaveLength(2)
|
||||
expect(result.individuppgifter[0].specifikationsnummer).toBe(1)
|
||||
expect(result.individuppgifter[1].specifikationsnummer).toBe(2)
|
||||
expect(result.individuppgifter[1].personnummer).toBe('199512152345')
|
||||
})
|
||||
|
||||
it('throws if personnummer cannot be decrypted', () => {
|
||||
const emp = makeEmployee({ personnummer: 'INVALID' })
|
||||
|
||||
expect(() => buildAGIPayload([emp], makeTotals())).toThrow(
|
||||
/Kunde inte dekryptera personnummer.*FK570=1/
|
||||
)
|
||||
})
|
||||
|
||||
it('omits huvuduppgift fields when zero', () => {
|
||||
const totals: AGITotals = {
|
||||
totalTax: 0,
|
||||
totalAvgifterBasis: 0,
|
||||
avgifterByCategory: {},
|
||||
}
|
||||
const result = buildAGIPayload([makeEmployee({ grossSalary: 0, taxWithheld: 0, avgifterBasis: 0 })], totals)
|
||||
const hu = result.huvuduppgift
|
||||
|
||||
expect(hu.avdragenSkatt).toBeUndefined()
|
||||
expect(hu.summaArbetsgivaravgifterUnderlag).toBeUndefined()
|
||||
expect(hu.avgifterUnderlagStandard).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,9 @@ import { storeTokens, getTokens, deleteTokens } from './lib/token-store'
|
||||
import { skvRequest, SkatteverketAuthError } from './lib/api-client'
|
||||
import { rutorToMomsuppgift, formatRedovisare, formatRedovisningsperiod } from './lib/mappers'
|
||||
import { calculateVatDeclaration } from '@/lib/reports/vat-declaration'
|
||||
import { agiSaveDraft, agiValidate, agiGetSubmission, agiDeleteDraft, agiLockPeriod, agiUnlockPeriod, agiGetSubmitted } from './lib/agi-client'
|
||||
import { buildAGIPayload } from './lib/agi-mappers'
|
||||
import type { AGIEmployeeData, AGITotals } from '@/lib/salary/agi/xml-generator'
|
||||
import type { VatPeriodType } from '@/types'
|
||||
|
||||
/**
|
||||
@@ -546,6 +549,364 @@ export const skatteverketExtension: Extension = {
|
||||
}
|
||||
},
|
||||
},
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// AGI (Arbetsgivardeklaration) routes
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
// ── AGI: Validate (dry run) ────────────────────────────────────
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/agi/validate',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) {
|
||||
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { arbetsgivare, period, payload } = await parseAGIRequest(request, ctx)
|
||||
|
||||
console.log('[skatteverket] AGI validating:', { arbetsgivare, period })
|
||||
|
||||
const result = await agiValidate(ctx.supabase, ctx.companyId, arbetsgivare, period, payload)
|
||||
|
||||
if (!result.ok) {
|
||||
console.error('[skatteverket] AGI validate error:', result.status, result.error)
|
||||
return NextResponse.json(
|
||||
{ error: `Skatteverket svarade med ${result.status}: ${result.error}` },
|
||||
{ status: result.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: result.data })
|
||||
} catch (err) {
|
||||
return handleSkvError(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── AGI: Save draft ────────────────────────────────────────────
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/agi/draft',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) {
|
||||
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { arbetsgivare, period, payload, salaryRunId } = await parseAGIRequest(request, ctx)
|
||||
|
||||
console.log('[skatteverket] AGI saving draft:', { arbetsgivare, period })
|
||||
|
||||
const result = await agiSaveDraft(ctx.supabase, ctx.companyId, arbetsgivare, period, payload)
|
||||
|
||||
if (!result.ok) {
|
||||
console.error('[skatteverket] AGI draft error:', result.status, result.error)
|
||||
return NextResponse.json(
|
||||
{ error: `Skatteverket svarade med ${result.status}: ${result.error}` },
|
||||
{ status: result.status }
|
||||
)
|
||||
}
|
||||
|
||||
// Track submission status and inlämningsId
|
||||
const inlamningId = result.data?.inlamningId
|
||||
await ctx.settings.set(
|
||||
`agi_submission_${period}`,
|
||||
JSON.stringify({
|
||||
status: 'draft_saved',
|
||||
arbetsgivare,
|
||||
period,
|
||||
inlamningId,
|
||||
salaryRunId,
|
||||
kontrollresultat: result.data?.kontrollresultat,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
)
|
||||
|
||||
// Update agi_declarations table with submission status
|
||||
if (salaryRunId) {
|
||||
await ctx.supabase
|
||||
.from('agi_declarations')
|
||||
.update({ status: 'exported' })
|
||||
.eq('salary_run_id', salaryRunId)
|
||||
.eq('company_id', ctx.companyId)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: result.data })
|
||||
} catch (err) {
|
||||
return handleSkvError(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── AGI: Get submission ────────────────────────────────────────
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/agi/submission',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) {
|
||||
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const arbetsgivare = url.searchParams.get('arbetsgivare')
|
||||
const period = url.searchParams.get('period')
|
||||
const inlamningId = url.searchParams.get('inlamningId')
|
||||
|
||||
if (!arbetsgivare || !period || !inlamningId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Saknar parametrar: arbetsgivare, period, inlamningId' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await agiGetSubmission(ctx.supabase, ctx.companyId, arbetsgivare, period, inlamningId)
|
||||
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Skatteverket svarade med ${result.status}: ${result.error}` },
|
||||
{ status: result.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: result.data })
|
||||
} catch (err) {
|
||||
return handleSkvError(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── AGI: Delete draft ──────────────────────────────────────────
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/agi/draft',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) {
|
||||
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const arbetsgivare = url.searchParams.get('arbetsgivare')
|
||||
const period = url.searchParams.get('period')
|
||||
const inlamningId = url.searchParams.get('inlamningId')
|
||||
|
||||
if (!arbetsgivare || !period || !inlamningId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Saknar parametrar: arbetsgivare, period, inlamningId' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await agiDeleteDraft(ctx.supabase, ctx.companyId, arbetsgivare, period, inlamningId)
|
||||
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Skatteverket svarade med ${result.status}: ${result.error}` },
|
||||
{ status: result.status }
|
||||
)
|
||||
}
|
||||
|
||||
await ctx.settings.set(`agi_submission_${period}`, null)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (err) {
|
||||
return handleSkvError(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── AGI: Lock period for signing ───────────────────────────────
|
||||
{
|
||||
method: 'PUT',
|
||||
path: '/agi/lock',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) {
|
||||
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const arbetsgivare = url.searchParams.get('arbetsgivare')
|
||||
const period = url.searchParams.get('period')
|
||||
|
||||
if (!arbetsgivare || !period) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Saknar parametrar: arbetsgivare, period' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await agiLockPeriod(ctx.supabase, ctx.companyId, arbetsgivare, period)
|
||||
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Skatteverket svarade med ${result.status}: ${result.error}` },
|
||||
{ status: result.status }
|
||||
)
|
||||
}
|
||||
|
||||
await ctx.settings.set(
|
||||
`agi_submission_${period}`,
|
||||
JSON.stringify({
|
||||
status: 'draft_locked',
|
||||
arbetsgivare,
|
||||
period,
|
||||
signeringslank: result.data?.signeringslank,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({ data: result.data })
|
||||
} catch (err) {
|
||||
return handleSkvError(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── AGI: Unlock period ─────────────────────────────────────────
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/agi/lock',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) {
|
||||
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const arbetsgivare = url.searchParams.get('arbetsgivare')
|
||||
const period = url.searchParams.get('period')
|
||||
|
||||
if (!arbetsgivare || !period) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Saknar parametrar: arbetsgivare, period' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await agiUnlockPeriod(ctx.supabase, ctx.companyId, arbetsgivare, period)
|
||||
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Skatteverket svarade med ${result.status}: ${result.error}` },
|
||||
{ status: result.status }
|
||||
)
|
||||
}
|
||||
|
||||
await ctx.settings.set(
|
||||
`agi_submission_${period}`,
|
||||
JSON.stringify({
|
||||
status: 'draft_saved',
|
||||
arbetsgivare,
|
||||
period,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (err) {
|
||||
return handleSkvError(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── AGI: Fetch submitted (after BankID signing) ────────────────
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/agi/submitted',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) {
|
||||
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const arbetsgivare = url.searchParams.get('arbetsgivare')
|
||||
const period = url.searchParams.get('period')
|
||||
|
||||
if (!arbetsgivare || !period) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Saknar parametrar: arbetsgivare, period' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await agiGetSubmitted(ctx.supabase, ctx.companyId, arbetsgivare, period)
|
||||
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Skatteverket svarade med ${result.status}: ${result.error}` },
|
||||
{ status: result.status }
|
||||
)
|
||||
}
|
||||
|
||||
// If we got a kvittensnummer, the AGI has been signed and submitted
|
||||
if (result.data?.kvittensnummer) {
|
||||
await ctx.settings.set(
|
||||
`agi_submission_${period}`,
|
||||
JSON.stringify({
|
||||
status: 'signed',
|
||||
arbetsgivare,
|
||||
period,
|
||||
kvittensnummer: result.data.kvittensnummer,
|
||||
tidpunkt: result.data.tidpunkt,
|
||||
signerare: result.data.signerare,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
)
|
||||
|
||||
// Update agi_declarations with submission receipt
|
||||
const periodYear = parseInt(period.slice(0, 4))
|
||||
const periodMonth = parseInt(period.slice(4, 6))
|
||||
await ctx.supabase
|
||||
.from('agi_declarations')
|
||||
.update({
|
||||
status: 'submitted',
|
||||
kvittensnummer: result.data.kvittensnummer,
|
||||
submitted_at: result.data.tidpunkt || new Date().toISOString(),
|
||||
submitted_by: ctx.userId,
|
||||
})
|
||||
.eq('company_id', ctx.companyId)
|
||||
.eq('period_year', periodYear)
|
||||
.eq('period_month', periodMonth)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: result.data })
|
||||
} catch (err) {
|
||||
return handleSkvError(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── AGI: Get submission status (local tracking) ────────────────
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/agi/status',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) {
|
||||
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const period = url.searchParams.get('period')
|
||||
|
||||
if (!period) {
|
||||
return NextResponse.json({ error: 'Saknar parameter: period' }, { status: 400 })
|
||||
}
|
||||
|
||||
const statusJson = await ctx.settings.get<string>(`agi_submission_${period}`)
|
||||
if (!statusJson) {
|
||||
return NextResponse.json({ data: null })
|
||||
}
|
||||
|
||||
try {
|
||||
return NextResponse.json({ data: JSON.parse(statusJson) })
|
||||
} catch {
|
||||
return NextResponse.json({ data: null })
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -624,6 +985,128 @@ function parseQueryParams(
|
||||
return { redovisare, redovisningsperiod }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate AGI submission request body.
|
||||
* Loads salary run data and builds the Skatteverket AGI JSON payload.
|
||||
*/
|
||||
async function parseAGIRequest(
|
||||
request: Request,
|
||||
ctx: ExtensionContext
|
||||
): Promise<{
|
||||
arbetsgivare: string
|
||||
period: string
|
||||
payload: ReturnType<typeof buildAGIPayload>
|
||||
salaryRunId: string
|
||||
}> {
|
||||
const body = await request.json()
|
||||
const { salaryRunId } = body as { salaryRunId: string }
|
||||
|
||||
if (!salaryRunId) {
|
||||
throw new Error('Saknar obligatoriskt fält: salaryRunId')
|
||||
}
|
||||
|
||||
// Get company settings for arbetsgivare formatting
|
||||
const { data: settings } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
.select('org_number, entity_type')
|
||||
.eq('company_id', ctx.companyId)
|
||||
.single()
|
||||
|
||||
if (!settings?.org_number) {
|
||||
throw new Error('Organisationsnummer saknas i företagsinställningar')
|
||||
}
|
||||
|
||||
// Load salary run
|
||||
const { data: run, error: runError } = await ctx.supabase
|
||||
.from('salary_runs')
|
||||
.select('*')
|
||||
.eq('id', salaryRunId)
|
||||
.eq('company_id', ctx.companyId)
|
||||
.single()
|
||||
|
||||
if (runError || !run) {
|
||||
throw new Error('Lönekörning hittades inte')
|
||||
}
|
||||
|
||||
if (!['review', 'approved', 'paid', 'booked'].includes(run.status)) {
|
||||
throw new Error('AGI kan bara skickas efter granskning')
|
||||
}
|
||||
|
||||
// Load employees with their data
|
||||
const { data: runEmployees } = await ctx.supabase
|
||||
.from('salary_run_employees')
|
||||
.select('*, employee:employees(personnummer, specification_number, f_skatt_status), line_items:salary_line_items(*)')
|
||||
.eq('salary_run_id', salaryRunId)
|
||||
|
||||
if (!runEmployees || runEmployees.length === 0) {
|
||||
throw new Error('Inga anställda i lönekörningen')
|
||||
}
|
||||
|
||||
// Build employee data
|
||||
const employeeData: AGIEmployeeData[] = runEmployees.map(sre => {
|
||||
const emp = sre.employee as { personnummer: string; specification_number: number; f_skatt_status: string } | null
|
||||
const lineItems = (sre.line_items || []) as Array<Record<string, unknown>>
|
||||
|
||||
const sumByType = (types: string[]) =>
|
||||
lineItems
|
||||
.filter(li => types.includes(li.item_type as string))
|
||||
.reduce((sum, li) => sum + ((li.amount as number) || 0), 0)
|
||||
|
||||
return {
|
||||
personnummer: emp?.personnummer || '',
|
||||
specificationNumber: emp?.specification_number || 0,
|
||||
grossSalary: sre.gross_salary,
|
||||
taxWithheld: sre.tax_withheld,
|
||||
avgifterBasis: sre.avgifter_basis,
|
||||
fSkattPayment: emp?.f_skatt_status === 'f_skatt' ? sre.gross_salary : undefined,
|
||||
benefitCar: sumByType(['benefit_car']) || undefined,
|
||||
benefitHousing: sumByType(['benefit_housing']) || undefined,
|
||||
benefitMeals: sumByType(['benefit_meals']) || undefined,
|
||||
benefitOther: sumByType(['benefit_wellness', 'benefit_other']) || undefined,
|
||||
sickDays: sre.sick_days > 0 ? sre.sick_days : undefined,
|
||||
vabDays: sre.vab_days > 0 ? sre.vab_days : undefined,
|
||||
parentalDays: sre.parental_days > 0 ? sre.parental_days : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
// Build totals with avgifter breakdown by category
|
||||
const avgifterByCategory: AGITotals['avgifterByCategory'] = {}
|
||||
for (const sre of runEmployees) {
|
||||
const dbCategory = sre.avgifter_category as string | null
|
||||
const category = dbCategory
|
||||
? (dbCategory === 'reduced_65plus' ? 'reduced65plus' : dbCategory === 'vaxa_stod' ? 'standard' : dbCategory)
|
||||
: (sre.avgifter_rate <= 0.1022 ? 'reduced65plus' : sre.avgifter_rate <= 0.2082 ? 'youth' : 'standard')
|
||||
const cat = avgifterByCategory[category as keyof typeof avgifterByCategory] || { basis: 0, amount: 0 }
|
||||
cat.basis += sre.avgifter_basis
|
||||
cat.amount += sre.avgifter_amount
|
||||
;(avgifterByCategory as Record<string, { basis: number; amount: number }>)[category] = cat
|
||||
}
|
||||
|
||||
const totals: AGITotals = {
|
||||
totalTax: run.total_tax,
|
||||
totalAvgifterBasis: runEmployees.reduce((s: number, e: { avgifter_basis: number }) => s + e.avgifter_basis, 0),
|
||||
avgifterByCategory,
|
||||
}
|
||||
|
||||
// Check if this is a correction
|
||||
const { data: existingAgi } = await ctx.supabase
|
||||
.from('agi_declarations')
|
||||
.select('id, status')
|
||||
.eq('company_id', ctx.companyId)
|
||||
.eq('period_year', run.period_year)
|
||||
.eq('period_month', run.period_month)
|
||||
.in('status', ['submitted', 'accepted'])
|
||||
.single()
|
||||
|
||||
const isCorrection = !!existingAgi
|
||||
|
||||
const arbetsgivare = formatRedovisare(settings.org_number, settings.entity_type)
|
||||
const period = formatRedovisningsperiod('monthly', run.period_year, run.period_month)
|
||||
const payload = buildAGIPayload(employeeData, totals, isCorrection)
|
||||
|
||||
return { arbetsgivare, period, payload, salaryRunId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Skatteverket errors to appropriate HTTP responses.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { skvRequest } from './api-client'
|
||||
import type { SkatteverketAGIInlamning, SkatteverketAGIKontrollresultat } from '../types'
|
||||
|
||||
/**
|
||||
* Skatteverket AGI (Arbetsgivardeklaration) API client.
|
||||
*
|
||||
* Follows the same pattern as the Momsdeklaration API:
|
||||
* kontrollera → utkast → lås → (BankID signering) → inlämnat
|
||||
*
|
||||
* Base URL: https://api.skatteverket.se/arbetsgivardeklaration/inlamning/v1
|
||||
*
|
||||
* Endpoint pattern:
|
||||
* /arbetsgivare/{arbetsgivarregistrerad}/redovisningsperioder/{redovisningsperiod}/...
|
||||
*/
|
||||
|
||||
const DEFAULT_AGI_API_BASE_URL =
|
||||
'https://api.test.skatteverket.se/arbetsgivardeklaration/inlamning/v1'
|
||||
|
||||
function getAgiApiBaseUrl(): string {
|
||||
return process.env.SKATTEVERKET_AGI_API_BASE_URL || DEFAULT_AGI_API_BASE_URL
|
||||
}
|
||||
|
||||
function basePath(arbetsgivare: string, period: string): string {
|
||||
return `/arbetsgivare/${arbetsgivare}/redovisningsperioder/${period}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate AGI data (dry run) without saving.
|
||||
* Returns validation errors/warnings.
|
||||
*/
|
||||
export async function agiValidate(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
arbetsgivare: string,
|
||||
period: string,
|
||||
payload: SkatteverketAGIInlamning
|
||||
): Promise<{ ok: boolean; status: number; data?: SkatteverketAGIKontrollresultat; error?: string }> {
|
||||
const response = await skvRequest(
|
||||
supabase,
|
||||
userId,
|
||||
'POST',
|
||||
`${basePath(arbetsgivare, period)}/kontrollera`,
|
||||
payload,
|
||||
{ baseUrl: getAgiApiBaseUrl() }
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
return { ok: false, status: response.status, error: text }
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return { ok: true, status: response.status, data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Save AGI as draft to Skatteverket's "Eget utrymme".
|
||||
* Returns kontrollresultat and inlämningsId.
|
||||
*/
|
||||
export async function agiSaveDraft(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
arbetsgivare: string,
|
||||
period: string,
|
||||
payload: SkatteverketAGIInlamning
|
||||
): Promise<{ ok: boolean; status: number; data?: { inlamningId?: string; kontrollresultat?: SkatteverketAGIKontrollresultat }; error?: string }> {
|
||||
const response = await skvRequest(
|
||||
supabase,
|
||||
userId,
|
||||
'POST',
|
||||
`${basePath(arbetsgivare, period)}/inlamningar`,
|
||||
payload,
|
||||
{ baseUrl: getAgiApiBaseUrl() }
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
return { ok: false, status: response.status, error: text }
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return { ok: true, status: response.status, data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific AGI submission.
|
||||
*/
|
||||
export async function agiGetSubmission(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
arbetsgivare: string,
|
||||
period: string,
|
||||
inlamningId: string
|
||||
): Promise<{ ok: boolean; status: number; data?: unknown; error?: string }> {
|
||||
const response = await skvRequest(
|
||||
supabase,
|
||||
userId,
|
||||
'GET',
|
||||
`${basePath(arbetsgivare, period)}/inlamningar/${inlamningId}`,
|
||||
undefined,
|
||||
{ baseUrl: getAgiApiBaseUrl() }
|
||||
)
|
||||
|
||||
if (response.status === 404) {
|
||||
return { ok: true, status: 404, data: null }
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
return { ok: false, status: response.status, error: text }
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return { ok: true, status: response.status, data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a draft AGI submission.
|
||||
*/
|
||||
export async function agiDeleteDraft(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
arbetsgivare: string,
|
||||
period: string,
|
||||
inlamningId: string
|
||||
): Promise<{ ok: boolean; status: number; error?: string }> {
|
||||
const response = await skvRequest(
|
||||
supabase,
|
||||
userId,
|
||||
'DELETE',
|
||||
`${basePath(arbetsgivare, period)}/inlamningar/${inlamningId}`,
|
||||
undefined,
|
||||
{ baseUrl: getAgiApiBaseUrl() }
|
||||
)
|
||||
|
||||
if (response.status !== 204 && !response.ok) {
|
||||
const text = await response.text()
|
||||
return { ok: false, status: response.status, error: text }
|
||||
}
|
||||
|
||||
return { ok: true, status: response.status }
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock the reporting period for signing.
|
||||
* Returns a signeringslänk for BankID signing on Skatteverket's site.
|
||||
*/
|
||||
export async function agiLockPeriod(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
arbetsgivare: string,
|
||||
period: string
|
||||
): Promise<{ ok: boolean; status: number; data?: { signeringslank?: string }; error?: string }> {
|
||||
const response = await skvRequest(
|
||||
supabase,
|
||||
userId,
|
||||
'PUT',
|
||||
`${basePath(arbetsgivare, period)}/las`,
|
||||
undefined,
|
||||
{ baseUrl: getAgiApiBaseUrl() }
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
return { ok: false, status: response.status, error: text }
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return { ok: true, status: response.status, data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlock a locked reporting period (cancel signing).
|
||||
*/
|
||||
export async function agiUnlockPeriod(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
arbetsgivare: string,
|
||||
period: string
|
||||
): Promise<{ ok: boolean; status: number; error?: string }> {
|
||||
const response = await skvRequest(
|
||||
supabase,
|
||||
userId,
|
||||
'DELETE',
|
||||
`${basePath(arbetsgivare, period)}/las`,
|
||||
undefined,
|
||||
{ baseUrl: getAgiApiBaseUrl() }
|
||||
)
|
||||
|
||||
if (response.status !== 204 && !response.ok) {
|
||||
const text = await response.text()
|
||||
return { ok: false, status: response.status, error: text }
|
||||
}
|
||||
|
||||
return { ok: true, status: response.status }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch submitted AGI (after signing).
|
||||
* Returns kvittensnummer and submission timestamp.
|
||||
*/
|
||||
export async function agiGetSubmitted(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
arbetsgivare: string,
|
||||
period: string
|
||||
): Promise<{ ok: boolean; status: number; data?: { kvittensnummer?: string; tidpunkt?: string; signerare?: string } | null; error?: string }> {
|
||||
const response = await skvRequest(
|
||||
supabase,
|
||||
userId,
|
||||
'GET',
|
||||
`${basePath(arbetsgivare, period)}/inlamnat`,
|
||||
undefined,
|
||||
{ baseUrl: getAgiApiBaseUrl() }
|
||||
)
|
||||
|
||||
if (response.status === 404) {
|
||||
return { ok: true, status: 404, data: null }
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
return { ok: false, status: response.status, error: text }
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return { ok: true, status: response.status, data }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { AGIEmployeeData, AGITotals } from '@/lib/salary/agi/xml-generator'
|
||||
import type { SkatteverketAGIInlamning, SkatteverketHuvuduppgift, SkatteverketIndividuppgift } from '../types'
|
||||
import { decryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
// Re-export shared formatting utilities
|
||||
export { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format'
|
||||
|
||||
/**
|
||||
* Convert gnubok salary run data to Skatteverket AGI JSON payload.
|
||||
*
|
||||
* JSON property names are derived from Skatteverket's XML element names,
|
||||
* following the same camelCase convention as the Momsdeklaration API.
|
||||
* The exact names should be verified against the RAML spec on Utvecklarportalen.
|
||||
*
|
||||
* CRITICAL: FK570 (specifikationsnummer) must stay consistent per employee.
|
||||
* Using a different number creates a new record instead of a correction.
|
||||
*/
|
||||
export function buildAGIPayload(
|
||||
employees: AGIEmployeeData[],
|
||||
totals: AGITotals,
|
||||
isCorrection: boolean = false
|
||||
): SkatteverketAGIInlamning {
|
||||
const huvuduppgift = buildHuvuduppgift(totals)
|
||||
const individuppgifter = employees.map(emp => buildIndividuppgift(emp))
|
||||
|
||||
return {
|
||||
rattelse: isCorrection,
|
||||
huvuduppgift,
|
||||
individuppgifter,
|
||||
}
|
||||
}
|
||||
|
||||
function buildHuvuduppgift(totals: AGITotals): SkatteverketHuvuduppgift {
|
||||
const result: SkatteverketHuvuduppgift = {}
|
||||
|
||||
if (totals.totalTax > 0) {
|
||||
result.avdragenSkatt = Math.round(totals.totalTax)
|
||||
}
|
||||
if (totals.totalAvgifterBasis > 0) {
|
||||
result.summaArbetsgivaravgifterUnderlag = Math.round(totals.totalAvgifterBasis)
|
||||
}
|
||||
|
||||
// Avgifter by category (rutor 060-062)
|
||||
if (totals.avgifterByCategory.standard) {
|
||||
result.avgifterUnderlagStandard = Math.round(totals.avgifterByCategory.standard.basis)
|
||||
}
|
||||
if (totals.avgifterByCategory.reduced65plus) {
|
||||
result.avgifterUnderlagAlderspension = Math.round(totals.avgifterByCategory.reduced65plus.basis)
|
||||
}
|
||||
if (totals.avgifterByCategory.youth) {
|
||||
result.avgifterUnderlagUngdom = Math.round(totals.avgifterByCategory.youth.basis)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function buildIndividuppgift(emp: AGIEmployeeData): SkatteverketIndividuppgift {
|
||||
// Decrypt personnummer — must be plaintext for Skatteverket
|
||||
let personnummer: string
|
||||
try {
|
||||
personnummer = decryptPersonnummer(emp.personnummer)
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Kunde inte dekryptera personnummer för anställd med FK570=${emp.specificationNumber}. ` +
|
||||
'AGI kan inte skickas utan giltigt personnummer.'
|
||||
)
|
||||
}
|
||||
|
||||
const result: SkatteverketIndividuppgift = {
|
||||
personnummer,
|
||||
specifikationsnummer: emp.specificationNumber,
|
||||
}
|
||||
|
||||
// Only include non-zero values (Skatteverket treats absent fields as 0)
|
||||
if (emp.grossSalary > 0) result.kontantBruttoloen = Math.round(emp.grossSalary)
|
||||
if (emp.taxWithheld > 0) result.avdragenSkatt = Math.round(emp.taxWithheld)
|
||||
if (emp.avgifterBasis > 0) result.underlagArbetsgivaravgifter = Math.round(emp.avgifterBasis)
|
||||
if (emp.fSkattPayment && emp.fSkattPayment > 0) result.ersattningFSkatt = Math.round(emp.fSkattPayment)
|
||||
|
||||
// Benefits (rutor 012-019)
|
||||
if (emp.benefitCar && emp.benefitCar > 0) result.formanBil = Math.round(emp.benefitCar)
|
||||
if (emp.benefitFuel && emp.benefitFuel > 0) result.formanDrivmedel = Math.round(emp.benefitFuel)
|
||||
if (emp.benefitHousing && emp.benefitHousing > 0) result.formanBostad = Math.round(emp.benefitHousing)
|
||||
if (emp.benefitMeals && emp.benefitMeals > 0) result.formanKost = Math.round(emp.benefitMeals)
|
||||
if (emp.benefitOther && emp.benefitOther > 0) result.formanOvrigt = Math.round(emp.benefitOther)
|
||||
|
||||
// Absence fields (from 2025)
|
||||
if (emp.sickDays && emp.sickDays > 0) result.sjukfranvaroDagar = Math.round(emp.sickDays)
|
||||
if (emp.vabDays && emp.vabDays > 0) result.vabDagar = Math.round(emp.vabDays)
|
||||
if (emp.parentalDays && emp.parentalDays > 0) result.foraldraledigDagar = Math.round(emp.parentalDays)
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -111,13 +111,14 @@ export async function skvRequest(
|
||||
userId: string,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown
|
||||
body?: unknown,
|
||||
options?: { baseUrl?: string }
|
||||
): Promise<Response> {
|
||||
const accessToken = await getValidToken(supabase, userId)
|
||||
|
||||
await enforceRateLimit()
|
||||
|
||||
const url = `${getApiBaseUrl()}${path}`
|
||||
const url = `${options?.baseUrl || getApiBaseUrl()}${path}`
|
||||
const headers: Record<string, string> = {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Client_Id': getApiGwClientId(),
|
||||
|
||||
@@ -84,6 +84,72 @@ export type DeclarationStatus =
|
||||
| 'signed'
|
||||
| 'decided'
|
||||
|
||||
// ── AGI (Arbetsgivardeklaration) types ──────────────────────────
|
||||
|
||||
/**
|
||||
* AGI submission payload — sent to Skatteverket inlämning API.
|
||||
*
|
||||
* JSON property names follow the same camelCase convention as the
|
||||
* Momsdeklaration API. Derived from Skatteverket's XML element names
|
||||
* and FK field codes. Verify against the RAML spec on Utvecklarportalen.
|
||||
*/
|
||||
export interface SkatteverketAGIInlamning {
|
||||
rattelse: boolean
|
||||
huvuduppgift: SkatteverketHuvuduppgift
|
||||
individuppgifter: SkatteverketIndividuppgift[]
|
||||
}
|
||||
|
||||
/** Employer-level totals (Huvuduppgift) */
|
||||
export interface SkatteverketHuvuduppgift {
|
||||
/** Ruta 001: Total avdragen skatt */
|
||||
avdragenSkatt?: number
|
||||
/** Ruta 020: Total underlag arbetsgivaravgifter */
|
||||
summaArbetsgivaravgifterUnderlag?: number
|
||||
/** Ruta 060: Avgifter — standard rate (31.42%) */
|
||||
avgifterUnderlagStandard?: number
|
||||
/** Ruta 061: Avgifter — ålderspension only (10.21%, 67+ from 2026) */
|
||||
avgifterUnderlagAlderspension?: number
|
||||
/** Ruta 062: Avgifter — youth rate (20.81%, ages 19-23, Apr 2026–Sep 2027) */
|
||||
avgifterUnderlagUngdom?: number
|
||||
}
|
||||
|
||||
/** Per-employee data (Individuppgift) */
|
||||
export interface SkatteverketIndividuppgift {
|
||||
/** FK215: Personnummer/samordningsnummer (12 digits, plaintext) */
|
||||
personnummer: string
|
||||
/** FK570: Specifikationsnummer — MUST stay consistent per employee */
|
||||
specifikationsnummer: number
|
||||
/** Ruta 011: Kontant bruttolön */
|
||||
kontantBruttoloen?: number
|
||||
/** Ruta 001: Avdragen skatt */
|
||||
avdragenSkatt?: number
|
||||
/** Ruta 012: Förmån bil */
|
||||
formanBil?: number
|
||||
/** Ruta 013: Förmån drivmedel */
|
||||
formanDrivmedel?: number
|
||||
/** Ruta 014: Förmån bostad */
|
||||
formanBostad?: number
|
||||
/** Ruta 015: Förmån kost */
|
||||
formanKost?: number
|
||||
/** Ruta 019: Förmån övrigt */
|
||||
formanOvrigt?: number
|
||||
/** Ruta 020: Underlag arbetsgivaravgifter */
|
||||
underlagArbetsgivaravgifter?: number
|
||||
/** Ruta 131: Ersättning till F-skatt holder */
|
||||
ersattningFSkatt?: number
|
||||
/** FK821: Sjukfrånvaro dagar */
|
||||
sjukfranvaroDagar?: number
|
||||
/** FK822: VAB dagar */
|
||||
vabDagar?: number
|
||||
/** FK823: Föräldraledighet dagar */
|
||||
foraldraledigDagar?: number
|
||||
}
|
||||
|
||||
/** AGI validation result from Skatteverket /kontrollera */
|
||||
export interface SkatteverketAGIKontrollresultat {
|
||||
kontroller?: SkatteverketKontroll[]
|
||||
}
|
||||
|
||||
export interface SkatteverketSubmission {
|
||||
id: string
|
||||
user_id: string
|
||||
|
||||
Reference in New Issue
Block a user