* 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>
305 lines
12 KiB
TypeScript
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)
|
|
}
|