Files
accounted/extensions/general/skatteverket/lib/skattekonto-sync.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

277 lines
8.8 KiB
TypeScript

import crypto from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { ExtensionContext } from '@/lib/extensions/types'
import { eventBus } from '@/lib/events/bus'
import { createLogger } from '@/lib/logger'
import { formatRedovisare } from '@/lib/skatteverket/format'
import { getSaldo, getTransaktioner } from './skattekonto-client'
import { SkatteverketAuthError } from './api-client'
import type {
SkatteverketBookedTransaction,
SkatteverketUpcomingTransaction,
SkatteverketSaldoResponse,
SkattekontoBalanceSnapshot,
StoredSkattekontoTransaction,
} from '../types'
const log = createLogger('skattekonto-sync')
const BALANCE_SNAPSHOT_KEY = 'skattekonto_balance_snapshot'
const LAST_SYNCED_AT_KEY = 'skattekonto_last_synced_at'
export interface SkattekontoSyncResult {
/** Number of new or status-promoted booked rows */
booked: number
/** Number of new or updated upcoming rows */
upcoming: number
/** Saldo at end of sync (mirrors snapshot) */
saldoSkatteverket: number
saldoKronofogden: number
/** Sync timestamp */
syncedAt: string
}
/**
* Compute the dedup key for a transaction.
*
* - When `transaktionsidentitet` is present (always on tidigare, sometimes
* on kommande), use it directly. It's stable across syncs.
* - Otherwise compute a sha256 hex over (date|amount|text) — stable enough
* for kommande, which graduate to tidigare with the same content.
*
* The point of this function is reproducibility: the same logical
* transaction must always produce the same dedup_key.
*/
export function computeDedupKey(tx: {
transaktionsidentitet?: number | null
transaktionsdatum: string
beloppSkatteverket: number
transaktionstext: string
}): string {
if (tx.transaktionsidentitet != null) {
return `id:${tx.transaktionsidentitet}`
}
const material = `${tx.transaktionsdatum}|${tx.beloppSkatteverket}|${tx.transaktionstext}`
return `h:${crypto.createHash('sha256').update(material).digest('hex')}`
}
/**
* Resolve the org/personnummer to send to Skatteverket as `omfragad`.
* Reads from company_settings — same source the existing momsdeklaration
* flow uses.
*/
async function resolveOmfragad(
supabase: SupabaseClient,
companyId: string,
): Promise<string> {
const { data: settings } = await supabase
.from('company_settings')
.select('org_number, entity_type')
.eq('company_id', companyId)
.single()
if (!settings?.org_number) {
throw new Error('Organisationsnummer saknas i företagsinställningar')
}
return formatRedovisare(settings.org_number, settings.entity_type)
}
/**
* Build the row to insert/upsert into skattekonto_transactions.
*/
function bookedToRow(
companyId: string,
tx: SkatteverketBookedTransaction,
): Omit<StoredSkattekontoTransaction, 'id' | 'imported_at' | 'updated_at' | 'journal_entry_id'> {
return {
company_id: companyId,
transaktionsidentitet: tx.transaktionsidentitet,
dedup_key: computeDedupKey(tx),
transaktionsdatum: tx.transaktionsdatum,
forfallodatum: null,
ranteberakningsdatum: tx.ranteberakningsdatum,
transaktionstext: tx.transaktionstext,
belopp_skatteverket: tx.beloppSkatteverket,
belopp_kronofogden: tx.beloppKronofogden,
status: 'booked',
}
}
function upcomingToRow(
companyId: string,
tx: SkatteverketUpcomingTransaction,
): Omit<StoredSkattekontoTransaction, 'id' | 'imported_at' | 'updated_at' | 'journal_entry_id'> {
return {
company_id: companyId,
transaktionsidentitet: tx.transaktionsidentitet ?? null,
dedup_key: computeDedupKey(tx),
transaktionsdatum: tx.transaktionsdatum,
forfallodatum: tx.forfallodatum,
ranteberakningsdatum: tx.ranteberakningsdatum,
transaktionstext: tx.transaktionstext,
belopp_skatteverket: tx.beloppSkatteverket,
belopp_kronofogden: tx.beloppKronofogden,
status: 'upcoming',
}
}
/**
* Sync skattekonto data for the active company in `ctx`.
*
* Steps:
* 1. Resolve omfragad from company_settings.
* 2. Fetch saldo + transaktioner in parallel (rate-limited).
* 3. Upsert rows by (company_id, dedup_key). When a kommande row graduates
* to tidigare it's updated in place — same dedup_key, status flips,
* transaktionsidentitet populated.
* 4. Cache the saldo response in extension_data with a fetched-at timestamp.
* 5. Emit skattekonto.synced and (when applicable) other events.
*/
export async function syncSkattekonto(
ctx: ExtensionContext,
): Promise<SkattekontoSyncResult> {
const omfragad = await resolveOmfragad(ctx.supabase, ctx.companyId)
let saldo: SkatteverketSaldoResponse
let transaktioner: Awaited<ReturnType<typeof getTransaktioner>>
try {
;[saldo, transaktioner] = await Promise.all([
getSaldo(ctx.supabase, ctx.userId, omfragad),
getTransaktioner(ctx.supabase, ctx.userId, omfragad),
])
} catch (err) {
if (err instanceof SkatteverketAuthError) {
if (
err.code === 'REFRESH_EXHAUSTED' ||
err.code === 'SESSION_EXPIRED' ||
err.code === 'TOKEN_CORRUPTED'
) {
await eventBus.emit({
type: 'skattekonto.connection.expired',
payload: {
reason: err.code,
userId: ctx.userId,
companyId: ctx.companyId,
},
})
}
}
throw err
}
const previousSnapshot = await ctx.settings.get<SkattekontoBalanceSnapshot>(
BALANCE_SNAPSHOT_KEY,
)
const previousBalance = previousSnapshot?.saldo.saldoSkatteverket ?? null
const bookedRows = transaktioner.tidigareTransaktioner.map(tx =>
bookedToRow(ctx.companyId, tx),
)
const upcomingRows = transaktioner.kommandeTransaktioner.map(tx =>
upcomingToRow(ctx.companyId, tx),
)
// Upsert in two steps to keep the conflict target consistent. We rely on
// the (company_id, dedup_key) unique constraint defined in the migration.
const allRows = [...bookedRows, ...upcomingRows]
// Find which dedup_keys are NEW (not yet in the table) so we can fire
// the `skattekonto.transaction.upcoming` event only for first-appearance
// upcoming rows.
const dedupKeys = allRows.map(r => r.dedup_key)
const { data: existingRows } = dedupKeys.length
? await ctx.supabase
.from('skattekonto_transactions')
.select('dedup_key, status')
.eq('company_id', ctx.companyId)
.in('dedup_key', dedupKeys)
: { data: [] }
const existingMap = new Map<string, { status: 'booked' | 'upcoming' }>()
for (const row of existingRows ?? []) {
existingMap.set(row.dedup_key as string, {
status: row.status as 'booked' | 'upcoming',
})
}
if (allRows.length > 0) {
const { error } = await ctx.supabase
.from('skattekonto_transactions')
.upsert(allRows, {
onConflict: 'company_id,dedup_key',
// Don't return rows — we already know what we wrote.
ignoreDuplicates: false,
})
if (error) {
log.error('upsert failed', { companyId: ctx.companyId, message: error.message })
throw new Error(`Kunde inte spara skattekonto-transaktioner: ${error.message}`)
}
}
// Cache balance snapshot.
const snapshot: SkattekontoBalanceSnapshot = {
saldo,
fetchedAt: Date.now(),
}
await ctx.settings.set(BALANCE_SNAPSHOT_KEY, snapshot)
await ctx.settings.set(LAST_SYNCED_AT_KEY, new Date().toISOString())
// Emit events.
await ctx.emit({
type: 'skattekonto.synced',
payload: {
booked: bookedRows.length,
upcoming: upcomingRows.length,
balanceSkv: saldo.saldoSkatteverket,
balanceKfm: saldo.saldoKronofogden,
userId: ctx.userId,
companyId: ctx.companyId,
},
})
// Sign flip → fire balance.changed.
if (
previousBalance !== null &&
Math.sign(previousBalance) !== Math.sign(saldo.saldoSkatteverket)
) {
await ctx.emit({
type: 'skattekonto.balance.changed',
payload: {
previousBalance,
currentBalance: saldo.saldoSkatteverket,
userId: ctx.userId,
companyId: ctx.companyId,
},
})
}
// First-appearance upcoming transactions.
for (const tx of transaktioner.kommandeTransaktioner) {
const key = computeDedupKey(tx)
if (existingMap.has(key)) continue
await ctx.emit({
type: 'skattekonto.transaction.upcoming',
payload: {
transaktionsdatum: tx.transaktionsdatum,
forfallodatum: tx.forfallodatum,
transaktionstext: tx.transaktionstext,
beloppSkatteverket: tx.beloppSkatteverket,
userId: ctx.userId,
companyId: ctx.companyId,
},
})
}
return {
booked: bookedRows.length,
upcoming: upcomingRows.length,
saldoSkatteverket: saldo.saldoSkatteverket,
saldoKronofogden: saldo.saldoKronofogden,
syncedAt: new Date().toISOString(),
}
}
export const SKATTEKONTO_BALANCE_SNAPSHOT_KEY = BALANCE_SNAPSHOT_KEY
export const SKATTEKONTO_LAST_SYNCED_AT_KEY = LAST_SYNCED_AT_KEY