diff --git a/extensions/general/arcim-migration/__tests__/import-documents.test.ts b/extensions/general/arcim-migration/__tests__/import-documents.test.ts new file mode 100644 index 00000000..bac019ea --- /dev/null +++ b/extensions/general/arcim-migration/__tests__/import-documents.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' + +import { resolveConsent } from '@/lib/providers/resolve-consent' +import { + fetchBokioUploads, + fetchBokioVoucherIndex, + downloadBokioUpload, + type BokioVoucherRef, +} from '@/lib/providers/bokio/attachments' +import { uploadDocument, computeSHA256 } from '@/lib/core/documents/document-service' +import { importProviderDocuments } from '../lib/import-documents' + +// The Bokio client is constructed but never called directly (the attachments +// module is mocked), so a bare stub avoids touching real config/rate-limiter. +vi.mock('@/lib/providers/bokio/client', () => ({ BokioClient: class {} })) +vi.mock('@/lib/providers/resolve-consent', () => ({ resolveConsent: vi.fn() })) +vi.mock('@/lib/providers/bokio/attachments', () => ({ + fetchBokioUploads: vi.fn(), + fetchBokioVoucherIndex: vi.fn(), + downloadBokioUpload: vi.fn(), +})) +vi.mock('@/lib/core/documents/document-service', () => ({ + uploadDocument: vi.fn(), + computeSHA256: vi.fn(), + ALLOWED_DOCUMENT_TYPES: ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'], +})) + +const mockResolveConsent = vi.mocked(resolveConsent) +const mockFetchUploads = vi.mocked(fetchBokioUploads) +const mockFetchVoucherIndex = vi.mocked(fetchBokioVoucherIndex) +const mockDownload = vi.mocked(downloadBokioUpload) +const mockUpload = vi.mocked(uploadDocument) +const mockSha256 = vi.mocked(computeSHA256) + +const COMPANY = 'company-1' +const USER = 'user-1' + +/** Supabase mock whose chained `.range()` resolves to the rows for that table. */ +function rangeMockSupabase(byTable: Record): SupabaseClient { + const builder = (table: string) => { + const node = { + select: () => node, + eq: () => node, + not: () => node, + order: () => node, + range: () => Promise.resolve({ data: byTable[table] ?? [], error: null }), + } + return node + } + return { from: (table: string) => builder(table) } as unknown as SupabaseClient +} + +function bytesOf(text: string): ArrayBuffer { + return new TextEncoder().encode(text).buffer as ArrayBuffer +} + +beforeEach(() => { + vi.clearAllMocks() + // Default: a Bokio consent. + mockResolveConsent.mockResolvedValue({ + consent: { provider: 'bokio' }, + accessToken: 'tok', + providerCompanyId: 'bokio-co', + } as never) + // Default: sha256 derived from the bytes so dedup is deterministic. + mockSha256.mockImplementation(async (buf: ArrayBuffer) => 'sha-' + Buffer.from(buf).toString('utf8')) + mockUpload.mockResolvedValue({ id: 'doc-1' } as never) +}) + +// A receipt linked to Bokio entry "V33", dated inside FY2021. +const VOUCHER_REF: BokioVoucherRef = { series: 'V', number: 33, date: '2021-03-01' } +const PERIODS = [{ id: 'fp-2021', period_start: '2021-02-04', period_end: '2021-12-31' }] +const GNUBOK_VOUCHERS = [ + { id: 'je-1', fiscal_period_id: 'fp-2021', source_voucher_series: 'V', source_voucher_number: 33 }, +] +const UPLOAD = { id: 'up-1', description: 'Kvitto', contentType: 'application/pdf', journalEntryId: 'bokio-je-1' } + +function wireBokio(opts: { existingHashes?: { sha256_hash: string }[] } = {}) { + mockFetchUploads.mockResolvedValue([UPLOAD] as never) + mockFetchVoucherIndex.mockResolvedValue(new Map([['bokio-je-1', VOUCHER_REF]])) + mockDownload.mockResolvedValue({ bytes: bytesOf('PDFBYTES'), contentType: 'application/octet-stream' }) + return rangeMockSupabase({ + fiscal_periods: PERIODS, + journal_entries: GNUBOK_VOUCHERS, + document_attachments: opts.existingHashes ?? [], + }) +} + +describe('importProviderDocuments', () => { + it('resolves a receipt to its verifikat and archives it linked via upload_source=api', async () => { + const supabase = wireBokio() + + const result = await importProviderDocuments({ supabase, companyId: COMPANY, userId: USER, consentId: 'c1' }) + + expect(result).toMatchObject({ provider: 'bokio', scanned: 1, linked: 1, skipped: 0, unmatched: 0, failed: 0 }) + expect(mockUpload).toHaveBeenCalledTimes(1) + const [, userId, companyId, file, metadata] = mockUpload.mock.calls[0] + expect(userId).toBe(USER) + expect(companyId).toBe(COMPANY) + expect(file).toMatchObject({ name: 'Kvitto.pdf', type: 'application/pdf' }) + expect(metadata).toEqual({ upload_source: 'api', journal_entry_id: 'je-1' }) + }) + + it('skips a receipt already archived for the company (sha256 idempotency)', async () => { + const supabase = wireBokio({ existingHashes: [{ sha256_hash: 'sha-PDFBYTES' }] }) + + const result = await importProviderDocuments({ supabase, companyId: COMPANY, userId: USER, consentId: 'c1' }) + + expect(result).toMatchObject({ scanned: 1, linked: 0, skipped: 1 }) + expect(mockUpload).not.toHaveBeenCalled() + }) + + it('counts a receipt as unmatched when no gnubok verifikat resolves', async () => { + // gnubok has the number in a DIFFERENT fiscal period — must not match. + const supabase = rangeMockSupabase({ + fiscal_periods: PERIODS, + journal_entries: [ + { id: 'je-x', fiscal_period_id: 'fp-2020', source_voucher_series: 'V', source_voucher_number: 33 }, + ], + document_attachments: [], + }) + mockFetchUploads.mockResolvedValue([UPLOAD] as never) + mockFetchVoucherIndex.mockResolvedValue(new Map([['bokio-je-1', VOUCHER_REF]])) + + const result = await importProviderDocuments({ supabase, companyId: COMPANY, userId: USER, consentId: 'c1' }) + + expect(result).toMatchObject({ scanned: 1, linked: 0, unmatched: 1 }) + expect(result.unmatchedSamples[0]).toMatchObject({ voucher: 'V33', date: '2021-03-01' }) + expect(mockDownload).not.toHaveBeenCalled() + }) + + it('dry run resolves the plan without downloading or writing', async () => { + const supabase = wireBokio() + + const result = await importProviderDocuments({ supabase, companyId: COMPANY, userId: USER, consentId: 'c1', dryRun: true }) + + expect(result).toMatchObject({ dryRun: true, scanned: 1, linked: 1 }) + expect(mockDownload).not.toHaveBeenCalled() + expect(mockUpload).not.toHaveBeenCalled() + }) + + it('is a no-op for non-Bokio providers in v1', async () => { + mockResolveConsent.mockResolvedValue({ + consent: { provider: 'fortnox' }, + accessToken: 'tok', + providerCompanyId: 'co', + } as never) + + const result = await importProviderDocuments({ supabase: rangeMockSupabase({}), companyId: COMPANY, userId: USER, consentId: 'c1' }) + + expect(result).toMatchObject({ provider: 'fortnox', scanned: 0, linked: 0 }) + expect(mockFetchUploads).not.toHaveBeenCalled() + }) + + it('counts a receipt as unmatched when its journalEntryId is not in the Bokio voucher index', async () => { + // e.g. an unparseable journalEntryNumber — must be reported, not dropped. + const supabase = wireBokio() + mockFetchVoucherIndex.mockResolvedValue(new Map()) + + const result = await importProviderDocuments({ supabase, companyId: COMPANY, userId: USER, consentId: 'c1' }) + + expect(result).toMatchObject({ scanned: 1, linked: 0, unmatched: 1 }) + expect(result.unmatchedSamples[0]).toMatchObject({ uploadId: 'up-1', voucher: '(unresolved)' }) + expect(mockDownload).not.toHaveBeenCalled() + }) + + it('handles a company with no uploads as an all-zero no-op', async () => { + const supabase = wireBokio() + mockFetchUploads.mockResolvedValue([] as never) + + const result = await importProviderDocuments({ supabase, companyId: COMPANY, userId: USER, consentId: 'c1' }) + + expect(result).toMatchObject({ scanned: 0, linked: 0, skipped: 0, unmatched: 0, failed: 0 }) + expect(mockUpload).not.toHaveBeenCalled() + }) + + it('is best-effort: a failed receipt is counted, not thrown, and the sweep continues', async () => { + const upload2 = { id: 'up-2', description: 'Faktura', contentType: 'application/pdf', journalEntryId: 'bokio-je-2' } + const supabase = rangeMockSupabase({ + fiscal_periods: PERIODS, + journal_entries: [ + { id: 'je-1', fiscal_period_id: 'fp-2021', source_voucher_series: 'V', source_voucher_number: 33 }, + { id: 'je-2', fiscal_period_id: 'fp-2021', source_voucher_series: 'V', source_voucher_number: 34 }, + ], + document_attachments: [], + }) + mockFetchUploads.mockResolvedValue([UPLOAD, upload2] as never) + mockFetchVoucherIndex.mockResolvedValue( + new Map([ + ['bokio-je-1', VOUCHER_REF], + ['bokio-je-2', { series: 'V', number: 34, date: '2021-04-01' }], + ]), + ) + // First download fails, second succeeds. + mockDownload + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce({ bytes: bytesOf('OK'), contentType: 'application/octet-stream' }) + + const result = await importProviderDocuments({ supabase, companyId: COMPANY, userId: USER, consentId: 'c1' }) + + expect(result).toMatchObject({ scanned: 2, linked: 1, failed: 1 }) + expect(mockUpload).toHaveBeenCalledTimes(1) + }) +}) diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts index 4071cbda..9f106a5c 100644 --- a/extensions/general/arcim-migration/index.ts +++ b/extensions/general/arcim-migration/index.ts @@ -18,6 +18,7 @@ import { import { providerSupportsSie, fetchProviderSieFiles, getAllowedFiscalYears } from './lib/sie-fetcher' import { mapCompanyInfo } from './lib/entity-mapper' import { executeMigration } from './lib/migration-orchestrator' +import { importProviderDocuments } from './lib/import-documents' import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers' import type { ArcimProvider } from './types' import { ARCIM_PROVIDERS } from './types' @@ -1097,6 +1098,69 @@ export const arcimMigrationExtension: Extension = { }, }, + // ── Import provider underlag (receipts) and link to verifikat ── + // Best-effort, re-runnable. Kept off the migration's critical path: the + // Bokio document API is rate-limited (200 req/60s) and a full receipt + // sweep issues hundreds of download calls, which would blow the 300s + // migration window. Pages /uploads, resolves each receipt's verifikat via + // the SIE-preserved Bokio voucher number, and archives it idempotently + // (skips content already stored for the company). Pass { dryRun: true } to + // preview the match plan without downloading or writing. + { + method: 'POST', + path: '/import-documents', + handler: async (request: Request, ctx?: ExtensionContext) => { + const log = ctx?.log ?? console + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const companyId = ctx?.companyId ?? user.id + + let consentId: string | undefined + let dryRun = false + try { + const body = (await request.json()) as { consentId?: string; dryRun?: boolean } + consentId = body?.consentId + dryRun = body?.dryRun === true + } catch { + // empty/invalid body — consentId check below rejects it + } + + if (!consentId) { + return NextResponse.json({ error: 'consentId is required' }, { status: 400 }) + } + + try { + const result = await importProviderDocuments({ + supabase, + companyId, + userId: user.id, + consentId, + dryRun, + }) + log.info('arcim import-documents completed', { + companyId, + dryRun, + scanned: result.scanned, + linked: result.linked, + skipped: result.skipped, + unmatched: result.unmatched, + failed: result.failed, + }) + return NextResponse.json({ success: true, dryRun, result }) + } catch (error) { + log.error('arcim import-documents failed', error as Error) + return errorResponseFromCode('PROVIDER_IMPORT_DOCUMENTS_FAILED', moduleLog, { + details: { reason: error instanceof Error ? error.message : 'unknown' }, + }) + } + }, + }, + // ── Accept consent (mark as fully connected after import) ───── { method: 'POST', diff --git a/extensions/general/arcim-migration/lib/import-documents.ts b/extensions/general/arcim-migration/lib/import-documents.ts new file mode 100644 index 00000000..2a12ca91 --- /dev/null +++ b/extensions/general/arcim-migration/lib/import-documents.ts @@ -0,0 +1,286 @@ +/** + * Provider document (underlag) import — best-effort, re-runnable. + * + * The migration imports the GL via SIE and the entity registers via the + * provider API, but the receipts/underlag attached to each verifikat are not + * carried by either. This step closes that gap for Bokio: it pages the Bokio + * `/uploads`, resolves each receipt's target gnubok verifikat from the + * SIE-preserved Bokio voucher number, and stores it through the document + * service (storage + document_attachments), linked to the journal entry. + * + * Guarantees: + * - Idempotent: a receipt already archived for this company (same content, + * keyed on company_id + sha256) is skipped, so re-runs don't duplicate. + * This matters because a receipt linked to a posted verifikat becomes + * räkenskapsinformation and is undeletable (BFL 7 kap 2§ / WORM triggers). + * - Best-effort: a per-receipt failure is counted and logged, never thrown, + * so one bad download can't abort the sweep. + * + * Driven from its own /import-documents route rather than the migration's + * critical path: the Bokio document API is rate-limited (200 req/60s) and a + * full sweep can issue hundreds of download calls. + */ + +import type { SupabaseClient } from '@supabase/supabase-js' +import { resolveConsent } from '@/lib/providers/resolve-consent' +import { BokioClient } from '@/lib/providers/bokio/client' +import { + fetchBokioUploads, + fetchBokioVoucherIndex, + downloadBokioUpload, + type BokioUpload, + type BokioVoucherRef, +} from '@/lib/providers/bokio/attachments' +import { + uploadDocument, + computeSHA256, + ALLOWED_DOCUMENT_TYPES, +} from '@/lib/core/documents/document-service' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { createLogger } from '@/lib/logger' + +const log = createLogger('extensions/arcim-migration/import-documents') + +export interface ImportDocumentsOptions { + supabase: SupabaseClient + companyId: string + userId: string + consentId: string + /** Resolve + report what would be attached without downloading or writing. */ + dryRun?: boolean +} + +export interface ImportDocumentsResult { + provider: string + /** Uploads carrying a journalEntryId that were considered. */ + scanned: number + /** Receipts newly archived and linked to their verifikat. */ + linked: number + /** Receipts already archived for this company (sha256 match) — re-run skip. */ + skipped: number + /** Uploads whose Bokio voucher number resolved to no gnubok verifikat. */ + unmatched: number + /** Receipts that failed to download/validate/store (counted, not thrown). */ + failed: number + dryRun: boolean + /** A few unmatched voucher labels, to aid diagnosis without dumping all. */ + unmatchedSamples: { uploadId: string; voucher: string; date: string }[] +} + +interface FiscalPeriodRow { + id: string + period_start: string + period_end: string +} + +interface VoucherRow { + id: string + fiscal_period_id: string + source_voucher_series: string | null + source_voucher_number: number | null +} + +const EXTENSION_BY_TYPE: Record = { + 'application/pdf': 'pdf', + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', +} + +/** Find the fiscal period whose date range contains a given date. */ +function periodIdForDate(periods: FiscalPeriodRow[], date: string): string | null { + const period = periods.find((p) => p.period_start <= date && date <= p.period_end) + return period?.id ?? null +} + +/** + * In-memory key for a verifikat: fiscal period + series + number. Scoping by + * period is essential — Bokio reuses voucher numbers across fiscal years. + */ +function voucherKey(periodId: string, series: string, number: number): string { + return `${periodId}|${series}|${number}` +} + +/** Synthesise a readable filename — the Bokio uploads list carries none. */ +function fileNameFor(upload: BokioUpload, ref: BokioVoucherRef, contentType: string | null): string { + const ext = (contentType && EXTENSION_BY_TYPE[contentType]) || 'bin' + const label = upload.description?.trim() || `${ref.series}${ref.number}` + return `${label}.${ext}` +} + +export async function importProviderDocuments( + opts: ImportDocumentsOptions, +): Promise { + const { supabase, companyId, userId, consentId, dryRun = false } = opts + + const resolved = await resolveConsent(companyId, consentId) + const provider = resolved.consent.provider as string + + const result: ImportDocumentsResult = { + provider, + scanned: 0, + linked: 0, + skipped: 0, + unmatched: 0, + failed: 0, + dryRun, + unmatchedSamples: [], + } + + // v1 supports Bokio only. Other providers are a no-op rather than an error + // so a mixed-provider caller can invoke this unconditionally. + if (provider !== 'bokio') { + log.info('document import skipped — provider not supported in v1', { provider }) + return result + } + + const { accessToken, providerCompanyId } = resolved + if (!providerCompanyId) { + throw new Error('Consent has no provider_company_id — cannot fetch Bokio uploads') + } + + const client = new BokioClient() + + // ── Bulk reads (one round of paged requests each, no per-item N+1) ── + const [uploads, voucherIndex, periods, vouchers, existingHashes] = await Promise.all([ + fetchBokioUploads(client, accessToken, providerCompanyId), + fetchBokioVoucherIndex(client, accessToken, providerCompanyId), + // A stable `.order('id')` is required: fetchAllRows pages with `.range()`, + // and PostgREST paging without a deterministic order can skip or repeat + // rows once a table exceeds one page (journal_entries crosses 1000 once + // several years are migrated), which would defeat both resolution and the + // hash dedup below. + fetchAllRows(({ from, to }) => + supabase + .from('fiscal_periods') + .select('id, period_start, period_end') + .eq('company_id', companyId) + .order('id', { ascending: true }) + .range(from, to), + ), + fetchAllRows(({ from, to }) => + supabase + .from('journal_entries') + .select('id, fiscal_period_id, source_voucher_series, source_voucher_number') + .eq('company_id', companyId) + .not('source_voucher_number', 'is', null) + .order('id', { ascending: true }) + .range(from, to), + ), + fetchAllRows<{ sha256_hash: string }>(({ from, to }) => + supabase + .from('document_attachments') + .select('sha256_hash') + .eq('company_id', companyId) + .order('id', { ascending: true }) + .range(from, to), + ), + ]) + + // Index gnubok verifikat by (period, series, number) for in-memory resolution. + const journalEntryByKey = new Map() + for (const v of vouchers) { + if (v.source_voucher_series == null || v.source_voucher_number == null) continue + journalEntryByKey.set( + voucherKey(v.fiscal_period_id, v.source_voucher_series, v.source_voucher_number), + v.id, + ) + } + + // Content hashes already archived for this company → idempotent skip set. + const seenHashes = new Set(existingHashes.map((r) => r.sha256_hash)) + + // Every upload that carries a journalEntryId is a receipt we're responsible + // for. Keep them all in scope (don't pre-filter on a resolvable voucher ref) + // so an upload whose Bokio entry number didn't parse, or resolves to no + // verifikat, is counted as unmatched rather than silently dropped. + const linkedUploads = uploads.filter((u) => u.journalEntryId != null) + + const recordUnmatched = (uploadId: string, voucher: string, date: string) => { + result.unmatched++ + if (result.unmatchedSamples.length < 20) { + result.unmatchedSamples.push({ uploadId, voucher, date }) + } + } + + for (const upload of linkedUploads) { + result.scanned++ + const ref = voucherIndex.get(upload.journalEntryId as string) + + if (!ref) { + // journalEntryId not in the Bokio voucher index (unparseable number, or + // an entry the API didn't return) — can't resolve a target verifikat. + recordUnmatched(upload.id, '(unresolved)', '') + continue + } + + const periodId = periodIdForDate(periods, ref.date) + const journalEntryId = periodId + ? journalEntryByKey.get(voucherKey(periodId, ref.series, ref.number)) + : undefined + + if (!journalEntryId) { + recordUnmatched(upload.id, `${ref.series}${ref.number}`, ref.date) + continue + } + + if (dryRun) { + // We can resolve the target without spending a download — count it as a + // would-link so the preview reflects the real plan. + result.linked++ + continue + } + + try { + const { bytes } = await downloadBokioUpload( + client, + accessToken, + providerCompanyId, + upload.id, + ) + + const sha256 = await computeSHA256(bytes) + if (seenHashes.has(sha256)) { + result.skipped++ + continue + } + + // Take the declared type from the upload's contentType (the download is + // octet-stream). If it isn't an allowed type, store without a declared + // type so uploadDocument skips magic validation rather than rejecting. + const declaredType = + upload.contentType && ALLOWED_DOCUMENT_TYPES.includes(upload.contentType) + ? upload.contentType + : undefined + + await uploadDocument( + supabase, + userId, + companyId, + { name: fileNameFor(upload, ref, upload.contentType), buffer: bytes, type: declaredType }, + { upload_source: 'api', journal_entry_id: journalEntryId }, + ) + + seenHashes.add(sha256) + result.linked++ + } catch (err) { + result.failed++ + log.error('failed to import a receipt', err as Error, { + uploadId: upload.id, + voucher: `${ref.series}${ref.number}`, + }) + } + } + + log.info('document import complete', { + companyId, + dryRun, + scanned: result.scanned, + linked: result.linked, + skipped: result.skipped, + unmatched: result.unmatched, + failed: result.failed, + }) + + return result +} diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 090ff1e2..69698d0e 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -1400,6 +1400,11 @@ const PROVIDER_MIGRATION: Record = { message_sv: 'Migrationen från leverantören misslyckades.', message_en: 'Provider migration failed.', }, + PROVIDER_IMPORT_DOCUMENTS_FAILED: { + httpStatus: 500, + message_sv: 'Kunde inte importera underlag från leverantören.', + message_en: 'Failed to import documents from provider.', + }, PROVIDER_DISCONNECT_FAILED: { httpStatus: 500, message_sv: 'Frånkoppling från leverantören misslyckades.', diff --git a/lib/providers/bokio/attachments.ts b/lib/providers/bokio/attachments.ts new file mode 100644 index 00000000..f1f80aba --- /dev/null +++ b/lib/providers/bokio/attachments.ts @@ -0,0 +1,124 @@ +import type { BokioClient } from './client'; + +/** + * Bokio document (upload) resource config + fetchers. + * + * Bokio exposes receipts/underlag via two endpoints: + * - GET /companies/{cid}/uploads — list, carries journalEntryId + * - GET /companies/{cid}/uploads/{id}/download — raw bytes (octet-stream) + * + * The list does NOT include a filename, and the download is served as + * application/octet-stream — so the real file type comes from the list item's + * `contentType`, and a filename has to be synthesised by the caller. + * + * The link between an upload and a gnubok verifikat is recovered from the + * Bokio journal entry's human voucher number (e.g. "V342"): the SIE import + * preserves it on journal_entries.source_voucher_series / source_voucher_number. + * Bokio restarts numbering at V1 every fiscal year, so the number alone is not + * unique — callers must scope the match by fiscal year (the entry's date). + */ + +const UPLOADS_PATH = '/uploads'; +const JOURNAL_ENTRIES_PATH = '/journal-entries'; + +/** Bokio's pageSize caps at 100. */ +const PAGE_SIZE = 100; + +/** A `V342`-style voucher number: one or more letters (series) + digits. */ +const VOUCHER_NUMBER_RE = /^([A-Za-z]+)(\d+)$/; + +export interface BokioUpload { + id: string; + description: string | null; + contentType: string | null; + journalEntryId: string | null; +} + +interface BokioJournalEntry { + id: string; + journalEntryNumber: string | null; + date: string; +} + +/** A Bokio voucher reference parsed from its journalEntryNumber. */ +export interface BokioVoucherRef { + /** Voucher series letter(s), e.g. "V". */ + series: string; + /** Numeric part of the voucher number, e.g. 342. */ + number: number; + /** Entry date (YYYY-MM-DD) — used to scope the match by fiscal year. */ + date: string; +} + +async function paginate( + client: BokioClient, + accessToken: string, + companyId: string, + path: string, +): Promise { + const all: T[] = []; + let page = 1; + let totalPages = 1; + + do { + const result = await client.getPage(accessToken, companyId, path, { + page, + pageSize: PAGE_SIZE, + }); + all.push(...result.items); + totalPages = result.totalPages; + page++; + } while (page <= totalPages); + + return all; +} + +/** Page every upload (receipt) for a company. */ +export function fetchBokioUploads( + client: BokioClient, + accessToken: string, + companyId: string, +): Promise { + return paginate(client, accessToken, companyId, UPLOADS_PATH); +} + +/** + * Build a GUID → voucher-reference index from Bokio's journal entries. + * An upload only carries the entry's GUID; this resolves it to the human + * voucher number (and date) that gnubok preserved from the SIE import. + * Entries with an unparseable number are skipped. + */ +export async function fetchBokioVoucherIndex( + client: BokioClient, + accessToken: string, + companyId: string, +): Promise> { + const entries = await paginate( + client, + accessToken, + companyId, + JOURNAL_ENTRIES_PATH, + ); + + const index = new Map(); + for (const entry of entries) { + const match = VOUCHER_NUMBER_RE.exec(entry.journalEntryNumber ?? ''); + if (!match) continue; + index.set(entry.id, { + series: match[1], + number: Number(match[2]), + date: entry.date, + }); + } + return index; +} + +/** Download a single upload's bytes. */ +export function downloadBokioUpload( + client: BokioClient, + accessToken: string, + companyId: string, + uploadId: string, +): Promise<{ bytes: ArrayBuffer; contentType: string | null }> { + return client.getBytes(accessToken, companyId, `${UPLOADS_PATH}/${uploadId}/download`); +} diff --git a/lib/providers/bokio/client.ts b/lib/providers/bokio/client.ts index 609dd603..8a4b7dac 100644 --- a/lib/providers/bokio/client.ts +++ b/lib/providers/bokio/client.ts @@ -172,6 +172,48 @@ export class BokioClient { return this.get(accessToken, path); } + /** + * Download a binary resource (e.g. an uploaded receipt) as raw bytes. + * Bokio serves `/uploads/{id}/download` as application/octet-stream, so the + * declared content type must come from the upload's own `contentType`, not + * the response header. Same rate-limit + retry envelope as get(). + */ + async getBytes( + accessToken: string, + companyId: string, + relativePath: string, + ): Promise<{ bytes: ArrayBuffer; contentType: string | null }> { + return withRetry( + async () => { + await this.rateLimiter.acquire(); + const url = `${this.baseUrl}/companies/${companyId}${relativePath}`; + const response = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new BokioApiError( + `Bokio API error: ${response.status} ${response.statusText}`, + response.status, + body, + ); + } + + return { + bytes: await response.arrayBuffer(), + contentType: response.headers.get('content-type'), + }; + }, + { + maxAttempts: 3, + initialDelayMs: 1000, + shouldRetry: isRetryableError, + }, + ); + } + async getCompany( accessToken: string, companyId: string,