Files
accounted/lib/providers/fortnox/client.ts
T
Mattsson a97b0023d4 feat: import Fortnox voucher attachments (#1541)
* feat: import Fortnox voucher attachments

* fix: show Fortnox document import follow-up

* fix: harden optional Fortnox document import

* test: pin optional Fortnox import flow

* fix: use browser timer handle type

* fix: avoid serializing OAuth resume state
2026-08-13 00:33:10 +02:00

296 lines
8.9 KiB
TypeScript

import { TokenBucketRateLimiter } from '../rate-limiter';
import { withRetry } from '../retry';
import { FORTNOX_BASE_URL, FORTNOX_RATE_LIMIT } from './config';
import { isTimeoutError } from '@/lib/http/fetch-with-timeout';
const FETCH_TIMEOUT_MS = 15_000;
export class FortnoxApiError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly body?: string,
public readonly retryAfterMs?: number,
) {
super(message);
this.name = 'FortnoxApiError';
}
}
function isRetryableError(error: unknown): boolean {
if (isTimeoutError(error)) return true;
if (error instanceof FortnoxApiError) {
if (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 404) {
return false;
}
return error.statusCode === 429 || error.statusCode >= 500;
}
return false;
}
export class FortnoxClient {
private readonly rateLimiter: TokenBucketRateLimiter;
private readonly baseUrl: string;
constructor(baseUrl?: string) {
this.baseUrl = baseUrl ?? FORTNOX_BASE_URL;
this.rateLimiter = new TokenBucketRateLimiter(FORTNOX_RATE_LIMIT, 'ratelimit:fortnox');
}
async get<T>(accessToken: string, path: string): Promise<T> {
return withRetry(
async () => {
await this.rateLimiter.acquire();
const url = `${this.baseUrl}${path}`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
let retryAfterMs: number | undefined;
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
retryAfterMs = retryAfter ? Math.ceil(parseFloat(retryAfter)) * 1000 : undefined;
}
throw new FortnoxApiError(
`Fortnox API error: ${response.status} ${response.statusText}`,
response.status,
body,
retryAfterMs,
);
}
return response.json() as Promise<T>;
},
{
maxAttempts: 6,
initialDelayMs: 2000,
maxDelayMs: 60_000,
shouldRetry: isRetryableError,
getDelayMs: (error) => {
if (error instanceof FortnoxApiError && error.retryAfterMs) {
return error.retryAfterMs;
}
return undefined;
},
},
);
}
async getText(accessToken: string, path: string): Promise<string> {
return withRetry(
async () => {
await this.rateLimiter.acquire();
const url = `${this.baseUrl}${path}`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
let retryAfterMs: number | undefined;
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
retryAfterMs = retryAfter ? Math.ceil(parseFloat(retryAfter)) * 1000 : undefined;
}
throw new FortnoxApiError(
`Fortnox API error: ${response.status} ${response.statusText}`,
response.status,
body,
retryAfterMs,
);
}
return response.text();
},
{
maxAttempts: 6,
initialDelayMs: 2000,
maxDelayMs: 60_000,
shouldRetry: isRetryableError,
getDelayMs: (error) => {
if (error instanceof FortnoxApiError && error.retryAfterMs) {
return error.retryAfterMs;
}
return undefined;
},
},
);
}
/**
* Fetch a binary resource with the same rate-limit/retry behavior as get().
* Used for the SIE export: response.text() would blind-decode as UTF-8 and
* irrecoverably turn CP437 å/ä/ö into U+FFFD, so callers must run the raw
* bytes through detectEncoding()/decodeBuffer() (mirrors Briox/BL clients).
*/
async getBytes(accessToken: string, path: string): Promise<ArrayBuffer> {
return withRetry(
async () => {
await this.rateLimiter.acquire();
const url = `${this.baseUrl}${path}`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
let retryAfterMs: number | undefined;
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
retryAfterMs = retryAfter ? Math.ceil(parseFloat(retryAfter)) * 1000 : undefined;
}
throw new FortnoxApiError(
`Fortnox API error: ${response.status} ${response.statusText}`,
response.status,
body,
retryAfterMs,
);
}
return response.arrayBuffer();
},
{
maxAttempts: 6,
initialDelayMs: 2000,
maxDelayMs: 60_000,
shouldRetry: isRetryableError,
getDelayMs: (error) => {
if (error instanceof FortnoxApiError && error.retryAfterMs) {
return error.retryAfterMs;
}
return undefined;
},
},
);
}
/**
* Fetch a binary resource and retain its declared content type.
* Attachment import needs both the raw bytes and the response metadata.
*/
async getBinary(
accessToken: string,
path: string,
): Promise<{ bytes: ArrayBuffer; contentType: string | null }> {
return withRetry(
async () => {
await this.rateLimiter.acquire();
const url = `${this.baseUrl}${path}`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
let retryAfterMs: number | undefined;
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
retryAfterMs = retryAfter ? Math.ceil(parseFloat(retryAfter)) * 1000 : undefined;
}
throw new FortnoxApiError(
`Fortnox API error: ${response.status} ${response.statusText}`,
response.status,
body,
retryAfterMs,
);
}
return {
bytes: await response.arrayBuffer(),
contentType: response.headers.get('Content-Type'),
};
},
{
maxAttempts: 6,
initialDelayMs: 2000,
maxDelayMs: 60_000,
shouldRetry: isRetryableError,
getDelayMs: (error) => {
if (error instanceof FortnoxApiError && error.retryAfterMs) {
return error.retryAfterMs;
}
return undefined;
},
},
);
}
async getPage<T>(
accessToken: string,
path: string,
listKey: string,
options?: { page?: number; pageSize?: number; lastModified?: string },
): Promise<{ items: T[]; page: number; totalPages: number; totalCount: number }> {
const params = new URLSearchParams();
params.set('page', String(options?.page ?? 1));
if (options?.pageSize) {
params.set('limit', String(options.pageSize));
}
if (options?.lastModified) {
params.set('lastmodified', options.lastModified);
}
const separator = path.includes('?') ? '&' : '?';
const fullPath = `${path}${separator}${params.toString()}`;
const response = await this.get<Record<string, unknown>>(accessToken, fullPath);
const meta = response['MetaInformation'] as
| { '@TotalPages': number; '@CurrentPage': number; '@TotalResources': number }
| undefined;
const totalPages = meta?.['@TotalPages'] ?? 1;
const currentPage = meta?.['@CurrentPage'] ?? 1;
const totalCount = meta?.['@TotalResources'] ?? 0;
const items = response[listKey];
return {
items: Array.isArray(items) ? (items as T[]) : [],
page: currentPage,
totalPages,
totalCount,
};
}
async getPaginated<T>(
accessToken: string,
path: string,
listKey: string,
options?: { lastModified?: string; pageSize?: number },
): Promise<T[]> {
const allItems: T[] = [];
let page = 1;
let totalPages = 1;
do {
const result = await this.getPage<T>(accessToken, path, listKey, {
page,
pageSize: options?.pageSize,
lastModified: options?.lastModified,
});
allItems.push(...result.items);
totalPages = result.totalPages;
page++;
} while (page <= totalPages);
return allItems;
}
}