/** * Wraps a single external HTTP call to a third-party provider (Fortnox, Bokio, * Visma, Briox, BL/Björn Lundén, Enable Banking, etc.) with structured * logging and code-mapped errors. * * Translates HTTP failures and network errors into ProviderCallError, which * the route wrapper's errorResponse() recognises as a structured code. This * keeps the user message + remediation consistent across providers without * each call site having to repeat the mapping. */ import { createLogger, type Logger } from '@/lib/logger' // Fortnox is the one provider with a typed predicate for "this account may not // read this resource" (403, or 400 with a permission body). classifyProviderError // reuses it instead of pattern-matching the Swedish sentence in the body, which // changes with the provider's locale and copy; import-documents.ts already // treats a Fortnox 403 the same way. import { isFortnoxPermissionError } from './fortnox/client' export type ProviderCallErrorCode = | 'PROVIDER_AUTH_EXPIRED' | 'PROVIDER_RESOURCE_FORBIDDEN' | 'PROVIDER_LICENSE_MISSING' | 'PROVIDER_API_MODULE_INACTIVE' | 'PROVIDER_RATE_LIMITED' | 'PROVIDER_UNREACHABLE' | 'PROVIDER_UPSTREAM_ERROR' export class ProviderCallError extends Error { readonly code: ProviderCallErrorCode readonly provider: string readonly status?: number readonly retryAfterSeconds?: number constructor( code: ProviderCallErrorCode, provider: string, message: string, extras: { status?: number; retryAfterSeconds?: number } = {}, ) { super(message) this.name = 'ProviderCallError' this.code = code this.provider = provider this.status = extras.status this.retryAfterSeconds = extras.retryAfterSeconds } } interface ProviderCallOptions { /** Provider id ('fortnox', 'bokio', 'visma', etc.). */ provider: string /** Short label for what this call does, e.g. 'fetch_invoices'. */ operation: string /** Optional logger; if omitted a `provider/` logger is created. */ log?: Logger /** Extra context merged into the log line. */ context?: Record } /** * Run an async callable that performs the actual HTTP request and translate * its failures. The callable should throw a `Response` (preferred) or a * regular Error; ProviderCallError is mapped from the response status. * * Example: * await withProviderCall( * { provider: 'fortnox', operation: 'fetch_invoices' }, * async () => { * const res = await fetch(url, { headers }) * if (!res.ok) throw res * return res.json() * }, * ) */ export async function withProviderCall( options: ProviderCallOptions, call: () => Promise, ): Promise { const log = (options.log ?? createLogger(`provider/${options.provider}`)).child({ provider: options.provider, providerOp: options.operation, ...options.context, }) const start = Date.now() try { const result = await call() log.info('provider call ok', { latencyMs: Date.now() - start }) return result } catch (raw) { const latencyMs = Date.now() - start if (raw instanceof Response) { const mapped = mapResponseError(raw, options.provider) log.error('provider call failed (http)', mapped, { latencyMs, status: raw.status, }) throw mapped } if (raw instanceof ProviderCallError) { log.error('provider call failed', raw, { latencyMs }) throw raw } if (raw instanceof Error && isNetworkError(raw)) { const wrapped = new ProviderCallError( 'PROVIDER_UNREACHABLE', options.provider, raw.message, ) log.error('provider call unreachable', wrapped, { latencyMs }) throw wrapped } // Unknown shape: re-throw so the outer handler can decide. We still log it. log.error('provider call failed (unknown)', raw as Error, { latencyMs }) throw raw } } function mapResponseError(res: Response, provider: string): ProviderCallError { if (res.status === 401 || res.status === 403) { return new ProviderCallError( 'PROVIDER_AUTH_EXPIRED', provider, `Provider authentication failed: ${res.status} ${res.statusText}`, { status: res.status }, ) } if (res.status === 429) { const retryAfter = parseRetryAfter(res.headers.get('retry-after')) return new ProviderCallError( 'PROVIDER_RATE_LIMITED', provider, `Provider rate limit hit: ${res.status} ${res.statusText}`, { status: res.status, retryAfterSeconds: retryAfter }, ) } if (res.status >= 500) { return new ProviderCallError( 'PROVIDER_UPSTREAM_ERROR', provider, `Provider upstream error: ${res.status} ${res.statusText}`, { status: res.status }, ) } // 4xx other than 401/403/429 is application-level: surface as upstream so // the user gets a meaningful Swedish message; the actual cause is in logs. return new ProviderCallError( 'PROVIDER_UPSTREAM_ERROR', provider, `Provider rejected request: ${res.status} ${res.statusText}`, { status: res.status }, ) } function parseRetryAfter(value: string | null): number | undefined { if (!value) return undefined const n = parseInt(value, 10) return Number.isFinite(n) ? n : undefined } function isNetworkError(err: Error): boolean { // node-undici throws TypeError('fetch failed') with a `cause` for DNS/TCP issues. if (err.name === 'TypeError' && /fetch failed/i.test(err.message)) return true if (err.name === 'AbortError') return true // Known undici error codes const cause = (err as Error & { cause?: { code?: string } }).cause if (cause?.code && ['ENOTFOUND', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN'].includes(cause.code)) { return true } return false } export interface ClassifyProviderErrorOptions { /** * True when an earlier provider call in the same run already returned data. * The access token is then provably alive, so a 403 after that point is the * provider closing ONE resource, not the grant dying. Leave it false (the * default) when the failing call is the first one: an opaque 403 there is * indistinguishable from a revoked grant and must keep saying "reconnect". * * Data returned, not a promise that resolved: several fetchers answer with * an empty list before issuing any request (a provider company id we do not * have, a register the provider does not expose), and those prove nothing. */ grantProven?: boolean } /** * Classify an error from a provider client (Fortnox/Bokio/Visma/Briox/BL) into * a structured error code. Reads `statusCode` (Fortnox client) or `status` * (other clients) off the thrown error and maps: * * 401 → PROVIDER_AUTH_EXPIRED * 403 → PROVIDER_RESOURCE_FORBIDDEN when the provider answers 403 only * for the resource (Fortnox, see isFortnoxPermissionError), or * when `options.grantProven` says an earlier call in the same run * already succeeded on this token; otherwise PROVIDER_AUTH_EXPIRED * 429 → PROVIDER_RATE_LIMITED * 5xx → PROVIDER_UPSTREAM_ERROR * network → PROVIDER_UNREACHABLE * other → null (caller falls back to its domain-specific code, e.g. * `PROVIDER_SIE_FETCH_FAILED`) * * Use at the boundary where a provider call's failure becomes a user-facing * response. Lets the toast show a specific Swedish message ("Anslutningen har * gått ut. Återanslut för att fortsätta." vs. "Försök igen om en stund.") * instead of the same generic message for every cause. */ export function classifyProviderError( error: unknown, options: ClassifyProviderErrorOptions = {}, ): ProviderCallErrorCode | null { if (error instanceof ProviderCallError) { // mapResponseError() sees one response and cannot know the run's history, // so a 403 it already labelled AUTH_EXPIRED is re-read here with it. if (error.code === 'PROVIDER_AUTH_EXPIRED' && error.status === 403 && options.grantProven) { return 'PROVIDER_RESOURCE_FORBIDDEN' } return error.code } if (!(error instanceof Error)) return null const status = (error as Error & { statusCode?: number; status?: number }).statusCode ?? (error as Error & { statusCode?: number; status?: number }).status // Provider clients (e.g. VismaApiError) carry the response body separately // from the Error message; both can hold the discriminating string. const body = (error as Error & { body?: unknown }).body const haystack = typeof body === 'string' ? `${error.message} ${body}` : error.message // Order matters: 401/403 with a module/license body is a subscription // problem, not a dead token. Mapping it to AUTH_EXPIRED would send the user // into a reconnect loop that can never succeed (the exact failure mode this // classification exists to prevent). if (isApiModuleInactiveError(haystack)) return 'PROVIDER_API_MODULE_INACTIVE' if (isMissingLicenseError(haystack)) return 'PROVIDER_LICENSE_MISSING' if (typeof status === 'number') { // A 403 is only a dead grant when nothing else says otherwise: a provider // that reserves 403 for the resource and answers 401 for a dead token // (Fortnox), or the same token having already answered earlier in this // run, both mean the grant is alive and one register is closed. 401 is // never downgraded: that IS a dead token. if (status === 403 && (options.grantProven || isFortnoxPermissionError(error))) { return 'PROVIDER_RESOURCE_FORBIDDEN' } if (status === 401 || status === 403) return 'PROVIDER_AUTH_EXPIRED' if (status === 429) return 'PROVIDER_RATE_LIMITED' if (status >= 500) return 'PROVIDER_UPSTREAM_ERROR' } if (isNetworkError(error)) return 'PROVIDER_UNREACHABLE' return null } /** * True when a provider token/OAuth failure means the integration license is * missing or inactive, NOT an ordinary expired/revoked grant. * * Fortnox answers its token endpoint with `error_missing_license` when the * customer's Fortnox account no longer carries the integration license. The * stored refresh token cannot be revived by re-authorizing: re-auth loops until * the customer re-orders the "Fortnox Integration" add-on. Distinguishing this * from a plain dead token lets callers say "activate the license, then * reconnect" instead of a bare "reconnect" that just fails again. * * Matches on the raw provider message string because the underlying refresh * helpers bake the body into the Error message; deliberately does NOT match * `invalid_grant` (that IS a revivable reconnect → PROVIDER_AUTH_EXPIRED). */ export function isMissingLicenseError(message: string): boolean { const haystack = message.toLowerCase() return ( haystack.includes('error_missing_license') || haystack.includes('missing_license') || haystack.includes('missing license') || haystack.includes('not have enough licenses') ) } /** * True when a provider 403 means the customer's subscription has API access * switched off or not included, NOT an expired/revoked grant. * * Visma eAccounting (Spiris) answers every data endpoint with * `ForbiddenRequestException - No access to module: api_standard` * (ErrorCode 4002) when the company's plan lacks the API module or it is not * activated under "Appar och tillägg". OAuth still succeeds (the identity * server is shared), so the stored tokens are valid; re-authorizing loops * forever. The fix is on the customer's side: activate the API module (an * add-on on smaller plans) and clear any "standardföretag" selection. * * Matches the raw provider body/message string, same approach as * isMissingLicenseError above. */ export function isApiModuleInactiveError(message: string): boolean { return /no access to module/i.test(message) }