* 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>
184 lines
5.1 KiB
TypeScript
184 lines
5.1 KiB
TypeScript
import crypto from 'crypto'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { refreshAccessToken } from './oauth'
|
|
import { getTokens, storeTokens } from './token-store'
|
|
import type { SkatteverketTokens } from '../types'
|
|
|
|
/**
|
|
* Skatteverket API client.
|
|
*
|
|
* Handles:
|
|
* - Automatic token refresh (transparent to callers)
|
|
* - Required API gateway headers
|
|
* - Rate limiting (4 req/sec per consumer)
|
|
* - Correlation ID generation
|
|
*/
|
|
|
|
const DEFAULT_API_BASE_URL = 'https://api.test.skatteverket.se/momsdeklaration/v1'
|
|
const MAX_REFRESH_COUNT = 10
|
|
const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000 // Refresh 5 min before expiry
|
|
|
|
// Simple in-memory token bucket for 4 req/sec rate limit
|
|
let lastRequestTime = 0
|
|
const MIN_REQUEST_INTERVAL_MS = 250 // 1000ms / 4 = 250ms
|
|
|
|
function getApiBaseUrl(): string {
|
|
return process.env.SKATTEVERKET_API_BASE_URL || DEFAULT_API_BASE_URL
|
|
}
|
|
|
|
function getApiGwClientId(): string {
|
|
const id = process.env.SKATTEVERKET_APIGW_CLIENT_ID
|
|
if (!id) throw new Error('SKATTEVERKET_APIGW_CLIENT_ID is required')
|
|
return id
|
|
}
|
|
|
|
function getApiGwClientSecret(): string {
|
|
const secret = process.env.SKATTEVERKET_APIGW_CLIENT_SECRET
|
|
if (!secret) throw new Error('SKATTEVERKET_APIGW_CLIENT_SECRET is required')
|
|
return secret
|
|
}
|
|
|
|
/**
|
|
* Ensure rate limit compliance (4 req/sec).
|
|
* Delays if the last request was too recent.
|
|
*/
|
|
async function enforceRateLimit(): Promise<void> {
|
|
const now = Date.now()
|
|
const elapsed = now - lastRequestTime
|
|
lastRequestTime = now // Claim the slot immediately to prevent concurrent bypass
|
|
if (elapsed < MIN_REQUEST_INTERVAL_MS) {
|
|
await new Promise(resolve => setTimeout(resolve, MIN_REQUEST_INTERVAL_MS - elapsed))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a valid access token, refreshing if needed.
|
|
* Throws if no tokens exist or refresh is exhausted.
|
|
*/
|
|
async function getValidToken(
|
|
supabase: SupabaseClient,
|
|
userId: string
|
|
): Promise<string> {
|
|
const tokens = await getTokens(supabase, userId)
|
|
if (!tokens) {
|
|
throw new SkatteverketAuthError(
|
|
'Inte ansluten till Skatteverket. Anslut med BankID först.',
|
|
'NOT_CONNECTED'
|
|
)
|
|
}
|
|
|
|
// Token still valid (with 5-min margin)
|
|
if (tokens.expires_at > Date.now() + TOKEN_REFRESH_MARGIN_MS) {
|
|
return tokens.access_token
|
|
}
|
|
|
|
// Need refresh
|
|
if (!tokens.refresh_token) {
|
|
throw new SkatteverketAuthError(
|
|
'Sessionen har gått ut. Logga in med BankID igen.',
|
|
'SESSION_EXPIRED'
|
|
)
|
|
}
|
|
|
|
if (tokens.refresh_count >= MAX_REFRESH_COUNT) {
|
|
throw new SkatteverketAuthError(
|
|
'Maximalt antal förnyelser uppnått. Logga in med BankID igen.',
|
|
'REFRESH_EXHAUSTED'
|
|
)
|
|
}
|
|
|
|
// Refresh — returns NEW refresh_token that must be stored
|
|
const refreshed = await refreshAccessToken(tokens.refresh_token, tokens.refresh_count)
|
|
const updatedTokens: SkatteverketTokens = {
|
|
...refreshed,
|
|
scope: tokens.scope,
|
|
}
|
|
await storeTokens(supabase, userId, updatedTokens)
|
|
|
|
return updatedTokens.access_token
|
|
}
|
|
|
|
/**
|
|
* Make an authenticated request to the Skatteverket API.
|
|
*
|
|
* Automatically handles:
|
|
* - Token refresh if expired
|
|
* - Required headers (Client_Id, Client_Secret, correlation ID)
|
|
* - Rate limiting
|
|
*/
|
|
export async function skvRequest(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
method: string,
|
|
path: string,
|
|
body?: unknown,
|
|
options?: { baseUrl?: string }
|
|
): Promise<Response> {
|
|
const accessToken = await getValidToken(supabase, userId)
|
|
|
|
await enforceRateLimit()
|
|
|
|
const url = `${options?.baseUrl || getApiBaseUrl()}${path}`
|
|
const headers: Record<string, string> = {
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'Client_Id': getApiGwClientId(),
|
|
'Client_Secret': getApiGwClientSecret(),
|
|
'skv_client_correlation_id': crypto.randomUUID(),
|
|
}
|
|
|
|
if (body !== undefined) {
|
|
headers['Content-Type'] = 'application/json'
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
method,
|
|
headers,
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
})
|
|
|
|
// Handle Skatteverket-specific auth errors
|
|
if (response.status === 403) {
|
|
const text = await response.text()
|
|
// Behörighet saknas — user is authenticated but not authorized for this company
|
|
if (text.includes('Behörighet') || text.includes('behörighet')) {
|
|
throw new SkatteverketAuthError(
|
|
'Du har inte behörighet att agera för detta företag hos Skatteverket. ' +
|
|
'Kontrollera att du är registrerad som firmatecknare eller deklarationsombud.',
|
|
'BEHORIGHET_SAKNAS'
|
|
)
|
|
}
|
|
throw new SkatteverketAuthError(
|
|
`Åtkomst nekad av Skatteverket (403): ${text}`,
|
|
'ACCESS_DENIED'
|
|
)
|
|
}
|
|
|
|
if (response.status === 401) {
|
|
throw new SkatteverketAuthError(
|
|
'Sessionen har gått ut. Logga in med BankID igen.',
|
|
'SESSION_EXPIRED'
|
|
)
|
|
}
|
|
|
|
return response
|
|
}
|
|
|
|
/**
|
|
* Structured error for Skatteverket auth/access issues.
|
|
* The `code` field helps the frontend show appropriate UI.
|
|
*/
|
|
export class SkatteverketAuthError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly code:
|
|
| 'NOT_CONNECTED'
|
|
| 'SESSION_EXPIRED'
|
|
| 'REFRESH_EXHAUSTED'
|
|
| 'BEHORIGHET_SAKNAS'
|
|
| 'ACCESS_DENIED'
|
|
) {
|
|
super(message)
|
|
this.name = 'SkatteverketAuthError'
|
|
}
|
|
}
|