* fix(scoping): skatteverket per company + true pristine gates + scoped dismissals Skatteverket connections become per (user, company): the token table carried BOTH UNIQUE(user_id) and UNIQUE(company_id) (two stacked half migrations), so one connection leaked "connected" onto every company the user belongs to, sync ran the token against the wrong orgnr (behorighet 403), and reconnecting from another company silently moved the row and went dark on the first company's crons. Token reads/writes are now scoped by company through the whole chain (token-store, api-client refresh coalescing, skvRequest and its 21 call sites, resolve-auth, crons, MCP), /skattekonto/saldo answers 401 NOT_CONNECTED for companies without their own row (which is what the page's startkort keys on), and the dashboard connect-nudge counts only the active company's row. Bookkeeping's pristine start card now keys on all-years emptiness via a count probe instead of "no active filters": the default fiscal-year selection counted as a filter, which made the card unreachable on brand-new companies (it showed "inga traffar" instead). Two browser-global localStorage keys become company-scoped with legacy fallbacks: the inbox onboarding dismissal (dismissing on one company hid the card everywhere) and the periodisering auto-detect toggle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(scoping): dedupe cron work per (user, company) + guard the ledger probe CodeRabbit findings on #1610: the skattekonto sync cron still deduped token rows by user_id alone, which would drop every company but one for multi-company operators (the exact scenario the PR fixes); and the all-years ledger probe could leave a stale false behind on a failed refetch, letting the pristine card render unconfirmed. The probe now resets to unknown in flight and carries the fetch generation guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
136 lines
4.9 KiB
TypeScript
136 lines
4.9 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, companyId, 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
|
|
}
|