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:
@@ -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 () => {
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user