fix(providers): accept Bokio's flat company-information body (live API differs from spec) (#1735)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-20 11:12:39 +02:00
committed by GitHub
parent e6c4fe2cf8
commit 9a7db6bbdf
3 changed files with 125 additions and 6 deletions
+1
View File
@@ -1104,3 +1104,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+76 -2
View File
@@ -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<Record<string, unknown>>(
'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'],
+48 -4
View File
@@ -39,6 +39,40 @@ function bokioAuthorizationHeader(accessToken: string): string {
return `Bearer ${normalizeBokioAccessToken(accessToken)}`;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function looksLikeBokioCompany(value: unknown): value is Record<string, unknown> {
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<string, unknown> | 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<T>(
accessToken: string,
companyId: string,
): Promise<T | null> {
try {
const normalizedCompanyId = companyId.trim();
const response = await this.get<{ companyInformation?: T }>(
const response = await this.get<unknown>(
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;