fix(providers): correct Bokio v1 connection validation (#1681)

Fixes #1670
This commit is contained in:
Mattsson
2026-08-18 23:00:44 +02:00
committed by GitHub
parent 9d59e509ab
commit 3ec76d39db
15 changed files with 459 additions and 67 deletions
+2
View File
@@ -2,6 +2,8 @@
One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
@@ -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 && (
<div className={cn(isWintLogin && 'order-2')}>
<label htmlFor="apiToken" className="text-sm font-medium">
{provider === 'briox' ? 'Applikationstoken' : isWintLogin ? 'Lösenord' : 'API-nyckel'}
{provider === 'briox'
? 'Applikationstoken'
: isWintLogin
? 'Lösenord'
: provider === 'bokio'
? t('ext_arcim_bokio_token_label')
: 'API-nyckel'}
</label>
<Input
id="apiToken"
@@ -800,6 +813,8 @@ function ConnectStep({
? 'Klistra in din applikationstoken'
: isWintLogin
? 'Ditt lösenord hos WINT'
: provider === 'bokio'
? t('ext_arcim_bokio_token_placeholder')
: 'Klistra in din API-nyckel'
}
value={apiToken}
@@ -2365,13 +2380,13 @@ export default function ArcimMigrationWorkspace({
if (!res.ok) {
const data = await res.json().catch(() => ({}))
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)
}
@@ -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<Response>
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')
})
})
+7 -2
View File
@@ -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 },
})
@@ -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 () => {
@@ -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<string, unknown> | null
try {
bokioCompany = await bokioClient.getCompany<Record<string, unknown>>(
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')
@@ -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).
+11 -4
View File
@@ -1916,8 +1916,8 @@ const PROVIDER_MIGRATION: Record<string, StructuredErrorEntry> = {
},
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<string, StructuredErrorEntry> = {
// 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,
@@ -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<Record<string, unknown>>(
'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<string, string>).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');
});
});
@@ -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',
},
});
});
});
+35 -3
View File
@@ -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<T | null> {
try {
return await this.get<T>(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;
+2 -2
View File
@@ -57,8 +57,8 @@ export const BOKIO_RESOURCE_CONFIGS: Partial<Record<ResourceType, BokioResourceC
paginated: false,
},
[ResourceType.CompanyInformation]: {
listEndpoint: '',
detailEndpoint: '',
listEndpoint: '/company-information',
detailEndpoint: '/company-information',
idField: 'id',
mapper: mapBokioToCompanyInformation,
singleton: true,
+3 -3
View File
@@ -309,7 +309,7 @@ export function mapBokioToSupplierInvoice(raw: Record<string, unknown>): 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<string, unknown>): CompanyInformationDto {
@@ -317,10 +317,10 @@ export function mapBokioToCompanyInformation(raw: Record<string, unknown>): 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 ? {
+5
View File
@@ -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.}}",
+5
View File
@@ -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.}}",