feat: import Fortnox voucher attachments (#1541)
* feat: import Fortnox voucher attachments * fix: show Fortnox document import follow-up * fix: harden optional Fortnox document import * test: pin optional Fortnox import flow * fix: use browser timer handle type * fix: avoid serializing OAuth resume state
This commit is contained in:
@@ -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 = (
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||
{t('ext_arcim_documents_title')}
|
||||
</CardTitle>
|
||||
)
|
||||
|
||||
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 (
|
||||
<Card aria-live="polite">
|
||||
<CardHeader>{title}</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.phase === 'offered') {
|
||||
return (
|
||||
<Card aria-live="polite">
|
||||
<CardHeader>
|
||||
{title}
|
||||
<CardDescription>
|
||||
{t('ext_arcim_documents_prompt', { count: state.found })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button className="min-h-11" onClick={onImport}>
|
||||
{t('ext_arcim_documents_import_action')}
|
||||
</Button>
|
||||
<Button variant="ghost" className="min-h-11" onClick={onDismiss}>
|
||||
{t('ext_arcim_documents_not_now')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.phase === 'empty') {
|
||||
return (
|
||||
<Card aria-live="polite">
|
||||
<CardHeader>
|
||||
{title}
|
||||
<CardDescription>{t('ext_arcim_documents_empty')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" className="min-h-11" onClick={onDiscover}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t('ext_arcim_documents_retry_discovery')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.phase === 'complete') {
|
||||
if (!state.result) {
|
||||
return (
|
||||
<Card aria-live="polite">
|
||||
<CardHeader>
|
||||
{title}
|
||||
<CardDescription>{t('ext_arcim_documents_result_description')}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card aria-live="polite">
|
||||
<CardHeader>
|
||||
{title}
|
||||
<CardDescription>{t('ext_arcim_documents_result_description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{outcomes.map(({ label, value, valueClassName }) => (
|
||||
<div key={label} className="flex flex-col">
|
||||
<dt className="order-2 text-xs text-muted-foreground">{label}</dt>
|
||||
<dd className={cn('order-1 font-display text-xl tabular-nums', valueClassName)}>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{unmatched > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('ext_arcim_documents_unmatched_help')}
|
||||
</p>
|
||||
)}
|
||||
{failed > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-destructive">
|
||||
{t('ext_arcim_documents_partial_failure')}
|
||||
</p>
|
||||
<Button variant="outline" className="min-h-11" onClick={onImport}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t('ext_arcim_documents_retry_import')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const reconnectRequired = state.problem?.reconnectRequired === true
|
||||
const discoveryFailed = state.phase === 'discovery-error'
|
||||
return (
|
||||
<Card aria-live="polite">
|
||||
<CardHeader>
|
||||
{title}
|
||||
<CardDescription className="text-foreground">
|
||||
{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')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{state.problem?.requestId && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('ext_arcim_documents_error_reference', {
|
||||
requestId: state.problem.requestId,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="min-h-11"
|
||||
onClick={reconnectRequired ? onReconnect : discoveryFailed ? onDiscover : onImport}
|
||||
>
|
||||
{reconnectRequired ? (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{reconnectRequired
|
||||
? t('ext_arcim_documents_reconnect_action')
|
||||
: discoveryFailed
|
||||
? t('ext_arcim_documents_retry_discovery')
|
||||
: t('ext_arcim_documents_retry_import')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
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({
|
||||
)
|
||||
})()}
|
||||
|
||||
<DocumentImportFollowUp
|
||||
state={documentImportState}
|
||||
onDiscover={onDiscoverDocuments}
|
||||
onImport={onImportDocuments}
|
||||
onDismiss={onDismissDocuments}
|
||||
onReconnect={onReconnectDocuments}
|
||||
/>
|
||||
|
||||
{/* ── Next steps ── */}
|
||||
<Card className="bg-muted/50">
|
||||
<CardHeader>
|
||||
@@ -1969,6 +2246,13 @@ export default function ArcimMigrationWorkspace({
|
||||
const [migrationProgress, setMigrationProgress] = useState(0)
|
||||
const [migrationResults, setMigrationResults] = useState<MigrationResults | null>(null)
|
||||
const [sieImportResults, setSieImportResults] = useState<ImportResult[]>([])
|
||||
const [documentImportState, dispatchDocumentImport] = useReducer(
|
||||
arcimDocumentImportReducer,
|
||||
INITIAL_ARCIM_DOCUMENT_IMPORT_STATE,
|
||||
)
|
||||
const documentReconnectActionRef = useRef<'discover' | 'import' | null>(null)
|
||||
const documentReconnectFailureCleanupRef = useRef<number | null>(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<TheaterModel | null>(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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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> = {},
|
||||
): 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()
|
||||
})
|
||||
})
|
||||
@@ -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<typeof setTimeout> | 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<ArcimDocumentImportResult>
|
||||
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<ArcimDocumentImportResult> {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user