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
This commit is contained in:
Mattsson
2026-08-13 00:33:10 +02:00
committed by GitHub
parent aff29b2b05
commit a97b0023d4
20 changed files with 2409 additions and 93 deletions
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from 'vitest';
import {
downloadFortnoxArchiveFile,
fetchFortnoxFileConnections,
fetchFortnoxFinancialYears,
} from '../attachments';
import { FortnoxApiError, type FortnoxClient } from '../client';
function clientWith(methods: Partial<FortnoxClient>): FortnoxClient {
return methods as FortnoxClient;
}
describe('Fortnox attachments', () => {
it('maps financial years and skips malformed rows', async () => {
const getPaginated = vi.fn().mockResolvedValue([
{ Id: 3, FromDate: '2021-01-01', ToDate: '2021-12-31' },
{ Id: '4', FromDate: '2022-01-01', ToDate: '2022-12-31' },
{ Id: null, FromDate: '2023-01-01', ToDate: '2023-12-31' },
]);
const client = clientWith({ getPaginated } as Partial<FortnoxClient>);
await expect(fetchFortnoxFinancialYears(client, 'token')).resolves.toEqual([
{ id: 3, fromDate: '2021-01-01', toDate: '2021-12-31' },
{ id: 4, fromDate: '2022-01-01', toDate: '2022-12-31' },
]);
expect(getPaginated).toHaveBeenCalledWith(
'token',
'/financialyears',
'FinancialYears',
{ pageSize: 500 },
);
});
it('queries each financial year, coerces fields, skips malformed rows, and deduplicates', async () => {
const duplicate = {
FileId: 'file-1',
Name: 'kvitto.pdf',
VoucherSeries: 'A',
VoucherNumber: '12',
VoucherYear: '3',
};
const getPaginated = vi
.fn()
.mockResolvedValueOnce([
duplicate,
duplicate,
{ FileId: '', VoucherSeries: 'A', VoucherNumber: '13', VoucherYear: '3' },
])
.mockResolvedValueOnce([
{
FileId: 'file-2',
Name: ' faktura.png ',
VoucherSeries: 'B',
VoucherNumber: 7,
VoucherYear: 4,
},
]);
const client = clientWith({ getPaginated } as Partial<FortnoxClient>);
await expect(fetchFortnoxFileConnections(client, 'token', [3, 4])).resolves.toEqual([
{
fileId: 'file-1',
name: 'kvitto.pdf',
series: 'A',
number: 12,
financialYearId: 3,
},
{
fileId: 'file-2',
name: 'faktura.png',
series: 'B',
number: 7,
financialYearId: 4,
},
]);
expect(getPaginated).toHaveBeenNthCalledWith(
1,
'token',
'/voucherfileconnections?financialyear=3',
'VoucherFileConnections',
{ pageSize: 500 },
);
expect(getPaginated).toHaveBeenNthCalledWith(
2,
'token',
'/voucherfileconnections?financialyear=4',
'VoucherFileConnections',
{ pageSize: 500 },
);
});
it('downloads through the archive path without fallback when it succeeds', async () => {
const response = { bytes: new ArrayBuffer(2), contentType: 'application/pdf' };
const getBinary = vi.fn().mockResolvedValue(response);
const client = clientWith({ getBinary } as Partial<FortnoxClient>);
await expect(downloadFortnoxArchiveFile(client, 'token', 'file-1')).resolves.toBe(response);
expect(getBinary).toHaveBeenCalledTimes(1);
expect(getBinary).toHaveBeenCalledWith('token', '/archive/file-1');
});
it('retries the query-form archive endpoint once after a 404', async () => {
const response = { bytes: new ArrayBuffer(2), contentType: 'image/jpeg' };
const getBinary = vi
.fn()
.mockRejectedValueOnce(new FortnoxApiError('not found', 404))
.mockResolvedValueOnce(response);
const client = clientWith({ getBinary } as Partial<FortnoxClient>);
await expect(downloadFortnoxArchiveFile(client, 'token', 'file-1')).resolves.toBe(response);
expect(getBinary).toHaveBeenNthCalledWith(1, 'token', '/archive/file-1');
expect(getBinary).toHaveBeenNthCalledWith(2, 'token', '/archive/?fileid=file-1');
});
it('propagates non-404 archive failures without trying the fallback', async () => {
const error = new FortnoxApiError('server error', 500);
const getBinary = vi.fn().mockRejectedValue(error);
const client = clientWith({ getBinary } as Partial<FortnoxClient>);
await expect(downloadFortnoxArchiveFile(client, 'token', 'file-1')).rejects.toBe(error);
expect(getBinary).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,54 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FortnoxApiError, FortnoxClient } from '../client';
describe('FortnoxClient.getBinary', () => {
beforeEach(() => {
vi.stubEnv('UPSTASH_REDIS_REST_URL', '');
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', '');
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
it('returns the raw bytes and response content type', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(Uint8Array.from([1, 2, 3]), {
status: 200,
headers: { 'Content-Type': 'application/pdf' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const client = new FortnoxClient('https://fortnox.example.test/3');
const result = await client.getBinary('access-token', '/archive/file-1');
expect(Array.from(new Uint8Array(result.bytes))).toEqual([1, 2, 3]);
expect(result.contentType).toBe('application/pdf');
expect(fetchMock).toHaveBeenCalledWith(
'https://fortnox.example.test/3/archive/file-1',
expect.objectContaining({
headers: { Authorization: 'Bearer access-token' },
}),
);
});
it('preserves the HTTP status on binary request failures', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response('missing', { status: 404, statusText: 'Not Found' }),
),
);
const client = new FortnoxClient('https://fortnox.example.test/3');
const error = await client
.getBinary('access-token', '/archive/file-1')
.catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(FortnoxApiError);
expect(error).toMatchObject({ statusCode: 404, body: 'missing' });
});
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { buildFortnoxAuthUrl } from '../oauth';
describe('Fortnox OAuth scopes', () => {
it('requests archive and file-connection access by default', () => {
const url = new URL(
buildFortnoxAuthUrl({
clientId: 'client-id',
clientSecret: 'client-secret',
redirectUri: 'https://accounted.example.test/callback',
}),
);
const scopes = new Set(url.searchParams.get('scope')?.split(' ') ?? []);
expect(scopes).toContain('bookkeeping');
expect(scopes).toContain('archive');
expect(scopes).toContain('connectfile');
});
});
+111
View File
@@ -0,0 +1,111 @@
import { FortnoxApiError, type FortnoxClient } from './client';
/**
* Fortnox voucher attachment resource config + fetchers.
*
* Voucher file connections identify the archive file and the target voucher
* directly. VoucherYear is Fortnox's financial-year ID, not a calendar year,
* so callers must resolve it through /financialyears before matching the
* SIE-preserved voucher series and number.
*/
const PAGE_SIZE = 500;
export interface FortnoxFinancialYear {
id: number;
fromDate: string;
toDate: string;
}
export interface FortnoxFileConnection {
fileId: string;
name: string | null;
series: string;
number: number;
financialYearId: number;
}
function finiteInteger(value: unknown): number | null {
const number = Number(value);
return Number.isInteger(number) && number > 0 ? number : null;
}
/** Fetch every Fortnox financial year available to the consent. */
export async function fetchFortnoxFinancialYears(
client: FortnoxClient,
accessToken: string,
): Promise<FortnoxFinancialYear[]> {
const rawYears = await client.getPaginated<Record<string, unknown>>(
accessToken,
'/financialyears',
'FinancialYears',
{ pageSize: PAGE_SIZE },
);
const years: FortnoxFinancialYear[] = [];
for (const raw of rawYears) {
const id = finiteInteger(raw.Id);
const fromDate = typeof raw.FromDate === 'string' ? raw.FromDate.trim() : '';
const toDate = typeof raw.ToDate === 'string' ? raw.ToDate.trim() : '';
if (id == null || !fromDate || !toDate) continue;
years.push({ id, fromDate, toDate });
}
return years;
}
/** Fetch and deduplicate voucher file connections for each financial year. */
export async function fetchFortnoxFileConnections(
client: FortnoxClient,
accessToken: string,
financialYearIds: number[],
): Promise<FortnoxFileConnection[]> {
const connections: FortnoxFileConnection[] = [];
const seen = new Set<string>();
for (const financialYearId of new Set(financialYearIds)) {
const rawConnections = await client.getPaginated<Record<string, unknown>>(
accessToken,
`/voucherfileconnections?financialyear=${financialYearId}`,
'VoucherFileConnections',
{ pageSize: PAGE_SIZE },
);
for (const raw of rawConnections) {
const fileId = typeof raw.FileId === 'string' ? raw.FileId.trim() : '';
const series = typeof raw.VoucherSeries === 'string' ? raw.VoucherSeries.trim() : '';
const number = finiteInteger(raw.VoucherNumber);
const itemFinancialYearId = finiteInteger(raw.VoucherYear);
if (!fileId || !series || number == null || itemFinancialYearId == null) continue;
const key = `${fileId}|${itemFinancialYearId}|${series}|${number}`;
if (seen.has(key)) continue;
seen.add(key);
const name = typeof raw.Name === 'string' && raw.Name.trim() ? raw.Name.trim() : null;
connections.push({
fileId,
name,
series,
number,
financialYearId: itemFinancialYearId,
});
}
}
return connections;
}
/** Download a Fortnox archive object, including the inbox-compatible fallback. */
export async function downloadFortnoxArchiveFile(
client: FortnoxClient,
accessToken: string,
fileId: string,
): Promise<{ bytes: ArrayBuffer; contentType: string | null }> {
const encodedFileId = encodeURIComponent(fileId);
try {
return await client.getBinary(accessToken, `/archive/${encodedFileId}`);
} catch (error) {
if (!(error instanceof FortnoxApiError) || error.statusCode !== 404) throw error;
return client.getBinary(accessToken, `/archive/?fileid=${encodedFileId}`);
}
}
+54
View File
@@ -177,6 +177,60 @@ export class FortnoxClient {
);
}
/**
* 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,
+2
View File
@@ -13,6 +13,8 @@ const DEFAULT_SCOPES = [
'customer',
'supplier',
'bookkeeping',
'archive',
'connectfile',
];
export function buildFortnoxAuthUrl(