Files
accounted/extensions/general/skatteverket/lib/post-connect-refresh.ts
T
MattssonandClaude Fable 5 0e9cca2750 Add/customer mcp (#1055)
* feat(mcp): kontoplan account tools + verifikat notes exposure

Two gaps reported by an MCP-driven user: no account management in the
API, and verifikat notes invisible to agents (they exist in the product
but MCP could neither read nor write them).

- add staged gnubok_create_account / gnubok_update_account (BAS 2026
  prefill for catalog numbers; rename/VAT-default/SRU/activate via
  update; both LOW risk reference data)
- add staged gnubok_set_voucher_note (notes-only annotation, legal on
  posted entries per the 20260608120000 trigger carve-out) and return
  entry_notes from gnubok_query_journal
- new pending_operations types create_account / update_account /
  set_voucher_note (CHECK migration + validate companion, applied to
  staging)
- tools/list payload ceiling 54K -> 56K (documented; wire contract,
  descriptions trimmed first)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skatteverket): unstick BankID connect flow and stale connection views

- respond to the OAuth callback immediately and run the post-connect
  refresh after the response (next/server after()): users no longer
  stare at Skatteverket's consumed consent page for up to 40s
- open the consent flow in a full tab instead of a 600x750 popup that
  hid the approve button below the fold
- disable connect buttons while the OAuth tab is open (parallel flows
  overwrote oauth_state + the PKCE verifier) and recover via a
  closed-tab watcher plus a delayed status refetch
- persist MISSING_SCOPE token health from the post-connect sync and
  show an actionable "approve all permissions" notice
- refetch connection state on tab visibility (settings connect panel,
  enable-banking panel, /skattekonto) so a connect completed in another
  tab or after a mobile app-switch shows up without a manual reload;
  fix /skattekonto never clearing its not-connected state

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(article-form): add article number field with validation to ArticleForm

* feat(account): enforce account type consistency with BAS class and add validation

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:13:53 +02:00

136 lines
4.8 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 { SkatteverketAuthError } from './api-client'
import { markNeedsReconsent, RECONSENT_ERROR_CODES } from './token-store'
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),
})
// A terminal auth error immediately after a fresh consent means the
// just-granted token itself is unusable; in practice MISSING_SCOPE,
// when the user skipped a behörighet on SKV's consent page. Persist
// the health flag (same mechanism as the crons) so /status reports
// needsReconsent and the connect panel can prompt with a specific
// "godkänn alla behörigheter" message instead of a silent success.
if (
err instanceof SkatteverketAuthError &&
(RECONSENT_ERROR_CODES as readonly string[]).includes(err.code)
) {
await markNeedsReconsent(supabase, userId, err.code)
}
}
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
}