From 47f03f4238756ed7a21b7a69f2cf7ed4cf189d00 Mon Sep 17 00:00:00 2001 From: Jonas Hagberg Date: Mon, 29 Jun 2026 22:13:51 +0200 Subject: [PATCH] feat(arcim): import Bokio underlag and link to verifikat (#786) (#813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(arcim): import Bokio underlag and link to verifikat Adds an optional, re-runnable step that pages the Bokio /uploads, resolves each receipt's target verifikat via the SIE-preserved voucher number, and archives it through the document service linked to the journal entry. Closes the gap where neither the SIE GL import nor the entity import carries the receipts/underlag attached to each verifikat. - lib/providers/bokio: getBytes() binary download + an attachments resource module (uploads list, GUID->voucher index, per-upload download); pageSize capped at 100, file type taken from the upload's contentType since the download is octet-stream - importProviderDocuments: bulk in-memory resolution keyed on (fiscal period, series, number) — scoped per fiscal year because Bokio restarts numbering at V1 each year; idempotent on (company_id, sha256) so re-runs don't duplicate the undeletable BFL-linked rows - POST /import-documents route, kept off the migration critical path because the Bokio document API is rate-limited (200 req/60s) - journal_entry_id link only for v1; reuses the document-service link path (same module as #804) rather than forking it Closes #786 Signed-off-by: Jonas Hagberg * fix(arcim): stable pagination order + account for unresolvable receipts Addresses two findings from a Codex review pass on the import step: - Add .order('id') to the paged journal_entries / document_attachments / fiscal_periods reads. fetchAllRows pages with .range(), and PostgREST paging without a deterministic order can skip/repeat rows once a table exceeds one page (journal_entries crosses 1000 across several migrated years), which would defeat both voucher resolution and the sha256 dedup. - Keep every upload carrying a journalEntryId in scope instead of pre-filtering on a resolvable voucher ref, so a receipt whose Bokio entry number didn't parse (or resolves to no verifikat) is counted as unmatched rather than silently dropped from the best-effort report. Tests: add unresolvable-ref and zero-uploads cases; mock now supports .order(). Signed-off-by: Jonas Hagberg --------- Signed-off-by: Jonas Hagberg --- .../__tests__/import-documents.test.ts | 205 +++++++++++++ extensions/general/arcim-migration/index.ts | 64 ++++ .../arcim-migration/lib/import-documents.ts | 286 ++++++++++++++++++ lib/errors/structured-errors.ts | 5 + lib/providers/bokio/attachments.ts | 124 ++++++++ lib/providers/bokio/client.ts | 42 +++ 6 files changed, 726 insertions(+) create mode 100644 extensions/general/arcim-migration/__tests__/import-documents.test.ts create mode 100644 extensions/general/arcim-migration/lib/import-documents.ts create mode 100644 lib/providers/bokio/attachments.ts 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,