* fix(settings): open Skatteverket connect in a popup and fix the skahmst scope check The full-page OAuth round-trip left Skatteverket's pages and the consumed callback URL in browser history directly beneath /settings/tax, so closing settings walked Back into a dead OAuth chain and re-prompted BankID auth. The connect buttons now use the AGIPanel popup + postMessage pattern (the callback already supports window.opener); the page never navigates and the panel refetches status on success. Full-page navigation remains only as the popup-blocked fallback. Also fixes the reconnect-button condition: it checked for a scope literally named 'skattekonto', but SKV grants 'skahmst', which kept "Anslut igen" permanently visible on healthy connections. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(skatteverket): extract per-declaration AGI kvittens reconciliation Behavior-identical extraction of the kvittens cron's core (fetch kvittens, promote declaration to submitted, stamp salary_runs, clear cached submission, complete deadline, notify) into lib/agi-kvittens-reconcile.ts so the upcoming post-connect refresh can reuse it. Auth-error mapping (needs_reconsent, grant revocation, APIGW config gaps) and run-level logging stay in the cron, which is why SkatteverketAuthError propagates out of the helper on purpose. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): auto-settle production-format AGI skattekonto draws The settlement matcher only understood the SKV test environment's single combined row ("Arbetsgivardeklaration YYYYMM"). Production books the draw as two rows with Swedish month names: "Avdragen skatt maj 2026" (= total_tax) and "Arbetsgivaravgift maj 2026" (= total_avgifter), so auto-settlement has never fired against production data and the salary page kept showing paid periods as unpaid. parseAgiPeriod now also reads the month-name form (which additionally lets match suggestions resolve the period on prod rows, including beslut rows). Settlement classifies draws with start-anchored regexes so correction rows like "Beslut 260703 arbetsgivaravgift mars 2026" can never qualify, and settles a period on either the combined row matching the whole declared amount or exactly one tax row + exactly one avgift row matching their respective totals to the ore, dated to the later of the pair. Anything non-exact (partial draws, duplicates) still falls back to the manual mark-paid button. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skatteverket): refresh skattekonto + kvittenser right after OAuth consent Nothing fetched Skatteverket data after a (re)connect: tokens were stored and the user redirected, with the next fetch left to the nightly cron. SKV's per-flow tokens (and refresh tokens) live ~65 minutes, so the crons usually find them dead; right after consent is the one reliable window for a personal-token fetch, which is why reconnecting never made stale numbers recover. The callback now awaits runPostConnectRefresh after storing tokens: a skattekonto sync (upsert + auto-settlement + balance snapshot) plus a kvittens re-check for this company's pending_signature AGI declarations. Awaited on purpose so popup-close means the data is already fresh and UI refetch listeners never race a background job; every step is best-effort and a refresh failure can never fail the connect that just succeeded. The callback's non-popup fallback also switches to window.location.replace so the consumed callback URL (one-shot code + state) drops out of history instead of re-running into a guaranteed CSRF error on Back. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(salary): reload the salary dashboard after a Skatteverket reconnect Listens for the BankID popup's skatteverket-oauth-success message and re-runs load(). Because the OAuth callback awaits the skattekonto sync and AGI auto-settlement before responding, the refetch already sees settled tax-payment state: the "Skatt att betala" card flips to paid the moment the popup closes, without leaving the page. Also logs the three decisions behind this series in DECISIONS.md (awaited post-connect refresh over after(), exact-pair settlement over per-period summing, popup flow over in-place history repair). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): verify OAuth popup source identity before trusting postMessage The three 'skatteverket-oauth-success' listeners accepted any same-origin message, letting any same-origin script trigger a data reload or a fake success toast (OWASP ASVS V3.2, SOC 2 CC6.1). The two components that open the popup now keep its handle in a ref and require event.source to be that exact window; a window reference cannot be forged by same-origin scripts, which is strictly stronger than a nonce threaded through the OAuth flow. The salary dashboard never opens the popup, so its raw message listener is replaced by a 'skatteverket-connection-updated' CustomEvent dispatched only after a component has source-verified the popup (and after disconnect, so connection-state consumers stay in sync). Also extends the connect consent copy (sv + en) to disclose that connecting immediately fetches skattekonto data and checks pending AGI receipts (GDPR Art.5(1)(b) transparency). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): CSP nonce and no-store on OAuth callback, deadline on post-connect refresh Both callback HTML responses now carry a per-response nonce CSP (default-src 'none'; script-src 'nonce-...'; base-uri 'none'; form-action 'none') so injected markup could never execute, plus Cache-Control: no-store because the callback URL carries a one-shot authorization code. The jsLiteral/appUrl injection invariants are documented at the definition site. The awaited post-connect refresh is bounded by a 30-second Promise.race deadline so a hung SKV call cannot hold the OAuth callback open; on timeout the refresh continues best-effort and the user still gets the success response. Refresh failures and timeouts now log through the structured logger with companyId + userId so they are attributable in log aggregation (ASVS V16, ISO 27001 A.8.15, SOC 2 CC8.1). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): tenant guard and redacting logs in kvittens reconcile, slim cron response The agi_declarations update now also filters on company_id (ASVS V8.2.1), matching the salary_runs pattern. The reconciler and the cron's error paths log through the structured logger so third-party error strings pass personnummer redaction; uuidKvittens is dropped from log context (GDPR Art.5(1)(f) minimization, declarationId suffices). response_data gains submittedAtEstimated so the signeradTid fallback can never be mistaken for the legal filing time, and the submitted_by comment now states explicitly that it records the technical submitter while response_data.signeradAv is the authoritative legal signatory (BFL 5 kap 6 par, BFNAR 2013:2 kap 8). The cron HTTP response omits companyId per result row and sends Cache-Control: no-store; the extension_data delete documents why the period-scoped cache key needs no declaration-id guard. Cron tests observe the logger via a mock without weakening any assertion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): diagnosable settlement refusals and signeradAv ROPA documentation Settlement refusals (candidates present but amounts mismatch) now emit a structured info log with declared-vs-drawn ore amounts per kind, so a rounding divergence between stored declaration totals and SKV's actual draw is diagnosable instead of silently falling back to the manual button. No transaction texts are logged (they can carry personal data). parseAgiPeriod documents the beslut-row audit: correction rows parse to their period on purpose for match-suggestion boosting; settlement never uses parseAgiPeriod (anchored classifiers + parseNumericAgiPeriod only) and the only callers require an exact amount+side 1630 match first. .compliance/ropa.yaml documents signeradAv (signer personnummer in the SKV kvittens stored in agi_declarations.response_data): lawful basis Art.6(1)(c) via BFL 5 kap 6 par / BFNAR 2013:2 kap 8, 7-year retention per BFL 7 kap 2 par, access via company-membership RLS. DECISIONS.md records the accepted-with-documentation calls from the compliance review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): add 'already_claimed' status to reconcile outcomes and enhance logging for pending lookups --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
122 lines
4.1 KiB
TypeScript
122 lines
4.1 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
|
import { hasCapability } from '@/lib/entitlements/has-capability'
|
|
import { CAPABILITY } from '@/lib/entitlements/keys'
|
|
import { createLogger } from '@/lib/logger'
|
|
import { syncSkattekonto } from './skattekonto-sync'
|
|
import { reconcileAgiDeclaration, type PendingAgiDeclaration } from './agi-kvittens-reconcile'
|
|
|
|
const log = createLogger('skatteverket-post-connect')
|
|
|
|
/**
|
|
* Refresh Skatteverket-derived data right after a successful BankID consent.
|
|
*
|
|
* SKV's `per`-flow tokens (and their refresh tokens) live ~65 minutes, so the
|
|
* nightly skattekonto cron and the 2-hourly kvittens cron usually find them
|
|
* dead: right after consent is the one reliable window where a personal-token
|
|
* fetch is guaranteed to work. The OAuth callback awaits this before
|
|
* responding, so when the popup closes the skattekonto rows are upserted, AGI
|
|
* tax payments are auto-settled, and stuck pending-signature declarations have
|
|
* had their kvittens re-checked; UI listeners can refetch without racing a
|
|
* background job.
|
|
*
|
|
* Best-effort by contract: every step has its own try/catch and this function
|
|
* never throws. A refresh failure must never fail the connect that just
|
|
* succeeded.
|
|
*/
|
|
export interface PostConnectRefreshResult {
|
|
synced: boolean
|
|
reconciled: number
|
|
}
|
|
|
|
/**
|
|
* Bound the kvittens re-checks so a pathological backlog cannot stall the
|
|
* OAuth callback: 12 covers a full year of monthly AGI periods.
|
|
*/
|
|
const MAX_KVITTENS_CHECKS = 12
|
|
|
|
export async function runPostConnectRefresh(
|
|
supabase: SupabaseClient,
|
|
userId: string,
|
|
companyId: string,
|
|
): Promise<PostConnectRefreshResult> {
|
|
const result: PostConnectRefreshResult = { synced: false, reconciled: 0 }
|
|
|
|
try {
|
|
if (!(await hasCapability(supabase, companyId, CAPABILITY.skatteverket))) {
|
|
return result
|
|
}
|
|
} catch (err) {
|
|
log.warn('post-connect capability check failed', {
|
|
companyId,
|
|
message: err instanceof Error ? err.message : String(err),
|
|
})
|
|
return result
|
|
}
|
|
|
|
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
|
|
|
|
try {
|
|
await syncSkattekonto(ctx)
|
|
result.synced = true
|
|
} catch (err) {
|
|
log.warn('post-connect skattekonto sync failed', {
|
|
companyId,
|
|
message: err instanceof Error ? err.message : String(err),
|
|
})
|
|
}
|
|
|
|
try {
|
|
const { data: pending, error: pendingError } = await supabase
|
|
.from('agi_declarations')
|
|
.select('id, company_id, salary_run_id, period_year, period_month')
|
|
.eq('company_id', companyId)
|
|
.eq('status', 'pending_signature')
|
|
.order('created_at', { ascending: true })
|
|
.limit(MAX_KVITTENS_CHECKS)
|
|
|
|
// Supabase returns query failures as a value, not a throw: without this
|
|
// branch a transient lookup failure would masquerade as "no pending rows".
|
|
if (pendingError) {
|
|
log.warn('post-connect kvittens lookup failed', {
|
|
companyId,
|
|
message: pendingError.message,
|
|
})
|
|
return result
|
|
}
|
|
|
|
for (const decl of (pending ?? []) as PendingAgiDeclaration[]) {
|
|
try {
|
|
const outcome = await reconcileAgiDeclaration(supabase, decl, {
|
|
reconciledBy: 'post-connect',
|
|
userId,
|
|
})
|
|
if (outcome.status === 'signed') {
|
|
result.reconciled++
|
|
} else if (outcome.status === 'error') {
|
|
// Non-throw failures (SKV HTTP errors, claim-update failures) are
|
|
// returned as an outcome; surface them so they stay attributable.
|
|
log.warn('post-connect kvittens reconcile returned error', {
|
|
companyId,
|
|
declarationId: decl.id,
|
|
message: outcome.error,
|
|
})
|
|
}
|
|
} catch (err) {
|
|
log.warn('post-connect kvittens reconcile failed', {
|
|
companyId,
|
|
declarationId: decl.id,
|
|
message: err instanceof Error ? err.message : String(err),
|
|
})
|
|
}
|
|
}
|
|
} catch (err) {
|
|
log.warn('post-connect kvittens lookup failed', {
|
|
companyId,
|
|
message: err instanceof Error ? err.message : String(err),
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|