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:
@@ -417,6 +417,110 @@ describe('uploadDocument', () => {
|
||||
expect(serviceRemove).toHaveBeenCalledWith([uploadedKey])
|
||||
expect(callerRemove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('coalesces concurrent idempotent uploads on one immutable document row', async () => {
|
||||
const rows = new Map<string, ReturnType<typeof makeDocumentAttachment>>()
|
||||
const upload = vi.fn().mockResolvedValue({ data: {}, error: null })
|
||||
const serviceRemove = vi.fn().mockResolvedValue({ data: [], error: null })
|
||||
serviceClientOverride = makeClient({ remove: serviceRemove })
|
||||
|
||||
let arrivals = 0
|
||||
let releaseInserts = () => {}
|
||||
const insertBarrier = new Promise<void>((resolve) => {
|
||||
releaseInserts = resolve
|
||||
})
|
||||
|
||||
const client = {
|
||||
from: vi.fn(() => {
|
||||
let insertPayload: Record<string, unknown> | null = null
|
||||
let queriedId = ''
|
||||
const builder: Record<string, unknown> = {}
|
||||
builder.insert = vi.fn((payload: Record<string, unknown>) => {
|
||||
insertPayload = payload
|
||||
return builder
|
||||
})
|
||||
builder.select = vi.fn(() => builder)
|
||||
builder.eq = vi.fn((column: string, value: string) => {
|
||||
if (column === 'id') queriedId = value
|
||||
return builder
|
||||
})
|
||||
builder.single = vi.fn(async () => {
|
||||
arrivals++
|
||||
if (arrivals === 2) releaseInserts()
|
||||
await insertBarrier
|
||||
const payload = insertPayload as Record<string, unknown>
|
||||
const id = payload.id as string
|
||||
if (rows.has(id)) {
|
||||
return { data: null, error: { code: '23505', message: 'duplicate key' } }
|
||||
}
|
||||
const row = makeDocumentAttachment(payload)
|
||||
rows.set(id, row)
|
||||
return { data: row, error: null }
|
||||
})
|
||||
builder.maybeSingle = vi.fn(async () => ({
|
||||
data: rows.get(queriedId) ?? null,
|
||||
error: null,
|
||||
}))
|
||||
return builder
|
||||
}),
|
||||
storage: {
|
||||
getBucket: vi.fn().mockResolvedValue({ data: { id: 'documents' }, error: null }),
|
||||
createBucket: vi.fn().mockResolvedValue({ data: { name: 'documents' }, error: null }),
|
||||
from: vi.fn().mockReturnValue({ upload }),
|
||||
},
|
||||
}
|
||||
|
||||
const handler = vi.fn()
|
||||
eventBus.on('document.uploaded', handler)
|
||||
const file = {
|
||||
name: 'kvitto.pdf',
|
||||
buffer: pdfBuffer('same retained receipt'),
|
||||
type: 'application/pdf',
|
||||
}
|
||||
const metadata = {
|
||||
upload_source: 'api' as const,
|
||||
journal_entry_id: 'je-1',
|
||||
idempotency_key: 'je-1',
|
||||
}
|
||||
|
||||
const documents = await Promise.all([
|
||||
uploadDocument(client as never, 'user-1', 'company-1', file, metadata),
|
||||
uploadDocument(client as never, 'user-1', 'company-1', file, metadata),
|
||||
])
|
||||
|
||||
expect(documents[0].id).toBe(documents[1].id)
|
||||
expect(rows.size).toBe(1)
|
||||
expect(upload).toHaveBeenCalledTimes(2)
|
||||
expect(upload.mock.calls[0]![0]).not.toBe(upload.mock.calls[1]![0])
|
||||
expect(serviceRemove).toHaveBeenCalledTimes(1)
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not treat a non-unique insert error as an idempotent winner', async () => {
|
||||
results = [
|
||||
{ data: null, error: { code: '42501', message: 'row-level security denied' } },
|
||||
]
|
||||
const serviceRemove = vi.fn().mockResolvedValue({ data: [], error: null })
|
||||
serviceClientOverride = makeClient({ remove: serviceRemove })
|
||||
const supabase = makeClient()
|
||||
|
||||
await expect(
|
||||
uploadDocument(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
{
|
||||
name: 'kvitto.pdf',
|
||||
buffer: pdfBuffer('retained receipt'),
|
||||
type: 'application/pdf',
|
||||
},
|
||||
{ journal_entry_id: 'je-1', idempotency_key: 'je-1' },
|
||||
),
|
||||
).rejects.toThrow('Failed to create document record: row-level security denied')
|
||||
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
expect(serviceRemove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('model-free signed document uploads', () => {
|
||||
|
||||
@@ -585,6 +585,20 @@ export async function computeSHA256(buffer: ArrayBuffer): Promise<string> {
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
async function deterministicDocumentId(
|
||||
companyId: string,
|
||||
idempotencyKey: string,
|
||||
sha256Hash: string,
|
||||
): Promise<string> {
|
||||
const input = new TextEncoder().encode(`${companyId}\u0000${idempotencyKey}\u0000${sha256Hash}`)
|
||||
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', input))
|
||||
const bytes = digest.slice(0, 16)
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x50
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a document and create a record with SHA-256 integrity hash
|
||||
*/
|
||||
@@ -597,6 +611,7 @@ export async function uploadDocument(
|
||||
upload_source?: DocumentUploadSource
|
||||
journal_entry_id?: string
|
||||
journal_entry_line_id?: string
|
||||
idempotency_key?: string
|
||||
/**
|
||||
* Content dedupe for intake channels: before storing, look for a
|
||||
* current-version document in the same company with the same SHA-256 and
|
||||
@@ -642,9 +657,22 @@ export async function uploadDocument(
|
||||
if (hit) return { ...hit, deduplicated: true }
|
||||
}
|
||||
|
||||
// Callers importing immutable third-party records can provide a stable
|
||||
// scope. The resulting row UUID makes the database primary key the atomic
|
||||
// claim for (company, scope, content), so concurrent serverless requests
|
||||
// converge without requiring a process-local lock.
|
||||
const reservedDocumentId = metadata.idempotency_key
|
||||
? await deterministicDocumentId(companyId, metadata.idempotency_key, sha256Hash)
|
||||
: null
|
||||
|
||||
// Company-scoped storage key: the tenant id must be IN the key so the
|
||||
// storage RLS policy can revoke access when a membership is removed.
|
||||
const storagePath = buildDocumentStoragePath(companyId, userId, file.name)
|
||||
// Idempotent calls use a unique object key per attempt. Their deterministic
|
||||
// document row, not Storage, arbitrates the race; the losing object is then
|
||||
// removed with the service role before this function returns.
|
||||
const storagePath = reservedDocumentId
|
||||
? buildReservedDocumentStoragePath(companyId, userId, crypto.randomUUID(), file.name)
|
||||
: buildDocumentStoragePath(companyId, userId, file.name)
|
||||
|
||||
// Upload to Supabase Storage
|
||||
const { error: uploadError } = await supabase.storage
|
||||
@@ -662,6 +690,7 @@ export async function uploadDocument(
|
||||
const { data, error } = await supabase
|
||||
.from('document_attachments')
|
||||
.insert({
|
||||
id: reservedDocumentId ?? crypto.randomUUID(),
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
storage_path: storagePath,
|
||||
@@ -681,6 +710,30 @@ export async function uploadDocument(
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (reservedDocumentId && error.code === '23505') {
|
||||
const { data: concurrent, error: concurrentError } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('id, user_id, company_id, storage_path, file_name, file_size_bytes, mime_type, sha256_hash, version, original_id, superseded_by_id, is_current_version, uploaded_by, upload_source, digitization_date, journal_entry_id, journal_entry_line_id, prev_version_hash, last_integrity_check_at, created_at, updated_at')
|
||||
.eq('id', reservedDocumentId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (!concurrentError && concurrent) {
|
||||
const existing = concurrent as DocumentAttachment
|
||||
await createServiceClientNoCookies()
|
||||
.storage.from(DOCUMENTS_BUCKET)
|
||||
.remove([storagePath])
|
||||
if (
|
||||
existing.sha256_hash !== sha256Hash ||
|
||||
existing.journal_entry_id !== (metadata.journal_entry_id || null)
|
||||
) {
|
||||
throw new Error('Idempotency key was already used for different document metadata')
|
||||
}
|
||||
|
||||
return existing
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up the just-uploaded object on record creation failure. The
|
||||
// documents bucket is WORM by design: storage.objects has NO DELETE
|
||||
// policy, so remove() on the caller's cookie-bound client is silently
|
||||
|
||||
@@ -1890,6 +1890,13 @@ const PROVIDER_MIGRATION: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Kunde inte importera underlag från leverantören.',
|
||||
message_en: 'Failed to import documents from provider.',
|
||||
},
|
||||
PROVIDER_DOCUMENT_SCOPES_REQUIRED: {
|
||||
httpStatus: 403,
|
||||
message_sv:
|
||||
'Fortnox-anslutningen saknar behörighet till Arkiv och Koppla fil. Koppla om Fortnox och godkänn behörigheterna för att importera underlag.',
|
||||
message_en:
|
||||
'The Fortnox connection lacks Archive and Connect file access. Reconnect Fortnox and approve those permissions to import documents.',
|
||||
},
|
||||
PROVIDER_DISCONNECT_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Frånkoppling från leverantören misslyckades.',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -13,6 +13,8 @@ const DEFAULT_SCOPES = [
|
||||
'customer',
|
||||
'supplier',
|
||||
'bookkeeping',
|
||||
'archive',
|
||||
'connectfile',
|
||||
];
|
||||
|
||||
export function buildFortnoxAuthUrl(
|
||||
|
||||
Reference in New Issue
Block a user