Files
accounted/components/extensions/general/arcim-document-import-flow.ts
T
Mattsson a97b0023d4 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
2026-08-13 00:33:10 +02:00

270 lines
7.8 KiB
TypeScript

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
}