diff --git a/apps/cohort/scripts/seed-demo-data.mjs b/apps/cohort/scripts/seed-demo-data.mjs
index cf3c0f05..00ead8cd 100644
--- a/apps/cohort/scripts/seed-demo-data.mjs
+++ b/apps/cohort/scripts/seed-demo-data.mjs
@@ -340,9 +340,15 @@ for (const c of data.calls ?? []) {
// Organisations: CORPORATE Customers of the space, with their kind, Arabic name and website as Customer Attributes.
const KINDS = ['bank_fi', 'university', 'regulator', 'partner'];
-const organisations = await obp('GET', `/obp/v4.0.0/banks/${space}/customers?${KINDS.map((k) => `organisation_kind=${k}`).join('&')}`, staff.token)
- .then((r) => r?.customers ?? [])
- .catch(() => []);
+// Every page: OBP-API pages the list, and a page can be short before the end.
+const organisations = [];
+for (let offset = 0; offset < 10000; offset += 200) {
+ const page = await obp('GET', `/obp/v7.0.0/banks/${space}/customers?organisation_kind=${KINDS.join(',')}&limit=200&offset=${offset}`, staff.token)
+ .then((r) => r?.customers ?? [])
+ .catch(() => []);
+ if (page.length === 0) break;
+ organisations.push(...page);
+}
for (const o of data.organisations ?? []) {
await step(`organisation ${o.name}`, async () => {
if (organisations.some((c) => c.legal_name === o.name)) return count('organisations', false);
diff --git a/apps/cohort/src/lib/organisations.test.ts b/apps/cohort/src/lib/organisations.test.ts
index f9eac938..305915d8 100644
--- a/apps/cohort/src/lib/organisations.test.ts
+++ b/apps/cohort/src/lib/organisations.test.ts
@@ -16,7 +16,7 @@
* along with this program. If not, see .
*/
import { describe, expect, it } from 'vitest';
-import { organisationOfCustomer, organisationsQuery } from './organisations';
+import { organisationOfCustomer, organisationsFromCustomers, organisationsQuery } from './organisations';
describe('organisations as Customers', () => {
it('reads an organisation from a Customer and its attributes', () => {
@@ -36,7 +36,28 @@ describe('organisations as Customers', () => {
expect(organisationOfCustomer({ customer_id: 'c-2', legal_name: 'X' }).kind).toBe('partner');
});
- it('lists every kind staff create, but not startups', () => {
- expect(organisationsQuery()).toBe('organisation_kind=bank_fi&organisation_kind=university&organisation_kind=regulator&organisation_kind=partner');
+ it('lists every Customer OBP-API returns for the query, because OBP-API filters by organisation_kind', () => {
+ // Tests that each Customer becomes one organisation, in OBP-API's order, with the kind from its attribute.
+ const listed = organisationsFromCustomers([
+ { customer_id: 'c-1', legal_name: 'Uni', customer_attributes: [{ name: 'organisation_kind', value: 'university' }] },
+ { customer_id: 'c-2', legal_name: 'Bank', customer_attributes: [{ name: 'organisation_kind', value: 'bank_fi' }] }
+ ]);
+ expect(listed.map((o) => [o.id, o.kind])).toEqual([
+ ['c-1', 'university'],
+ ['c-2', 'bank_fi']
+ ]);
+ });
+
+ it('does not filter the Customers a second time', () => {
+ // Tests that Cohort trusts OBP-API's answer: the query asks OBP-API for the kinds staff create, and a
+ // Customer without organisation_kind is still listed, not dropped by a second filter in Cohort.
+ const listed = organisationsFromCustomers([{ customer_id: 'c-3', legal_name: 'Other' }]);
+ expect(listed.map((o) => o.id)).toEqual(['c-3']);
+ });
+
+ it('lists every kind staff create, but not startups, as one parameter with comma-separated values', () => {
+ // Tests the query Cohort sends: one organisation_kind parameter with the four kinds, without startup.
+ // OBP-API refuses a query parameter name given twice (OBP-09016); v7.0.0 accepts several values separated by commas.
+ expect(organisationsQuery()).toBe('organisation_kind=bank_fi,university,regulator,partner');
});
});
diff --git a/apps/cohort/src/lib/organisations.ts b/apps/cohort/src/lib/organisations.ts
index 0f120557..0d3c1ace 100644
--- a/apps/cohort/src/lib/organisations.ts
+++ b/apps/cohort/src/lib/organisations.ts
@@ -50,5 +50,18 @@ export function organisationOfCustomer(c: { customer_id?: unknown; legal_name?:
};
}
-/** The query listing the organisations staff created: Customers whose organisation_kind is any creatable kind. */
-export const organisationsQuery = () => CREATABLE_KINDS.map((k) => `${ORGANISATION_ATTRIBUTES.kind}=${k}`).join('&');
+type CustomerWithAttributes = Parameters[0];
+
+/**
+ * The organisations OBP-API returns for organisationsQuery(). OBP-API filters the Customers by their
+ * organisation_kind attribute, so Cohort shows each of them as it is.
+ */
+export function organisationsFromCustomers(customers: CustomerWithAttributes[]) {
+ return customers.map(organisationOfCustomer);
+}
+
+/**
+ * The query listing the organisations staff created: Customers whose organisation_kind is any creatable kind.
+ * One parameter with comma-separated values, as GET /obp/v7.0.0/banks/BANK_ID/customers takes them.
+ */
+export const organisationsQuery = () => `${ORGANISATION_ATTRIBUTES.kind}=${CREATABLE_KINDS.map(encodeURIComponent).join(',')}`;
diff --git a/apps/cohort/src/lib/server/organisationCustomers.test.ts b/apps/cohort/src/lib/server/organisationCustomers.test.ts
new file mode 100644
index 00000000..10789d38
--- /dev/null
+++ b/apps/cohort/src/lib/server/organisationCustomers.test.ts
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2025-2026 TESOBE GmbH
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('./obp', () => ({ obpRequests: { get: vi.fn() } }));
+
+const { obpRequests } = await import('./obp');
+const { organisationCustomers } = await import('./organisationCustomers');
+const get = vi.mocked(obpRequests.get);
+
+/** A page of n Customers as OBP-API returns it, with ids starting at `from`. */
+const page = (n: number, from = 0) => ({
+ customers: Array.from({ length: n }, (_, i) => ({ customer_id: `c-${from + i}` }))
+});
+
+// organisationCustomers reads the organisations' Customers from OBP-API v7.0.0, page by page.
+describe('organisationCustomers', () => {
+ beforeEach(() => get.mockReset());
+
+ it('reads every page of the attribute-filtered Customers, not only the first 50', async () => {
+ // Tests that Cohort asks for 200 at a time and keeps reading: OBP-API returns 50 without a limit.
+ get
+ .mockResolvedValueOnce(page(200))
+ .mockResolvedValueOnce(page(3, 200))
+ .mockResolvedValueOnce(page(0));
+ const customers = await organisationCustomers(
+ 'cohort',
+ 'organisation_kind=bank_fi,university',
+ 'token-1'
+ );
+ // All three pages are read: 200 + 3 Customers.
+ expect(customers).toHaveLength(203);
+ // The first request: the attribute filter, limit=200 and offset=0, on v7.0.0.
+ expect(get).toHaveBeenNthCalledWith(
+ 1,
+ '/obp/v7.0.0/banks/cohort/customers?organisation_kind=bank_fi,university&limit=200&offset=0',
+ 'token-1'
+ );
+ // The next request moves on by the page size: offset=200.
+ expect(get).toHaveBeenNthCalledWith(
+ 2,
+ '/obp/v7.0.0/banks/cohort/customers?organisation_kind=bank_fi,university&limit=200&offset=200',
+ 'token-1'
+ );
+ });
+
+ it('does not stop at a page OBP-API returned short, only at an empty one', async () => {
+ // Tests the end of the list: only an empty page ends it. A page can be shorter than the limit before
+ // the end, when a matching attribute belongs to a Customer that no longer exists.
+ get
+ .mockResolvedValueOnce(page(199))
+ .mockResolvedValueOnce(page(50, 200))
+ .mockResolvedValueOnce(page(0));
+ expect(
+ await organisationCustomers('cohort', 'organisation_kind=partner', 'token-1')
+ ).toHaveLength(249);
+ // 199 is short, yet the next page is read; the empty third page ends the reading.
+ expect(get).toHaveBeenCalledTimes(3);
+ });
+
+ it('stops at an empty page', async () => {
+ // Tests a space without organisations: one request, an empty list, no further pages.
+ get.mockResolvedValueOnce(page(0));
+ expect(await organisationCustomers('cohort', 'organisation_kind=partner', 'token-1')).toEqual(
+ []
+ );
+ expect(get).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/cohort/src/lib/server/organisationCustomers.ts b/apps/cohort/src/lib/server/organisationCustomers.ts
new file mode 100644
index 00000000..3a93a2f5
--- /dev/null
+++ b/apps/cohort/src/lib/server/organisationCustomers.ts
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2025-2026 TESOBE GmbH
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import type { organisationOfCustomer } from '$lib/organisations';
+import { obpRequests } from '$lib/server/obp';
+
+type CustomerWithAttributes = Parameters[0];
+
+const PAGE_SIZE = 200;
+const MAX_PAGES = 50;
+
+/**
+ * Every Customer of the space matching the attribute query, from
+ * GET /obp/v7.0.0/banks/BANK_ID/customers. The endpoint pages its results (50 by default),
+ * so pages are read until one is empty: a page can be short before the end, when a matching
+ * attribute belongs to a Customer that no longer exists.
+ */
+export async function organisationCustomers(
+ space: string,
+ attributeQuery: string,
+ token: string
+): Promise {
+ const all: CustomerWithAttributes[] = [];
+ for (let page = 0; page < MAX_PAGES; page++) {
+ const response = await obpRequests.get(
+ `/obp/v7.0.0/banks/${space}/customers?${attributeQuery}&limit=${PAGE_SIZE}&offset=${page * PAGE_SIZE}`,
+ token
+ );
+ const customers: CustomerWithAttributes[] = response?.customers ?? [];
+ all.push(...customers);
+ if (customers.length === 0) break;
+ }
+ return all;
+}
diff --git a/apps/cohort/src/routes/(protected)/staff/organisations/+page.server.ts b/apps/cohort/src/routes/(protected)/staff/organisations/+page.server.ts
index 4ad8fde4..87ab67fe 100644
--- a/apps/cohort/src/routes/(protected)/staff/organisations/+page.server.ts
+++ b/apps/cohort/src/routes/(protected)/staff/organisations/+page.server.ts
@@ -16,10 +16,11 @@
* along with this program. If not, see .
*/
import { fail } from '@sveltejs/kit';
-import { isOrganisationKind, ORGANISATION_ATTRIBUTES, organisationOfCustomer, organisationsQuery, type OrganisationKind } from '$lib/organisations';
+import { isOrganisationKind, ORGANISATION_ATTRIBUTES, organisationsFromCustomers, organisationsQuery, type OrganisationKind } from '$lib/organisations';
import { PARTICIPANT } from '$lib/server/entities/definitions';
import { messageOf } from '$lib/server/errors';
import { obpRequests } from '$lib/server/obp';
+import { organisationCustomers } from '$lib/server/organisationCustomers';
import { idOf, listRecords, userToken } from '$lib/server/records';
import { cohortSpace } from '$lib/server/space';
import { staffAccess } from '$lib/server/staffAccess';
@@ -74,14 +75,11 @@ export const load: PageServerLoad = async ({ locals }) => {
const [customers, participants] = await Promise.all([
lacking.read
? Promise.resolve([])
- : obpRequests
- .get(`/obp/v4.0.0/banks/${space()}/customers?${organisationsQuery()}`, token)
- .then((r) => (r?.customers ?? []) as Parameters[0][])
- .catch(() => []),
+ : organisationCustomers(space(), organisationsQuery(), token).catch(() => []),
listRecords(PARTICIPANT, '', token).catch(() => [])
]);
const organisations: { id: string; name: string; name_ar: string; kind: OrganisationKind; customer_id: string; website: string }[] = [
- ...customers.map(organisationOfCustomer),
+ ...organisationsFromCustomers(customers),
// A startup's registered company is an organisation too, once; listed from its participation.
...[...new Map(participants.filter((p) => p.customer_id).map((p) => [String(p.customer_id), p])).values()].map((p) => ({
id: `participant:${idOf(PARTICIPANT, p)}`,
diff --git a/packages/shared/src/lib/server/obp/obpApiVersion.test.ts b/packages/shared/src/lib/server/obp/obpApiVersion.test.ts
index b368a807..9125eb58 100644
--- a/packages/shared/src/lib/server/obp/obpApiVersion.test.ts
+++ b/packages/shared/src/lib/server/obp/obpApiVersion.test.ts
@@ -16,13 +16,11 @@ const OLD_VERSION = /obp\/(v(?:[1-4])\.\d+\.\d+|v5\.0\.0)(?![\d.])/g;
/** A string literal that is only a version, as in `const API_VERSION = 'v4.0.0'`, used to build paths. */
const OLD_VERSION_LITERAL = /['"`](v(?:[1-4])\.\d+\.\d+|v5\.0\.0)['"`]/g;
-/** file (repo-relative) + path fragment -> why it is still below v5.1.0 */
-const NO_V510_EQUIVALENT: Record = {
- 'apps/cohort/src/routes/(protected)/staff/organisations/+page.server.ts|/obp/v4.0.0/banks/${space()}/customers?':
- 'v4.0.0 filters customers by attribute; v5.1.0 serves getCustomersAtOneBank without attribute filtering',
- 'apps/cohort/scripts/seed-demo-data.mjs|/obp/v4.0.0/banks/${space}/customers?':
- 'v4.0.0 filters customers by attribute; v5.1.0 serves getCustomersAtOneBank without attribute filtering'
-};
+/**
+ * file (repo-relative) + path fragment -> why it is still below v5.1.0. Empty: every OBP-API call in the
+ * app stack now has a v5.1.0 or newer equivalent, so the test allows no exception.
+ */
+const NO_V510_EQUIVALENT: Record = {};
function sourceFiles(dir: string): string[] {
const out: string[] = [];