From a97b0023d495e9d86fd67175b358b8bc66e9030c Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:33:10 +0200 Subject: [PATCH] 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 --- DECISIONS.md | 5 + .../general/ArcimMigrationWorkspace.tsx | 523 +++++++++++++++++- .../arcim-document-import-flow.test.ts | 269 +++++++++ .../general/arcim-document-import-flow.ts | 269 +++++++++ .../__tests__/import-documents-route.test.ts | 120 ++++ .../__tests__/import-documents.test.ts | 365 +++++++++++- .../__tests__/oauth-callback-state.test.ts | 20 + extensions/general/arcim-migration/index.ts | 45 +- .../arcim-migration/lib/import-documents.ts | 311 ++++++++--- .../__tests__/document-service.test.ts | 104 ++++ lib/core/documents/document-service.ts | 55 +- lib/errors/structured-errors.ts | 7 + .../fortnox/__tests__/attachments.test.ts | 124 +++++ .../fortnox/__tests__/client.test.ts | 54 ++ lib/providers/fortnox/__tests__/oauth.test.ts | 20 + lib/providers/fortnox/attachments.ts | 111 ++++ lib/providers/fortnox/client.ts | 54 ++ lib/providers/fortnox/oauth.ts | 2 + messages/en.json | 22 + messages/sv.json | 22 + 20 files changed, 2409 insertions(+), 93 deletions(-) create mode 100644 components/extensions/general/__tests__/arcim-document-import-flow.test.ts create mode 100644 components/extensions/general/arcim-document-import-flow.ts create mode 100644 extensions/general/arcim-migration/__tests__/import-documents-route.test.ts create mode 100644 lib/providers/fortnox/__tests__/attachments.test.ts create mode 100644 lib/providers/fortnox/__tests__/client.test.ts create mode 100644 lib/providers/fortnox/__tests__/oauth.test.ts create mode 100644 lib/providers/fortnox/attachments.ts diff --git a/DECISIONS.md b/DECISIONS.md index aad7d183..e492d744 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -876,6 +876,11 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-11] Anthropic, Vercel and Supabase removed from the portal directory: all three email their invoices to European customers, so listing them told the user to go and log in for a document already in their inbox. The directory's bar is "does not send the invoice", not "also has a portal", and the poll it was seeded from asked which portals people log into, which people answered with where an invoice can ALSO be found. The same objection may reach further down the list; an entry is a claim that the invoice cannot be had any other way and is worth checking per vendor. [2026-08-11] Portal URLs are swept by scripts/check-portal-urls.mts rather than trusted: the directory shipped with 18 hand-written paths, none opened, the file said so and shipped anyway, and a founder then hit a 404 on Google Workspace (/ac/billing/history). A sweep found GitHub's /settings/billing 404 too. Rule now is the shallowest URL that certainly resolves: landing one click short of the invoice costs little, landing on an error page spends the trust the feature runs on. Google, OpenAI and Hetzner refuse automated requests, so they cannot be swept and are kept shallow deliberately; only a genuine 404 fails the script, since failing on an unreachable host would train people to ignore it. Trygg Hansa removed: neither candidate URL could be reached at all. [2026-08-11] Credit-note deduction fields (deduction_total, per-item deduction_amount) stay POSITIVE magnitudes, unlike every other amount on a credit note: both columns carry CHECK (>= 0) in the DB, and negating them made every ROT/RUT credit fail at insert (prod support case 2026-08-11). Verified inert: the reversing verifikat recomputes the ROT/RUT split from quantity/unit_price (generateRotRutLines), the PDF hides the deduction section for credit notes, getAmountToPay skips deductions when credited_invoice_id is set, and ROT payout candidates require status='paid', which invoices_credit_note_not_paid makes impossible for credit notes. Any future reader summing these fields across invoice + credit note must special-case credit notes. +[2026-08-12] Fortnox and Bokio receipt imports share one private attachment adapter: keeping period resolution, WORM upload, per-verifikat hash deduplication, content sniffing, and best-effort counters in one loop prevents provider branches from drifting on compliance and idempotency. +[2026-08-12] Provider receipt imports derive a document UUID from company, verifikat, and content hash: the existing document primary key becomes an atomic cross-request claim without a migration, while each attempt uses a unique storage object and a losing insert verifies the retained winner before removing only its own unreferenced object. +[2026-08-12] The Fortnox migration completion prompt reports dry-run scanned attachments as "found", not would-link counts: provider availability is known before download, while content duplicates and final importability are only honest after the user starts the idempotent import. +[2026-08-12] The Fortnox document follow-up resolves the provider from the completed preview and stays visible after a zero-result scan: preview consent data is authoritative across wizard state transitions, and a visible empty state prevents a successful migration from silently losing the promised document step. +[2026-08-12] Fortnox document import stays opt-in after the SIE migration: the automatic follow-up performs only a dry-run count, and document downloads and WORM writes begin only when the user clicks the separate import action. [2026-08-12] Skattekonto booking claim race: the loser leaves its just-created draft unlinked instead of deleting it. lib/bookkeeping/engine.ts exposes no draft-discard function and journal tables are never raw-deleted; the delete_last_voucher RPC exists but is owner/admin-gated and lives outside the engine, so best-effort cleanup could fail on role and add phantom-delete audit noise. An orphan draft is legally deletable by the user in /bookkeeping; the request that lost the claim returns ALREADY_BOOKED and never commits. [2026-08-12] SLP on supplier invoices mirrors the reverse-charge zero-net pair mechanism (apply_slp item flag) instead of free-form credit rows: keeps the 2440 balance guarantee and the item model intact; year-end calculator nets off posted 7533 to avoid double provision. [2026-08-12] Enable Banking auth-method pin narrowed to hidden_method=true + psu_types match: PR #854's blanket decoupled pin broke Lunar-class banks; hidden-only restores its stated intent. diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx index 9782eb5d..a10bb4a3 100644 --- a/components/extensions/general/ArcimMigrationWorkspace.tsx +++ b/components/extensions/general/ArcimMigrationWorkspace.tsx @@ -1,6 +1,7 @@ 'use client' -import { useState, useCallback, useEffect, useRef } from 'react' +import { useState, useCallback, useEffect, useReducer, useRef } from 'react' +import { useTranslations } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Progress } from '@/components/ui/progress' @@ -37,8 +38,22 @@ import { Calendar, XCircle, BookOpen, + Paperclip, } from 'lucide-react' import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' +import { + ARCIM_DOCUMENT_OAUTH_RESUME_KEY, + INITIAL_ARCIM_DOCUMENT_IMPORT_STATE, + ArcimDocumentImportRequestError, + arcimDocumentImportReducer, + documentOAuthProblemFromReason, + parseArcimDocumentOAuthResume, + requestArcimDocumentImport, + resolveArcimDocumentFollowUpProvider, + watchArcimOAuthPopup, + type ArcimDocumentImportProblem, + type ArcimDocumentImportState, +} from './arcim-document-import-flow' type ArcimProvider = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden' | 'wint' @@ -101,6 +116,42 @@ function displayError(err: unknown, nonErrorFallback?: string): string { return getUserErrorMessage(err) } +function documentImportProblem(error: unknown): ArcimDocumentImportProblem { + if (error instanceof ArcimDocumentImportRequestError) return error.problem + return { code: null, requestId: null, reconnectRequired: false } +} + +function storeDocumentOAuthResume( + action: 'discover' | 'import', +): void { + try { + window.sessionStorage.setItem( + ARCIM_DOCUMENT_OAUTH_RESUME_KEY, + action, + ) + } catch { + // Full-page recovery is best-effort when browser storage is unavailable. + } +} + +function readDocumentOAuthResume() { + try { + return parseArcimDocumentOAuthResume( + window.sessionStorage.getItem(ARCIM_DOCUMENT_OAUTH_RESUME_KEY), + ) + } catch { + return null + } +} + +function clearDocumentOAuthResume(): void { + try { + window.sessionStorage.removeItem(ARCIM_DOCUMENT_OAUTH_RESUME_KEY) + } catch { + // Nothing else is required when browser storage is unavailable. + } +} + /** * Read the /migrate NDJSON stream: one JSON object per line. `progress` * events carry the orchestrator's real step labels and anchors; the stream @@ -1573,18 +1624,233 @@ function FiscalYearResult({ result, index }: { result: ImportResult; index: numb ) } +function DocumentImportFollowUp({ + state, + onDiscover, + onImport, + onDismiss, + onReconnect, +}: { + state: ArcimDocumentImportState + onDiscover: () => void + onImport: () => void + onDismiss: () => void + onReconnect: () => void +}) { + const t = useTranslations('extensions') + + if (state.phase === 'hidden' || state.phase === 'dismissed') return null + + const title = ( + + + {t('ext_arcim_documents_title')} + + ) + + if ( + state.phase === 'discovering' || + state.phase === 'importing' || + state.phase === 'reconnecting' + ) { + const label = + state.phase === 'discovering' + ? t('ext_arcim_documents_discovering') + : state.phase === 'importing' + ? t('ext_arcim_documents_importing') + : t('ext_arcim_documents_reconnecting') + + return ( + + {title} + +
+
+
+
+ ) + } + + if (state.phase === 'offered') { + return ( + + + {title} + + {t('ext_arcim_documents_prompt', { count: state.found })} + + + + + + + + ) + } + + if (state.phase === 'empty') { + return ( + + + {title} + {t('ext_arcim_documents_empty')} + + + + + + ) + } + + if (state.phase === 'complete') { + if (!state.result) { + return ( + + + {title} + {t('ext_arcim_documents_result_description')} + + + ) + } + + const { linked, skipped, unmatched, failed } = state.result + const outcomes = [ + { + label: t('ext_arcim_documents_imported'), + value: linked, + valueClassName: 'text-foreground', + }, + { + label: t('ext_arcim_documents_skipped'), + value: skipped, + valueClassName: 'text-foreground', + }, + { + label: t('ext_arcim_documents_unmatched'), + value: unmatched, + valueClassName: 'text-foreground', + }, + { + label: t('ext_arcim_documents_failed'), + value: failed, + valueClassName: failed > 0 ? 'text-destructive' : 'text-foreground', + }, + ] + + return ( + + + {title} + {t('ext_arcim_documents_result_description')} + + +
+ {outcomes.map(({ label, value, valueClassName }) => ( +
+
{label}
+
+ {value} +
+
+ ))} +
+ {unmatched > 0 && ( +

+ {t('ext_arcim_documents_unmatched_help')} +

+ )} + {failed > 0 && ( +
+

+ {t('ext_arcim_documents_partial_failure')} +

+ +
+ )} +
+
+ ) + } + + const reconnectRequired = state.problem?.reconnectRequired === true + const discoveryFailed = state.phase === 'discovery-error' + return ( + + + {title} + + {state.problem?.message + ? state.problem.message + : reconnectRequired + ? t('ext_arcim_documents_scope_error') + : discoveryFailed + ? t('ext_arcim_documents_discovery_error') + : t('ext_arcim_documents_import_error')} + + + + {state.problem?.requestId && ( +

+ {t('ext_arcim_documents_error_reference', { + requestId: state.problem.requestId, + })} +

+ )} + +
+
+ ) +} + function ResultStep({ results, sieResults, error, + documentImportState, onDone, onRetry, + onDiscoverDocuments, + onImportDocuments, + onDismissDocuments, + onReconnectDocuments, }: { results: MigrationResults | null sieResults: ImportResult[] error: string | null + documentImportState: ArcimDocumentImportState onDone: () => void onRetry: () => void + onDiscoverDocuments: () => void + onImportDocuments: () => void + onDismissDocuments: () => void + onReconnectDocuments: () => void }) { if (error) { return ( @@ -1616,7 +1882,10 @@ function ResultStep({ ) } - const hasResults = results || sieResults.length > 0 + const hasResults = + results || + sieResults.length > 0 || + (documentImportState.phase !== 'hidden' && documentImportState.phase !== 'dismissed') if (!hasResults) return null // Compute combined SIE stats @@ -1780,6 +2049,14 @@ function ResultStep({ ) })()} + + {/* ── Next steps ── */} @@ -1969,6 +2246,13 @@ export default function ArcimMigrationWorkspace({ const [migrationProgress, setMigrationProgress] = useState(0) const [migrationResults, setMigrationResults] = useState(null) const [sieImportResults, setSieImportResults] = useState([]) + const [documentImportState, dispatchDocumentImport] = useReducer( + arcimDocumentImportReducer, + INITIAL_ARCIM_DOCUMENT_IMPORT_STATE, + ) + const documentReconnectActionRef = useRef<'discover' | 'import' | null>(null) + const documentReconnectFailureCleanupRef = useRef(null) + const stopOAuthPopupWatchRef = useRef<(() => void) | null>(null) // Knowledge-graph theater for the migrating step, built from the already // client-held parsed SIE. Null falls back to the plain progress card. const [theaterModel, setTheaterModel] = useState(null) @@ -2043,6 +2327,10 @@ export default function ArcimMigrationWorkspace({ const data = await res.json() setPreview(data) + const previewProvider = data?.consent?.provider + if (ARCIM_PROVIDERS.some((provider) => provider.id === previewProvider)) { + setSelectedProvider(previewProvider as ArcimProvider) + } // If SIE is not available, disable SIE import by default if (!data.sieAvailable) { @@ -2105,13 +2393,34 @@ export default function ArcimMigrationWorkspace({ await loadPreview(existingConsentId) }, [loadPreview]) + const clearOAuthPopupWatch = useCallback(() => { + stopOAuthPopupWatchRef.current?.() + stopOAuthPopupWatchRef.current = null + }, []) + + const clearDocumentReconnectFailureCleanup = useCallback(() => { + if (documentReconnectFailureCleanupRef.current) { + window.clearTimeout(documentReconnectFailureCleanupRef.current) + documentReconnectFailureCleanupRef.current = null + } + }, []) + + useEffect(() => () => { + clearOAuthPopupWatch() + clearDocumentReconnectFailureCleanup() + }, [clearDocumentReconnectFailureCleanup, clearOAuthPopupWatch]) + // Re-authorize a dead connection in place. Re-runs provider auth against the // SAME consent so fresh tokens overwrite the expired pair: no disconnect. // OAuth providers open the login popup (the existing postMessage listener // reloads the preview on success); token providers drop to the credential // form. Triggered from the "Återanslut" CTA after a sync hits // PROVIDER_AUTH_EXPIRED. - const handleReconnect = useCallback(async (provider: ArcimProvider, existingConsentId: string) => { + const handleReconnect = useCallback(async ( + provider: ArcimProvider, + existingConsentId: string, + options?: { onFailure?: () => void }, + ) => { setError(null) setAuthExpired(false) setLicenseMissing(false) @@ -2145,8 +2454,10 @@ export default function ArcimMigrationWorkspace({ setAuthType(data.authType) if (data.authType === 'oauth' && data.authUrl) { + let activePopup: Window | null = null if (popup && !popup.closed) { popup.location.href = data.authUrl + activePopup = popup } else { // The pre-opened popup was blocked or closed; retrying here is a // long shot (the activation may be gone) but strictly better than @@ -2156,8 +2467,22 @@ export default function ArcimMigrationWorkspace({ const retry = window.open(data.authUrl, 'arcim-oauth', `width=${w},height=${h},left=${left},top=${top}`) if (!retry) { window.location.href = data.authUrl + } else { + activePopup = retry } } + if (activePopup) { + clearOAuthPopupWatch() + stopOAuthPopupWatchRef.current = watchArcimOAuthPopup(activePopup, () => { + stopOAuthPopupWatchRef.current = null + if (options?.onFailure) { + options.onFailure() + } else { + setError('Inloggningsfönstret stängdes innan anslutningen var klar. Försök igen.') + setAuthExpired(true) + } + }) + } setAuthUrl(data.authUrl) } else { popup?.close() @@ -2168,13 +2493,92 @@ export default function ArcimMigrationWorkspace({ } } catch (err) { popup?.close() - setError(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte återansluta') - setAuthExpired(true) + if (options?.onFailure) { + options.onFailure() + } else { + setError(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte återansluta') + setAuthExpired(true) + } } finally { setIsLoading(false) } + }, [clearOAuthPopupWatch]) + + const runDocumentDiscovery = useCallback(async ( + currentConsentId: string, + provider: ArcimProvider | null, + migrationSucceeded: boolean, + ) => { + dispatchDocumentImport({ + type: 'discovery-started', + provider, + migrationSucceeded, + }) + if (provider !== 'fortnox' || !migrationSucceeded) return + + try { + const result = await requestArcimDocumentImport(currentConsentId, true) + dispatchDocumentImport({ type: 'discovery-succeeded', result }) + } catch (documentError) { + dispatchDocumentImport({ + type: 'discovery-failed', + problem: documentImportProblem(documentError), + }) + } }, []) + const runDocumentImport = useCallback(async (currentConsentId: string) => { + dispatchDocumentImport({ type: 'import-started' }) + try { + const result = await requestArcimDocumentImport(currentConsentId, false) + dispatchDocumentImport({ type: 'import-succeeded', result }) + } catch (documentError) { + dispatchDocumentImport({ + type: 'import-failed', + problem: documentImportProblem(documentError), + }) + } + }, []) + + const handleDocumentReconnect = useCallback(() => { + if (!consentId) return + clearDocumentReconnectFailureCleanup() + const reconnectAction = + documentImportState.phase === 'discovery-error' ? 'discover' : 'import' + const priorProblem = documentImportState.problem ?? { + code: null, + requestId: null, + reconnectRequired: false, + } + + documentReconnectActionRef.current = reconnectAction + storeDocumentOAuthResume(reconnectAction) + dispatchDocumentImport({ type: 'reconnect-started' }) + void handleReconnect('fortnox', consentId, { + onFailure: () => { + dispatchDocumentImport( + reconnectAction === 'discover' + ? { type: 'discovery-failed', problem: priorProblem } + : { type: 'import-failed', problem: priorProblem }, + ) + // Keep the action briefly after the popup-close grace period. Some + // browsers deliver the successful postMessage after reporting the + // popup as closed; that success must remain authoritative. + documentReconnectFailureCleanupRef.current = window.setTimeout(() => { + documentReconnectActionRef.current = null + clearDocumentOAuthResume() + documentReconnectFailureCleanupRef.current = null + }, 30_000) + }, + }) + }, [ + clearDocumentReconnectFailureCleanup, + consentId, + documentImportState.phase, + documentImportState.problem, + handleReconnect, + ]) + // Disconnect an existing consent const handleDisconnect = useCallback(async (consentIdToDelete: string) => { try { @@ -2233,6 +2637,7 @@ export default function ArcimMigrationWorkspace({ const url = new URL(window.location.href) const migrationStatus = url.searchParams.get('migration') const callbackConsentId = url.searchParams.get('consentId') + const documentResume = readDocumentOAuthResume() if (migrationStatus === 'connected' && callbackConsentId) { // Clean URL @@ -2240,14 +2645,43 @@ export default function ArcimMigrationWorkspace({ url.searchParams.delete('consentId') window.history.replaceState({}, '', url.pathname) - await loadPreview(callbackConsentId) + clearDocumentOAuthResume() + if (documentResume) { + clearDocumentReconnectFailureCleanup() + documentReconnectActionRef.current = null + setConsentId(callbackConsentId) + setSelectedProvider('fortnox') + setStep('result') + if (documentResume.action === 'discover') { + await runDocumentDiscovery(callbackConsentId, 'fortnox', true) + } else { + await runDocumentImport(callbackConsentId) + } + } else { + await loadPreview(callbackConsentId) + } } else if (migrationStatus === 'error') { const callbackProvider = url.searchParams.get('provider') as ArcimProvider | null const reason = url.searchParams.get('reason') || 'OAuth-anslutningen misslyckades. Försök igen.' url.searchParams.delete('migration') url.searchParams.delete('provider') url.searchParams.delete('reason') + url.searchParams.delete('consentId') window.history.replaceState({}, '', url.pathname) + clearDocumentOAuthResume() + if (documentResume && callbackConsentId) { + clearDocumentReconnectFailureCleanup() + setConsentId(callbackConsentId) + setSelectedProvider('fortnox') + setStep('result') + const problem = documentOAuthProblemFromReason(reason) + dispatchDocumentImport( + documentResume.action === 'discover' + ? { type: 'discovery-failed', problem } + : { type: 'import-failed', problem }, + ) + return + } setError(reason) toast({ title: 'Anslutning misslyckades', description: reason, variant: 'destructive' }) if (callbackProvider) { @@ -2257,7 +2691,13 @@ export default function ArcimMigrationWorkspace({ setStep('provider') } } - }, [loadPreview, toast]) + }, [ + clearDocumentReconnectFailureCleanup, + loadPreview, + runDocumentDiscovery, + runDocumentImport, + toast, + ]) // Check for OAuth callback on mount (fallback for non-popup flow) useEffect(() => { @@ -2286,18 +2726,60 @@ export default function ArcimMigrationWorkspace({ function handleMessage(event: MessageEvent) { if (event.origin !== window.location.origin) return if (event.data?.type === 'arcim-oauth-success' && event.data.consentId) { + clearOAuthPopupWatch() + clearDocumentReconnectFailureCleanup() + const reconnectAction = documentReconnectActionRef.current + if (reconnectAction) { + documentReconnectActionRef.current = null + clearDocumentOAuthResume() + setConsentId(event.data.consentId) + setSelectedProvider('fortnox') + setStep('result') + if (reconnectAction === 'discover') { + void runDocumentDiscovery(event.data.consentId, 'fortnox', true) + } else { + void runDocumentImport(event.data.consentId) + } + return + } loadPreview(event.data.consentId) } else if (event.data?.type === 'arcim-oauth-error') { + clearOAuthPopupWatch() + clearDocumentReconnectFailureCleanup() const reason = typeof event.data.reason === 'string' && event.data.reason ? event.data.reason : 'OAuth-anslutningen misslyckades. Försök igen.' + const reconnectAction = documentReconnectActionRef.current + if (reconnectAction) { + documentReconnectActionRef.current = null + clearDocumentOAuthResume() + const problem = documentImportState.problem ?? { + code: null, + requestId: null, + reconnectRequired: true, + } + dispatchDocumentImport( + reconnectAction === 'discover' + ? { type: 'discovery-failed', problem } + : { type: 'import-failed', problem }, + ) + return + } setError(reason) toast({ title: 'Anslutning misslyckades', description: reason, variant: 'destructive' }) } } window.addEventListener('message', handleMessage) return () => window.removeEventListener('message', handleMessage) - }, [loadPreview, toast]) + }, [ + clearOAuthPopupWatch, + clearDocumentReconnectFailureCleanup, + documentImportState.problem, + loadPreview, + runDocumentDiscovery, + runDocumentImport, + toast, + ]) // Load SIE data when entering mapping step const loadSIEData = useCallback(async () => { @@ -2380,6 +2862,7 @@ export default function ArcimMigrationWorkspace({ setMigrationStep('Startar migrering...') setMigrationProgress(5) setError(null) + dispatchDocumentImport({ type: 'reset' }) // Build the theater from the parsed SIE the client already holds. // Best-effort: any failure just leaves the plain progress card. @@ -2512,6 +2995,14 @@ export default function ArcimMigrationWorkspace({ setMigrationProgress(100) setStep('result') + const documentProvider = resolveArcimDocumentFollowUpProvider( + preview?.consent.provider, + selectedProvider, + ) + if (documentProvider) { + void runDocumentDiscovery(consentId, documentProvider, true) + } + if (hadStepErrors) { toast({ title: 'Migrering delvis genomförd', @@ -2528,7 +3019,7 @@ export default function ArcimMigrationWorkspace({ setError(displayError(err)) setStep('result') } - }, [consentId, migrationOptions, sieData, toast]) + }, [consentId, migrationOptions, preview, runDocumentDiscovery, selectedProvider, sieData, toast]) const handleDone = useCallback(() => { // Reset wizard @@ -2542,11 +3033,14 @@ export default function ArcimMigrationWorkspace({ setMigrationOptions(DEFAULT_OPTIONS) setMigrationResults(null) setSieImportResults([]) + dispatchDocumentImport({ type: 'reset' }) + clearDocumentReconnectFailureCleanup() + documentReconnectActionRef.current = null setTheaterModel(null) setError(null) // Refresh status so provider step shows updated import history fetchStatus() - }, [fetchStatus]) + }, [clearDocumentReconnectFailureCleanup, fetchStatus]) // ── Render ───────────────────────────────────────────────────── @@ -2662,11 +3156,20 @@ export default function ArcimMigrationWorkspace({ results={migrationResults} sieResults={sieImportResults} error={error} + documentImportState={documentImportState} onDone={handleDone} onRetry={() => { setError(null) setStep('options') }} + onDiscoverDocuments={() => { + if (consentId) void runDocumentDiscovery(consentId, 'fortnox', true) + }} + onImportDocuments={() => { + if (consentId) void runDocumentImport(consentId) + }} + onDismissDocuments={() => dispatchDocumentImport({ type: 'dismissed' })} + onReconnectDocuments={handleDocumentReconnect} /> )} diff --git a/components/extensions/general/__tests__/arcim-document-import-flow.test.ts b/components/extensions/general/__tests__/arcim-document-import-flow.test.ts new file mode 100644 index 00000000..edf29a3d --- /dev/null +++ b/components/extensions/general/__tests__/arcim-document-import-flow.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it, vi } from 'vitest' +import { + ARCIM_DOCUMENT_OAUTH_RESUME_KEY, + INITIAL_ARCIM_DOCUMENT_IMPORT_STATE, + PROVIDER_DOCUMENT_SCOPES_REQUIRED, + ArcimDocumentImportRequestError, + arcimDocumentImportReducer, + documentOAuthProblemFromReason, + parseArcimDocumentOAuthResume, + requestArcimDocumentImport, + resolveArcimDocumentFollowUpProvider, + watchArcimOAuthPopup, + type ArcimDocumentImportResult, +} from '../arcim-document-import-flow' + +function result( + overrides: Partial = {}, +): ArcimDocumentImportResult { + return { + provider: 'fortnox', + scanned: 7, + linked: 5, + skipped: 0, + unmatched: 2, + failed: 0, + dryRun: true, + unmatchedSamples: [], + ...overrides, + } +} + +describe('Fortnox document follow-up state', () => { + it('offers the prompt after a successful Fortnox migration using the honest found count', () => { + const discovering = arcimDocumentImportReducer( + INITIAL_ARCIM_DOCUMENT_IMPORT_STATE, + { type: 'discovery-started', provider: 'fortnox', migrationSucceeded: true }, + ) + const offered = arcimDocumentImportReducer(discovering, { + type: 'discovery-succeeded', + result: result({ scanned: 7, linked: 5, unmatched: 2 }), + }) + + expect(offered.phase).toBe('offered') + expect(offered.found).toBe(7) + }) + + it('does not start discovery for a non-Fortnox migration', () => { + expect( + arcimDocumentImportReducer(INITIAL_ARCIM_DOCUMENT_IMPORT_STATE, { + type: 'discovery-started', + provider: 'bokio', + migrationSucceeded: true, + }), + ).toEqual(INITIAL_ARCIM_DOCUMENT_IMPORT_STATE) + }) + + it('keeps the document step visible when Fortnox has no attachments', () => { + const discovering = arcimDocumentImportReducer( + INITIAL_ARCIM_DOCUMENT_IMPORT_STATE, + { type: 'discovery-started', provider: 'fortnox', migrationSucceeded: true }, + ) + const empty = arcimDocumentImportReducer(discovering, { + type: 'discovery-succeeded', + result: result({ scanned: 0, linked: 0, unmatched: 0 }), + }) + + expect(empty.phase).toBe('empty') + expect(empty.result?.scanned).toBe(0) + }) + + it('uses the completed preview provider before transient selection state', () => { + expect(resolveArcimDocumentFollowUpProvider('fortnox', null)).toBe('fortnox') + expect(resolveArcimDocumentFollowUpProvider('fortnox', 'bokio')).toBe('fortnox') + expect(resolveArcimDocumentFollowUpProvider(undefined, 'fortnox')).toBe('fortnox') + expect(resolveArcimDocumentFollowUpProvider('bokio', 'fortnox')).toBeNull() + }) + + it('keeps a dry-run failure in a retryable document state, separate from migration success', () => { + const problem = { code: 'TRANSIENT_ERROR', requestId: 'req_test', reconnectRequired: false } + const state = arcimDocumentImportReducer( + { phase: 'discovering', found: 0, result: null, problem: null }, + { type: 'discovery-failed', problem }, + ) + + expect(state).toEqual({ + phase: 'discovery-error', + found: 0, + result: null, + problem, + }) + }) + + it('keeps all outcome counts after a successful import', () => { + const imported = result({ + dryRun: false, + scanned: 7, + linked: 3, + skipped: 2, + unmatched: 1, + failed: 1, + }) + const state = arcimDocumentImportReducer( + { phase: 'importing', found: 7, result: null, problem: null }, + { type: 'import-succeeded', result: imported }, + ) + + expect(state.phase).toBe('complete') + expect(state.result).toMatchObject({ + linked: 3, + skipped: 2, + unmatched: 1, + failed: 1, + }) + }) + + it('keeps the dry-run result offered until the user explicitly starts import', () => { + const offered = arcimDocumentImportReducer( + { phase: 'discovering', found: 0, result: null, problem: null }, + { type: 'discovery-succeeded', result: result({ dryRun: true }) }, + ) + + expect(offered).toMatchObject({ phase: 'offered', result: { dryRun: true } }) + expect( + arcimDocumentImportReducer(offered, { type: 'import-started' }), + ).toMatchObject({ phase: 'importing' }) + }) + + it('allows OAuth success to replace an earlier popup-close failure', () => { + const problem = { code: null, requestId: null, reconnectRequired: true } + const failed = arcimDocumentImportReducer( + { phase: 'reconnecting', found: 7, result: result(), problem }, + { type: 'import-failed', problem }, + ) + const importing = arcimDocumentImportReducer(failed, { type: 'import-started' }) + const complete = arcimDocumentImportReducer(importing, { + type: 'import-succeeded', + result: result({ dryRun: false, linked: 7, unmatched: 0 }), + }) + + expect(failed.phase).toBe('import-error') + expect(complete).toMatchObject({ phase: 'complete', result: { linked: 7 } }) + }) + + it('marks the archive/connectfile scope error as reconnect-required', async () => { + const fetcher = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: { + code: PROVIDER_DOCUMENT_SCOPES_REQUIRED, + message: 'scope required', + requestId: 'req_scope', + }, + }), + { status: 403, headers: { 'Content-Type': 'application/json' } }, + ), + ) + + const error = await requestArcimDocumentImport( + 'consent-1', + true, + fetcher, + ).catch((caught) => caught) + + expect(error).toBeInstanceOf(ArcimDocumentImportRequestError) + expect(error.problem).toEqual({ + code: PROVIDER_DOCUMENT_SCOPES_REQUIRED, + requestId: 'req_scope', + reconnectRequired: true, + }) + }) +}) + +describe('document import endpoint request', () => { + it('uses POST dry-run discovery without downloading automatically', async () => { + const fetcher = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, result: result() }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + await requestArcimDocumentImport('consent-1', true, fetcher) + + expect(fetcher).toHaveBeenCalledWith( + '/api/extensions/ext/arcim-migration/import-documents', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ consentId: 'consent-1', dryRun: true }), + }), + ) + }) + + it('rejects a success payload without unmatched samples', async () => { + const invalid = result() + const { unmatchedSamples: _unmatchedSamples, ...withoutSamples } = invalid + const fetcher = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, result: withoutSamples }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + await expect( + requestArcimDocumentImport('consent-1', true, fetcher), + ).rejects.toBeInstanceOf(ArcimDocumentImportRequestError) + }) +}) + +describe('document scope OAuth recovery', () => { + it('round-trips the full-page redirect resume action and rejects malformed state', () => { + expect(ARCIM_DOCUMENT_OAUTH_RESUME_KEY).toBe('arcim-document-oauth-resume') + expect(parseArcimDocumentOAuthResume('import')).toEqual({ + action: 'import', + }) + expect(parseArcimDocumentOAuthResume('unknown')).toBeNull() + }) + + it('only treats scope and consent failures as reconnectable', () => { + expect( + documentOAuthProblemFromReason('Tredjepartsappen saknar rätt behörigheter'), + ).toMatchObject({ + code: PROVIDER_DOCUMENT_SCOPES_REQUIRED, + reconnectRequired: true, + }) + expect(documentOAuthProblemFromReason('Du avbröt anslutningen')).toMatchObject({ + code: null, + reconnectRequired: true, + }) + expect(documentOAuthProblemFromReason('Leverantören är tillfälligt nere')).toEqual({ + code: null, + requestId: null, + reconnectRequired: false, + message: 'Leverantören är tillfälligt nere', + }) + }) + + it('restores retry controls when the OAuth popup is closed', () => { + vi.useFakeTimers() + const popup = { closed: false } + const onClosed = vi.fn() + const stopWatching = watchArcimOAuthPopup(popup, onClosed, 10, 20) + + vi.advanceTimersByTime(20) + expect(onClosed).not.toHaveBeenCalled() + + popup.closed = true + vi.advanceTimersByTime(10) + expect(onClosed).not.toHaveBeenCalled() + vi.advanceTimersByTime(20) + expect(onClosed).toHaveBeenCalledOnce() + + stopWatching() + vi.useRealTimers() + }) + + it('lets a queued OAuth success cancel the popup-close grace period', () => { + vi.useFakeTimers() + const popup = { closed: true } + const onClosed = vi.fn() + const stopWatching = watchArcimOAuthPopup(popup, onClosed, 10, 20) + + vi.advanceTimersByTime(10) + stopWatching() + vi.advanceTimersByTime(20) + + expect(onClosed).not.toHaveBeenCalled() + vi.useRealTimers() + }) +}) diff --git a/components/extensions/general/arcim-document-import-flow.ts b/components/extensions/general/arcim-document-import-flow.ts new file mode 100644 index 00000000..8895ab2f --- /dev/null +++ b/components/extensions/general/arcim-document-import-flow.ts @@ -0,0 +1,269 @@ +export const ARCIM_DOCUMENT_IMPORT_ENDPOINT = + '/api/extensions/ext/arcim-migration/import-documents' + +export const PROVIDER_DOCUMENT_SCOPES_REQUIRED = + 'PROVIDER_DOCUMENT_SCOPES_REQUIRED' + +export const ARCIM_DOCUMENT_OAUTH_RESUME_KEY = + 'arcim-document-oauth-resume' + +export type ArcimDocumentOAuthResumeAction = 'discover' | 'import' + +export interface ArcimDocumentOAuthResume { + action: ArcimDocumentOAuthResumeAction +} + +export function parseArcimDocumentOAuthResume( + value: string | null, +): ArcimDocumentOAuthResume | null { + if (value !== 'discover' && value !== 'import') return null + return { action: value } +} + +/** Poll a provider popup so closing it cannot leave the UI reconnecting forever. */ +export function watchArcimOAuthPopup( + popup: { closed: boolean }, + onClosed: () => void, + intervalMs: number = 500, + graceMs: number = 500, +): () => void { + let active = true + let graceTimeout: ReturnType | null = null + const interval = globalThis.setInterval(() => { + if (!popup.closed || !active) return + globalThis.clearInterval(interval) + graceTimeout = globalThis.setTimeout(() => { + if (!active) return + active = false + onClosed() + }, graceMs) + }, intervalMs) + + return () => { + if (!active) return + active = false + globalThis.clearInterval(interval) + if (graceTimeout) globalThis.clearTimeout(graceTimeout) + } +} + +export interface ArcimDocumentImportResult { + provider: string + scanned: number + linked: number + skipped: number + unmatched: number + failed: number + dryRun: boolean + unmatchedSamples: { uploadId: string; voucher: string; date: string }[] +} + +export interface ArcimDocumentImportProblem { + code: string | null + requestId: string | null + reconnectRequired: boolean + message?: string +} + +export function documentOAuthProblemFromReason( + reason: string, +): ArcimDocumentImportProblem { + const normalized = reason.toLowerCase() + const scopeFailure = + normalized.includes('invalid_scope') || + normalized.includes('scope') || + normalized.includes('behörighet') + const consentDenied = + normalized.includes('access_denied') || + normalized.includes('denied') || + normalized.includes('nekad') || + normalized.includes('avbröt') + + return { + code: scopeFailure ? PROVIDER_DOCUMENT_SCOPES_REQUIRED : null, + requestId: null, + reconnectRequired: scopeFailure || consentDenied, + message: reason, + } +} + +export type ArcimDocumentImportPhase = + | 'hidden' + | 'discovering' + | 'offered' + | 'empty' + | 'dismissed' + | 'discovery-error' + | 'importing' + | 'complete' + | 'import-error' + | 'reconnecting' + +export interface ArcimDocumentImportState { + phase: ArcimDocumentImportPhase + found: number + result: ArcimDocumentImportResult | null + problem: ArcimDocumentImportProblem | null +} + +export const INITIAL_ARCIM_DOCUMENT_IMPORT_STATE: ArcimDocumentImportState = { + phase: 'hidden', + found: 0, + result: null, + problem: null, +} + +export type ArcimDocumentImportAction = + | { type: 'reset' } + | { + type: 'discovery-started' + provider: string | null + migrationSucceeded: boolean + } + | { type: 'discovery-succeeded'; result: ArcimDocumentImportResult } + | { type: 'discovery-failed'; problem: ArcimDocumentImportProblem } + | { type: 'dismissed' } + | { type: 'import-started' } + | { type: 'import-succeeded'; result: ArcimDocumentImportResult } + | { type: 'import-failed'; problem: ArcimDocumentImportProblem } + | { type: 'reconnect-started' } + +/** + * The completed preview is the authoritative provider source. The selected + * provider is only a fallback for older flows that do not retain preview data. + */ +export function resolveArcimDocumentFollowUpProvider( + previewProvider: string | null | undefined, + selectedProvider: string | null | undefined, +): 'fortnox' | null { + const provider = previewProvider ?? selectedProvider + return provider === 'fortnox' ? provider : null +} + +/** + * Keeps the optional document step separate from the completed migration. + * A discovery or import failure can therefore never replace the migration's + * own success result with a failure state. + */ +export function arcimDocumentImportReducer( + state: ArcimDocumentImportState, + action: ArcimDocumentImportAction, +): ArcimDocumentImportState { + switch (action.type) { + case 'reset': + return INITIAL_ARCIM_DOCUMENT_IMPORT_STATE + case 'discovery-started': + if (action.provider !== 'fortnox' || !action.migrationSucceeded) { + return INITIAL_ARCIM_DOCUMENT_IMPORT_STATE + } + return { phase: 'discovering', found: 0, result: null, problem: null } + case 'discovery-succeeded': + if (action.result.provider !== 'fortnox') { + return INITIAL_ARCIM_DOCUMENT_IMPORT_STATE + } + if (action.result.scanned <= 0) { + return { + phase: 'empty', + found: 0, + result: action.result, + problem: null, + } + } + return { + phase: 'offered', + found: action.result.scanned, + result: action.result, + problem: null, + } + case 'discovery-failed': + return { + phase: 'discovery-error', + found: 0, + result: null, + problem: action.problem, + } + case 'dismissed': + return { ...state, phase: 'dismissed', problem: null } + case 'import-started': + return { ...state, phase: 'importing', problem: null } + case 'import-succeeded': + return { + phase: 'complete', + found: state.found || action.result.scanned, + result: action.result, + problem: null, + } + case 'import-failed': + return { ...state, phase: 'import-error', problem: action.problem } + case 'reconnect-started': + return { ...state, phase: 'reconnecting' } + } +} + +export class ArcimDocumentImportRequestError extends Error { + constructor(readonly problem: ArcimDocumentImportProblem) { + super(problem.code ?? 'ARCIM_DOCUMENT_IMPORT_FAILED') + this.name = 'ArcimDocumentImportRequestError' + } +} + +function problemFromPayload(payload: unknown): ArcimDocumentImportProblem { + const error = (payload as { error?: unknown } | null)?.error + const structured = + error && typeof error === 'object' + ? (error as { code?: unknown; requestId?: unknown }) + : null + const code = typeof structured?.code === 'string' ? structured.code : null + const requestId = + typeof structured?.requestId === 'string' ? structured.requestId : null + + return { + code, + requestId, + reconnectRequired: code === PROVIDER_DOCUMENT_SCOPES_REQUIRED, + } +} + +function isDocumentImportResult(value: unknown): value is ArcimDocumentImportResult { + if (!value || typeof value !== 'object') return false + const result = value as Partial + return ( + typeof result.provider === 'string' && + typeof result.scanned === 'number' && + typeof result.linked === 'number' && + typeof result.skipped === 'number' && + typeof result.unmatched === 'number' && + typeof result.failed === 'number' && + typeof result.dryRun === 'boolean' && + Array.isArray(result.unmatchedSamples) + ) +} + +/** Call the existing POST route for both discovery and the actual import. */ +export async function requestArcimDocumentImport( + consentId: string, + dryRun: boolean, + fetcher: typeof fetch = fetch, +): Promise { + const response = await fetcher(ARCIM_DOCUMENT_IMPORT_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ consentId, dryRun }), + }) + const payload = await response.json().catch(() => null) + + if (!response.ok) { + throw new ArcimDocumentImportRequestError(problemFromPayload(payload)) + } + + const result = (payload as { result?: unknown } | null)?.result + if (!isDocumentImportResult(result)) { + throw new ArcimDocumentImportRequestError({ + code: null, + requestId: null, + reconnectRequired: false, + }) + } + + return result +} diff --git a/extensions/general/arcim-migration/__tests__/import-documents-route.test.ts b/extensions/general/arcim-migration/__tests__/import-documents-route.test.ts new file mode 100644 index 00000000..47cb11c4 --- /dev/null +++ b/extensions/general/arcim-migration/__tests__/import-documents-route.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { createMockRequest, createMockSupabase, parseJsonResponse } from '@/tests/helpers' +import type { ExtensionContext } from '@/lib/extensions/types' + +vi.mock('../lib/import-documents', () => { + class FortnoxDocumentScopesRequiredError extends Error { + readonly code = 'PROVIDER_DOCUMENT_SCOPES_REQUIRED' + + constructor() { + super('Fortnox consent lacks archive/connectfile scope: reconnect required') + } + } + + return { + FortnoxDocumentScopesRequiredError, + importProviderDocuments: vi.fn(), + } +}) + +vi.mock('../lib/provider-client', () => ({ + createConsent: vi.fn(), + getConsent: vi.fn(), + listConsents: vi.fn(), + generateOtc: vi.fn(), + consumeOAuthState: vi.fn(), + getAuthUrl: vi.fn(), + exchangeAuthToken: vi.fn(), + submitProviderToken: vi.fn(), + acceptConsent: vi.fn(), + deleteConsent: vi.fn(), + resolveConsent: vi.fn(), + fetchCompanyInfoDirect: vi.fn(), + ProviderTokenInvalidError: class ProviderTokenInvalidError extends Error {}, + ProviderCompanyMismatchError: class ProviderCompanyMismatchError extends Error {}, + ConsentNotFoundError: class ConsentNotFoundError extends Error {}, +})) + +import { arcimMigrationExtension } from '../index' +import { + FortnoxDocumentScopesRequiredError, + importProviderDocuments, +} from '../lib/import-documents' + +const route = (arcimMigrationExtension.apiRoutes ?? []).find( + (candidate) => + candidate.method === 'POST' && candidate.path === '/import-documents', +)! + +type RouteHandler = (request: Request, ctx?: ExtensionContext) => Promise +const handler = route.handler as RouteHandler + +function buildContext(): ExtensionContext { + const { supabase } = createMockSupabase() + ;(supabase as unknown as { auth: unknown }).auth = { + getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }), + } + return { supabase, companyId: 'company-1' } as unknown as ExtensionContext +} + +function request(dryRun: boolean) { + return createMockRequest( + 'http://localhost/api/extensions/ext/arcim-migration/import-documents', + { + method: 'POST', + body: { consentId: 'consent-1', dryRun }, + }, + ) +} + +describe('POST /import-documents', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('passes dry-run discovery through without storing documents', async () => { + ;(importProviderDocuments as Mock).mockResolvedValue({ + provider: 'fortnox', + scanned: 4, + linked: 3, + skipped: 0, + unmatched: 1, + failed: 0, + dryRun: true, + unmatchedSamples: [], + }) + + const response = await handler(request(true), buildContext()) + const { status, body } = await parseJsonResponse<{ + success: boolean + dryRun: boolean + result: { scanned: number } + }>(response) + + expect(status).toBe(200) + expect(body).toMatchObject({ success: true, dryRun: true, result: { scanned: 4 } }) + expect(importProviderDocuments).toHaveBeenCalledWith( + expect.objectContaining({ + companyId: 'company-1', + consentId: 'consent-1', + dryRun: true, + }), + ) + }) + + it('returns an actionable 403 when Fortnox lacks archive/connectfile scopes', async () => { + ;(importProviderDocuments as Mock).mockRejectedValue( + new FortnoxDocumentScopesRequiredError(), + ) + + const response = await handler(request(false), buildContext()) + const { status, body } = await parseJsonResponse<{ + error: { code: string; message: string; message_en?: string } + }>(response) + + expect(status).toBe(403) + expect(body.error.code).toBe('PROVIDER_DOCUMENT_SCOPES_REQUIRED') + expect(body.error.message).toContain('Koppla om Fortnox') + expect(body.error.message_en).toContain('Reconnect Fortnox') + }) +}) diff --git a/extensions/general/arcim-migration/__tests__/import-documents.test.ts b/extensions/general/arcim-migration/__tests__/import-documents.test.ts index 0d847e91..47a95133 100644 --- a/extensions/general/arcim-migration/__tests__/import-documents.test.ts +++ b/extensions/general/arcim-migration/__tests__/import-documents.test.ts @@ -8,18 +8,35 @@ import { downloadBokioUpload, type BokioVoucherRef, } from '@/lib/providers/bokio/attachments' +import { FortnoxApiError } from '@/lib/providers/fortnox/client' +import { + downloadFortnoxArchiveFile, + fetchFortnoxFileConnections, + fetchFortnoxFinancialYears, + type FortnoxFileConnection, + type FortnoxFinancialYear, +} from '@/lib/providers/fortnox/attachments' import { uploadDocument, computeSHA256, detectFileMagic } 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/fortnox/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, FortnoxClient: 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/providers/fortnox/attachments', () => ({ + fetchFortnoxFinancialYears: vi.fn(), + fetchFortnoxFileConnections: vi.fn(), + downloadFortnoxArchiveFile: vi.fn(), +})) vi.mock('@/lib/core/documents/document-service', () => ({ uploadDocument: vi.fn(), computeSHA256: vi.fn(), @@ -31,6 +48,9 @@ const mockResolveConsent = vi.mocked(resolveConsent) const mockFetchUploads = vi.mocked(fetchBokioUploads) const mockFetchVoucherIndex = vi.mocked(fetchBokioVoucherIndex) const mockDownload = vi.mocked(downloadBokioUpload) +const mockFetchFortnoxFinancialYears = vi.mocked(fetchFortnoxFinancialYears) +const mockFetchFortnoxFileConnections = vi.mocked(fetchFortnoxFileConnections) +const mockDownloadFortnoxArchiveFile = vi.mocked(downloadFortnoxArchiveFile) const mockUpload = vi.mocked(uploadDocument) const mockSha256 = vi.mocked(computeSHA256) const mockDetectMagic = vi.mocked(detectFileMagic) @@ -76,9 +96,21 @@ beforeEach(() => { 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 }, + { id: 'je-1', fiscal_period_id: 'fp-2021', entry_date: '2021-03-01', source_voucher_series: 'V', source_voucher_number: 33 }, ] const UPLOAD = { id: 'up-1', description: 'Kvitto', contentType: 'application/pdf', journalEntryId: 'bokio-je-1' } +const FORTNOX_YEAR: FortnoxFinancialYear = { + id: 3, + fromDate: '2021-02-04', + toDate: '2021-12-31', +} +const FORTNOX_CONNECTION: FortnoxFileConnection = { + fileId: 'fortnox-file-1', + name: 'kvitto.pdf', + series: 'A', + number: 12, + financialYearId: 3, +} function wireBokio( opts: { existingAttachments?: { sha256_hash: string; journal_entry_id: string | null }[] } = {}, @@ -93,6 +125,45 @@ function wireBokio( }) } +function wireFortnox( + opts: { + years?: FortnoxFinancialYear[] + connections?: FortnoxFileConnection[] + periods?: typeof PERIODS + vouchers?: typeof GNUBOK_VOUCHERS + providerCompanyId?: string + } = {}, +) { + mockResolveConsent.mockResolvedValue({ + consent: { provider: 'fortnox' }, + accessToken: 'fortnox-token', + providerCompanyId: opts.providerCompanyId, + } as never) + mockFetchFortnoxFinancialYears.mockResolvedValue(opts.years ?? [FORTNOX_YEAR]) + mockFetchFortnoxFileConnections.mockResolvedValue( + opts.connections ?? [FORTNOX_CONNECTION], + ) + mockDownloadFortnoxArchiveFile.mockResolvedValue({ + bytes: bytesOf('FORTNOX-PDF'), + contentType: 'application/pdf', + }) + return rangeMockSupabase({ + fiscal_periods: opts.periods ?? PERIODS, + journal_entries: + opts.vouchers ?? + [ + { + id: 'je-1', + fiscal_period_id: 'fp-2021', + entry_date: '2021-03-01', + source_voucher_series: 'A', + source_voucher_number: 12, + }, + ], + document_attachments: [], + }) +} + describe('importProviderDocuments', () => { it('resolves a receipt to its verifikat and archives it linked via upload_source=api', async () => { const supabase = wireBokio() @@ -105,7 +176,11 @@ describe('importProviderDocuments', () => { 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' }) + expect(metadata).toEqual({ + upload_source: 'api', + journal_entry_id: 'je-1', + idempotency_key: 'je-1', + }) }) it('skips a receipt already archived on the same verifikat (sha256 + journal entry idempotency)', async () => { @@ -159,17 +234,297 @@ describe('importProviderDocuments', () => { expect(mockUpload).not.toHaveBeenCalled() }) - it('is a no-op for non-Bokio providers in v1', async () => { + it('imports a Fortnox receipt with its real filename and no provider company id', async () => { + const supabase = wireFortnox() + + const result = await importProviderDocuments({ + supabase, + companyId: COMPANY, + userId: USER, + consentId: 'c1', + }) + + expect(result).toMatchObject({ + provider: 'fortnox', + scanned: 1, + linked: 1, + skipped: 0, + unmatched: 0, + failed: 0, + }) + expect(mockFetchFortnoxFinancialYears).toHaveBeenCalledWith( + expect.anything(), + 'fortnox-token', + ) + expect(mockFetchFortnoxFileConnections).toHaveBeenCalledWith( + expect.anything(), + 'fortnox-token', + [3], + ) + const [, , , file, metadata] = mockUpload.mock.calls[0] + expect(file).toMatchObject({ name: 'kvitto.pdf', type: 'application/pdf' }) + expect(metadata).toEqual({ + upload_source: 'api', + journal_entry_id: 'je-1', + idempotency_key: 'je-1', + }) + }) + + it('is a no-op for unsupported providers', async () => { mockResolveConsent.mockResolvedValue({ - consent: { provider: 'fortnox' }, + consent: { provider: 'visma' }, 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(result).toMatchObject({ provider: 'visma', scanned: 0, linked: 0 }) expect(mockFetchUploads).not.toHaveBeenCalled() + expect(mockFetchFortnoxFinancialYears).not.toHaveBeenCalled() + }) + + it('counts a Fortnox connection with an unknown financial year as unmatched', async () => { + const supabase = wireFortnox({ + connections: [{ ...FORTNOX_CONNECTION, financialYearId: 99 }], + }) + + const result = await importProviderDocuments({ + supabase, + companyId: COMPANY, + userId: USER, + consentId: 'c1', + }) + + expect(result).toMatchObject({ scanned: 1, linked: 0, unmatched: 1, failed: 0 }) + expect(result.unmatchedSamples[0]).toEqual({ + uploadId: 'fortnox-file-1', + voucher: '(unresolved)', + date: '', + }) + expect(mockDownloadFortnoxArchiveFile).not.toHaveBeenCalled() + }) + + it('counts a Fortnox financial year outside every local period as unmatched', async () => { + const supabase = wireFortnox({ + years: [{ id: 3, fromDate: '2020-01-01', toDate: '2020-12-31' }], + }) + + const result = await importProviderDocuments({ + supabase, + companyId: COMPANY, + userId: USER, + consentId: 'c1', + }) + + expect(result).toMatchObject({ scanned: 1, linked: 0, unmatched: 1, failed: 0 }) + expect(result.unmatchedSamples[0]).toMatchObject({ voucher: 'A12', date: '2020-01-01' }) + expect(mockDownloadFortnoxArchiveFile).not.toHaveBeenCalled() + }) + + it('dry-runs Fortnox matches without downloading or writing', async () => { + const supabase = wireFortnox() + + const result = await importProviderDocuments({ + supabase, + companyId: COMPANY, + userId: USER, + consentId: 'c1', + dryRun: true, + }) + + expect(result).toMatchObject({ dryRun: true, scanned: 1, linked: 1, failed: 0 }) + expect(mockDownloadFortnoxArchiveFile).not.toHaveBeenCalled() + expect(mockUpload).not.toHaveBeenCalled() + }) + + it('matches a Fortnox voucher by its booking date across split local periods', async () => { + const supabase = wireFortnox({ + periods: [ + { id: 'fp-2021-h1', period_start: '2021-02-04', period_end: '2021-06-30' }, + { id: 'fp-2021-h2', period_start: '2021-07-01', period_end: '2021-12-31' }, + ], + vouchers: [ + { + id: 'je-h2', + fiscal_period_id: 'fp-2021-h2', + entry_date: '2021-09-15', + source_voucher_series: 'A', + source_voucher_number: 12, + }, + ], + }) + + const result = await importProviderDocuments({ + supabase, + companyId: COMPANY, + userId: USER, + consentId: 'c1', + dryRun: true, + }) + + expect(result).toMatchObject({ scanned: 1, linked: 1, unmatched: 0 }) + }) + + it('keeps a dotted Bokio description as a basename and appends the effective extension', async () => { + const supabase = wireBokio() + mockFetchUploads.mockResolvedValue([{ ...UPLOAD, description: 'Kvitto 1.2' }] as never) + + await importProviderDocuments({ + supabase, + companyId: COMPANY, + userId: USER, + consentId: 'c1', + }) + + const [, , , file] = mockUpload.mock.calls[0] + expect(file).toMatchObject({ name: 'Kvitto 1.2.pdf', type: 'application/pdf' }) + }) + + it('sanitizes an extensionless Fortnox filename and appends the effective extension', async () => { + const supabase = wireFortnox({ + connections: [{ ...FORTNOX_CONNECTION, name: '../A12' }], + }) + + await importProviderDocuments({ + supabase, + companyId: COMPANY, + userId: USER, + consentId: 'c1', + }) + + const [, , , file] = mockUpload.mock.calls[0] + expect(file).toMatchObject({ name: 'A12.pdf', type: 'application/pdf' }) + }) + + it('re-resolves consent once after a Fortnox 401 and retries the same attachment', async () => { + const supabase = wireFortnox() + mockResolveConsent + .mockResolvedValueOnce({ + consent: { provider: 'fortnox' }, + accessToken: 'expired-token', + } as never) + .mockResolvedValueOnce({ + consent: { provider: 'fortnox' }, + accessToken: 'fresh-token', + } as never) + mockDownloadFortnoxArchiveFile + .mockRejectedValueOnce(new FortnoxApiError('unauthorized', 401)) + .mockResolvedValueOnce({ bytes: bytesOf('FORTNOX-PDF'), contentType: 'application/pdf' }) + + const result = await importProviderDocuments({ + supabase, + companyId: COMPANY, + userId: USER, + consentId: 'c1', + }) + + expect(result).toMatchObject({ scanned: 1, linked: 1, failed: 0 }) + expect(mockResolveConsent).toHaveBeenCalledTimes(2) + expect(mockDownloadFortnoxArchiveFile).toHaveBeenNthCalledWith( + 1, + expect.anything(), + 'expired-token', + 'fortnox-file-1', + ) + expect(mockDownloadFortnoxArchiveFile).toHaveBeenNthCalledWith( + 2, + expect.anything(), + 'fresh-token', + 'fortnox-file-1', + ) + }) + + it('counts a repeated Fortnox 401 as failed after the one refresh attempt', async () => { + const supabase = wireFortnox() + mockResolveConsent + .mockResolvedValueOnce({ + consent: { provider: 'fortnox' }, + accessToken: 'expired-token', + } as never) + .mockResolvedValueOnce({ + consent: { provider: 'fortnox' }, + accessToken: 'still-invalid-token', + } as never) + mockDownloadFortnoxArchiveFile.mockRejectedValue( + new FortnoxApiError('unauthorized', 401), + ) + + const result = await importProviderDocuments({ + supabase, + companyId: COMPANY, + userId: USER, + consentId: 'c1', + }) + + expect(result).toMatchObject({ scanned: 1, linked: 0, failed: 1 }) + expect(mockResolveConsent).toHaveBeenCalledTimes(2) + expect(mockDownloadFortnoxArchiveFile).toHaveBeenCalledTimes(2) + }) + + it('explains that a Fortnox 403 requires reconnecting with attachment scopes', async () => { + const supabase = wireFortnox() + mockFetchFortnoxFinancialYears.mockRejectedValue( + new FortnoxApiError('forbidden', 403), + ) + + await expect( + importProviderDocuments({ + supabase, + companyId: COMPANY, + userId: USER, + consentId: 'c1', + }), + ).rejects.toThrow('Fortnox consent lacks archive/connectfile scope: reconnect required') + }) + + it('explains that a Fortnox archive-download 403 requires reconnecting', async () => { + const supabase = wireFortnox() + mockDownloadFortnoxArchiveFile.mockRejectedValue( + new FortnoxApiError('forbidden', 403), + ) + + await expect( + importProviderDocuments({ + supabase, + companyId: COMPANY, + userId: USER, + consentId: 'c1', + }), + ).rejects.toThrow('Fortnox consent lacks archive/connectfile scope: reconnect required') + }) + + it('does not WORM-link a receipt when the local source voucher identity is ambiguous', async () => { + const supabase = wireFortnox({ + vouchers: [ + { + id: 'je-1', + fiscal_period_id: 'fp-2021', + entry_date: '2021-03-01', + source_voucher_series: 'A', + source_voucher_number: 12, + }, + { + id: 'je-duplicate', + fiscal_period_id: 'fp-2021', + entry_date: '2021-03-01', + source_voucher_series: 'A', + source_voucher_number: 12, + }, + ], + }) + + const result = await importProviderDocuments({ + supabase, + companyId: COMPANY, + userId: USER, + consentId: 'c1', + }) + + expect(result).toMatchObject({ scanned: 1, linked: 0, unmatched: 1, failed: 0 }) + expect(result.unmatchedSamples[0]).toMatchObject({ voucher: 'A12' }) + expect(mockDownloadFortnoxArchiveFile).not.toHaveBeenCalled() + expect(mockUpload).not.toHaveBeenCalled() }) it('counts a receipt as unmatched when its journalEntryId is not in the Bokio voucher index', async () => { diff --git a/extensions/general/arcim-migration/__tests__/oauth-callback-state.test.ts b/extensions/general/arcim-migration/__tests__/oauth-callback-state.test.ts index 3eae9ec9..c43f64ce 100644 --- a/extensions/general/arcim-migration/__tests__/oauth-callback-state.test.ts +++ b/extensions/general/arcim-migration/__tests__/oauth-callback-state.test.ts @@ -251,6 +251,26 @@ describe('GET /callback: full-page fallback when there is no opener', () => { expect(target.searchParams.get('migration')).toBe('error') expect(target.searchParams.get('reason')).toContain(GENERIC_REJECTION) }) + + it('includes the consent id when a full-page provider error can be resumed', async () => { + ;(consumeOAuthState as Mock).mockResolvedValue({ + consentId: 'consent-1', + provider: 'fortnox', + }) + + const res = await callbackHandler( + callbackRequest({ + error: 'access_denied', + error_description: 'User denied consent', + state: 'one-time-token', + }), + ) + const target = fallbackNavigation(await res.text()) + + expect(target.searchParams.get('migration')).toBe('error') + expect(target.searchParams.get('consentId')).toBe('consent-1') + expect(exchangeAuthToken).not.toHaveBeenCalled() + }) }) /** diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts index cc48cfab..cfa63f91 100644 --- a/extensions/general/arcim-migration/index.ts +++ b/extensions/general/arcim-migration/index.ts @@ -20,7 +20,10 @@ 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 { + FortnoxDocumentScopesRequiredError, + importProviderDocuments, +} from './lib/import-documents' import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers' import type { ArcimProvider } from './types' import { ARCIM_PROVIDERS } from './types' @@ -510,10 +513,11 @@ export const arcimMigrationExtension: Extension = { const jsLiteral = (value: unknown) => JSON.stringify(value ?? '').replace(/ { + const respondWithError = (reason: string, consentId?: string) => { const fallbackUrl = new URL(`${appUrl}/import`) fallbackUrl.searchParams.set('migration', 'error') fallbackUrl.searchParams.set('reason', reason) + if (consentId) fallbackUrl.searchParams.set('consentId', consentId) const escapedReason = reason .replace(/&/g, '&') @@ -550,7 +554,18 @@ export const arcimMigrationExtension: Extension = { hasCode: !!code, hasState: !!stateRaw, }) - return respondWithError(translateOAuthError(oauthError, oauthErrorDescription)) + let consentId: string | undefined + if (stateRaw) { + try { + consentId = (await consumeOAuthState(stateRaw))?.consentId + } catch (error) { + log.error('OAuth callback could not resolve failed consent', error) + } + } + return respondWithError( + translateOAuthError(oauthError, oauthErrorDescription), + consentId, + ) } if (!code || !stateRaw) { @@ -562,6 +577,7 @@ export const arcimMigrationExtension: Extension = { return respondWithError('Återanropet saknade code eller state. Försök igen.') } + let callbackConsentId: string | undefined try { // Single source of truth for who this callback belongs to: the // server-written provider_otc row, consumed atomically here. The row @@ -584,6 +600,7 @@ export const arcimMigrationExtension: Extension = { } const { consentId, provider } = resolvedState + callbackConsentId = consentId // Must match the redirect_uri the authorization request was built // with, so both come from resolveArcimCallbackUrl. @@ -610,7 +627,7 @@ export const arcimMigrationExtension: Extension = { } catch (error) { log.error('OAuth callback exchange failed', error) const reason = error instanceof Error ? error.message : 'Okänt fel vid tokenutbyte.' - return respondWithError(reason) + return respondWithError(reason, callbackConsentId) } }, }, @@ -1252,13 +1269,12 @@ 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. + // Best-effort, re-runnable. Kept off the migration's critical path because + // the Bokio and Fortnox APIs are rate-limited and a full receipt sweep can + // issue hundreds of download calls. Resolves each receipt's verifikat via + // the SIE-preserved provider voucher number and archives it idempotently. + // Fortnox consents need archive and connectfile scopes. Pass + // { dryRun: true } to preview the match plan without downloading or writing. { method: 'POST', path: '/import-documents', @@ -1307,6 +1323,13 @@ export const arcimMigrationExtension: Extension = { return NextResponse.json({ success: true, dryRun, result }) } catch (error) { log.error('arcim import-documents failed', error as Error) + if (error instanceof FortnoxDocumentScopesRequiredError) { + return errorResponseFromCode( + 'PROVIDER_DOCUMENT_SCOPES_REQUIRED', + moduleLog, + { status: 403 }, + ) + } return errorResponseFromCode('PROVIDER_IMPORT_DOCUMENTS_FAILED', moduleLog, { details: { reason: error instanceof Error ? error.message : 'unknown' }, }) diff --git a/extensions/general/arcim-migration/lib/import-documents.ts b/extensions/general/arcim-migration/lib/import-documents.ts index d4505d69..b891d8eb 100644 --- a/extensions/general/arcim-migration/lib/import-documents.ts +++ b/extensions/general/arcim-migration/lib/import-documents.ts @@ -3,10 +3,10 @@ * * 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. + * carried by either. This step closes that gap for Bokio and Fortnox: it + * resolves each receipt's target gnubok verifikat from the SIE-preserved + * provider 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 verifikat (same content @@ -21,20 +21,26 @@ * 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. + * critical path: provider document APIs are rate-limited and a full sweep can + * issue hundreds of download calls. Fortnox requires archive and connectfile + * scopes; existing consents must reconnect before this import can run. */ import type { SupabaseClient } from '@supabase/supabase-js' -import { resolveConsent } from '@/lib/providers/resolve-consent' +import { resolveConsent, type ResolvedConsent } 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 { FortnoxApiError, FortnoxClient } from '@/lib/providers/fortnox/client' +import { + downloadFortnoxArchiveFile, + fetchFortnoxFileConnections, + fetchFortnoxFinancialYears, +} from '@/lib/providers/fortnox/attachments' import { uploadDocument, computeSHA256, @@ -46,6 +52,15 @@ import { createLogger } from '@/lib/logger' const log = createLogger('extensions/arcim-migration/import-documents') +export class FortnoxDocumentScopesRequiredError extends Error { + readonly code = 'PROVIDER_DOCUMENT_SCOPES_REQUIRED' + + constructor() { + super('Fortnox consent lacks archive/connectfile scope: reconnect required') + this.name = 'FortnoxDocumentScopesRequiredError' + } +} + export interface ImportDocumentsOptions { supabase: SupabaseClient companyId: string @@ -57,13 +72,13 @@ export interface ImportDocumentsOptions { export interface ImportDocumentsResult { provider: string - /** Uploads carrying a journalEntryId that were considered. */ + /** Provider attachments linked to a voucher that were considered. */ scanned: number /** Receipts newly archived and linked to their verifikat. */ linked: number /** Receipts already archived for this verifikat (sha256 + journal entry match): re-run skip. */ skipped: number - /** Uploads whose Bokio voucher number resolved to no gnubok verifikat. */ + /** Attachments whose provider voucher resolved to no gnubok verifikat. */ unmatched: number /** Receipts that failed to download/validate/store (counted, not thrown). */ failed: number @@ -81,10 +96,24 @@ interface FiscalPeriodRow { interface VoucherRow { id: string fiscal_period_id: string + entry_date: string source_voucher_series: string | null source_voucher_number: number | null } +interface ProviderAttachment { + id: string + fileName: string | null + fileNameIsBaseName: boolean + declaredContentType: string | null + ref: { series: string; number: number; date: string; dateTo?: string } | null +} + +interface ProviderAttachmentSource { + list(): Promise + download(id: string): Promise<{ bytes: ArrayBuffer; contentType: string | null }> +} + const EXTENSION_BY_TYPE: Record = { 'application/pdf': 'pdf', 'image/jpeg': 'jpg', @@ -100,17 +129,122 @@ function periodIdForDate(periods: FiscalPeriodRow[], date: string): string | nul /** * In-memory key for a verifikat: fiscal period + series + number. Scoping by - * period is essential: Bokio reuses voucher numbers across fiscal years. + * period is essential: providers may reuse 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 { +/** Remove path/control characters while retaining a readable archive name. */ +function sanitizeProviderFileName(fileName: string): string { + return ( + fileName + .trim() + .replace(/[\u0000-\u001f\u007f<>:"/\\|?*]/g, '_') + .replace(/_+/g, '_') + .replace(/^[._ ]+|[. _]+$/g, '') + .slice(0, 180) || 'file' + ) +} + +function normalizedContentType(contentType: string | null): string | null { + const normalized = contentType?.split(';', 1)[0]?.trim().toLowerCase() + return normalized || null +} + +/** Keep a provider filename when available, otherwise synthesize one. */ +function fileNameFor( + attachment: ProviderAttachment, + ref: NonNullable, + contentType: string | null, +): string { const ext = (contentType && EXTENSION_BY_TYPE[contentType]) || 'bin' - const label = upload.description?.trim() || `${ref.series}${ref.number}` - return `${label}.${ext}` + const providerName = attachment.fileName?.trim() + if (!providerName) return `${ref.series}${ref.number}.${ext}` + + const sanitized = sanitizeProviderFileName(providerName) + return !attachment.fileNameIsBaseName && /\.[A-Za-z0-9]+$/.test(sanitized) + ? sanitized + : `${sanitized}.${ext}` +} + +function bokioSource( + client: BokioClient, + resolved: ResolvedConsent, +): ProviderAttachmentSource { + const { accessToken, providerCompanyId } = resolved + if (!providerCompanyId) { + throw new Error('Consent has no provider_company_id: cannot fetch Bokio uploads') + } + + return { + async list() { + const [uploads, voucherIndex] = await Promise.all([ + fetchBokioUploads(client, accessToken, providerCompanyId), + fetchBokioVoucherIndex(client, accessToken, providerCompanyId), + ]) + + return uploads + .filter((upload) => upload.journalEntryId != null) + .map((upload: BokioUpload): ProviderAttachment => ({ + id: upload.id, + // Bokio exposes a description rather than a real filename. Treat it + // as the preferred basename so the adapter preserves current names. + fileName: upload.description?.trim() || null, + fileNameIsBaseName: true, + declaredContentType: upload.contentType, + ref: voucherIndex.get(upload.journalEntryId as string) ?? null, + })) + }, + download(id) { + return downloadBokioUpload(client, accessToken, providerCompanyId, id) + }, + } +} + +function fortnoxSource( + client: FortnoxClient, + accessToken: string, +): ProviderAttachmentSource { + return { + async list() { + try { + const financialYears = await fetchFortnoxFinancialYears(client, accessToken) + const connections = await fetchFortnoxFileConnections( + client, + accessToken, + financialYears.map((year) => year.id), + ) + const financialYearById = new Map(financialYears.map((year) => [year.id, year])) + + return connections.map((connection): ProviderAttachment => { + const financialYear = financialYearById.get(connection.financialYearId) + return { + id: connection.fileId, + fileName: connection.name, + fileNameIsBaseName: false, + declaredContentType: null, + ref: financialYear + ? { + series: connection.series, + number: connection.number, + date: financialYear.fromDate, + dateTo: financialYear.toDate, + } + : null, + } + }) + } catch (error) { + if (error instanceof FortnoxApiError && error.statusCode === 403) { + throw new FortnoxDocumentScopesRequiredError() + } + throw error + } + }, + download(id) { + return downloadFortnoxArchiveFile(client, accessToken, id) + }, + } } export async function importProviderDocuments( @@ -118,7 +252,7 @@ export async function importProviderDocuments( ): Promise { const { supabase, companyId, userId, consentId, dryRun = false } = opts - const resolved = await resolveConsent(companyId, consentId) + let resolved = await resolveConsent(companyId, consentId) const provider = resolved.consent.provider as string const result: ImportDocumentsResult = { @@ -132,24 +266,23 @@ export async function importProviderDocuments( unmatchedSamples: [], } - // v1 supports Bokio only. Other providers are a no-op rather than an error + // Unsupported 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 }) + if (provider !== 'bokio' && provider !== 'fortnox') { + log.info('document import skipped: provider not supported', { 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() + const bokioClient = provider === 'bokio' ? new BokioClient() : null + const fortnoxClient = provider === 'fortnox' ? new FortnoxClient() : null + const source = (): ProviderAttachmentSource => + provider === 'bokio' + ? bokioSource(bokioClient as BokioClient, resolved) + : fortnoxSource(fortnoxClient as FortnoxClient, resolved.accessToken) // ── Bulk reads (one round of paged requests each, no per-item N+1) ── - const [uploads, voucherIndex, periods, vouchers, existingAttachments] = await Promise.all([ - fetchBokioUploads(client, accessToken, providerCompanyId), - fetchBokioVoucherIndex(client, accessToken, providerCompanyId), + const [attachments, periods, vouchers, existingAttachments] = await Promise.all([ + source().list(), // 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 @@ -166,7 +299,7 @@ export async function importProviderDocuments( fetchAllRows(({ from, to }) => supabase .from('journal_entries') - .select('id, fiscal_period_id, source_voucher_series, source_voucher_number') + .select('id, fiscal_period_id, entry_date, source_voucher_series, source_voucher_number') .eq('company_id', companyId) .not('source_voucher_number', 'is', null) .order('id', { ascending: true }) @@ -184,12 +317,22 @@ export async function importProviderDocuments( // Index gnubok verifikat by (period, series, number) for in-memory resolution. const journalEntryByKey = new Map() + const journalEntriesBySourceRef = new Map() + const ambiguousVoucherKeys = new Set() 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, - ) + const sourceRef = `${v.source_voucher_series}|${v.source_voucher_number}` + journalEntriesBySourceRef.set(sourceRef, [ + ...(journalEntriesBySourceRef.get(sourceRef) ?? []), + v, + ]) + const key = voucherKey(v.fiscal_period_id, v.source_voucher_series, v.source_voucher_number) + if (journalEntryByKey.has(key)) { + journalEntryByKey.delete(key) + ambiguousVoucherKeys.add(key) + } else if (!ambiguousVoucherKeys.has(key)) { + journalEntryByKey.set(key, v.id) + } } // (content, verifikat) pairs already archived → idempotent skip set. Keyed @@ -202,12 +345,6 @@ export async function importProviderDocuments( .map((r) => attachmentKey(r.sha256_hash, r.journal_entry_id as string)), ) - // 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) { @@ -215,24 +352,31 @@ export async function importProviderDocuments( } } - for (const upload of linkedUploads) { + let refreshedAfterUnauthorized = false + + for (const attachment of attachments) { result.scanned++ - const ref = voucherIndex.get(upload.journalEntryId as string) + const ref = attachment.ref 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)', '') + recordUnmatched(attachment.id, '(unresolved)', '') continue } - const periodId = periodIdForDate(periods, ref.date) - const journalEntryId = periodId - ? journalEntryByKey.get(voucherKey(periodId, ref.series, ref.number)) - : undefined + let journalEntryId: string | undefined + if (ref.dateTo) { + const candidates = (journalEntriesBySourceRef.get(`${ref.series}|${ref.number}`) ?? []) + .filter((voucher) => ref.date <= voucher.entry_date && voucher.entry_date <= ref.dateTo!) + journalEntryId = candidates.length === 1 ? candidates[0].id : undefined + } else { + const periodId = periodIdForDate(periods, ref.date) + journalEntryId = periodId + ? journalEntryByKey.get(voucherKey(periodId, ref.series, ref.number)) + : undefined + } if (!journalEntryId) { - recordUnmatched(upload.id, `${ref.series}${ref.number}`, ref.date) + recordUnmatched(attachment.id, `${ref.series}${ref.number}`, ref.date) continue } @@ -243,47 +387,82 @@ export async function importProviderDocuments( continue } - try { - const { bytes } = await downloadBokioUpload( - client, - accessToken, - providerCompanyId, - upload.id, - ) + const importAttachment = async () => { + const { bytes, contentType } = await source().download(attachment.id) const sha256 = await computeSHA256(bytes) if (seenAttachments.has(attachmentKey(sha256, journalEntryId))) { result.skipped++ - continue + return } - // Trust the bytes over Bokio's metadata: the uploads list occasionally - // declares the wrong contentType (a JPEG stored as image/png), which + // Trust the bytes over provider metadata: APIs occasionally declare the + // wrong content type (a JPEG stored as image/png), which // would fail magic validation. Sniff the real format first and fall // back to the declared type only when no signature is recognised; if // neither yields an allowed type, store without a declared type so // uploadDocument skips magic validation rather than rejecting. + const declaredType = normalizedContentType( + attachment.declaredContentType ?? contentType, + ) const sniffedType = detectFileMagic(new Uint8Array(bytes)) const effectiveType = sniffedType ?? - (upload.contentType && ALLOWED_DOCUMENT_TYPES.includes(upload.contentType) - ? upload.contentType + (declaredType && ALLOWED_DOCUMENT_TYPES.includes(declaredType) + ? declaredType : undefined) await uploadDocument( supabase, userId, companyId, - { name: fileNameFor(upload, ref, effectiveType ?? upload.contentType), buffer: bytes, type: effectiveType }, - { upload_source: 'api', journal_entry_id: journalEntryId }, + { + name: fileNameFor(attachment, ref, effectiveType ?? declaredType), + buffer: bytes, + type: effectiveType, + }, + { + upload_source: 'api', + journal_entry_id: journalEntryId, + idempotency_key: journalEntryId, + }, ) seenAttachments.add(attachmentKey(sha256, journalEntryId)) result.linked++ - } catch (err) { + } + + try { + await importAttachment() + } catch (error) { + let finalError = error + if ( + provider === 'fortnox' && + error instanceof FortnoxApiError && + error.statusCode === 401 && + !refreshedAfterUnauthorized + ) { + refreshedAfterUnauthorized = true + try { + resolved = await resolveConsent(companyId, consentId) + await importAttachment() + continue + } catch (retryError) { + finalError = retryError + } + } + + if ( + provider === 'fortnox' && + finalError instanceof FortnoxApiError && + finalError.statusCode === 403 + ) { + throw new FortnoxDocumentScopesRequiredError() + } + result.failed++ - log.error('failed to import a receipt', err as Error, { - uploadId: upload.id, + log.error('failed to import a receipt', finalError as Error, { + uploadId: attachment.id, voucher: `${ref.series}${ref.number}`, }) } diff --git a/lib/core/documents/__tests__/document-service.test.ts b/lib/core/documents/__tests__/document-service.test.ts index f0726a06..d51de80d 100644 --- a/lib/core/documents/__tests__/document-service.test.ts +++ b/lib/core/documents/__tests__/document-service.test.ts @@ -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>() + 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((resolve) => { + releaseInserts = resolve + }) + + const client = { + from: vi.fn(() => { + let insertPayload: Record | null = null + let queriedId = '' + const builder: Record = {} + builder.insert = vi.fn((payload: Record) => { + 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 + 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', () => { diff --git a/lib/core/documents/document-service.ts b/lib/core/documents/document-service.ts index ca6c8f7d..500a5681 100644 --- a/lib/core/documents/document-service.ts +++ b/lib/core/documents/document-service.ts @@ -585,6 +585,20 @@ export async function computeSHA256(buffer: ArrayBuffer): Promise { return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') } +async function deterministicDocumentId( + companyId: string, + idempotencyKey: string, + sha256Hash: string, +): Promise { + 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 diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 88caa713..61c82c66 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -1890,6 +1890,13 @@ const PROVIDER_MIGRATION: Record = { 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.', diff --git a/lib/providers/fortnox/__tests__/attachments.test.ts b/lib/providers/fortnox/__tests__/attachments.test.ts new file mode 100644 index 00000000..609efe17 --- /dev/null +++ b/lib/providers/fortnox/__tests__/attachments.test.ts @@ -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 { + 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); + + 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); + + 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); + + 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); + + 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); + + await expect(downloadFortnoxArchiveFile(client, 'token', 'file-1')).rejects.toBe(error); + expect(getBinary).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/providers/fortnox/__tests__/client.test.ts b/lib/providers/fortnox/__tests__/client.test.ts new file mode 100644 index 00000000..f41178fc --- /dev/null +++ b/lib/providers/fortnox/__tests__/client.test.ts @@ -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' }); + }); +}); diff --git a/lib/providers/fortnox/__tests__/oauth.test.ts b/lib/providers/fortnox/__tests__/oauth.test.ts new file mode 100644 index 00000000..9f89467e --- /dev/null +++ b/lib/providers/fortnox/__tests__/oauth.test.ts @@ -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'); + }); +}); diff --git a/lib/providers/fortnox/attachments.ts b/lib/providers/fortnox/attachments.ts new file mode 100644 index 00000000..a50651cd --- /dev/null +++ b/lib/providers/fortnox/attachments.ts @@ -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 { + const rawYears = await client.getPaginated>( + 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 { + const connections: FortnoxFileConnection[] = []; + const seen = new Set(); + + for (const financialYearId of new Set(financialYearIds)) { + const rawConnections = await client.getPaginated>( + 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}`); + } +} diff --git a/lib/providers/fortnox/client.ts b/lib/providers/fortnox/client.ts index 1865ee24..fc384cf7 100644 --- a/lib/providers/fortnox/client.ts +++ b/lib/providers/fortnox/client.ts @@ -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( accessToken: string, path: string, diff --git a/lib/providers/fortnox/oauth.ts b/lib/providers/fortnox/oauth.ts index 5ed08a3f..5d663375 100644 --- a/lib/providers/fortnox/oauth.ts +++ b/lib/providers/fortnox/oauth.ts @@ -13,6 +13,8 @@ const DEFAULT_SCOPES = [ 'customer', 'supplier', 'bookkeeping', + 'archive', + 'connectfile', ]; export function buildFortnoxAuthUrl( diff --git a/messages/en.json b/messages/en.json index e17f90e6..2ef2981a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5067,6 +5067,28 @@ "ext_arcim_migration_name": "System migration", "ext_arcim_migration_description": "Migrate bookkeeping from Fortnox, Visma, Bokio, Björn Lundén or Briox", "ext_arcim_migration_long_description": "Move all bookkeeping data from your old system to accounted. Imports chart of accounts, vouchers, customers, suppliers and open invoices automatically via a secure API integration directly with the provider.", + "ext_arcim_documents_title": "Optional documents from Fortnox", + "ext_arcim_documents_discovering": "Checking for supporting documents in Fortnox...", + "ext_arcim_documents_prompt": "{count, plural, one {The SIE import is complete. We found # supporting document in Fortnox that you can optionally import and link to the voucher.} other {The SIE import is complete. We found # supporting documents in Fortnox that you can optionally import and link to the vouchers.}}", + "ext_arcim_documents_empty": "No supporting documents linked to vouchers were found in Fortnox.", + "ext_arcim_documents_import_action": "Also import documents from Fortnox", + "ext_arcim_documents_not_now": "Not now", + "ext_arcim_documents_importing": "Importing and linking the documents to the vouchers...", + "ext_arcim_documents_reconnecting": "Waiting for Fortnox to reconnect...", + "ext_arcim_documents_discovery_error": "The migration is complete, but we could not check the documents in Fortnox. Try again without rerunning the migration.", + "ext_arcim_documents_import_error": "The migration is still complete, but the documents could not be imported. Try again; documents already imported will be skipped.", + "ext_arcim_documents_scope_error": "The Fortnox connection lacks access to Archive and Connect file. Reconnect Fortnox and approve those permissions to continue.", + "ext_arcim_documents_reconnect_action": "Reconnect Fortnox", + "ext_arcim_documents_retry_discovery": "Check again", + "ext_arcim_documents_retry_import": "Try importing again", + "ext_arcim_documents_error_reference": "Error reference: {requestId}", + "ext_arcim_documents_result_description": "The document import is complete.", + "ext_arcim_documents_imported": "Imported", + "ext_arcim_documents_skipped": "Already present", + "ext_arcim_documents_unmatched": "Unmatched", + "ext_arcim_documents_failed": "Failed", + "ext_arcim_documents_unmatched_help": "Unmatched documents were not stored. The corresponding voucher is missing or has an ambiguous identity in the imported bookkeeping.", + "ext_arcim_documents_partial_failure": "Some documents could not be imported. You can retry without creating duplicates.", "ext_tic_name": "Company information", "ext_tic_description": "Fetch company information automatically at signup", "ext_tic_long_description": "Auto-fill company details by entering an organization number. Retrieves address, VAT registration, F-tax status and bank details from public registries via TIC.", diff --git a/messages/sv.json b/messages/sv.json index f4157fb7..7f95bc59 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -5067,6 +5067,28 @@ "ext_arcim_migration_name": "Systemmigration", "ext_arcim_migration_description": "Migrera bokföring från Fortnox, Visma, Bokio, Björn Lundén eller Briox", "ext_arcim_migration_long_description": "Flytta all bokföringsdata från ditt gamla system till accounted. Importerar kontoplan, verifikationer, kunder, leverantörer och öppna fakturor automatiskt via säker API-integration direkt med leverantören.", + "ext_arcim_documents_title": "Valfria underlag från Fortnox", + "ext_arcim_documents_discovering": "Kontrollerar om det finns underlag i Fortnox...", + "ext_arcim_documents_prompt": "{count, plural, one {SIE-importen är klar. Vi hittade # underlag i Fortnox som du valfritt kan importera och koppla till verifikatet.} other {SIE-importen är klar. Vi hittade # underlag i Fortnox som du valfritt kan importera och koppla till verifikaten.}}", + "ext_arcim_documents_empty": "Inga underlag kopplade till verifikat hittades i Fortnox.", + "ext_arcim_documents_import_action": "Importera även underlag från Fortnox", + "ext_arcim_documents_not_now": "Inte nu", + "ext_arcim_documents_importing": "Importerar och kopplar underlagen till verifikaten...", + "ext_arcim_documents_reconnecting": "Väntar på att Fortnox ska kopplas om...", + "ext_arcim_documents_discovery_error": "Migreringen är klar, men vi kunde inte kontrollera underlagen i Fortnox. Försök igen utan att köra om migreringen.", + "ext_arcim_documents_import_error": "Migreringen är fortfarande klar, men underlagen kunde inte importeras. Försök igen; redan importerade underlag hoppas över.", + "ext_arcim_documents_scope_error": "Fortnox-anslutningen saknar behörighet till Arkiv och Koppla fil. Koppla om Fortnox och godkänn behörigheterna för att fortsätta.", + "ext_arcim_documents_reconnect_action": "Koppla om Fortnox", + "ext_arcim_documents_retry_discovery": "Kontrollera igen", + "ext_arcim_documents_retry_import": "Försök importera igen", + "ext_arcim_documents_error_reference": "Felreferens: {requestId}", + "ext_arcim_documents_result_description": "Underlagsimporten är klar.", + "ext_arcim_documents_imported": "Importerade", + "ext_arcim_documents_skipped": "Fanns redan", + "ext_arcim_documents_unmatched": "Utan matchning", + "ext_arcim_documents_failed": "Misslyckades", + "ext_arcim_documents_unmatched_help": "Underlag utan matchning sparades inte. Motsvarande verifikat saknas eller har en tvetydig identitet i den importerade bokföringen.", + "ext_arcim_documents_partial_failure": "Några underlag kunde inte importeras. Du kan försöka igen utan att skapa dubletter.", "ext_tic_name": "Bolagsuppgifter", "ext_tic_description": "Hämta företagsinformation automatiskt vid registrering", "ext_tic_long_description": "Fyll i företagsuppgifter automatiskt genom att ange organisationsnummer. Hämtar adress, momsregistrering, F-skattestatus och bankuppgifter från offentliga register via TIC.",