diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx index 01076d34..fc5fabdb 100644 --- a/components/extensions/general/ArcimMigrationWorkspace.tsx +++ b/components/extensions/general/ArcimMigrationWorkspace.tsx @@ -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 () => { diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts index e6d34718..458445d6 100644 --- a/extensions/general/arcim-migration/index.ts +++ b/extensions/general/arcim-migration/index.ts @@ -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

Anslutningen misslyckades: ${escapedReason}

` + + 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 = `

Anslutningen lyckades. Du kan stänga denna flik.

` @@ -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 = `

Något gick fel. Du kan stänga denna flik.

` - - 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) } }, }, diff --git a/supabase/migrations/20260421120000_journal_entries_with_related_rpc.sql b/supabase/migrations/20260421120000_journal_entries_with_related_rpc.sql index 587b2bce..6925e1a3 100644 --- a/supabase/migrations/20260421120000_journal_entries_with_related_rpc.sql +++ b/supabase/migrations/20260421120000_journal_entries_with_related_rpc.sql @@ -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(