diff --git a/DECISIONS.md b/DECISIONS.md index 6c7a9b24..fe579542 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -2,6 +2,8 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and humans when a non-obvious choice is made (approach picked over an alternative, dependency declined, action stopped by a CLAUDE.md rule). Read before re-litigating a past decision. +[2026-08-18] Bokio connection validation uses the documented GET /v1/companies/{companyId}/company-information contract and treats only 401/403 as credential rejection: the removed bare company path returned 404 for valid credentials, while a 404 from the documented endpoint identifies the company ID and other failures are not evidence that the token is wrong (#1670). + [2026-08-13] E-invoice product-truth correction covers the MCP workflow skills and the MCP-exposed swedish-invoice-compliance atom: the atom's "for Accounted e-invoice generation" heading made the same unsupported product claim as issue #1577, so all active guidance now directs external delivery followed by gnubok_mark_invoice_as_sent; Peppol implementation remains tracked in #546. [2026-08-13] KU10 organisation identities normalize to the 12-digit `16`-prefixed form and receive structural XSD validation without a new Luhn gate: Skatteverket KU schema 12.0 requires that shape, names FK201 `UppgiftslamnarId`, and explicitly leaves check-digit validation outside the schema. diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx index fa87d258..06235198 100644 --- a/components/extensions/general/ArcimMigrationWorkspace.tsx +++ b/components/extensions/general/ArcimMigrationWorkspace.tsx @@ -672,6 +672,7 @@ function ConnectStep({ onTokenSubmit: (apiToken: string, companyId: string) => void onBack: () => void }) { + const t = useTranslations('extensions') const providerName = ARCIM_PROVIDERS.find(p => p.id === provider)?.name ?? provider const [apiToken, setApiToken] = useState('') const [companyId, setCompanyId] = useState('') @@ -691,7 +692,9 @@ function ConnectStep({ ? 'Företagsnyckel (User-Key)' : provider === 'wint' ? 'E-postadress' - : 'Företags-ID' + : provider === 'bokio' + ? t('ext_arcim_bokio_company_id_label') + : 'Företags-ID' const tokenDescription = isClientCredentials ? `Ange din företagsnyckel (User-Key) från Björn Lundén. ${branding.appName.toLowerCase()} ansluter automatiskt via sin integrationspartner-åtkomst.` @@ -699,6 +702,10 @@ function ConnectStep({ ? `Logga in med dina WINT-uppgifter för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata. Lösenordet används en gång för att skapa anslutningen och sparas aldrig.` : provider === 'briox' ? `Ange ditt konto-ID och din applikationstoken från Briox för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata.` + : provider === 'bokio' + ? t('ext_arcim_bokio_token_description', { + appName: branding.appName.toLowerCase(), + }) : `Ange din API-nyckel från ${providerName} för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata.` const tokenHelpText = isClientCredentials @@ -706,7 +713,7 @@ function ConnectStep({ : isWintLogin ? `Använd samma e-postadress och lösenord som när du loggar in på app.wint.se. Kräver ditt WINT-konto BankID-inloggning kan anslutningen inte skapas ännu: be i så fall WINT om en SIE-fil och importera den manuellt.` : provider === 'bokio' - ? `Du hittar din API-nyckel i ${providerName} under Inställningar \u2192 Integrationer \u2192 API. Ditt företags-ID är det GUID som syns i URL:en när du är inloggad, t.ex. https://app.bokio.se/ditt-företags-id/settings-r/private-integrations.` + ? t('ext_arcim_bokio_token_help') : provider === 'briox' ? `Skapa din applikationstoken i Briox under Admin \u2192 Anv\u00e4ndare \u2192 kugghjulet vid din anv\u00e4ndare \u2192 Applikationstoken. Ditt konto-ID \u00e4r det l\u00e5nga numret inom parentes bredvid f\u00f6retagsnamnet under "Ditt konto" i menyn till h\u00f6ger.` : `Du hittar din applikationstoken i ${providerName} under Administration \u2192 Integrationer.` @@ -788,7 +795,13 @@ function ConnectStep({ {needsApiToken && (
({})) - throw new Error(apiErrorMessage(data, `HTTP ${res.status}`)) + throw apiError(data, `HTTP ${res.status}`) } // Token stored: consent is now accepted, proceed to preview await loadPreview(consentId) } catch (err) { - setError(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte ansluta') + setError(displayError(err, 'Kunde inte ansluta')) } finally { setIsLoading(false) } 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 47cb11c4..07d4f3a1 100644 --- a/extensions/general/arcim-migration/__tests__/import-documents-route.test.ts +++ b/extensions/general/arcim-migration/__tests__/import-documents-route.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { createMockRequest, createMockSupabase, parseJsonResponse } from '@/tests/helpers' +import { eventBus } from '@/lib/events/bus' import type { ExtensionContext } from '@/lib/extensions/types' vi.mock('../lib/import-documents', () => { @@ -17,29 +18,44 @@ vi.mock('../lib/import-documents', () => { } }) -vi.mock('../lib/provider-client', () => ({ - createConsent: vi.fn(), - getConsent: vi.fn(), - listConsents: vi.fn(), - generateOtc: vi.fn(), - consumeOAuthState: vi.fn(), - getAuthUrl: vi.fn(), - exchangeAuthToken: vi.fn(), - submitProviderToken: vi.fn(), - acceptConsent: vi.fn(), - deleteConsent: vi.fn(), - resolveConsent: vi.fn(), - fetchCompanyInfoDirect: vi.fn(), - ProviderTokenInvalidError: class ProviderTokenInvalidError extends Error {}, - ProviderCompanyMismatchError: class ProviderCompanyMismatchError extends Error {}, - ConsentNotFoundError: class ConsentNotFoundError extends Error {}, -})) +vi.mock('../lib/provider-client', () => { + class ProviderTokenInvalidError extends Error { + constructor( + message: string, + readonly kind: 'credentials' | 'company-not-found' = 'credentials', + ) { + super(message) + } + } + + return { + createConsent: vi.fn(), + getConsent: vi.fn(), + listConsents: vi.fn(), + generateOtc: vi.fn(), + consumeOAuthState: vi.fn(), + getAuthUrl: vi.fn(), + exchangeAuthToken: vi.fn(), + submitProviderToken: vi.fn(), + acceptConsent: vi.fn(), + deleteConsent: vi.fn(), + resolveConsent: vi.fn(), + fetchCompanyInfoDirect: vi.fn(), + ProviderTokenInvalidError, + ProviderCompanyMismatchError: class ProviderCompanyMismatchError extends Error {}, + ConsentNotFoundError: class ConsentNotFoundError extends Error {}, + } +}) import { arcimMigrationExtension } from '../index' import { FortnoxDocumentScopesRequiredError, importProviderDocuments, } from '../lib/import-documents' +import { + ProviderTokenInvalidError, + submitProviderToken, +} from '../lib/provider-client' const route = (arcimMigrationExtension.apiRoutes ?? []).find( (candidate) => @@ -48,6 +64,10 @@ const route = (arcimMigrationExtension.apiRoutes ?? []).find( type RouteHandler = (request: Request, ctx?: ExtensionContext) => Promise const handler = route.handler as RouteHandler +const submitTokenRoute = (arcimMigrationExtension.apiRoutes ?? []).find( + (candidate) => candidate.method === 'POST' && candidate.path === '/submit-token', +)! +const submitTokenHandler = submitTokenRoute.handler as RouteHandler function buildContext(): ExtensionContext { const { supabase } = createMockSupabase() @@ -67,9 +87,25 @@ function request(dryRun: boolean) { ) } +function submitTokenRequest() { + return createMockRequest( + 'http://localhost/api/extensions/ext/arcim-migration/submit-token', + { + method: 'POST', + body: { + consentId: 'consent-1', + provider: 'bokio', + apiToken: 'not-a-real-token', + companyId: '9b408943-7a1e-47ac-85a7-ac52b2c210d3', + }, + }, + ) +} + describe('POST /import-documents', () => { beforeEach(() => { vi.clearAllMocks() + eventBus.clear() }) it('passes dry-run discovery through without storing documents', async () => { @@ -118,3 +154,61 @@ describe('POST /import-documents', () => { expect(body.error.message_en).toContain('Reconnect Fortnox') }) }) + +describe('POST /submit-token Bokio error mapping', () => { + beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + }) + + it('reports a 401/403 authentication verdict as rejected integration details', async () => { + ;(submitProviderToken as Mock).mockRejectedValue( + new ProviderTokenInvalidError('Bokio rejected the integration token (HTTP 403)'), + ) + + const response = await submitTokenHandler(submitTokenRequest(), buildContext()) + const { status, body } = await parseJsonResponse<{ + error: { code: string; message: string; message_en?: string } + }>(response) + + expect(status).toBe(422) + expect(body.error.code).toBe('PROVIDER_TOKEN_INVALID') + expect(body.error.message).toContain('avvisade autentiseringen') + expect(body.error.message_en).toContain('rejected the authentication') + }) + + it('reports a Bokio 404 as a company-ID failure instead of rejected credentials', async () => { + ;(submitProviderToken as Mock).mockRejectedValue( + new ProviderTokenInvalidError( + 'Bokio does not know that company id', + 'company-not-found', + ), + ) + + const response = await submitTokenHandler(submitTokenRequest(), buildContext()) + const { status, body } = await parseJsonResponse<{ + error: { code: string; message: string; message_en?: string } + }>(response) + + expect(status).toBe(422) + expect(body.error.code).toBe('BOKIO_COMPANY_NOT_FOUND') + expect(body.error.message).toContain('företags-ID') + expect(body.error.message_en).toContain('company ID') + }) + + it('keeps an unclassified provider/configuration failure generic', async () => { + ;(submitProviderToken as Mock).mockRejectedValue( + new Error('Bokio company-information response is missing companyInformation'), + ) + + const response = await submitTokenHandler(submitTokenRequest(), buildContext()) + const { status, body } = await parseJsonResponse<{ + error: { code: string; message: string; message_en?: string } + }>(response) + + expect(status).toBe(500) + expect(body.error.code).toBe('PROVIDER_TOKEN_SUBMIT_FAILED') + expect(body.error.message).toContain('kontrollera integrationsuppgifterna') + expect(body.error.message_en).toContain('verify the integration details') + }) +}) diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts index 70ad84e8..ab5e6dc4 100644 --- a/extensions/general/arcim-migration/index.ts +++ b/extensions/general/arcim-migration/index.ts @@ -470,9 +470,14 @@ export const arcimMigrationExtension: Extension = { details: { consentId }, }) } - // Wrong credentials (provider actively rejected them): tell the - // user to re-check the pasted values instead of a generic 500. + // Provider rejected authentication or could not resolve the Bokio + // company: return the actionable problem instead of a generic 500. if (error instanceof ProviderTokenInvalidError) { + if (error.kind === 'company-not-found') { + return errorResponseFromCode('BOKIO_COMPANY_NOT_FOUND', moduleLog, { + details: { provider, reason: error.message }, + }) + } return errorResponseFromCode('PROVIDER_TOKEN_INVALID', moduleLog, { details: { provider, reason: error.message }, }) diff --git a/extensions/general/arcim-migration/lib/__tests__/provider-client.test.ts b/extensions/general/arcim-migration/lib/__tests__/provider-client.test.ts index cf2d1fb2..8f84094f 100644 --- a/extensions/general/arcim-migration/lib/__tests__/provider-client.test.ts +++ b/extensions/general/arcim-migration/lib/__tests__/provider-client.test.ts @@ -111,7 +111,7 @@ describe('submitProviderToken', () => { mock.enqueue({ data: null }) // token upsert mockBokioGetCompany.mockResolvedValueOnce({ name: 'Testbolaget AB', - orgNumber: '5560125790', + organizationNumber: '5560125790', }) const result = await submitProviderToken('consent-1', 'bokio', 'tok', 'bokio-guid', 'company-A') @@ -136,7 +136,7 @@ describe('submitProviderToken', () => { mock.enqueue({ data: { org_number: '5560125790' } }) // target company mockBokioGetCompany.mockResolvedValueOnce({ name: 'Någon Annans Bolag AB', - orgNumber: '5566778899', // a different legal entity + organizationNumber: '5566778899', // a different legal entity }) const err: unknown = await submitProviderToken( @@ -168,7 +168,7 @@ describe('submitProviderToken', () => { // Same company, hyphenated and with the century prefix Bokio may return. mockBokioGetCompany.mockResolvedValueOnce({ name: 'Testbolaget AB', - orgNumber: '556012-5790', + organizationNumber: '556012-5790', }) await expect( @@ -185,7 +185,7 @@ describe('submitProviderToken', () => { mock.enqueue({ data: null }) // token upsert mockBokioGetCompany.mockResolvedValueOnce({ name: 'Testbolaget AB', - orgNumber: '5560125790', + organizationNumber: '5560125790', }) await expect( @@ -197,25 +197,82 @@ describe('submitProviderToken', () => { expect(tablesTouched()).toContain('provider_consent_tokens') }) - it('maps a 404 from the Bokio probe to invalid credentials', async () => { + it('maps a 404 from the Bokio probe to a company-specific failure', async () => { mock.enqueue({ data: [{ id: 'consent-1' }] }) // getCompany() maps 404 to null: an unknown GUID, not an outage. mockBokioGetCompany.mockResolvedValueOnce(null) - await expect( - submitProviderToken('consent-1', 'bokio', 'tok', 'bad-guid', 'company-A'), - ).rejects.toBeInstanceOf(ProviderTokenInvalidError) + const err: unknown = await submitProviderToken( + 'consent-1', + 'bokio', + 'tok', + 'bad-guid', + 'company-A', + ).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(ProviderTokenInvalidError) + expect(err).toMatchObject({ kind: 'company-not-found' }) expect(tablesTouched()).not.toContain('provider_consent_tokens') }) - it('maps a 401 from the Bokio probe to invalid credentials', async () => { + it.each([401, 403])('maps a %s from the Bokio probe to invalid credentials', async (status) => { mock.enqueue({ data: [{ id: 'consent-1' }] }) - mockBokioGetCompany.mockRejectedValueOnce(new BokioApiError('Bokio API error: 401', 401)) + mockBokioGetCompany.mockRejectedValueOnce( + new BokioApiError(`Bokio API error: ${status}`, status), + ) - await expect( - submitProviderToken('consent-1', 'bokio', 'tok', 'bokio-guid', 'company-A'), - ).rejects.toBeInstanceOf(ProviderTokenInvalidError) + const err: unknown = await submitProviderToken( + 'consent-1', + 'bokio', + 'tok', + 'bokio-guid', + 'company-A', + ).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(ProviderTokenInvalidError) + expect(err).toMatchObject({ kind: 'credentials' }) + }) + + it('trims the token and company id and removes a pasted Bearer prefix before probing or storing', async () => { + mock.enqueue({ data: [{ id: 'consent-1' }] }) + mock.enqueue({ data: { org_number: '5560125790' } }) + mock.enqueue({ data: null }) + mock.enqueue({ data: null }) + mockBokioGetCompany.mockResolvedValueOnce({ + name: 'Testbolaget AB', + organizationNumber: '5560125790', + }) + + await submitProviderToken( + 'consent-1', + 'bokio', + ' Bearer copied-token==\r\n', + ' bokio-guid ', + 'company-A', + ) + + expect(mockBokioGetCompany).toHaveBeenCalledWith('copied-token==', 'bokio-guid') + expect(mock.findCall('provider_consent_tokens', 'upsert')?.[0]).toMatchObject({ + access_token: 'copied-token==', + provider_company_id: 'bokio-guid', + }) + }) + + it('does NOT map another Bokio 4xx to invalid credentials', async () => { + mock.enqueue({ data: [{ id: 'consent-1' }] }) + mockBokioGetCompany.mockRejectedValueOnce(new BokioApiError('Bokio API error: 400', 400)) + + const err: unknown = await submitProviderToken( + 'consent-1', + 'bokio', + 'tok', + 'bokio-guid', + 'company-A', + ).catch((e: unknown) => e) + + expect(err).toBeInstanceOf(BokioApiError) + expect(err).not.toBeInstanceOf(ProviderTokenInvalidError) }) it('does NOT map a transient 503 from the Bokio probe to invalid credentials', async () => { diff --git a/extensions/general/arcim-migration/lib/provider-client.ts b/extensions/general/arcim-migration/lib/provider-client.ts index d029f121..677c60e5 100644 --- a/extensions/general/arcim-migration/lib/provider-client.ts +++ b/extensions/general/arcim-migration/lib/provider-client.ts @@ -17,7 +17,11 @@ import { refreshBjornLundenToken } from '@/lib/providers/bjornlunden/oauth' import { BjornLundenClient, BjornLundenApiError } from '@/lib/providers/bjornlunden/client' import { exchangeBrioxCode } from '@/lib/providers/briox/oauth' import { BrioxApiError } from '@/lib/providers/briox/client' -import { BokioClient, BokioApiError } from '@/lib/providers/bokio/client' +import { + BokioClient, + BokioApiError, + normalizeBokioAccessToken, +} from '@/lib/providers/bokio/client' import { WintClient, WintApiError } from '@/lib/providers/wint/client' import { loginWint, WintLoginRejectedError } from '@/lib/providers/wint/oauth' import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number' @@ -39,7 +43,10 @@ const wintClient = new WintClient() * the user to re-check what they pasted. */ export class ProviderTokenInvalidError extends Error { - constructor(message: string) { + constructor( + message: string, + public readonly kind: 'credentials' | 'company-not-found' = 'credentials', + ) { super(message) this.name = 'ProviderTokenInvalidError' } @@ -482,31 +489,41 @@ export async function submitProviderToken( // company GUID is typed in by hand. Nothing upstream ties either to the // Accounted company being imported into, so a token/GUID for the user's other // company imports that company's customers, suppliers and invoices here with - // no error at all. Probe /companies/{guid} before storing anything: it both - // proves the credentials work and returns the orgNumber to compare. + // no error at all. Probe the documented company-information endpoint before + // storing anything: it proves the credentials work and returns the + // organizationNumber to compare. if (provider === 'bokio') { - if (!providerCompanyId) { - throw new ProviderTokenInvalidError('Bokio requires a company id') + const bokioCompanyId = providerCompanyId?.trim() ?? '' + accessToken = normalizeBokioAccessToken(apiToken) + + if (!accessToken) { + throw new ProviderTokenInvalidError('Bokio requires an integration token') } + if (!bokioCompanyId) { + throw new ProviderTokenInvalidError( + 'Bokio requires a company id', + 'company-not-found', + ) + } + storedProviderCompanyId = bokioCompanyId let bokioCompany: Record | null try { bokioCompany = await bokioClient.getCompany>( accessToken, - providerCompanyId, + bokioCompanyId, ) } catch (error) { if (error instanceof BokioApiError) { - // 429/5xx are transient provider failures, not a verdict on the token: - // rethrow so the route reports a generic submit failure rather than - // telling the user their credentials are wrong. 401/403/404 mean the - // token or the GUID genuinely does not open this company. - if (error.statusCode === 429 || error.statusCode >= 500) { - throw error + // Only 401/403 are authentication verdicts. A 404 from the documented + // company-information endpoint means the company id is unknown or is + // not available to this company-scoped token. Other statuses can be a + // provider/API failure and must not be blamed on the pasted token. + if (error.statusCode === 401 || error.statusCode === 403) { + throw new ProviderTokenInvalidError( + `Bokio rejected the integration token (HTTP ${error.statusCode})`, + ) } - throw new ProviderTokenInvalidError( - `Bokio rejected the credentials (HTTP ${error.statusCode})`, - ) } throw error } @@ -514,13 +531,18 @@ export async function submitProviderToken( // getCompany() maps 404 to null: an unknown GUID is a bad company id, not // an outage. if (!bokioCompany) { - throw new ProviderTokenInvalidError('Bokio does not know that company id') + throw new ProviderTokenInvalidError( + 'Bokio does not know that company id', + 'company-not-found', + ) } const bokioName = typeof bokioCompany['name'] === 'string' ? (bokioCompany['name'] as string).trim() : '' - const bokioOrgNumber = normalizeOrgNumber(bokioCompany['orgNumber'] as string | undefined) + const bokioOrgNumber = normalizeOrgNumber( + bokioCompany['organizationNumber'] as string | undefined, + ) const { data: targetCompany } = await supabase .from('companies') diff --git a/lib/errors/__tests__/structured-errors.test.ts b/lib/errors/__tests__/structured-errors.test.ts index b22c587f..d40ae54e 100644 --- a/lib/errors/__tests__/structured-errors.test.ts +++ b/lib/errors/__tests__/structured-errors.test.ts @@ -44,6 +44,7 @@ describe('structured-errors registry', () => { expect(codes.length).toBeGreaterThan(20) expect(codes).toContain('JOURNAL_ENTRY_NOT_BALANCED') expect(codes).toContain('PROVIDER_AUTH_EXPIRED') + expect(codes).toContain('BOKIO_COMPANY_NOT_FOUND') expect(codes).toContain('CANNOT_EDIT_NON_DRAFT') expect(codes).toContain('MANDATORY_DIMENSION_MISSING') // Node network system codes registered as retryable transients (#337). diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 11b0b6ee..174ff90f 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -1916,8 +1916,8 @@ const PROVIDER_MIGRATION: Record = { }, PROVIDER_TOKEN_SUBMIT_FAILED: { httpStatus: 500, - message_sv: 'Tokensubmissionen misslyckades.', - message_en: 'Failed to submit provider token.', + message_sv: 'Kunde inte kontrollera integrationsuppgifterna hos leverantören. Försök igen.', + message_en: 'Could not verify the integration details with the provider. Try again.', }, PROVIDER_TOKEN_INVALID: { // 422 (not 401): the UPSTREAM provider rejected the pasted credentials. @@ -1926,9 +1926,16 @@ const PROVIDER_MIGRATION: Record = { // error code, never on the HTTP status. httpStatus: 422, message_sv: - 'Leverantören avvisade uppgifterna. Kontrollera att konto-ID och applikationstoken stämmer och försök igen.', + 'Leverantören avvisade autentiseringen. Kontrollera integrationsuppgifterna och försök igen.', message_en: - 'The provider rejected the credentials. Check that the account ID and application token are correct and try again.', + 'The provider rejected the authentication. Check the integration details and try again.', + }, + BOKIO_COMPANY_NOT_FOUND: { + httpStatus: 422, + message_sv: + 'Bokio hittade inte företaget. Kontrollera företags-ID:t och att integrationstoken skapades för samma företag.', + message_en: + 'Bokio could not find the company. Check the company ID and that the integration token was created for the same company.', }, PROVIDER_COMPANY_MISMATCH: { // 422, same reasoning as PROVIDER_TOKEN_INVALID: the credentials are valid, diff --git a/lib/providers/bokio/__tests__/client.test.ts b/lib/providers/bokio/__tests__/client.test.ts new file mode 100644 index 00000000..26b73a5b --- /dev/null +++ b/lib/providers/bokio/__tests__/client.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + BokioApiError, + BokioClient, + BokioResponseError, + normalizeBokioAccessToken, +} from '../client'; +import { BOKIO_BASE_URL } from '../config'; + +const COMPANY_ID = '9b408943-7a1e-47ac-85a7-ac52b2c210d3'; + +describe('BokioClient', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.stubGlobal('fetch', vi.fn()); + }); + + it('targets the official Bokio API v1 base URL', () => { + expect(BOKIO_BASE_URL).toBe('https://api.bokio.se/v1'); + }); + + it('uses the documented v1 company-information path and unwraps its response', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + Response.json({ + companyInformation: { + id: COMPANY_ID, + name: 'Testbolaget AB', + organizationNumber: '556677-8899', + }, + }), + ); + + const result = await new BokioClient().getCompany>( + 'integration-token', + COMPANY_ID, + ); + + expect(result).toMatchObject({ + id: COMPANY_ID, + organizationNumber: '556677-8899', + }); + expect(fetch).toHaveBeenCalledWith( + `${BOKIO_BASE_URL}/companies/${COMPANY_ID}/company-information`, + expect.objectContaining({ + headers: { + Accept: 'application/json', + Authorization: 'Bearer integration-token', + }, + }), + ); + }); + + it('normalizes a pasted Bearer header and surrounding whitespace once', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + Response.json({ companyInformation: { id: COMPANY_ID } }), + ); + + await new BokioClient().getCompany(' bEaReR copied-token==\r\n', ` ${COMPANY_ID} `); + + const [, init] = vi.mocked(fetch).mock.calls[0]!; + expect((init?.headers as Record).Authorization).toBe( + 'Bearer copied-token==', + ); + }); + + it('returns null for a company-information 404', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response('', { status: 404, statusText: 'Not Found' }), + ); + + await expect( + new BokioClient().getCompany('integration-token', COMPANY_ID), + ).resolves.toBeNull(); + }); + + it.each([400, 401, 403])( + 'preserves a company-information HTTP %i as a Bokio API error', + async (statusCode) => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response('', { status: statusCode, statusText: 'Request failed' }), + ); + + const error = await new BokioClient() + .getCompany('integration-token', COMPANY_ID) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(BokioApiError); + expect((error as BokioApiError).statusCode).toBe(statusCode); + }, + ); + + it('keeps an invalid response envelope distinct from a company 404', async () => { + vi.mocked(fetch).mockResolvedValueOnce(Response.json({ name: 'Unexpected shape' })); + + await expect( + new BokioClient().getCompany('integration-token', COMPANY_ID), + ).rejects.toBeInstanceOf(BokioResponseError); + }); +}); + +describe('normalizeBokioAccessToken', () => { + it.each([ + [' raw-token ', 'raw-token'], + ['Bearer copied-token', 'copied-token'], + [' bearer\tsecondary-token\n', 'secondary-token'], + ])('normalizes %j', (input, expected) => { + expect(normalizeBokioAccessToken(input)).toBe(expected); + }); + + it('does not remove internal token characters', () => { + expect(normalizeBokioAccessToken('token with spaces')).toBe('token with spaces'); + }); +}); diff --git a/lib/providers/bokio/__tests__/mapper.test.ts b/lib/providers/bokio/__tests__/mapper.test.ts new file mode 100644 index 00000000..60ded80b --- /dev/null +++ b/lib/providers/bokio/__tests__/mapper.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { mapBokioToCompanyInformation } from '../mapper'; + +describe('mapBokioToCompanyInformation', () => { + it('maps the documented company-information v1 fields', () => { + const result = mapBokioToCompanyInformation({ + id: '9b408943-7a1e-47ac-85a7-ac52b2c210d3', + name: 'Testbolaget AB', + organizationNumber: '556677-8899', + companyType: 'limitedCompany', + address: { + line1: 'Testgatan 1', + city: 'Göteborg', + postalCode: '123 45', + country: 'SE', + }, + }); + + expect(result).toMatchObject({ + companyName: 'Testbolaget AB', + organizationNumber: '556677-8899', + legalEntity: { + registrationName: 'Testbolaget AB', + companyId: '556677-8899', + }, + address: { + streetName: 'Testgatan 1', + cityName: 'Göteborg', + postalZone: '123 45', + countryCode: 'SE', + }, + }); + }); +}); diff --git a/lib/providers/bokio/client.ts b/lib/providers/bokio/client.ts index 0a5231e8..93cda3fe 100644 --- a/lib/providers/bokio/client.ts +++ b/lib/providers/bokio/client.ts @@ -19,6 +19,26 @@ export class BokioApiError extends Error { } } +export class BokioResponseError extends Error { + constructor(message: string) { + super(message); + this.name = 'BokioResponseError'; + } +} + +/** + * Accept either the integration token itself or a copied Authorization value. + * Only surrounding whitespace and one explicit Bearer scheme are removed: + * internal token characters are left untouched. + */ +export function normalizeBokioAccessToken(accessToken: string): string { + return accessToken.trim().replace(/^Bearer\s+/i, '').trim(); +} + +function bokioAuthorizationHeader(accessToken: string): string { + return `Bearer ${normalizeBokioAccessToken(accessToken)}`; +} + function isRetryableError(error: unknown): boolean { if (isTimeoutError(error)) return true; if (error instanceof BokioApiError) { @@ -54,7 +74,7 @@ export class BokioClient { const url = `${this.baseUrl}${path}`; const response = await fetch(url, { headers: { - Authorization: `Bearer ${accessToken}`, + Authorization: bokioAuthorizationHeader(accessToken), Accept: 'application/json', }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), @@ -188,7 +208,7 @@ export class BokioClient { await this.rateLimiter.acquire(); const url = `${this.baseUrl}/companies/${companyId}${relativePath}`; const response = await fetch(url, { - headers: { Authorization: `Bearer ${accessToken}` }, + headers: { Authorization: bokioAuthorizationHeader(accessToken) }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); @@ -219,7 +239,19 @@ export class BokioClient { companyId: string, ): Promise { try { - return await this.get(accessToken, `/companies/${companyId}`); + const normalizedCompanyId = companyId.trim(); + const response = await this.get<{ companyInformation?: T }>( + accessToken, + `/companies/${encodeURIComponent(normalizedCompanyId)}/company-information`, + ); + + if (response.companyInformation == null) { + throw new BokioResponseError( + 'Bokio company-information response is missing companyInformation', + ); + } + + return response.companyInformation; } catch (err) { if (err instanceof BokioApiError && err.statusCode === 404) { return null; diff --git a/lib/providers/bokio/config.ts b/lib/providers/bokio/config.ts index 24dee3ec..e6fb16ab 100644 --- a/lib/providers/bokio/config.ts +++ b/lib/providers/bokio/config.ts @@ -57,8 +57,8 @@ export const BOKIO_RESOURCE_CONFIGS: Partial): Supplie * Map Bokio Company to CompanyInformationDto. * * Bokio Company fields: - * - id, name, orgNumber, vatNumber, currency, country + * - id, name, organizationNumber, companyType * - address: { line1, line2, city, postalCode, country } */ export function mapBokioToCompanyInformation(raw: Record): CompanyInformationDto { @@ -317,10 +317,10 @@ export function mapBokioToCompanyInformation(raw: Record): Comp return { companyName: (raw['name'] as string) ?? '', - organizationNumber: raw['orgNumber'] as string | undefined, + organizationNumber: raw['organizationNumber'] as string | undefined, legalEntity: { registrationName: (raw['name'] as string) ?? '', - companyId: raw['orgNumber'] as string | undefined, + companyId: raw['organizationNumber'] as string | undefined, companyIdSchemeId: 'SE:ORGNR', }, address: address ? { diff --git a/messages/en.json b/messages/en.json index cc95db15..d7e5c32c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5276,6 +5276,11 @@ "ext_arcim_migration_name": "System migration", "ext_arcim_migration_description": "Migrate bookkeeping from Fortnox, Visma, Bokio, Björn Lundén or Briox", "ext_arcim_migration_long_description": "Move all bookkeeping data from your old system to accounted. Imports chart of accounts, vouchers, customers, suppliers and open invoices automatically via a secure API integration directly with the provider.", + "ext_arcim_bokio_token_description": "Enter the integration token and company ID from Bokio to let {appName} read your bookkeeping data.", + "ext_arcim_bokio_token_help": "Create or copy your integration token in Bokio under Settings → API Tokens. The company ID is the GUID in the browser address when you view the company overview. The token and company ID must come from the same Bokio company.", + "ext_arcim_bokio_token_label": "Integration token", + "ext_arcim_bokio_token_placeholder": "Paste your integration token", + "ext_arcim_bokio_company_id_label": "Company ID", "ext_arcim_documents_title": "Optional documents from Fortnox", "ext_arcim_documents_discovering": "Checking for supporting documents in Fortnox...", "ext_arcim_documents_prompt": "{count, plural, one {The SIE import is complete. We found # supporting document in Fortnox that you can optionally import and link to the voucher.} other {The SIE import is complete. We found # supporting documents in Fortnox that you can optionally import and link to the vouchers.}}", diff --git a/messages/sv.json b/messages/sv.json index 63914af6..ff83c5eb 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -5276,6 +5276,11 @@ "ext_arcim_migration_name": "Systemmigration", "ext_arcim_migration_description": "Migrera bokföring från Fortnox, Visma, Bokio, Björn Lundén eller Briox", "ext_arcim_migration_long_description": "Flytta all bokföringsdata från ditt gamla system till accounted. Importerar kontoplan, verifikationer, kunder, leverantörer och öppna fakturor automatiskt via säker API-integration direkt med leverantören.", + "ext_arcim_bokio_token_description": "Ange integrationstoken och företags-ID från Bokio för att ge {appName} tillgång att läsa din bokföringsdata.", + "ext_arcim_bokio_token_help": "Skapa eller kopiera din integrationstoken i Bokio under Inställningar → API Tokens. Företags-ID:t är det GUID som syns i webbadressen när du visar företagets översikt. Token och företags-ID måste komma från samma Bokio-företag.", + "ext_arcim_bokio_token_label": "Integrationstoken", + "ext_arcim_bokio_token_placeholder": "Klistra in din integrationstoken", + "ext_arcim_bokio_company_id_label": "Företags-ID", "ext_arcim_documents_title": "Valfria underlag från Fortnox", "ext_arcim_documents_discovering": "Kontrollerar om det finns underlag i Fortnox...", "ext_arcim_documents_prompt": "{count, plural, one {SIE-importen är klar. Vi hittade # underlag i Fortnox som du valfritt kan importera och koppla till verifikatet.} other {SIE-importen är klar. Vi hittade # underlag i Fortnox som du valfritt kan importera och koppla till verifikaten.}}",