* feat(salary): per-day absence tracking with calendar UX Replace aggregated-day absence counts with per-day records so payroll calculations can correctly enforce Swedish legal rules that depend on actual dates: karensavdrag once per sjuklöneperiod, återinsjuknande within 5 calendar days, allmänt högriskskydd cap of 10 karensavdrag per rolling 12 months, day-8 läkarintyg flag, day-15 transition to Försäkringskassan. Adds: - salary_absence_days table (RLS, dedup unique on employee+date+type) - /api/salary/employees/[id]/absence CRUD route - deriveAbsenceLineItems helper that walks per-day records into sjuklöneperioder and emits correctly-classified line items, with the existing absence-calculator formulas reused for VAB / parental - Per-employee pay-spec detail page with month-grid AbsenceCalendar - Calculate route now derives line items from the calendar before running the salary engine, replacing the prior sumQuantity model - Salary run GET surfaces the formatted Skatteverket arbetsgivare ID so downstream UI can build extension URLs without a second round-trip - GET /salary/runs/[id]/employees/[employeeId] for the detail page Tests: 15 new unit tests covering segment merge, återinsjuknande within 5 days, högriskskydd cap, FK transition flag, läkarintyg flag, VAB/parental semesterlönegrundande ceilings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skatteverket): harden API client + add NEXT_PUBLIC_SKATTEVERKET_ENABLED feature flag Three hardening fixes from the prior audit, plus a runtime extension toggle for phased rollout. api-client.ts: - Map 429 to a new SkatteverketAuthError code RATE_LIMITED with a Swedish user message. The 4 req/sec local rate limiter normally prevents this, but the per-consumer gateway quota can still hit. - Extend the error union with TOKEN_CORRUPTED for the token-store fix below. token-store.ts: - Surface decryption failures instead of silently returning null. A rotated key or tampered ciphertext used to look like "not connected"; callers now get TOKEN_CORRUPTED with a clear "anslut igen med BankID" message and a structured log line for ops. Extension dispatcher (app/api/extensions/ext/[...path]/route.ts): - Per-extension feature flag table. When NEXT_PUBLIC_SKATTEVERKET_ENABLED is not exactly "true", the dispatcher returns 503 with code EXTENSION_DISABLED, letting ops disable a single integration mid- rollout without redeploying or removing it from extensions.config.json. UI panels (SkatteverketPanel, AGIPanel) detect the 503 and render an empty state. Tests: 7 api-client cases (401/403/403-Behörighet/429/5xx/200/auth-error codes) + 2 token-store cases (no-row → null, corrupted → TOKEN_CORRUPTED). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(salary): emit AGI Frånvarouppgift per SKV 4785, add AGIPanel for one-click submission AGI XML upgrade: - Emit <gem:Franvarouppgift> top-level blocks for VAB and parental leave events sourced from salary_absence_days, per SKV 4785 + technical doc. Element order matches the spec example file. TILLFALLIG_FORALDRAPENNING for VAB / FORALDRAPENNING for parental, with FranvaroTimmarTFP (FK825) or FranvaroTimmarFP (FK827) for hours. Stable 1-based specifikationsnummer per (employee, period), date-sorted. Skipped entirely for periods before 202501. - Sick days are NOT emitted (they go to Försäkringskassan). - FK499 TotalSjuklonekostnad now derived from sick_day2_14.quantity × dailyRate × 0.80 instead of Math.abs(amount). The line-item amount is the net deduction (lostPay − sjuklon), not the cost, so the prior formula understated by a factor of four. AGI submission UI: - New AGIPanel mirroring SkatteverketPanel's validate → draft → lock → BankID-sign → poll-submitted flow. Detects 503 EXTENSION_DISABLED and renders a clear empty state. Replaces the bare "Skicka till Skatteverket" button on /salary/runs/[id], keeping the AGI XML download as a sibling for archival / manual upload fallback. - Salary run rows now link to the per-employee detail page added in the previous commit. Tests: 14 new agi-xml cases covering element order, type↔hour-field mapping, specifikationsnummer ordering, fractional-hour formatting, range clamping (0.01-24.00), period guard at 202501 boundary, placement after Blankett blocks, multi-employee date ordering, required-fields invariant, omission when no events. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skatteverket): skattekonto integration — read-only saldo + transactions, daily sync, per-row bokför Adds read-only Skattekonto v2.1 access via the existing BankID OAuth flow (extends the OAuth scope with `skattekonto`). Daily background sync pulls saldo + transactions, dedupes on (company_id, dedup_key), and surfaces the data in a /skattekonto dashboard plus a settings panel for connection management. Backend: - skattekonto-client.ts: GET /skattekonton/{omfragad}/saldo and /transaktioner. Felkod 1–5 mapped to Swedish messages via dedicated SkatteverketSkattekontoError. - skattekonto-sync.ts: parallel saldo + transaktioner fetch, UPSERT on (company_id, dedup_key) so kommande rows graduate to tidigare in place. Dedup key uses transaktionsidentitet when available, else sha256 of (date|amount|text). Caches saldo snapshot in extension_data. Emits skattekonto.synced / balance.changed (sign flip) / transaction.upcoming (first appearance) / connection.expired. - skattekonto-booking.ts: keyword→counter-account rules with AB/EF differentiation (2510 vs 2012 for preliminärskatt; 2731/2710/2650 for arbetsgivaravgifter/avdragen skatt/moms; 8423/8313 for kostnads-/intäktsränta). Creates a draft journal entry against BAS 1630, leaves it for the user to review and commit. Throws NO_COUNTER_ACCOUNT instead of guessing when no rule matches. - Daily cron at 0 4 * * * (Swedish 06:00). Double-gated by CRON_SECRET and NEXT_PUBLIC_SKATTEVERKET_ENABLED. Per-company cooldown of 1 hour, time budget 50s, distinct `expired` status for token-exhaustion separate from generic errors. Database: - skattekonto_transactions: company-scoped with RLS, unique (company_id, dedup_key), indexed on (company_id, date DESC) and (company_id, status). journal_entry_id FK with ON DELETE SET NULL so a row can be re-bokförd after entry deletion. Frontend: - /skattekonto/page.tsx: dashboard with saldo card, transactions list (booked + upcoming), per-row "Bokför" action. - /settings/skatteverket: connection panel showing scope/expiry. - Extension toggle in SettingsSidebar (gated by ENABLED_EXTENSION_IDS). Tests: 9 booking-rule cases (counter-account guessing, AB/EF divergence, no-match throw) + 7 mapper cases (dedup key stability, sign convention, kommande→tidigare graduation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address PR review findings Build: - Fix Next.js build failure: Zod refuses .partial() on a refined schema. Replace AbsenceRangeQuerySchema.partial().extend(...) in the absence DELETE handler with a fresh z.object that defines its own optional fields. Greptile findings (PR #388): - skattekonto_transactions UPDATE policy was missing WITH CHECK; without it a user could mutate company_id to one they don't belong to. Edit the original migration for fresh applies + add a follow-up migration that drops/recreates the policy with both clauses (already applied to prod via Supabase MCP). - FK499 TotalSjuklonekostnad now reads sjuklonRate from run.calculation_params (snapshot taken at calc time) instead of a hardcoded 0.80, so an operator override (e.g. CBA-specific rate) is honored. Falls back to 0.80 for older runs without the snapshot. - Rename NEXT_PUBLIC_SKATTEVERKET_ENABLED → SKATTEVERKET_ENABLED so the flag is server-side only. NEXT_PUBLIC_* vars are inlined into the client bundle at build time, which would create split-brain (server 503 vs client still rendering enabled flow) on a flag flip without redeploy. UI panels detect 503 by response code, not by reading the env directly, so no client-visible change is needed. - Add pg-real RLS smoke tests for both new tables (salary_absence_days and skattekonto_transactions): tenant SELECT isolation, UPDATE WITH CHECK enforcement, unique-constraint enforcement, cross-tenant dedup key allowed. Swedish compliance review: - Document the högriskskydd cap interpretation in derive-absence-line-items.ts. We count *sjuklöneperioder* in the rolling 12-month window, matching the law's plain reading ("från och med den 11:e sjukperioden ... görs inget karensavdrag"). An alternative reading counts only periods that actually had karens deducted; that requires persisting per-period karens-deduction state, which gnubok doesn't yet do. The period-count reading can over- suppress, never under-suppress, so it's the safer default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): inline skattekonto fixtures so core-only CI runs without dev_docs dev_docs/ is gitignored, so the skattekonto-mappers test failed in CI when it tried to readFileSync from dev_docs/skattekonto(2.1.0)/examples/. Inline the saldoResponse + transaktionerResponse fixtures verbatim from the spec; the test still verifies our mappers + dedup-key logic against the same shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
242 lines
7.8 KiB
TypeScript
242 lines
7.8 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))
|
|
}
|
|
}
|
|
|
|
// Coalesce concurrent refresh attempts within this Node.js process. Without
|
|
// this, two parallel SKV requests from the same user (e.g. rapid UI clicks)
|
|
// would both call SKV's /token endpoint with the same refresh_token; SKV
|
|
// rotates that token on first use, so the second call would fail with 401.
|
|
// Cross-process races (separate Vercel function instances) are mitigated by
|
|
// the re-read inside the critical section: if another process refreshed
|
|
// while we waited on the network, we just use that newer token.
|
|
const refreshInFlight = new Map<string, Promise<string>>()
|
|
|
|
/**
|
|
* 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 — coalesce concurrent attempts.
|
|
const inFlight = refreshInFlight.get(userId)
|
|
if (inFlight) return inFlight
|
|
|
|
const promise = refreshTokenForUser(supabase, userId)
|
|
.finally(() => refreshInFlight.delete(userId))
|
|
refreshInFlight.set(userId, promise)
|
|
return promise
|
|
}
|
|
|
|
async function refreshTokenForUser(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
): Promise<string> {
|
|
// Re-read after entering the critical section. Another process may have
|
|
// refreshed while we were waiting; if so, the row now has a new
|
|
// refresh_token and a future expiry — just hand it back.
|
|
const tokens = await getTokens(supabase, userId)
|
|
if (!tokens) {
|
|
throw new SkatteverketAuthError(
|
|
'Inte ansluten till Skatteverket. Anslut med BankID först.',
|
|
'NOT_CONNECTED'
|
|
)
|
|
}
|
|
if (tokens.expires_at > Date.now() + TOKEN_REFRESH_MARGIN_MS) {
|
|
return tokens.access_token
|
|
}
|
|
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'
|
|
)
|
|
}
|
|
|
|
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/throttle errors uniformly so callers
|
|
// can catch a single error type rather than parsing status codes inline.
|
|
if (response.status === 401) {
|
|
throw new SkatteverketAuthError(
|
|
'Sessionen har gått ut. Logga in med BankID igen.',
|
|
'SESSION_EXPIRED'
|
|
)
|
|
}
|
|
|
|
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 === 429) {
|
|
// Skatteverket may include a Retry-After header. We surface a generic
|
|
// Swedish message — callers can inspect the header on the thrown error
|
|
// if they need to schedule a retry. The 4 req/sec local rate limiter
|
|
// should normally prevent this; a 429 here implies the per-consumer
|
|
// gateway quota was exceeded.
|
|
throw new SkatteverketAuthError(
|
|
'Skatteverket är överbelastat eller har strypt anropen. Försök igen om en stund.',
|
|
'RATE_LIMITED'
|
|
)
|
|
}
|
|
|
|
return response
|
|
}
|
|
|
|
/**
|
|
* Structured error for Skatteverket auth/access/throttle issues.
|
|
* The `code` field helps the frontend show appropriate UI.
|
|
*
|
|
* Codes:
|
|
* NOT_CONNECTED — no tokens stored; user needs to run BankID flow
|
|
* SESSION_EXPIRED — 401 from SKV; refresh exhausted or token rejected
|
|
* REFRESH_EXHAUSTED — refresh count hit cap (10) before user re-auth
|
|
* BEHORIGHET_SAKNAS — 403 with "Behörighet" body; user not authorized
|
|
* for this company at SKV (firmatecknare / ombud)
|
|
* ACCESS_DENIED — generic 403
|
|
* RATE_LIMITED — 429 from SKV API gateway
|
|
* TOKEN_CORRUPTED — stored tokens cannot be decrypted (key rotated
|
|
* or row tampered with); user must reconnect
|
|
*/
|
|
export class SkatteverketAuthError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly code:
|
|
| 'NOT_CONNECTED'
|
|
| 'SESSION_EXPIRED'
|
|
| 'REFRESH_EXHAUSTED'
|
|
| 'BEHORIGHET_SAKNAS'
|
|
| 'ACCESS_DENIED'
|
|
| 'RATE_LIMITED'
|
|
| 'TOKEN_CORRUPTED'
|
|
) {
|
|
super(message)
|
|
this.name = 'SkatteverketAuthError'
|
|
}
|
|
}
|