Bug/fortnox import (#298)

* feat: enhance journal entry handling with follow-up entries and related RPC

* fix: improve validation for journal entry lines to ensure proper submission criteria

* fix: enhance OAuth error handling and user feedback in Arcim migration process

* fix: add OAuth error translation for user-friendly feedback in Fortnox integration
This commit is contained in:
Mattsson
2026-04-21 13:25:13 +02:00
committed by GitHub
parent 64cd6a0989
commit 129664533c
3 changed files with 102 additions and 26 deletions
@@ -1702,10 +1702,13 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
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')
window.history.replaceState({}, '', url.pathname)
setError('OAuth-anslutningen misslyckades. Försök igen.')
setError(reason)
toast({ title: 'Anslutning misslyckades', description: reason, variant: 'destructive' })
if (callbackProvider) {
setSelectedProvider(callbackProvider)
setStep('connect')
@@ -1713,7 +1716,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
setStep('provider')
}
}
}, [loadPreview])
}, [loadPreview, toast])
// Check for OAuth callback on mount (fallback for non-popup flow)
useEffect(() => {
@@ -1728,12 +1731,16 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
if (event.data?.type === 'arcim-oauth-success' && event.data.consentId) {
loadPreview(event.data.consentId)
} else if (event.data?.type === 'arcim-oauth-error') {
setError('OAuth-anslutningen misslyckades. Försök igen.')
const reason = typeof event.data.reason === 'string' && event.data.reason
? event.data.reason
: 'OAuth-anslutningen misslyckades. Försök igen.'
setError(reason)
toast({ title: 'Anslutning misslyckades', description: reason, variant: 'destructive' })
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [loadPreview])
}, [loadPreview, toast])
// Load SIE data when entering mapping step
const loadSIEData = useCallback(async () => {
+85 -21
View File
@@ -30,6 +30,29 @@ const ALLOWED_FISCAL_YEARS = new Set([2024, 2025, 2026])
const fortnoxClient = new FortnoxClient()
/**
* Map known OAuth error codes from providers (Fortnox, Visma) to actionable
* Swedish guidance. Falls back to the raw provider message so we never hide
* unknown errors from the user.
*/
function translateOAuthError(error: string, description: string | null): string {
const haystack = `${error} ${description ?? ''}`.toLowerCase()
if (haystack.includes('missing license') || haystack.includes('not have enough licenses')) {
return 'Du behöver aktivera tilläggstjänsten "Fortnox Integration" (~149 kr/mån) på ditt Fortnox-konto innan du kan ansluta. Aktivera den under Inställningar → Tilläggstjänster i Fortnox och försök igen.'
}
if (error === 'access_denied') {
return 'Du avbröt anslutningen i leverantörens inloggning. Försök igen om du vill koppla kontot.'
}
if (error === 'invalid_scope') {
return 'Tredjepartsappen har inte rätt behörigheter för ditt konto. Kontakta supporten.'
}
return description ? `${error}: ${description}` : error
}
/**
* Provider Migration extension
*
@@ -314,9 +337,59 @@ export const arcimMigrationExtension: Extension = {
const url = new URL(request.url)
const code = url.searchParams.get('code')
const stateRaw = url.searchParams.get('state')
const oauthError = url.searchParams.get('error')
const oauthErrorDescription = url.searchParams.get('error_description')
const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
// JSON-encode for safe embedding inside <script>. Escapes quotes/unicode
// and `</` so the value can't break out of the script tag.
const jsLiteral = (value: unknown) =>
JSON.stringify(value ?? '').replace(/</g, '\\u003c')
const respondWithError = (reason: string) => {
const fallbackUrl = new URL(`${appUrl}/import`)
fallbackUrl.searchParams.set('migration', 'error')
fallbackUrl.searchParams.set('reason', reason)
const escapedReason = reason
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
const html = `<!DOCTYPE html><html><body><script>
if (window.opener) {
window.opener.postMessage({ type: 'arcim-oauth-error', reason: ${jsLiteral(reason)} }, ${jsLiteral(appUrl)});
window.close();
} else {
window.location.href = ${jsLiteral(fallbackUrl.toString())};
}
</script><p>Anslutningen misslyckades: ${escapedReason}</p></body></html>`
return new Response(html, {
status: 200,
headers: { 'Content-Type': 'text/html' },
})
}
// Provider returned an OAuth error (user cancelled, missing API
// subscription on the Fortnox side, invalid scope, etc.)
if (oauthError) {
log.error('OAuth callback returned provider error', {
error: oauthError,
errorDescription: oauthErrorDescription,
hasCode: !!code,
hasState: !!stateRaw,
})
return respondWithError(translateOAuthError(oauthError, oauthErrorDescription))
}
if (!code || !stateRaw) {
return NextResponse.json({ error: 'Missing code or state' }, { status: 400 })
log.error('OAuth callback missing code or state', {
hasCode: !!code,
hasState: !!stateRaw,
queryKeys: Array.from(url.searchParams.keys()),
})
return respondWithError('Återanropet saknade code eller state. Försök igen.')
}
try {
@@ -343,22 +416,26 @@ export const arcimMigrationExtension: Extension = {
}
if (!consentId || !provider) {
return NextResponse.json({ error: 'No active migration session' }, { status: 400 })
log.error('OAuth callback could not resolve consent or provider', {
hasConsentId: !!consentId,
hasProvider: !!provider,
})
return respondWithError('Ingen aktiv migrationssession hittades. Starta om anslutningen.')
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
const redirectUri = `${appUrl}/api/extensions/ext/arcim-migration/callback`
// Exchange OAuth code directly with the provider
await exchangeAuthToken(consentId, provider, code, redirectUri)
// Return an HTML page that notifies the opener tab and closes itself
const successUrl = `${appUrl}/import?migration=connected&consentId=${encodeURIComponent(consentId)}`
const html = `<!DOCTYPE html><html><body><script>
if (window.opener) {
window.opener.postMessage({ type: 'arcim-oauth-success', consentId: '${consentId}' }, '${appUrl}');
window.opener.postMessage({ type: 'arcim-oauth-success', consentId: ${jsLiteral(consentId)} }, ${jsLiteral(appUrl)});
window.close();
} else {
window.location.href = '${appUrl}/import?migration=connected&consentId=${consentId}';
window.location.href = ${jsLiteral(successUrl)};
}
</script><p>Anslutningen lyckades. Du kan stänga denna flik.</p></body></html>`
@@ -367,22 +444,9 @@ export const arcimMigrationExtension: Extension = {
headers: { 'Content-Type': 'text/html' },
})
} catch (error) {
log.error('OAuth callback error:', error)
const appUrl = process.env.NEXT_PUBLIC_APP_URL || ''
const html = `<!DOCTYPE html><html><body><script>
if (window.opener) {
window.opener.postMessage({ type: 'arcim-oauth-error' }, '${appUrl}');
window.close();
} else {
window.location.href = '${appUrl}/import?migration=error';
}
</script><p>Något gick fel. Du kan stänga denna flik.</p></body></html>`
return new Response(html, {
status: 200,
headers: { 'Content-Type': 'text/html' },
})
log.error('OAuth callback exchange failed', error)
const reason = error instanceof Error ? error.message : 'Okänt fel vid tokenutbyte.'
return respondWithError(reason)
}
},
},
@@ -110,7 +110,12 @@ AS $$
'out_of_period', (p.fiscal_period_id IS DISTINCT FROM p_period_id)
) AS entry,
p.total AS total_count
FROM paged p;
FROM paged p
ORDER BY
CASE WHEN p_sort_date = 'asc' THEN p.entry_date END ASC NULLS LAST,
CASE WHEN p_sort_date = 'desc' THEN p.entry_date END DESC NULLS LAST,
p.voucher_series,
p.voucher_number;
$$;
GRANT EXECUTE ON FUNCTION public.list_fiscal_period_entries_with_related(