Files
accounted/extensions/general/skatteverket/lib/skattekonto-client.ts
T
Jakob WennbergandClaude Opus 4.7 f3fd4c0822 feat(salary, skatteverket): per-day absence + AGI Frånvarouppgift + skattekonto + hardening (#388)
* 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>
2026-05-04 19:01:21 +02:00

158 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { SupabaseClient } from '@supabase/supabase-js'
import { skvRequest } from './api-client'
import type {
SkatteverketSaldoResponse,
SkatteverketTransaktionerResponse,
SkatteverketFel,
} from '../types'
/**
* Skatteverket Skattekonto API v2.1.0 client.
*
* Spec: https://api.skatteverket.se/beskattning/skattekonto/v2
* Test: https://api.test.skatteverket.se/beskattning/skattekonto/v2
*
* The personal OAuth (BankID) tokens already used for momsdeklaration also
* grant access to skattekonto when the OAuth scope includes `skattekonto`.
* skvRequest() handles the gateway headers, rate limiting, and refresh.
*/
const DEFAULT_SKATTEKONTO_BASE_URL =
'https://api.test.skatteverket.se/beskattning/skattekonto/v2'
function getBaseUrl(): string {
return (
process.env.SKATTEVERKET_SKATTEKONTO_API_BASE_URL ||
DEFAULT_SKATTEKONTO_BASE_URL
)
}
/**
* Map Skatteverket error codes (felkod 1–5) to Swedish user messages.
*
* Codes per dev_docs/skattekonto(2.1.0)/examples/felkod_*.json.
*/
function mapFelkodToMessage(fel: SkatteverketFel): string {
switch (fel.felkod) {
case 1:
return 'Felaktigt organisationsnummer.'
case 2:
return 'Felaktigt datum.'
case 3:
return 'Inget skattekonto är registrerat hos Skatteverket.'
case 4:
return 'Internt fel hos Skatteverket. Försök igen om en stund.'
case 5:
return 'Skattekontot är stängt.'
default:
return fel.felmeddelande || `Skatteverket-fel ${fel.felkod}`
}
}
/**
* Throws a typed error with a Swedish message when Skatteverket returns
* a non-200 response. The skvRequest() helper has already mapped 401/403/429
* to SkatteverketAuthError, so here we only handle 400/404/500/503 and the
* felkod envelope returned in the body.
*/
async function handleErrorResponse(response: Response): Promise<never> {
let fel: SkatteverketFel | null = null
try {
fel = (await response.json()) as SkatteverketFel
} catch {
// body wasn't JSON — fall through to generic message
}
if (fel && typeof fel.felkod === 'number') {
throw new SkatteverketSkattekontoError(
mapFelkodToMessage(fel),
fel.felkod,
response.status,
)
}
throw new SkatteverketSkattekontoError(
`Skatteverket svarade med ${response.status}`,
null,
response.status,
)
}
/**
* Structured error for skattekonto-specific Skatteverket failures.
* Distinct from SkatteverketAuthError (which signals auth/access/throttle).
*/
export class SkatteverketSkattekontoError extends Error {
constructor(
message: string,
public readonly felkod: number | null,
public readonly httpStatus: number,
) {
super(message)
this.name = 'SkatteverketSkattekontoError'
}
}
/**
* GET /skattekonton/{omfragad}/saldo
*
* @param omfragad 10/12-digit org/personnummer (formatRedovisare format)
* @param datum Optional ISO date (YYYY-MM-DD); fetch balance as of date
*/
export async function getSaldo(
supabase: SupabaseClient,
userId: string,
omfragad: string,
datum?: string,
): Promise<SkatteverketSaldoResponse> {
const qs = datum ? `?datum=${encodeURIComponent(datum)}` : ''
const response = await skvRequest(
supabase,
userId,
'GET',
`/skattekonton/${omfragad}/saldo${qs}`,
undefined,
{ baseUrl: getBaseUrl() },
)
if (!response.ok) {
await handleErrorResponse(response)
}
return (await response.json()) as SkatteverketSaldoResponse
}
/**
* GET /skattekonton/{omfragad}/transaktioner
*
* @param omfragad 10/12-digit org/personnummer
* @param datumFrom Optional ISO date (YYYY-MM-DD). Defaults at SKV to
* 555 days back; max lookback is 915 days.
*/
export async function getTransaktioner(
supabase: SupabaseClient,
userId: string,
omfragad: string,
datumFrom?: string,
): Promise<SkatteverketTransaktionerResponse> {
const qs = datumFrom ? `?datumFrom=${encodeURIComponent(datumFrom)}` : ''
const response = await skvRequest(
supabase,
userId,
'GET',
`/skattekonton/${omfragad}/transaktioner${qs}`,
undefined,
{ baseUrl: getBaseUrl() },
)
if (!response.ok) {
await handleErrorResponse(response)
}
const data = (await response.json()) as Partial<SkatteverketTransaktionerResponse>
return {
tidigareTransaktioner: data.tidigareTransaktioner ?? [],
kommandeTransaktioner: data.kommandeTransaktioner ?? [],
}
}