From 9a7db6bbdf54a620c2665bddad0da3b2cc895d69 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 20 Aug 2026 11:12:39 +0200 Subject: [PATCH] fix(providers): accept Bokio's flat company-information body (live API differs from spec) (#1735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(providers): accept Bokio's flat company-information body Bokio's published v1 spec wraps GET /companies/{id}/company-information in `{ companyInformation }`, but the live api.bokio.se/v1 returns the company object flat (`{ id, name, organizationNumber, companyType, address, ... }`). #1681 moved the connection probe to the spec shape, so a valid integration token now gets a 200 from Bokio and then fails here with BokioResponseError, surfaced to the user as "Kunde inte kontrollera integrationsuppgifterna hos leverantören. Försök igen." (seen in prod on 2026-08-20). getCompany now unwraps the envelope when present and otherwise accepts the flat company object; only a body that is neither (empty object, array, null envelope, paged list) still raises BokioResponseError. Tests cover both shapes and the rejected ones. Refs #1670, follow-up to #1681. Co-Authored-By: Claude Fable 5 * fix(providers): reject malformed Bokio companyInformation envelopes When the documented envelope key is present, the company must be inside it and carry an identifying field; `{ companyInformation: {} }` or an envelope without id/name/organizationNumber now raises BokioResponseError instead of passing through, and outer fields are never used as a fallback in that case. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + lib/providers/bokio/__tests__/client.test.ts | 78 +++++++++++++++++++- lib/providers/bokio/client.ts | 52 ++++++++++++- 3 files changed, 125 insertions(+), 6 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 544730b0..15740397 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1104,3 +1104,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-19] New-customer payment terms fall back to company_settings.invoice_default_days (then 30) in every create path (UI, internal API, v1, bulk, MCP staged op) via lib/customers/resolveDefaultPaymentTerms, rather than adding a separate customer-terms setting: one setting, one meaning, and the invoice flow already reads the same column. [2026-08-19] The CI build OOM is the TYPE-CHECK pass, not bundle growth: measured with tsc --extendedDiagnostics the repo needs ~4.19 GB at 506d030b and ~4.19 GB on a branch on top of it, i.e. a steady-state ceiling against Node 20 default old-space (~4 GB), not any one PR's regression. Fixed on main independently by raising the build heap to 8192, which this branch keeps; recording the measurement so the next person does not go hunting in a diff. Vercel builds already run with a larger heap and were never affected. +[2026-08-20] Bokio getCompany accepts both the spec envelope and the live flat body: the published v1 spec (bokio/bokio-api company-api.yaml) wraps company-information in `companyInformation`, but api.bokio.se/v1 returned the company object flat on a 200 in prod (BokioResponseError in logs, customer script showed the same). Tolerating both instead of picking one means a spec/live drift in either direction can no longer turn a valid integration token into a connection failure. diff --git a/lib/providers/bokio/__tests__/client.test.ts b/lib/providers/bokio/__tests__/client.test.ts index 26b73a5b..eab04c29 100644 --- a/lib/providers/bokio/__tests__/client.test.ts +++ b/lib/providers/bokio/__tests__/client.test.ts @@ -4,6 +4,7 @@ import { BokioClient, BokioResponseError, normalizeBokioAccessToken, + unwrapBokioCompanyInformation, } from '../client'; import { BOKIO_BASE_URL } from '../config'; @@ -89,8 +90,42 @@ describe('BokioClient', () => { }, ); - it('keeps an invalid response envelope distinct from a company 404', async () => { - vi.mocked(fetch).mockResolvedValueOnce(Response.json({ name: 'Unexpected shape' })); + it('accepts the flat company object the live v1 API returns (no envelope)', async () => { + // Observed on api.bokio.se/v1 in production 2026-08-20: a 200 whose body + // is the company itself, not `{ companyInformation }` as the spec says. + vi.mocked(fetch).mockResolvedValueOnce( + Response.json({ + id: COMPANY_ID, + name: 'Testbolaget AB', + companyType: 'limitedCompany', + organizationNumber: '5566778899', + email: 'ekonomi@example.se', + hasBBA: false, + address: { line1: 'Testgatan 1', city: 'STOCKHOLM', postalCode: '111 23', country: 'SE' }, + }), + ); + + const result = await new BokioClient().getCompany>( + 'integration-token', + COMPANY_ID, + ); + + expect(result).toMatchObject({ + id: COMPANY_ID, + name: 'Testbolaget AB', + organizationNumber: '5566778899', + }); + }); + + it.each([ + ['empty object', {}], + ['null envelope', { companyInformation: null }], + ['empty envelope', { companyInformation: {} }], + ['envelope without identifying fields', { companyInformation: { foo: 'bar' } }], + ['array body', []], + ['paged list body', { items: [], totalItems: 0, totalPages: 0, currentPage: 1 }], + ])('keeps an unusable response body (%s) distinct from a company 404', async (_label, body) => { + vi.mocked(fetch).mockResolvedValueOnce(Response.json(body)); await expect( new BokioClient().getCompany('integration-token', COMPANY_ID), @@ -98,6 +133,45 @@ describe('BokioClient', () => { }); }); +describe('unwrapBokioCompanyInformation', () => { + it('prefers the documented envelope when present', () => { + expect( + unwrapBokioCompanyInformation({ companyInformation: { id: COMPANY_ID }, id: 'outer' }), + ).toEqual({ id: COMPANY_ID }); + }); + + it('falls back to a flat company object', () => { + expect(unwrapBokioCompanyInformation({ id: COMPANY_ID, name: 'Testbolaget AB' })).toEqual({ + id: COMPANY_ID, + name: 'Testbolaget AB', + }); + }); + + it('does not fall back to outer fields when the envelope is malformed', () => { + expect( + unwrapBokioCompanyInformation({ companyInformation: { foo: 'bar' }, id: COMPANY_ID }), + ).toBeNull(); + expect( + unwrapBokioCompanyInformation({ companyInformation: {}, name: 'Outer AB' }), + ).toBeNull(); + }); + + it.each([ + null, + 'text', + 42, + [], + {}, + { companyInformation: 'nope' }, + { companyInformation: {} }, + { companyInformation: { foo: 'bar' } }, + { companyInformation: [{ id: COMPANY_ID }] }, + { foo: 'bar' }, + ])('returns null for %j', (body) => { + expect(unwrapBokioCompanyInformation(body)).toBeNull(); + }); +}); + describe('normalizeBokioAccessToken', () => { it.each([ [' raw-token ', 'raw-token'], diff --git a/lib/providers/bokio/client.ts b/lib/providers/bokio/client.ts index 93cda3fe..5f40c3c7 100644 --- a/lib/providers/bokio/client.ts +++ b/lib/providers/bokio/client.ts @@ -39,6 +39,40 @@ function bokioAuthorizationHeader(accessToken: string): string { return `Bearer ${normalizeBokioAccessToken(accessToken)}`; } +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function looksLikeBokioCompany(value: unknown): value is Record { + return ( + isPlainObject(value) && + (typeof value['id'] === 'string' || + typeof value['name'] === 'string' || + typeof value['organizationNumber'] === 'string') + ); +} + +/** + * Extract the company object from a company-information response body. + * If the documented `companyInformation` envelope key is present, the company + * must be inside it (a malformed envelope is rejected, never bypassed via + * outer fields); otherwise the flat object the live API returns is accepted. + * Either way the object must carry an identifying field, so `{}` or an + * unrelated JSON object is rejected. Returns null when no company is found. + */ +export function unwrapBokioCompanyInformation( + body: unknown, +): Record | null { + if (!isPlainObject(body)) return null; + + if ('companyInformation' in body) { + const wrapped = body['companyInformation']; + return looksLikeBokioCompany(wrapped) ? wrapped : null; + } + + return looksLikeBokioCompany(body) ? body : null; +} + function isRetryableError(error: unknown): boolean { if (isTimeoutError(error)) return true; if (error instanceof BokioApiError) { @@ -234,24 +268,34 @@ export class BokioClient { ); } + /** + * Probe one company via the documented v1 company-information endpoint. + * + * Bokio's published v1 spec wraps the body as `{ companyInformation: {...} }`, + * but the live api.bokio.se/v1 returns the company object flat + * (`{ id, name, organizationNumber, companyType, address, ... }`). Both are + * accepted: a valid token must never be reported as a failure because the + * spec and the deployed API disagree on the envelope. + */ async getCompany( accessToken: string, companyId: string, ): Promise { try { const normalizedCompanyId = companyId.trim(); - const response = await this.get<{ companyInformation?: T }>( + const response = await this.get( accessToken, `/companies/${encodeURIComponent(normalizedCompanyId)}/company-information`, ); - if (response.companyInformation == null) { + const company = unwrapBokioCompanyInformation(response); + if (company == null) { throw new BokioResponseError( - 'Bokio company-information response is missing companyInformation', + 'Bokio company-information response contains no company object', ); } - return response.companyInformation; + return company as T; } catch (err) { if (err instanceof BokioApiError && err.statusCode === 404) { return null;