fix(providers): name the real Björn Lundén connect failure (integration not activated, not bad credentials) (#2322)

* fix(providers): name the real Björn Lundén connect failure: integration not activated, not bad credentials

Every Björn Lundén connect in prod has failed with "Leverantören avvisade
autentiseringen" (10 consents since June; only BL's own sandbox company ever
received tokens). Live-verified against a real customer User-Key today: BL
answers 403 "<service>:READ is out of allowed scope for service provider
Arcim" on every read endpoint. The key is right and binds the company; the
company has simply never activated our integration, and it cannot until BL
moves the listing out of sandbox. The generic 403 mapping told the user to
re-check what they pasted, which can never help.

- BjornLundenClient: isBjornLundenScopeError / isBjornLundenUnknownKeyError,
  matching the verbatim live 403 and 500 bodies.
- submitProviderToken: 403-with-scope-body -> ProviderTokenInvalidError kind
  'integration-not-activated'; 500/404 -> 'company-key-not-found'; 401 (our
  own client_credentials token refused) rethrows as a generic submit failure
  instead of blaming the pasted key.
- New 422 structured errors BL_INTEGRATION_NOT_ACTIVATED and
  BL_COMPANY_KEY_NOT_FOUND with Swedish/English copy that names the fix
  (activate under Integrationer in Lundify, else SIE) and where the GUID is.
- Wizard copy for BL moved to i18n keys and reordered: activate first, then
  paste the key; the key only works once the integration is activated.
- Tests: route mapping for both kinds, probe classification incl. the
  captured live bodies, registry entries pinned to 422.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQG9jNyxM7mwMUHWBrFUxY

* fix(providers): drop the unknown-key body matcher, the live BL 500 body is not stable

Verifying through BjornLundenClient against apigateway.blinfo.se, a made-up
User-Key answered 500 with a Spring BeanCreationException for
databaseConnector, not the null getCurrentUser() message captured earlier.
The unknown-key verdict already keys on the status alone in
submitProviderToken; keep only the 403 scope matcher, whose body IS stable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQG9jNyxM7mwMUHWBrFUxY

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-05 17:20:08 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 41a5728ca7
commit 473b1fd2eb
12 changed files with 264 additions and 12 deletions
+1
View File
@@ -1604,3 +1604,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-05] Supplier and customer pages: the register fills the row's own contact fields (e-mail, phone, postal address, VAT number) when they are empty or still carry what the register said last time, marked "från SCB" by equality with the registry fact, never a value a person typed. Chosen over a read-only fallback because the row is what payment files and documents use; provenance by equality instead of a source column because it needs no schema and a person's edit ends it by itself. Företagsuppgifter keeps only what the register alone knows (status line, industry, seat, size). Agents get the party read-only first: ?expand=party on v1 supplier/customer detail, party_id on list rows, and gnubok_get_party in MCP; the parties resource (suggest, promote, enrich) comes as its own v1 surface next.
[2026-09-05] Invoice PDF statutory notices follow the document language: the export notice (stored in Swedish on invoices.reverse_charge_text at create time) is matched against the shared EXPORT_NOTICE_SV constant and rendered from LABELS, so existing invoices are fixed too, and the English footer reads "Approved for F-tax (Godkänd för F-skatt)". Rejected translating at create time (would leave every existing export invoice Swedish) and deriving purely from vat_treatment (would override custom or v1-supplied text). The old Swedish-only F-skatt footer cited Peppol SE-R-005, which governs the UBL file, not the PDF; the swedish-invoice-compliance skill (invoice-rules.md §4) states ML has no language requirement for invoice text, and the literal Swedish phrase stays on the English PDF in parentheses. All notice boxes (proforma, quote, VAT notice, notes) now share one neutral style; custom invoice layouts are a separate feature request.
[2026-09-05] Utlägg becomes an answer, not a page: the Underlag pane asks "Vem betalade?" (Företaget / Jag, privat / En anställd / Ingen ännu) and books a privately paid receipt in place through POST /api/expense-claims; the person owed surfaces as a Betala row in Att göra (lib/worklist expense_payout, one item per person) and the Utlägg nav row is gated on existing claims like Körjournal. Chosen over a fourth item in the Bokföring split button (that menu is three ways to type one verifikat, not a list of document kinds) and over keeping the two-step wizard as the entry point: a kvitto paid with a private card differs from any other purchase only in the credit account, and 93 percent of companies on prod are owner-only, for whom a module for that one bit is the wrong shape. Phase 2 (bank-driven repayment, open items shared with leverantörsfakturor, via lön) and phase 3 (retire the wizard, per-person list under Löner) are filed as follow-ups.
[2026-09-05] Björn Lundén connect: a 403 whose body says "out of allowed scope for service provider" is mapped to its own BL_INTEGRATION_NOT_ACTIVATED verdict (the key is right, the company never activated the integration) instead of the generic "leverantören avvisade autentiseringen"; live-verified against a real customer key, where every read endpoint answered exactly that while a made-up key answered 500. Root cause of every failed BL connect in prod (10 consents, only BL's own sandbox company ever got tokens): the integration is still a sandbox listing at BL, so no real company can activate it. Chose a message that names the fix (activate in Lundify, else SIE) over hiding the provider state; the Lundify activation-redirect flow and document/line-level fetching are filed as follow-ups rather than built blind before BL releases the integration.
@@ -728,7 +728,7 @@ function ConnectStep({
: '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.`
? t('ext_arcim_bl_token_description', { appName: branding.appName })
: isWintLogin
? `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'
@@ -740,7 +740,7 @@ function ConnectStep({
: `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
? `Företagsnyckeln (User-Key) är ett GUID som du hittar i Lundify under Integrationer → kugghjulet vid integrationen, eller i aktiveringsmejlet från Björn Lundén.`
? t('ext_arcim_bl_token_help')
: 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'
@@ -35,7 +35,11 @@ vi.mock('../lib/provider-client', () => {
class ProviderTokenInvalidError extends Error {
constructor(
message: string,
readonly kind: 'credentials' | 'company-not-found' = 'credentials',
readonly kind:
| 'credentials'
| 'company-not-found'
| 'integration-not-activated'
| 'company-key-not-found' = 'credentials',
) {
super(message)
}
@@ -287,3 +291,49 @@ describe('POST /submit-token Bokio error mapping', () => {
expect(body.error.message_en).toContain('verify the integration details')
})
})
describe('POST /submit-token Björn Lundén error mapping', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
it('reports a valid key whose company never activated the integration as an activation problem, not bad credentials', async () => {
;(submitProviderToken as Mock).mockRejectedValue(
new ProviderTokenInvalidError(
'Björn Lundén: the company behind this User-Key has not activated the integration',
'integration-not-activated',
),
)
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('BL_INTEGRATION_NOT_ACTIVATED')
expect(body.error.message).toContain('Aktivera integrationen')
expect(body.error.message).not.toContain('avvisade autentiseringen')
expect(body.error.message_en).toContain('Activate the integration')
})
it('reports an unknown User-Key as a key problem with the place to copy it from', async () => {
;(submitProviderToken as Mock).mockRejectedValue(
new ProviderTokenInvalidError(
'Björn Lundén found no company for the key (HTTP 500)',
'company-key-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('BL_COMPANY_KEY_NOT_FOUND')
expect(body.error.message).toContain('hittade inget företag')
expect(body.error.message_en).toContain('found no company')
})
})
@@ -580,6 +580,18 @@ export const arcimMigrationExtension: Extension = {
details: { provider, reason: error.message },
})
}
// BL: a valid key whose company never activated the integration
// is not a credentials problem; say what actually unblocks it.
if (error.kind === 'integration-not-activated') {
return errorResponseFromCode('BL_INTEGRATION_NOT_ACTIVATED', moduleLog, {
details: { provider, reason: error.message },
})
}
if (error.kind === 'company-key-not-found') {
return errorResponseFromCode('BL_COMPANY_KEY_NOT_FOUND', moduleLog, {
details: { provider, reason: error.message },
})
}
return errorResponseFromCode('PROVIDER_TOKEN_INVALID', moduleLog, {
details: { provider, reason: error.message },
})
@@ -353,6 +353,82 @@ describe('submitProviderToken', () => {
).rejects.toBeInstanceOf(ProviderTokenInvalidError)
})
it('maps a 403 "out of allowed scope" from the BL probe to integration-not-activated (live-verified body) and stores nothing', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] })
// Verbatim shape of BL's answer for a real customer key whose company
// never activated the integration (2026-09-05).
mockBlGet.mockRejectedValueOnce(
new BjornLundenApiError(
'Björn Lunden API error: 403 Forbidden',
403,
'{"headers":{},"body":{"status":"FORBIDDEN","message":"Calls to details:READ is out of allowed scope for service provider Arcim "},"statusCode":"FORBIDDEN","statusCodeValue":403}',
),
)
const err: unknown = await submitProviderToken(
'consent-1',
'bjornlunden',
'client_credentials',
'user-key-guid',
'company-A',
).catch((e: unknown) => e)
expect(err).toBeInstanceOf(ProviderTokenInvalidError)
expect((err as ProviderTokenInvalidError).kind).toBe('integration-not-activated')
expect(tablesTouched()).not.toContain('provider_consent_tokens')
})
it('keeps a 403 WITHOUT the scope wording as plain rejected credentials', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] })
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError('Björn Lunden API error: 403', 403, ''))
const err: unknown = await submitProviderToken(
'consent-1',
'bjornlunden',
'client_credentials',
'user-key-guid',
'company-A',
).catch((e: unknown) => e)
expect(err).toBeInstanceOf(ProviderTokenInvalidError)
expect((err as ProviderTokenInvalidError).kind).toBe('credentials')
})
it('reports 500 (unknown key) and 404 as company-key-not-found, not generic bad credentials', async () => {
for (const status of [500, 404]) {
mock.enqueue({ data: [{ id: 'consent-1' }] })
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError(`Björn Lunden API error: ${status}`, status))
const err: unknown = await submitProviderToken(
'consent-1',
'bjornlunden',
'client_credentials',
'user-key-guid',
'company-A',
).catch((e: unknown) => e)
expect(err).toBeInstanceOf(ProviderTokenInvalidError)
expect((err as ProviderTokenInvalidError).kind).toBe('company-key-not-found')
}
})
it('does NOT blame the pasted key for a 401 (that is our own client_credentials token being refused)', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] })
mockBlGet.mockRejectedValueOnce(new BjornLundenApiError('Björn Lunden API error: 401', 401))
const err: unknown = await submitProviderToken(
'consent-1',
'bjornlunden',
'client_credentials',
'user-key-guid',
'company-A',
).catch((e: unknown) => e)
expect(err).toBeInstanceOf(BjornLundenApiError)
expect(err).not.toBeInstanceOf(ProviderTokenInvalidError)
expect(tablesTouched()).not.toContain('provider_consent_tokens')
})
it('stores BL tokens (and labels the consent) when the probe succeeds', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
mock.enqueue({ data: null }) // consent company_name update
@@ -17,7 +17,11 @@ import {
import { exchangeFortnoxCode } from '@/lib/providers/fortnox/oauth'
import { buildVismaAuthUrl, exchangeVismaCode } from '@/lib/providers/visma/oauth'
import { refreshBjornLundenToken } from '@/lib/providers/bjornlunden/oauth'
import { BjornLundenClient, BjornLundenApiError } from '@/lib/providers/bjornlunden/client'
import {
BjornLundenClient,
BjornLundenApiError,
isBjornLundenScopeError,
} from '@/lib/providers/bjornlunden/client'
import { exchangeBrioxCode } from '@/lib/providers/briox/oauth'
import { BrioxApiError } from '@/lib/providers/briox/client'
import {
@@ -54,7 +58,14 @@ const wintClient = new WintClient()
export class ProviderTokenInvalidError extends Error {
constructor(
message: string,
public readonly kind: 'credentials' | 'company-not-found' = 'credentials',
public readonly kind:
| 'credentials'
| 'company-not-found'
// BL: the User-Key opened a company that has not activated our
// integration (no scopes granted to the service provider).
| 'integration-not-activated'
// BL: no company could be bound to the User-Key at all.
| 'company-key-not-found' = 'credentials',
) {
super(message)
this.name = 'ProviderTokenInvalidError'
@@ -613,15 +624,33 @@ export async function submitProviderToken(
}
} catch (error) {
if (error instanceof BjornLundenApiError) {
// 429 and gateway-style 5xx (502/503/504) are transient provider
// failures, not a verdict on the key: rethrow so the route reports a
// generic submit failure instead of "your key is wrong". 500 stays
// mapped to invalid credentials: per the sandbox finding above, 500
// IS the bad-key signal at BL. Tradeoff: a genuine BL 500 outage also
// reads as a rejected key.
if (error.statusCode === 429 || error.statusCode >= 501) {
// Live-verified 2026-09-05 against a real customer key: a company that
// has NOT activated our integration answers 403 "<service>:READ is out
// of allowed scope for service provider <name>". The key is right and
// the grant is missing, so this must not read as "check what you
// pasted": the fix is activating the integration in Lundify.
if (isBjornLundenScopeError(error)) {
throw new ProviderTokenInvalidError(
'Björn Lundén: the company behind this User-Key has not activated the integration (no scopes granted to the service provider)',
'integration-not-activated',
)
}
// 401 is OUR client_credentials token being refused, never the
// customer's key. 429 and gateway-style 5xx (502/503/504) are
// transient. All three rethrow so the route reports a generic submit
// failure instead of blaming the pasted key.
if (error.statusCode === 401 || error.statusCode === 429 || error.statusCode >= 501) {
throw error
}
// Per the sandbox finding above, 500 IS the unknown-key signal at BL
// (404 is the same verdict from the gateway). Tradeoff: a genuine BL
// 500 outage also reads as an unknown key.
if (error.statusCode === 500 || error.statusCode === 404) {
throw new ProviderTokenInvalidError(
`Björn Lundén found no company for the key (HTTP ${error.statusCode})`,
'company-key-not-found',
)
}
throw new ProviderTokenInvalidError(
`Björn Lundén rejected the company key (HTTP ${error.statusCode})`,
)
@@ -39,6 +39,18 @@ describe('structured-errors registry', () => {
}
})
it('has 422 entries for the Björn Lundén connect verdicts (valid key, missing activation; unknown key)', () => {
for (const code of ['BL_INTEGRATION_NOT_ACTIVATED', 'BL_COMPANY_KEY_NOT_FOUND']) {
const entry = getErrorEntry(code)
expect(entry, `missing entry for ${code}`).toBeDefined()
// 422, never 401: the caller's own session is fine and a 401 can trip
// client-side auth interceptors into logging the user out.
expect(entry?.httpStatus).toBe(422)
expect(entry?.message_sv).toBeTruthy()
expect(entry?.message_en).toBeTruthy()
}
})
it('has an entry for every code the link-transaction service can emit', () => {
for (const code of [
'LINK_TX_JE_NOT_FOUND',
+21
View File
@@ -2492,6 +2492,27 @@ const PROVIDER_MIGRATION: Record<string, StructuredErrorEntry> = {
message_en:
'Bokio could not find the company. Check the company ID and that the integration token was created for the same company.',
},
BL_INTEGRATION_NOT_ACTIVATED: {
// 422, same reasoning as PROVIDER_TOKEN_INVALID. The User-Key opened a
// real company, but that company has granted our service provider no
// scopes (BL: "out of allowed scope for service provider"). Nothing the
// user re-pastes can fix this: the integration must be activated on the
// BL side, and until BL has released it for the company it cannot be.
httpStatus: 422,
message_sv:
'Företagsnyckeln stämmer, men företaget har inte aktiverat Accounted som integration i Björn Lundén. Aktivera integrationen under Integrationer i Lundify eller BL Administration och försök igen. Saknas Accounted i listan är integrationen inte släppt för ditt företag ännu: importera via SIE-fil så länge.',
message_en:
'The company key is valid, but the company has not activated Accounted as an integration in Björn Lundén. Activate the integration under Integrations in Lundify or BL Administration and try again. If Accounted is missing from the list, the integration has not been released for your company yet: import via a SIE file for now.',
},
BL_COMPANY_KEY_NOT_FOUND: {
// 422: BL could not bind any company to the pasted User-Key (typo,
// truncated GUID, key from a different BL environment).
httpStatus: 422,
message_sv:
'Björn Lundén hittade inget företag för den här företagsnyckeln. Kontrollera att hela nyckeln (GUID) är kopierad från Integrationer → kugghjulet i Lundify och försök igen.',
message_en:
'Björn Lundén found no company for this company key. Check that the whole key (GUID) was copied from Integrations → the gear icon in Lundify and try again.',
},
PROVIDER_COMPANY_MISMATCH: {
// 422, same reasoning as PROVIDER_TOKEN_INVALID: the credentials are valid,
// but they open a DIFFERENT legal entity than the one being imported into.
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { BjornLundenApiError, isBjornLundenScopeError } from '@/lib/providers/bjornlunden/client'
// Verbatim body captured from apigateway.blinfo.se on 2026-09-05 for a real
// customer User-Key whose company never activated the integration. The helper
// must keep matching this exact shape.
const SCOPE_BODY =
'{"headers":{},"body":{"status":"FORBIDDEN","timestamp":"2026-09-05 03:48:38","message":"Calls to details:READ is out of allowed scope for service provider Arcim ","debugMessage":"Calls to details:READ is out of allowed scope for service provider Arcim ","causeChain":[{"name":"ChainBreakingAuthException","message":"Calls to details:READ is out of allowed scope for service provider Arcim "}]},"statusCode":"FORBIDDEN","statusCodeValue":403}'
describe('isBjornLundenScopeError', () => {
it('recognises the live 403 "out of allowed scope" body', () => {
const err = new BjornLundenApiError('Björn Lunden API error: 403 Forbidden', 403, SCOPE_BODY)
expect(isBjornLundenScopeError(err)).toBe(true)
})
it('is false for a 403 without the scope wording, for other statuses, and for foreign errors', () => {
expect(isBjornLundenScopeError(new BjornLundenApiError('403', 403, '{"message":"Forbidden"}'))).toBe(false)
expect(isBjornLundenScopeError(new BjornLundenApiError('403', 403))).toBe(false)
expect(isBjornLundenScopeError(new BjornLundenApiError('500', 500, SCOPE_BODY))).toBe(false)
expect(isBjornLundenScopeError(new Error('out of allowed scope'))).toBe(false)
})
})
+25
View File
@@ -19,6 +19,31 @@ export class BjornLundenApiError extends Error {
}
}
/**
* True when BL answered 403 because the company behind the User-Key has not
* activated our integration: the service provider holds no scopes for that
* company. Live-verified 2026-09-05 against a real customer key:
*
* {"body":{"status":"FORBIDDEN","message":"Calls to details:READ is out of
* allowed scope for service provider Arcim "}, "statusCodeValue":403}
*
* The key itself is right, so this must never be reported as "check what
* you pasted": the fix is on the BL side (activate the integration).
*
* An UNKNOWN key is a different signal: BL fails to bind the company database
* and answers 500. That body is not stable (observed both a null
* ServiceInfo.getCurrentUser() message and a Spring BeanCreationException for
* databaseConnector), so callers key the unknown-key verdict on the status
* alone; only the 403 case has a body worth matching.
*/
export function isBjornLundenScopeError(error: unknown): boolean {
return (
error instanceof BjornLundenApiError &&
error.statusCode === 403 &&
/out of allowed scope/i.test(error.body ?? '')
)
}
function isRetryableError(error: unknown): boolean {
if (isTimeoutError(error)) return true;
if (error instanceof BjornLundenApiError) {
+2
View File
@@ -5656,6 +5656,8 @@
"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_bl_token_description": "First activate {appName} under Integrations in Lundify or BL Administration, then enter the company key (User-Key). {appName} connects through Björn Lundén's integration-partner access, so no API key of your own is needed.",
"ext_arcim_bl_token_help": "The company key (User-Key) is a GUID shown at the gear icon for the integration under Integrations in Lundify, or in the activation e-mail from Björn Lundén. The key only works once the integration is activated for the 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",
+2
View File
@@ -5656,6 +5656,8 @@
"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_bl_token_description": "Aktivera först {appName} under Integrationer i Lundify eller BL Administration, och ange sedan företagets nyckel (User-Key). {appName} ansluter via Björn Lundéns integrationspartner-åtkomst, så ingen egen API-nyckel behövs.",
"ext_arcim_bl_token_help": "Företagsnyckeln (User-Key) är ett GUID som visas vid kugghjulet för integrationen under Integrationer i Lundify, eller i aktiveringsmejlet från Björn Lundén. Nyckeln fungerar bara när integrationen är aktiverad för företaget.",
"ext_arcim_bokio_token_label": "Integrationstoken",
"ext_arcim_bokio_token_placeholder": "Klistra in din integrationstoken",
"ext_arcim_bokio_company_id_label": "Företags-ID",