Files
accounted/lib/providers/with-provider-call.ts
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

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

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

305 lines
12 KiB
TypeScript

/**
* 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/<provider>` logger is created. */
log?: Logger
/** Extra context merged into the log line. */
context?: Record<string, unknown>
}
/**
* 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<T>(
options: ProviderCallOptions,
call: () => Promise<T>,
): Promise<T> {
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)
}