* 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>
136 lines
4.8 KiB
TypeScript
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
|
|
}
|