Skip to content

Commit ae094e1

Browse files
committed
Update submanage to use offering, and reduce changes
1 parent fc29d27 commit ae094e1

32 files changed

Lines changed: 613 additions & 440 deletions

apps/payments/next/app/[locale]/subscriptions/manage/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,8 @@ export default async function Manage({
293293
>
294294
{freeAccess.map((grant, index) => (
295295
<li
296-
key={`${grant.clientId}-${index}`}
297-
aria-labelledby={`${grant.clientId}-free-access-information`}
296+
key={`${grant.offeringApiIdentifier}-${index}`}
297+
aria-labelledby={`${grant.offeringApiIdentifier}-free-access-information`}
298298
className="leading-6 pb-4 last:pb-0"
299299
>
300300
<div className="w-full py-6 text-grey-600 bg-white rounded-xl border border-grey-200 opacity-100 shadow-[0_0_16px_0_rgba(0,0,0,0.08)] tablet:px-6 tablet:py-8">

libs/free-access-program/src/lib/factories.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ export const FreeAccessRecordFactory = (
1010
): FreeAccessRecord => ({
1111
entitlementId: faker.string.uuid(),
1212
email: faker.internet.email().toLowerCase(),
13+
offeringApiIdentifiers: [
14+
`offering-${faker.string.alphanumeric({ length: 8 })}`,
15+
],
1316
capabilities: {
1417
[faker.string.alphanumeric({ length: 16 })]: [
1518
`cap-${faker.string.alphanumeric({ length: 8 })}`,

libs/free-access-program/src/lib/free-access-program.manager.spec.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,99 @@ describe('FreeAccessProgramManager', () => {
228228
});
229229
});
230230

231+
describe('findOfferingIdsForEmail', () => {
232+
it('returns an empty array for null/undefined email without querying Strapi', async () => {
233+
expect(await manager.findOfferingIdsForEmail(null)).toEqual([]);
234+
expect(await manager.findOfferingIdsForEmail(undefined)).toEqual([]);
235+
expect(strapiClient.queryUncached).not.toHaveBeenCalled();
236+
});
237+
238+
it('returns the deduped offering apiIdentifiers for matching active records', async () => {
239+
withStrapiAccesses([
240+
{
241+
documentId: 'ent-a',
242+
internalName: 'A',
243+
offerings: [
244+
{
245+
apiIdentifier: 'vpn',
246+
capabilities: [
247+
{ slug: 'vpn', services: [{ oauthClientId: 'client-a' }] },
248+
],
249+
},
250+
{
251+
apiIdentifier: 'relay',
252+
capabilities: [
253+
{ slug: 'relay', services: [{ oauthClientId: 'client-b' }] },
254+
],
255+
},
256+
],
257+
matchers: [
258+
{
259+
__typename: 'ComponentMatchersEmailList',
260+
emails: { 'user@example.com': ['2027-01-01', ''] },
261+
},
262+
],
263+
},
264+
{
265+
documentId: 'ent-b',
266+
internalName: 'B',
267+
offerings: [
268+
{
269+
apiIdentifier: 'vpn', // duplicates ent-a's vpn → must dedupe
270+
capabilities: [
271+
{ slug: 'vpn', services: [{ oauthClientId: 'client-a' }] },
272+
],
273+
},
274+
{
275+
apiIdentifier: 'monitor',
276+
capabilities: [
277+
{ slug: 'monitor', services: [{ oauthClientId: 'client-c' }] },
278+
],
279+
},
280+
],
281+
matchers: [
282+
{
283+
__typename: 'ComponentMatchersEmailList',
284+
emails: { 'user@example.com': ['2027-01-01', ''] },
285+
},
286+
],
287+
},
288+
]);
289+
290+
const result = await manager.findOfferingIdsForEmail(
291+
'user@example.com'
292+
);
293+
expect(result.sort()).toEqual(['monitor', 'relay', 'vpn']);
294+
});
295+
296+
it('excludes offerings from expired records', async () => {
297+
withStrapiAccesses([
298+
{
299+
documentId: 'ent-stale',
300+
internalName: 'Stale',
301+
offerings: [
302+
{
303+
apiIdentifier: 'vpn',
304+
capabilities: [
305+
{ slug: 'vpn', services: [{ oauthClientId: 'client-a' }] },
306+
],
307+
},
308+
],
309+
matchers: [
310+
{
311+
__typename: 'ComponentMatchersEmailList',
312+
emails: { 'user@example.com': ['2025-12-31', ''] },
313+
},
314+
],
315+
},
316+
]);
317+
318+
expect(
319+
await manager.findOfferingIdsForEmail('user@example.com')
320+
).toEqual([]);
321+
});
322+
});
323+
231324
describe('getFreshProjection', () => {
232325
it('returns the projected snapshot keyed by entitlement_email', async () => {
233326
withStrapiAccesses([

libs/free-access-program/src/lib/free-access-program.manager.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,32 @@ export class FreeAccessProgramManager {
8181
email?: string | null
8282
): Promise<ClientIdCapabilityMap> {
8383
if (!email) return {};
84+
return mergeCapabilities(await this.findActiveRecordsForEmail(email));
85+
}
86+
87+
/**
88+
* Deduped list of offering `apiIdentifier`s the email currently has
89+
* free access to. Used by the subscription-management page to render
90+
* one card per offering, after filtering against the user's active
91+
* stripe subscriptions.
92+
*/
93+
async findOfferingIdsForEmail(
94+
email?: string | null
95+
): Promise<string[]> {
96+
if (!email) return [];
97+
const records = await this.findActiveRecordsForEmail(email);
98+
const ids = new Set<string>();
99+
for (const record of records) {
100+
for (const id of record.offeringApiIdentifiers ?? []) {
101+
if (id) ids.add(id);
102+
}
103+
}
104+
return [...ids];
105+
}
106+
107+
private async findActiveRecordsForEmail(
108+
email: string
109+
): Promise<FreeAccessRecord[]> {
84110
const snapshot = await this.getCachedProjection();
85111
const normalizedEmail = email.toLowerCase();
86112
const now = Date.now();
@@ -90,7 +116,7 @@ export class FreeAccessProgramManager {
90116
if (record.expiresAt <= now) continue;
91117
matching.push(record);
92118
}
93-
return mergeCapabilities(matching);
119+
return matching;
94120
}
95121

96122
/**

libs/free-access-program/src/lib/free-access-program.projector.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ describe('projectAccess', () => {
1515
const baseInput = (): NormalizedAccess => ({
1616
documentId: 'ent-1',
1717
internalName: 'VPN beta',
18+
offeringApiIdentifiers: ['vpn', 'relay'],
1819
capabilities: [
1920
{
2021
slug: 'vpn-beta',
@@ -55,6 +56,29 @@ describe('projectAccess', () => {
5556
new Date('2027-01-01T00:00:00.000Z').getTime()
5657
);
5758
expect(alice?.createdAt).toBe(NOW.getTime());
59+
expect([...(alice?.offeringApiIdentifiers ?? [])].sort()).toEqual([
60+
'relay',
61+
'vpn',
62+
]);
63+
});
64+
65+
it('dedupes offering apiIdentifiers and skips empty entries', () => {
66+
const input = baseInput();
67+
input.offeringApiIdentifiers = ['vpn', 'vpn', '', 'relay'];
68+
input.emailLists = [{ 'user@example.com': ['2027-12-31', ''] }];
69+
const result = projectAccess(input, NOW);
70+
expect(result.records[0]?.offeringApiIdentifiers).toEqual([
71+
'vpn',
72+
'relay',
73+
]);
74+
});
75+
76+
it('emits an empty offering list when none are linked', () => {
77+
const input = baseInput();
78+
input.offeringApiIdentifiers = [];
79+
input.emailLists = [{ 'user@example.com': ['2027-12-31', ''] }];
80+
const result = projectAccess(input, NOW);
81+
expect(result.records[0]?.offeringApiIdentifiers).toEqual([]);
5882
});
5983

6084
it('skips entitlements missing a documentId', () => {
@@ -202,6 +226,7 @@ describe('normalizeWebhookEntry', () => {
202226
internalName: 'VPN beta',
203227
offerings: [
204228
{
229+
apiIdentifier: 'vpn',
205230
capabilities: [
206231
{ id: 1, slug: 'vpn-beta', services: [{ oauthClientId: 'cli' }] },
207232
],
@@ -224,6 +249,7 @@ describe('normalizeWebhookEntry', () => {
224249
expect(normalized).toEqual({
225250
documentId: 'ent-1',
226251
internalName: 'VPN beta',
252+
offeringApiIdentifiers: ['vpn'],
227253
capabilities: [
228254
{ id: 1, slug: 'vpn-beta', services: [{ oauthClientId: 'cli' }] },
229255
],
@@ -234,6 +260,20 @@ describe('normalizeWebhookEntry', () => {
234260
});
235261
});
236262

263+
it('extracts apiIdentifier from each offering on the webhook entry', () => {
264+
const normalized = normalizeWebhookEntry({
265+
id: 1,
266+
documentId: 'ent-1',
267+
offerings: [
268+
{ apiIdentifier: 'vpn', capabilities: [] },
269+
{ apiIdentifier: 'relay', capabilities: [] },
270+
],
271+
matchers: [],
272+
});
273+
274+
expect(normalized.offeringApiIdentifiers).toEqual(['vpn', 'relay']);
275+
});
276+
237277
it('flattens capabilities across multiple offerings', () => {
238278
const normalized = normalizeWebhookEntry({
239279
id: 1,
@@ -284,6 +324,7 @@ describe('normalizeGraphQLAccess', () => {
284324
internalName: 'VPN beta',
285325
offerings: [
286326
{
327+
apiIdentifier: 'vpn',
287328
capabilities: [
288329
{
289330
slug: 'vpn-beta',
@@ -304,6 +345,7 @@ describe('normalizeGraphQLAccess', () => {
304345
expect(normalized.emailLists).toEqual([
305346
{ 'a@example.com': ['2026-12-31', 'd'] },
306347
]);
348+
expect(normalized.offeringApiIdentifiers).toEqual(['vpn']);
307349
});
308350

309351
it('flattens capabilities across multiple offerings', () => {

libs/free-access-program/src/lib/free-access-program.projector.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface GraphQLAccessRow {
2222
internalName?: string | null;
2323
offerings?: ReadonlyArray<
2424
| {
25+
apiIdentifier?: string | null;
2526
capabilities?: ReadonlyArray<{
2627
slug?: string | null;
2728
services?: ReadonlyArray<
@@ -68,6 +69,8 @@ export interface ProjectionResult {
6869
export interface NormalizedAccess {
6970
documentId?: string | null;
7071
internalName?: string | null;
72+
/** Stable `apiIdentifier`s of the offerings this access grants. */
73+
offeringApiIdentifiers?: ReadonlyArray<string>;
7174
capabilities?: ReadonlyArray<{
7275
slug?: string | null;
7376
services?: ReadonlyArray<{ oauthClientId?: string | null } | null> | null;
@@ -106,6 +109,13 @@ export function projectAccess(
106109

107110
const records: FreeAccessRecord[] = [];
108111
const createdAt = now.getTime();
112+
const offeringApiIdentifiers = Object.freeze([
113+
...new Set(
114+
(input.offeringApiIdentifiers ?? []).filter(
115+
(id): id is string => typeof id === 'string' && id.length > 0
116+
)
117+
),
118+
]);
109119

110120
for (const rawEmails of input.emailLists ?? []) {
111121
if (Array.isArray(rawEmails)) {
@@ -152,6 +162,7 @@ export function projectAccess(
152162
records.push({
153163
entitlementId: documentId,
154164
email,
165+
offeringApiIdentifiers,
155166
capabilities: capabilityMap,
156167
expiresAt: expiresAtDate.getTime(),
157168
description: description ?? '',
@@ -177,6 +188,7 @@ export function normalizeGraphQLAccess(
177188
return {
178189
documentId: access.documentId,
179190
internalName: access.internalName,
191+
offeringApiIdentifiers: collectOfferingApiIdentifiers(access.offerings),
180192
capabilities: flattenOfferingCapabilities(access.offerings),
181193
emailLists,
182194
};
@@ -198,11 +210,31 @@ export function normalizeWebhookEntry(
198210
return {
199211
documentId: entry.documentId,
200212
internalName: entry.internalName,
213+
offeringApiIdentifiers: collectOfferingApiIdentifiers(entry.offerings),
201214
capabilities: flattenOfferingCapabilities(entry.offerings),
202215
emailLists,
203216
};
204217
}
205218

219+
/**
220+
* Collect the stable `apiIdentifier` of every offering linked to an access.
221+
* Skips offerings missing one — they can't be cross-referenced against
222+
* stripe subs without it, so they're not useful for the management page.
223+
*/
224+
function collectOfferingApiIdentifiers(
225+
offerings:
226+
| ReadonlyArray<{ apiIdentifier?: string | null } | null | undefined>
227+
| null
228+
| undefined
229+
): string[] {
230+
const out: string[] = [];
231+
for (const offering of offerings ?? []) {
232+
const id = offering?.apiIdentifier;
233+
if (typeof id === 'string' && id.length > 0) out.push(id);
234+
}
235+
return out;
236+
}
237+
206238
/**
207239
* Strapi links each `access` to zero or more offerings, each of which has
208240
* its own capabilities. The downstream projector treats capabilities as a

libs/free-access-program/src/lib/free-access-program.reconciler.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
FreeAccessProgramManager,
1414
type FreeAccessSnapshot,
1515
} from './free-access-program.manager';
16-
import type { ClientIdCapabilityMap, FreeAccessRecord } from './types';
16+
import type { ClientIdCapabilityMap } from './types';
1717

1818
/** Single email's capability delta, in `processEmailListChange` shape. */
1919
export interface CapabilityChange {

libs/free-access-program/src/lib/free-access-program.webhook.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export interface StrapiAccessWebhookPayload {
2828
offerings?: Array<{
2929
id?: number;
3030
documentId?: string;
31+
apiIdentifier?: string;
3132
capabilities?: Array<{
3233
id?: number;
3334
slug?: string;

libs/free-access-program/src/lib/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ export interface FreeAccessRecord {
1818
entitlementId: string;
1919
/** Lowercased email address granted access. */
2020
email: string;
21+
/**
22+
* Stable `apiIdentifier` of every Strapi offering this access grants. The
23+
* subscription-management page keys cards by offering; auth-server's
24+
* capability merge uses the per-client capability map below.
25+
*/
26+
offeringApiIdentifiers: readonly string[];
2127
/** Capabilities to apply per oauthClientId for this email. */
2228
capabilities: ClientIdCapabilityMap;
2329
/** When the grant expires (ms since epoch, UTC). */

0 commit comments

Comments
 (0)