Files
accounted/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts
T
MattssonandClaude Fable 5 0510d4c13f fix(sync): stop expired trials from starving automatic bank and skattekonto sync (#1376)
The bank sync and skattekonto sync crons fetched the 50 oldest
connection/token rows and only then checked entitlements per item, so
expired-trial rows permanently occupied every batch slot and entitled
companies were never synced automatically.

Fetch all candidate rows, resolve capability grants in bulk via the new
getCompanyIdsWithCapability() (company and firm grants cascade, expired
grants excluded, explicit per-company disable wins), and apply the
50-item run cap after filtering. Entitlement query failures now fail the
run instead of silently skipping every company.

Fixes #563

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 17:32:39 +02:00

319 lines
13 KiB
TypeScript

import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { verifyCronSecret } from '@/lib/auth/cron'
import { getCompanyIdsWithCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { syncSkattekonto, SKATTEKONTO_LAST_SYNCED_AT_KEY } from '@/extensions/general/skatteverket/lib/skattekonto-sync'
import { computeSkattekontoDrift, maybeAlertDrift } from '@/extensions/general/skatteverket/lib/skattekonto-drift'
import { SkatteverketAuthError, type SkvAuth } from '@/extensions/general/skatteverket/lib/api-client'
import { SkatteverketSkattekontoError } from '@/extensions/general/skatteverket/lib/skattekonto-client'
import { markNeedsReconsent, RECONSENT_ERROR_CODES } from '@/extensions/general/skatteverket/lib/token-store'
import { getSystemAuthMode, isSystemAuthConfigured } from '@/extensions/general/skatteverket/lib/system-auth/config'
import { listVerifiedCompanies, markGrantRevoked } from '@/extensions/general/skatteverket/lib/connection-store'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { currentSkvEnvironment, hasVerifiedGrant } from '@/extensions/general/skatteverket/lib/resolve-auth'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
ensureInitialized()
export const maxDuration = 60
const MAX_COMPANIES_PER_RUN = 50
/**
* GET /api/extensions/skatteverket/skattekonto/sync/cron
*
* Daily skattekonto sync (cron 0 4 * * *: 04:00 UTC, 06:00 Swedish time).
* Pulls saldo + transactions and persists them to skattekonto_transactions.
*
* Work list is a union keyed by company:
* 1. System-mode entries: companies with a verified lasombud grant, synced
* on Accounted's own CCG credentials (no user token involved, so the
* 65-minute personal-token lifetime stops mattering here). Active only
* when SKATTEVERKET_SYSTEM_AUTH_MODE=on.
* 2. User-mode entries: companies with an active personal token, exactly
* the pre-hybrid behavior. Fallback during the transition and for
* companies that never grant the behorighet.
*
* Shadow mode logs per user-mode company whether a verified grant exists,
* without any behavior change: the rollout confidence signal.
*
* Skips a company if it was synced within the last hour (cooldown),
* to keep manual + cron triggers from racing each other.
*
* Time budget: 50s (Vercel default 60s function timeout, 10s margin).
*
* Per-company errors are logged but do not abort the run. A system-token
* minting failure (SYSTEM_AUTH_FAILED) short-circuits the remaining
* system-mode entries (one config problem, not fifty) but user-mode entries
* still run.
*/
export async function GET(request: Request) {
const authError = verifyCronSecret(request)
if (authError) return authError
// Respect the runtime extension toggle. When the integration is disabled
// the cron should no-op rather than spam Skatteverket with stale tokens.
if (process.env.SKATTEVERKET_ENABLED !== 'true') {
return NextResponse.json({ message: 'Skatteverket extension disabled', processed: 0 })
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return NextResponse.json({ error: 'Missing Supabase configuration' }, { status: 500 })
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
// User-token entries. The token row is keyed by user_id but carries
// company_id (multi-tenant refactor). Rows flagged needs_reconsent are
// excluded: SKV's per-flow refresh tokens live 65 minutes, so a connection
// that failed with a terminal auth error can never heal on its own.
let tokens
try {
tokens = await fetchAllRows(
({ from, to }) => supabase
.from('skatteverket_tokens')
.select('user_id, company_id, expires_at, refresh_count')
.eq('status', 'active')
.order('expires_at', { ascending: true })
.order('user_id', { ascending: true })
.range(from, to),
{ dedupeBy: token => token.user_id },
)
} catch (error) {
console.error('[skattekonto-sync-cron] Failed to fetch tokens', {
message: error instanceof Error ? error.message : String(error),
})
return NextResponse.json({ error: 'Failed to fetch tokens' }, { status: 500 })
}
const systemAuthActive = getSystemAuthMode() === 'on' && isSystemAuthConfigured()
const systemCompanies = systemAuthActive
? await listVerifiedCompanies(currentSkvEnvironment(), 'lasombud')
: []
type WorkItem = { companyId: string; userId: string; source: 'system' | 'user' }
const tokenByCompany = new Map<string, string>()
for (const token of tokens) {
if (token.company_id) tokenByCompany.set(token.company_id as string, token.user_id as string)
}
const work: WorkItem[] = []
const systemCompanyIds = new Set<string>()
for (const company of systemCompanies) {
// ctx still needs a user identity (events, drift emails). Prefer the
// token owner, fall back to whoever verified the grant.
const userId = tokenByCompany.get(company.company_id) ?? company.created_by
if (!userId) continue
systemCompanyIds.add(company.company_id)
work.push({ companyId: company.company_id, userId, source: 'system' })
}
for (const token of tokens) {
const companyId = token.company_id as string | null
if (!companyId) {
console.warn('[skattekonto-sync-cron] token without company_id skipped', {
userId: token.user_id,
})
continue
}
if (systemCompanyIds.has(companyId)) continue
work.push({ companyId, userId: token.user_id as string, source: 'user' })
}
if (work.length === 0) {
return NextResponse.json({ message: 'No connected companies', processed: 0 })
}
let entitledCompanyIds
try {
entitledCompanyIds = await getCompanyIdsWithCapability(
supabase,
work.map(item => item.companyId),
CAPABILITY.skatteverket,
)
} catch (error) {
console.error('[skattekonto-sync-cron] Failed to resolve entitled companies', {
message: error instanceof Error ? error.message : String(error),
})
return NextResponse.json({ error: 'Failed to resolve entitlements' }, { status: 500 })
}
// Limit the eligible work list, not the raw token list. Expired trials and
// disabled modules must not occupy all 50 positions ahead of paying firms.
const entitledWork = work
.filter(item => entitledCompanyIds.has(item.companyId))
.slice(0, MAX_COMPANIES_PER_RUN)
console.info('[skattekonto-sync-cron] Work list built', {
candidates: work.length,
entitledCompanies: entitledCompanyIds.size,
selected: entitledWork.length,
})
const startTime = Date.now()
const TIME_BUDGET_MS = 50_000
const SYNC_COOLDOWN_MS = 60 * 60 * 1000 // 1 hour
const shadowMode = getSystemAuthMode() === 'shadow'
type Result = {
userId: string
companyId: string
source: 'system' | 'user'
status: 'synced' | 'skipped_cooldown' | 'expired' | 'grant_revoked' | 'system_auth_failed' | 'error'
booked?: number
upcoming?: number
error?: string
}
const results: Result[] = []
// One config problem, not one per company: after the first minting
// failure, skip the remaining system-mode entries this run.
let systemAuthFailed = false
for (const item of entitledWork) {
if (Date.now() - startTime > TIME_BUDGET_MS) {
console.log(`[skattekonto-sync-cron] Time budget reached after ${results.length} companies`)
break
}
const { companyId, userId, source } = item
if (source === 'system' && systemAuthFailed) {
results.push({ userId, companyId, source, status: 'system_auth_failed', error: 'skipped after first failure' })
continue
}
try {
// Cooldown: skip if synced within the last hour.
const { data: lastSyncRow } = await supabase
.from('extension_data')
.select('value, updated_at')
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.eq('key', SKATTEKONTO_LAST_SYNCED_AT_KEY)
.maybeSingle()
const lastSyncedAt = lastSyncRow?.value as string | undefined
if (lastSyncedAt) {
const elapsed = Date.now() - new Date(lastSyncedAt).getTime()
if (elapsed < SYNC_COOLDOWN_MS) {
results.push({ userId, companyId, source, status: 'skipped_cooldown' })
continue
}
}
if (shadowMode) {
// Rollout signal only: would this company have run on system auth?
const grantReady = await hasVerifiedGrant(companyId, 'lasombud')
console.info('[skattekonto-sync-cron] shadow: grant state', { companyId, grantReady })
}
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
const auth: SkvAuth =
source === 'system' ? { mode: 'system' } : { mode: 'user', supabase, userId }
const syncResult = await syncSkattekonto(ctx, auth)
// Drift check: compare the fresh SKV saldo against GL 1630 sum. Emits
// `skattekonto.drift_detected` when |drift| > tolerance and not throttled.
try {
const drift = await computeSkattekontoDrift(ctx)
if (drift) await maybeAlertDrift(ctx, drift)
} catch (driftErr) {
console.error('[skattekonto-sync-cron] Drift check failed', {
userId,
companyId,
message: driftErr instanceof Error ? driftErr.message : String(driftErr),
})
}
results.push({
userId,
companyId,
source,
status: 'synced',
booked: syncResult.booked,
upcoming: syncResult.upcoming,
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
if (source === 'system' && err instanceof SkatteverketAuthError) {
// System-mode failures never touch skatteverket_tokens.
if (err.code === 'SYSTEM_AUTH_FAILED') {
if (!systemAuthFailed) {
systemAuthFailed = true
console.warn(
'[skattekonto-sync-cron] system token minting/authentication failed; skipping remaining system-mode entries this run',
{ companyId, message },
)
}
results.push({ userId, companyId, source, status: 'system_auth_failed', error: err.code })
continue
}
if (err.code === 'OMBUD_GRANT_MISSING') {
// The company withdrew the behorighet: downgrade the connection
// row so the next run falls back to the user token (if any). The
// sync cron doubles as the ongoing grant liveness probe.
await markGrantRevoked(companyId, currentSkvEnvironment(), 'lasombud', err.code)
results.push({ userId, companyId, source, status: 'grant_revoked', error: err.code })
continue
}
}
// Terminal user-token auth states are a known outcome: surface them
// distinctly, persist the health flag so this cron stops retrying the
// row, and let the UI prompt for re-consent proactively.
if (
source === 'user' &&
err instanceof SkatteverketAuthError &&
(RECONSENT_ERROR_CODES as readonly string[]).includes(err.code)
) {
await markNeedsReconsent(supabase, userId, err.code)
results.push({ userId, companyId, source, status: 'expired', error: err.code })
continue
}
// TOKEN_REVOKED auto-deletes the row inside skvRequest — treat it as
// the same quiet "reconnect needed" outcome, not a runtime error.
if (source === 'user' && err instanceof SkatteverketAuthError && err.code === 'TOKEN_REVOKED') {
results.push({ userId, companyId, source, status: 'expired', error: err.code })
continue
}
const felkod = err instanceof SkatteverketSkattekontoError ? err.felkod : null
console.error('[skattekonto-sync-cron] Sync failed', {
userId,
companyId,
source,
message,
felkod,
})
results.push({ userId, companyId, source, status: 'error', error: getErrorMessage(err) })
}
}
const synced = results.filter(r => r.status === 'synced').length
const skipped = results.filter(r => r.status === 'skipped_cooldown').length
const expired = results.filter(r => r.status === 'expired').length
const grantRevoked = results.filter(r => r.status === 'grant_revoked').length
const systemAuthFailures = results.filter(r => r.status === 'system_auth_failed').length
const errors = results.filter(r => r.status === 'error').length
console.log(
`[skattekonto-sync-cron] Processed ${results.length}: ${synced} synced, ${skipped} cooldown, ${expired} expired, ${grantRevoked} grant revoked, ${systemAuthFailures} system-auth failures, ${errors} errors`,
)
return NextResponse.json({
processed: results.length,
synced,
skipped,
expired,
grantRevoked,
systemAuthFailures,
errors,
results,
})
}