fix(providers): correct Bokio v1 connection validation (#1681)
Fixes #1670
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ? {
|
||||
|
||||
@@ -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.}}",
|
||||
|
||||
@@ -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.}}",
|
||||
|
||||
Reference in New Issue
Block a user