diff --git a/DECISIONS.md b/DECISIONS.md index fcceefaa..c099a2a8 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1135,3 +1135,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-19] #1674 audit fixed the in-repo disclosures only (privacy sub-processor Bedrock row now names Anthropic as model vendor, replay paragraph states the deny-by-default guarantee, both locked by app/(public)/privacy/__tests__): Anthropic was deliberately NOT added as its own sub-processor row because no code path sends data to Anthropic (hosted uses Bedrock in eu-north-1, lib/ai/provider.ts); the artifact the prospect actually read (published DPA PDF or marketing security page listing Anthropic/OpenAI in the US) lives outside this repo and needs founder/legal action, as does any underbitraede-list wording. [2026-08-19] #1674 audit fixed the in-repo disclosures only (privacy sub-processor Bedrock row now says AI requests go to Amazon Bedrock and the models are Anthropics Claude running inside Bedrock, replay paragraph states the deny-by-default guarantee, both locked by app/(public)/privacy/__tests__): Anthropic was not added as its own sub-processor row because the HOSTED posture is Bedrock by credential precedence (lib/ai/provider.ts: static AWS keys select Bedrock, region is AWS_REGION with eu-north-1 as the default, not a guarantee); a direct Anthropic API path DOES exist in code for self-hosted deployments (AI_PROVIDER=anthropic, or ANTHROPIC_API_KEY without static AWS keys), so "no code path sends data to Anthropic" would be false and the page asserts nothing about Anthropics underbitraede status either way; whether Anthropic is an underbitraede, plus aligning the published DPA PDF / marketing security page (which listed Anthropic/OpenAI in the US), is founder/legal action outside this repo. [2026-08-20] Reverted #1765: the company switcher is NOT mounted at the top of the desktop sidebar. Founder call after seeing it live: switching belongs in the bottom user block only (the UserMenu flyout), so the sidebar top stays brand + collapse and the nav starts immediately below. #1664's "one-click from the top" framing is therefore declined, not merely unimplemented; the logo title tooltip went back with the revert since it shipped inside the same commit. Do not re-add a top-of-sidebar switcher from #1664 without a new founder decision. +[2026-08-20] Fortnox voucher-attachment scopes (Arkivplats + Koppla filer) are requested per authorize call from the underlag follow-up only, never from an ordinary connect, and gated on FORTNOX_DOCUMENT_SCOPES_APPROVED in lib/providers/fortnox/oauth.ts (the portal-registration switch). Two reasons: Fortnox derives customer licence requirements from what the integration requests, so an all-connects request would put an Arkivplats licence in front of customers who never import a receipt (the portal says so in as many words); and a scope the registered app lacks makes authorize reject with invalid_scope before login, so keeping it off the default connect caps the blast radius at the underlag flow instead of every Fortnox connection (incident 2026-08-13). A document consent is always a superset of an ordinary one, because the callback overwrites the consent's tokens in place and a narrower grant would revoke the migration's own ledger access. While the flag is false the attachment 403 reports PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE with no action offered, instead of reconnect advice for a permission we never ask for: that advice sent Klura AB around the OAuth loop four times and to buy the Fortnox Arkiv module for nothing (support case 2026-08-20). Portal registration alone changes nothing observable, which is why turning the scopes on and back off that day neither caused nor fixed the error. diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx index a43e86dd..42ddc71c 100644 --- a/components/extensions/general/ArcimMigrationWorkspace.tsx +++ b/components/extensions/general/ArcimMigrationWorkspace.tsx @@ -35,6 +35,7 @@ import { arcimDocumentImportReducer, documentOAuthProblemFromReason, parseArcimDocumentOAuthResume, + PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE, requestArcimDocumentImport, resolveArcimDocumentFollowUpProvider, watchArcimOAuthPopup, @@ -1586,11 +1587,17 @@ function DocumentImportFollowUp({ const reconnectRequired = state.problem?.reconnectRequired === true const discoveryFailed = state.phase === 'discovery-error' + // Fortnox has not granted the file permissions to the integration itself, so + // neither reconnecting nor retrying can succeed: state it and offer nothing. + const scopesUnavailable = + state.problem?.code === PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE return (
{title}

- {state.problem?.message + {scopesUnavailable + ? t('ext_arcim_documents_scope_unavailable') + : state.problem?.message ? state.problem.message : reconnectRequired ? t('ext_arcim_documents_scope_error') @@ -1612,21 +1619,23 @@ function DocumentImportFollowUp({ })}

)} - + {!scopesUnavailable && ( + + )}
) } @@ -2192,7 +2201,7 @@ export default function ArcimMigrationWorkspace({ const handleReconnect = useCallback(async ( provider: ArcimProvider, existingConsentId: string, - options?: { onFailure?: () => void }, + options?: { onFailure?: () => void; documentScopes?: boolean }, ) => { setError(null) setAuthExpired(false) @@ -2215,7 +2224,11 @@ export default function ArcimMigrationWorkspace({ const res = await fetch('/api/extensions/ext/arcim-migration/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider, reconnect: true }), + body: JSON.stringify({ + provider, + reconnect: true, + documentScopes: options?.documentScopes === true, + }), }) if (!res.ok) { const data = await res.json().catch(() => ({})) @@ -2328,6 +2341,9 @@ export default function ArcimMigrationWorkspace({ storeDocumentOAuthResume(reconnectAction) dispatchDocumentImport({ type: 'reconnect-started' }) void handleReconnect('fortnox', consentId, { + // The whole point of this reconnect is the attachment permissions, so + // this is the one path that asks Fortnox for them. + documentScopes: true, onFailure: () => { dispatchDocumentImport( reconnectAction === 'discover' diff --git a/components/extensions/general/__tests__/arcim-document-import-flow.test.ts b/components/extensions/general/__tests__/arcim-document-import-flow.test.ts index edf29a3d..24ed65d9 100644 --- a/components/extensions/general/__tests__/arcim-document-import-flow.test.ts +++ b/components/extensions/general/__tests__/arcim-document-import-flow.test.ts @@ -3,6 +3,7 @@ import { ARCIM_DOCUMENT_OAUTH_RESUME_KEY, INITIAL_ARCIM_DOCUMENT_IMPORT_STATE, PROVIDER_DOCUMENT_SCOPES_REQUIRED, + PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE, ArcimDocumentImportRequestError, arcimDocumentImportReducer, documentOAuthProblemFromReason, @@ -168,6 +169,36 @@ describe('Fortnox document follow-up state', () => { reconnectRequired: true, }) }) + + // The scopes are missing from the connect request itself, so offering a + // reconnect would loop the user forever (Klura AB, 2026-08-20). + it('never offers a reconnect when the scopes are unavailable to the integration', async () => { + const fetcher = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: { + code: PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE, + message: 'scopes unavailable', + requestId: 'req_unavailable', + }, + }), + { 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_UNAVAILABLE, + requestId: 'req_unavailable', + reconnectRequired: false, + }) + }) }) describe('document import endpoint request', () => { diff --git a/components/extensions/general/arcim-document-import-flow.ts b/components/extensions/general/arcim-document-import-flow.ts index 9a692173..2e83e31a 100644 --- a/components/extensions/general/arcim-document-import-flow.ts +++ b/components/extensions/general/arcim-document-import-flow.ts @@ -4,6 +4,13 @@ export const ARCIM_DOCUMENT_IMPORT_ENDPOINT = export const PROVIDER_DOCUMENT_SCOPES_REQUIRED = 'PROVIDER_DOCUMENT_SCOPES_REQUIRED' +/** + * The connect request does not ask Fortnox for Arkiv and Koppla fil at all, + * so no reconnect can grant them: the error offers no action, only the truth. + */ +export const PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE = + 'PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE' + export const ARCIM_DOCUMENT_OAUTH_RESUME_KEY = 'arcim-document-oauth-resume' diff --git a/extensions/general/arcim-migration/__tests__/fortnox-document-scopes.test.ts b/extensions/general/arcim-migration/__tests__/fortnox-document-scopes.test.ts new file mode 100644 index 00000000..3c8ef0eb --- /dev/null +++ b/extensions/general/arcim-migration/__tests__/fortnox-document-scopes.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from 'vitest' + +/** + * The voucher-attachment scopes (Arkivplats + Koppla filer) are opt-in per + * authorize call rather than part of every Fortnox connect. Two reasons, both + * of which have already cost us once: + * + * 1. Fortnox derives its customer licence requirements from what the + * integration requests, so asking every customer for Arkivplats would put + * a licence in front of people who never import a receipt. + * 2. A scope the registered app lacks makes authorize reject with + * invalid_scope before login. Keeping it off the ordinary connect means + * the blast radius of getting it wrong is the underlag flow, not every + * Fortnox connection in production (incident 2026-08-13). + */ + +vi.mock('@/lib/supabase/server', () => ({ + createServiceClient: () => ({ from: vi.fn() }), + createServiceClientNoCookies: () => ({ from: vi.fn() }), +})) + +vi.mock('@/lib/providers/oauth-config', () => ({ + getOAuthConfig: () => ({ + clientId: 'client-id', + clientSecret: 'client-secret', + redirectUri: 'https://accounted.example.test/callback', + }), +})) + +import { + fortnoxConsentScopes, + FORTNOX_DOCUMENT_SCOPES, +} from '@/lib/providers/fortnox/oauth' +import { getAuthUrl } from '../lib/provider-client' + +const scopesOf = (url: string) => + new URL(url).searchParams.get('scope')?.split(' ') ?? [] + +describe('Fortnox document scopes are opt-in', () => { + it('leaves the attachment scopes out of an ordinary consent', () => { + expect(fortnoxConsentScopes()).not.toContain('archive') + expect(fortnoxConsentScopes()).not.toContain('connectfile') + }) + + // The OAuth callback overwrites the consent's tokens in place, so a document + // consent that dropped the base scopes would silently revoke the migration's + // own access to the ledger it just imported. + it('never drops the base scopes when asking for the attachment scopes', () => { + const base = fortnoxConsentScopes() + const withDocuments = fortnoxConsentScopes({ documents: true }) + + for (const scope of base) { + expect(withDocuments).toContain(scope) + } + expect(withDocuments.length).toBeGreaterThanOrEqual(base.length) + expect(new Set(withDocuments).size).toBe(withDocuments.length) + }) + + it('names both attachment scopes, so the portal registration has a source of truth', () => { + expect(FORTNOX_DOCUMENT_SCOPES).toEqual(['archive', 'connectfile']) + }) + + it('builds the ordinary connect URL without the attachment scopes', async () => { + const { url } = await getAuthUrl('fortnox', 'state-1', 'https://cb.test') + + expect(scopesOf(url)).toEqual(fortnoxConsentScopes()) + expect(scopesOf(url)).not.toContain('archive') + }) + + it('builds the underlag reconnect URL from the document consent scopes', async () => { + const { url } = await getAuthUrl('fortnox', 'state-2', 'https://cb.test', { + documentScopes: true, + }) + + expect(scopesOf(url)).toEqual(fortnoxConsentScopes({ documents: true })) + for (const scope of fortnoxConsentScopes()) { + expect(scopesOf(url)).toContain(scope) + } + }) +}) diff --git a/extensions/general/arcim-migration/__tests__/import-documents-route.test.ts b/extensions/general/arcim-migration/__tests__/import-documents-route.test.ts index 07d4f3a1..8b691c29 100644 --- a/extensions/general/arcim-migration/__tests__/import-documents-route.test.ts +++ b/extensions/general/arcim-migration/__tests__/import-documents-route.test.ts @@ -18,6 +18,19 @@ vi.mock('../lib/import-documents', () => { } }) +const fortnoxOAuth = vi.hoisted(() => ({ documentScopesApproved: false })) + +vi.mock('@/lib/providers/fortnox/oauth', async (importOriginal) => { + const actual = + await importOriginal() + return { + ...actual, + get FORTNOX_DOCUMENT_SCOPES_APPROVED() { + return fortnoxOAuth.documentScopesApproved + }, + } +}) + vi.mock('../lib/provider-client', () => { class ProviderTokenInvalidError extends Error { constructor( @@ -106,6 +119,7 @@ describe('POST /import-documents', () => { beforeEach(() => { vi.clearAllMocks() eventBus.clear() + fortnoxOAuth.documentScopesApproved = false }) it('passes dry-run discovery through without storing documents', async () => { @@ -138,7 +152,8 @@ describe('POST /import-documents', () => { ) }) - it('returns an actionable 403 when Fortnox lacks archive/connectfile scopes', async () => { + it('asks the user to reconnect only once the connect request carries the scopes', async () => { + fortnoxOAuth.documentScopesApproved = true ;(importProviderDocuments as Mock).mockRejectedValue( new FortnoxDocumentScopesRequiredError(), ) @@ -153,6 +168,27 @@ describe('POST /import-documents', () => { expect(body.error.message).toContain('Koppla om Fortnox') expect(body.error.message_en).toContain('Reconnect Fortnox') }) + + // Klura AB, 2026-08-20: the connect request does not ask Fortnox for Arkiv + // and Koppla fil at all, so the reconnect advice sent the user around a loop + // four times (and to buy the Fortnox Arkiv module) for nothing. + it('says the permission is missing on our side while the scopes are unapproved', async () => { + fortnoxOAuth.documentScopesApproved = false + ;(importProviderDocuments as Mock).mockRejectedValue( + new FortnoxDocumentScopesRequiredError(), + ) + + const response = await handler(request(false), buildContext()) + const { status, body } = await parseJsonResponse<{ + error: { code: string; message: string; message_en?: string } + }>(response) + + expect(status).toBe(403) + expect(body.error.code).toBe('PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE') + expect(body.error.message).not.toContain('Koppla om Fortnox') + expect(body.error.message).toContain('Att koppla om hjälper inte') + expect(body.error.message_en).toContain('Reconnecting will not help') + }) }) describe('POST /submit-token Bokio error mapping', () => { diff --git a/extensions/general/arcim-migration/__tests__/import-documents.test.ts b/extensions/general/arcim-migration/__tests__/import-documents.test.ts index 563b6568..d46389f2 100644 --- a/extensions/general/arcim-migration/__tests__/import-documents.test.ts +++ b/extensions/general/arcim-migration/__tests__/import-documents.test.ts @@ -479,6 +479,36 @@ describe('importProviderDocuments', () => { ).rejects.toThrow('Fortnox consent lacks archive/connectfile scope: reconnect required') }) + // Six companies hit exactly this between 2026-08-13 and 08-19 and were told + // "kunde inte importera underlag, försök igen", with a retry that could not + // work: Fortnox answers an unscoped attachment listing with 400 plus a + // behörighet message, not only with 403. + it('treats a Fortnox 400 that names the missing permission as the same failure', async () => { + const supabase = wireFortnox() + mockFetchFortnoxFinancialYears.mockRejectedValue( + new FortnoxApiError( + 'bad request', + 400, + JSON.stringify({ + ErrorInformation: { + error: 1, + message: 'Otillräcklig behörighet för att utföra anropet', + code: 2000663, + }, + }), + ), + ) + + await expect( + importProviderDocuments({ + supabase, + companyId: COMPANY, + userId: USER, + consentId: 'c1', + }), + ).rejects.toThrow('Fortnox consent lacks archive/connectfile scope: reconnect required') + }) + it('explains that a Fortnox archive-download 403 requires reconnecting', async () => { const supabase = wireFortnox() mockDownloadFortnoxArchiveFile.mockRejectedValue( diff --git a/extensions/general/arcim-migration/__tests__/oauth-callback-state.test.ts b/extensions/general/arcim-migration/__tests__/oauth-callback-state.test.ts index c43f64ce..2c19e68b 100644 --- a/extensions/general/arcim-migration/__tests__/oauth-callback-state.test.ts +++ b/extensions/general/arcim-migration/__tests__/oauth-callback-state.test.ts @@ -323,7 +323,47 @@ describe('OAuth redirect_uri symmetry between authorize and exchange', () => { ) expect(res.status).toBe(200) - expect(getAuthUrl).toHaveBeenCalledWith('fortnox', 'otc-code-1', OVERRIDE_URI) + expect(getAuthUrl).toHaveBeenCalledWith( + 'fortnox', + 'otc-code-1', + OVERRIDE_URI, + // A first connect never asks for the voucher-attachment scopes: those + // carry a Fortnox licence requirement and belong to the opt-in underlag + // reconnect only. + { documentScopes: undefined }, + ) + }) + + // The underlag follow-up is the only caller allowed to widen the consent. + it('reconnect asks for the attachment scopes only when the underlag flow requests them', async () => { + ;(listConsents as Mock).mockResolvedValue([ + { id: 'consent-1', provider: 'fortnox', status: 1 }, + ]) + + const reconnect = (documentScopes?: boolean) => + connectHandler( + createMockRequest('http://localhost/api/extensions/ext/arcim-migration/connect', { + method: 'POST', + body: { provider: 'fortnox', reconnect: true, ...(documentScopes === undefined ? {} : { documentScopes }) }, + }), + connectCtx(), + ) + + expect((await reconnect(true)).status).toBe(200) + expect(getAuthUrl).toHaveBeenLastCalledWith( + 'fortnox', + 'otc-code-1', + OVERRIDE_URI, + { documentScopes: true }, + ) + + expect((await reconnect()).status).toBe(200) + expect(getAuthUrl).toHaveBeenLastCalledWith( + 'fortnox', + 'otc-code-1', + OVERRIDE_URI, + { documentScopes: false }, + ) }) it('exchange leg passes the SAME env-override redirect URI to exchangeAuthToken', async () => { diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts index f32d8145..5352a8fe 100644 --- a/extensions/general/arcim-migration/index.ts +++ b/extensions/general/arcim-migration/index.ts @@ -34,6 +34,7 @@ import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/ import { loadMappings, generateImportPreview, executeSIEImport, findOverlappingPeriodImports } from '@/lib/import/sie-import' import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference' import type { ProviderName } from '@/lib/providers/types' +import { FORTNOX_DOCUMENT_SCOPES_APPROVED } from '@/lib/providers/fortnox/oauth' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { classifyProviderError } from '@/lib/providers/with-provider-call' import { FortnoxApiError, fortnoxErrorMessage } from '@/lib/providers/fortnox/client' @@ -119,7 +120,11 @@ function resolveArcimCallbackUrl(provider: ArcimProvider | ProviderName): string * consent_id, so re-running OAuth against the same consent overwrites a dead * refresh-token pair in place: no disconnect/recreate needed. */ -async function buildArcimOAuthUrl(consentId: string, provider: ArcimProvider): Promise { +async function buildArcimOAuthUrl( + consentId: string, + provider: ArcimProvider, + options?: { documentScopes?: boolean }, +): Promise { // Server-side state row: consent id, provider (via the consent), expiry and a // consumed marker all live in provider_otc. The `state` handed to the provider // is that row's opaque random primary key, nothing more. @@ -131,7 +136,9 @@ async function buildArcimOAuthUrl(consentId: string, provider: ArcimProvider): P // above. It deliberately encodes NOTHING. The previous base64url JSON payload // was attacker-authored input the callback trusted, so anyone who learned a // consent id could redirect their own provider tokens onto that consent. - const { url } = await getAuthUrl(provider, otc.code, callbackUrl) + const { url } = await getAuthUrl(provider, otc.code, callbackUrl, { + documentScopes: options?.documentScopes, + }) return url } @@ -259,11 +266,23 @@ export const arcimMigrationExtension: Extension = { const companyId = ctx?.companyId ?? user.id - const { provider, companyName, orgNumber, reconnect } = await request.json() as { + const { + provider, + companyName, + orgNumber, + reconnect, + documentScopes, + } = await request.json() as { provider: ArcimProvider companyName?: string orgNumber?: string reconnect?: boolean + /** + * Reconnect specifically to grant the voucher-attachment scopes. + * Only the underlag follow-up sets it, so an ordinary connect never + * asks the customer for Arkivplats and Koppla filer. + */ + documentScopes?: boolean } if (!provider) { @@ -300,7 +319,9 @@ export const arcimMigrationExtension: Extension = { await ctx.settings.set('provider', provider) } if (providerInfo.authType === 'oauth') { - const authUrl = await buildArcimOAuthUrl(stale.id, provider) + const authUrl = await buildArcimOAuthUrl(stale.id, provider, { + documentScopes: documentScopes === true, + }) return NextResponse.json({ consentId: stale.id, authType: 'oauth', @@ -1386,8 +1407,13 @@ export const arcimMigrationExtension: Extension = { error instanceof FortnoxApiError ? error.body?.slice(0, 500) : undefined, }) if (error instanceof FortnoxDocumentScopesRequiredError) { + // Reconnecting only helps once the connect request actually asks + // for Arkiv and Koppla fil. While it does not, say so plainly + // instead of sending the user around a loop that cannot succeed. return errorResponseFromCode( - 'PROVIDER_DOCUMENT_SCOPES_REQUIRED', + FORTNOX_DOCUMENT_SCOPES_APPROVED + ? 'PROVIDER_DOCUMENT_SCOPES_REQUIRED' + : 'PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE', moduleLog, { status: 403 }, ) diff --git a/extensions/general/arcim-migration/lib/provider-client.ts b/extensions/general/arcim-migration/lib/provider-client.ts index 677c60e5..52b7cc90 100644 --- a/extensions/general/arcim-migration/lib/provider-client.ts +++ b/extensions/general/arcim-migration/lib/provider-client.ts @@ -10,7 +10,10 @@ import { randomBytes } from 'node:crypto' import { createServiceClient } from '@/lib/supabase/server' import type { ProviderName } from '@/lib/providers/types' import { getOAuthConfig } from '@/lib/providers/oauth-config' -import { buildFortnoxAuthUrl } from '@/lib/providers/fortnox/oauth' +import { + buildFortnoxAuthUrl, + fortnoxConsentScopes, +} from '@/lib/providers/fortnox/oauth' import { exchangeFortnoxCode } from '@/lib/providers/fortnox/oauth' import { buildVismaAuthUrl, exchangeVismaCode } from '@/lib/providers/visma/oauth' import { refreshBjornLundenToken } from '@/lib/providers/bjornlunden/oauth' @@ -314,6 +317,12 @@ export async function getAuthUrl( provider: ProviderName, state?: string, redirectUri?: string, + /** + * Ask Fortnox for the voucher-attachment scopes as well. Opt-in, so only a + * user who wants their underlag is put in front of the extra permissions + * (and their licence requirements). + */ + options?: { documentScopes?: boolean }, ): Promise<{ url: string }> { const config = getOAuthConfig(provider) @@ -323,7 +332,10 @@ export async function getAuthUrl( : config if (provider === 'fortnox') { - const url = buildFortnoxAuthUrl(effectiveConfig, { state }) + const url = buildFortnoxAuthUrl(effectiveConfig, { + state, + scopes: fortnoxConsentScopes({ documents: options?.documentScopes }), + }) return { url } } diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index cd6ea4eb..283f47d2 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -2037,6 +2037,13 @@ const PROVIDER_MIGRATION: Record = { message_en: 'The Fortnox connection lacks Archive and Connect file access. Reconnect Fortnox and approve those permissions to import documents.', }, + PROVIDER_DOCUMENT_SCOPES_UNAVAILABLE: { + httpStatus: 403, + message_sv: + 'Filimport från Fortnox är inte påslagen än: behörigheterna Arkiv och Koppla fil saknas för Accounted-integrationen hos Fortnox. Att koppla om hjälper inte, vi aktiverar det så snart behörigheten är på plats. Allt annat i migreringen är importerat.', + message_en: + 'Fortnox file import is not enabled yet: the Archive and Connect file permissions are missing for the Accounted integration at Fortnox. Reconnecting will not help; we enable this as soon as the permission is in place. Everything else in the migration was imported.', + }, PROVIDER_DISCONNECT_FAILED: { httpStatus: 500, message_sv: 'Frånkoppling från leverantören misslyckades.', diff --git a/lib/providers/fortnox/__tests__/oauth.test.ts b/lib/providers/fortnox/__tests__/oauth.test.ts index cb4acc51..ba58b49d 100644 --- a/lib/providers/fortnox/__tests__/oauth.test.ts +++ b/lib/providers/fortnox/__tests__/oauth.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { buildFortnoxAuthUrl } from '../oauth'; +import { + buildFortnoxAuthUrl, + fortnoxConsentScopes, + FORTNOX_DOCUMENT_SCOPES, + FORTNOX_DOCUMENT_SCOPES_APPROVED, +} from '../oauth'; describe('Fortnox OAuth scopes', () => { // Pins the 2026-08-13 incident fix: the registered Fortnox app does not @@ -22,4 +27,39 @@ describe('Fortnox OAuth scopes', () => { expect(scopes).not.toContain('archive'); expect(scopes).not.toContain('connectfile'); }); + + // The document-import error message is derived from this flag, so that a + // user is never told to reconnect for a permission the connect request does + // not ask for. Flipping it here without enabling the scopes in the Fortnox + // Developer Portal reintroduces the 2026-08-13 invalid_scope outage. + it('keeps the document scopes flagged as not approved, and names both of them', () => { + expect(FORTNOX_DOCUMENT_SCOPES_APPROVED).toBe(false); + expect(FORTNOX_DOCUMENT_SCOPES).toEqual(['archive', 'connectfile']); + }); + + // Even once the portal registration lands, opting in must never cost the + // consent its ledger access: the callback overwrites its tokens in place. + it('keeps a document consent a superset of an ordinary one', () => { + const withDocuments = fortnoxConsentScopes({ documents: true }); + for (const scope of fortnoxConsentScopes()) { + expect(withDocuments).toContain(scope); + } + }); + + it('requests the document scopes only when they are explicitly passed in', () => { + const url = new URL( + buildFortnoxAuthUrl( + { + clientId: 'client-id', + clientSecret: 'client-secret', + redirectUri: 'https://accounted.example.test/callback', + }, + { scopes: ['bookkeeping', ...FORTNOX_DOCUMENT_SCOPES] }, + ), + ); + const scopes = new Set(url.searchParams.get('scope')?.split(' ') ?? []); + + expect(scopes).toContain('archive'); + expect(scopes).toContain('connectfile'); + }); }); diff --git a/lib/providers/fortnox/oauth.ts b/lib/providers/fortnox/oauth.ts index bea5288d..f378ad6b 100644 --- a/lib/providers/fortnox/oauth.ts +++ b/lib/providers/fortnox/oauth.ts @@ -6,23 +6,50 @@ import { OAUTH_REVOKE_TIMEOUT_MS, } from '@/lib/http/fetch-with-timeout'; -const DEFAULT_SCOPES = [ +const BASE_SCOPES = [ 'companyinformation', 'invoice', 'supplierinvoice', 'customer', 'supplier', 'bookkeeping', - // 'archive' and 'connectfile' (voucher attachment import) must NOT be - // requested until the registered Fortnox app has them approved in the - // Fortnox Developer Portal: requesting a scope the app lacks makes the - // authorize endpoint reject with invalid_scope BEFORE login, which kills - // every Fortnox connect (prod incident 2026-08-13). The document import - // detects the missing scopes at runtime (403 becomes - // PROVIDER_DOCUMENT_SCOPES_REQUIRED) and surfaces a reconnect follow-up - // instead of failing the migration. ]; +/** Arkivplats + Koppla filer: what the voucher attachment import reads. */ +export const FORTNOX_DOCUMENT_SCOPES = ['archive', 'connectfile']; + +/** + * Whether the registered Fortnox app has Arkivplats and Koppla filer enabled in + * the Fortnox Developer Portal (integration 39254). Requesting a scope the app + * lacks makes the authorize endpoint reject with invalid_scope BEFORE login, + * which kills every Fortnox connect (prod incident 2026-08-13), so this stays + * false until the portal registration has both. Flip it in the same change that + * enables them there. + * + * It gates the opt-in document consent below and the document-import error + * message, never the ordinary connect: a user is never told to reconnect for a + * permission we don't ask for (support case Klura AB, 2026-08-20). + */ +export const FORTNOX_DOCUMENT_SCOPES_APPROVED: boolean = false; + +/** + * The scopes a Fortnox consent is minted with. The document scopes are opt-in + * per authorize call, because Fortnox derives its customer licence + * requirements from what an integration requests: a customer who never imports + * receipts should not be asked to hold an Arkivplats licence to connect at all. + * + * The base scopes always ride along. The OAuth callback overwrites the + * consent's tokens in place, so a document consent minted from the two extra + * scopes alone would strip the migration's own access to the ledger. + */ +export function fortnoxConsentScopes(options?: { documents?: boolean }): string[] { + const withDocuments = + options?.documents === true && FORTNOX_DOCUMENT_SCOPES_APPROVED; + return withDocuments + ? [...BASE_SCOPES, ...FORTNOX_DOCUMENT_SCOPES] + : [...BASE_SCOPES]; +} + export function buildFortnoxAuthUrl( config: OAuthConfig, options?: { scopes?: string[]; state?: string }, @@ -34,7 +61,9 @@ export function buildFortnoxAuthUrl( access_type: 'offline', }); - const scopes = options?.scopes?.length ? options.scopes : DEFAULT_SCOPES; + const scopes = options?.scopes?.length + ? options.scopes + : fortnoxConsentScopes(); params.set('scope', scopes.join(' ')); if (options?.state) { diff --git a/messages/en.json b/messages/en.json index f8fd8b32..a5a8708e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5442,6 +5442,7 @@ "ext_arcim_documents_discovery_error": "The migration is complete, but we could not check the documents in Fortnox. Try again without rerunning the migration.", "ext_arcim_documents_import_error": "The migration is still complete, but the documents could not be imported. Try again; documents already imported will be skipped.", "ext_arcim_documents_scope_error": "The Fortnox connection lacks access to Archive and Connect file. Reconnect Fortnox and approve those permissions to continue.", + "ext_arcim_documents_scope_unavailable": "Fortnox file import is not enabled yet: the Archive and Connect file permissions are missing for the Accounted integration at Fortnox. Reconnecting will not help; we enable this as soon as the permission is in place. Everything else in the migration was imported.", "ext_arcim_documents_reconnect_action": "Reconnect Fortnox", "ext_arcim_documents_retry_discovery": "Check again", "ext_arcim_documents_retry_import": "Try importing again", diff --git a/messages/sv.json b/messages/sv.json index be82d010..e995eca0 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -5442,6 +5442,7 @@ "ext_arcim_documents_discovery_error": "Migreringen är klar, men vi kunde inte kontrollera underlagen i Fortnox. Försök igen utan att köra om migreringen.", "ext_arcim_documents_import_error": "Migreringen är fortfarande klar, men underlagen kunde inte importeras. Försök igen; redan importerade underlag hoppas över.", "ext_arcim_documents_scope_error": "Fortnox-anslutningen saknar behörighet till Arkiv och Koppla fil. Koppla om Fortnox och godkänn behörigheterna för att fortsätta.", + "ext_arcim_documents_scope_unavailable": "Filimport från Fortnox är inte påslagen än: behörigheterna Arkiv och Koppla fil saknas för Accounted-integrationen hos Fortnox. Att koppla om hjälper inte, vi aktiverar det så snart behörigheten är på plats. Allt annat i migreringen är importerat.", "ext_arcim_documents_reconnect_action": "Koppla om Fortnox", "ext_arcim_documents_retry_discovery": "Kontrollera igen", "ext_arcim_documents_retry_import": "Försök importera igen",