Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/payments/api/.env
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Auth Server
AUTH_SERVER_EMAIL_CAPABILITY_CONFIG__BASE_URL=http://localhost:9000/v1
AUTH_SERVER_EMAIL_CAPABILITY_CONFIG__SUBSCRIPTIONS_SECRET=devsecret

# MySQLConfig
MYSQL_CONFIG__DATABASE=fxa
MYSQL_CONFIG__HOST=::1
Expand Down
4 changes: 4 additions & 0 deletions apps/payments/api/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import {
} from '@fxa/payments/api-server';
import { AuthModule } from '@fxa/payments/auth';
import {
AuthServerEmailCapabilityClient,
CmsWebhooksController,
CmsWebhookService,
EmailCapabilityWebhookService,
FxaWebhooksController,
FxaWebhookService,
StripeEventManager,
Expand Down Expand Up @@ -118,6 +120,8 @@ import { PaymentsMetricsAggregatorService } from '@fxa/payments/metrics-aggregat
StrapiClient,
CmsContentValidationManager,
CmsWebhookService,
EmailCapabilityWebhookService,
AuthServerEmailCapabilityClient,
FxaWebhookService,
NimbusManager,
NimbusManagerConfig,
Expand Down
11 changes: 10 additions & 1 deletion apps/payments/api/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import { PaypalClientConfig } from '@fxa/payments/paypal';
import { StripeConfig } from '@fxa/payments/stripe';
import { StrapiClientConfig } from '@fxa/shared/cms';
import { MySQLConfig } from '@fxa/shared/db/mysql/core';
import { FxaWebhookConfig, StripeEventConfig } from '@fxa/payments/webhooks';
import {
AuthServerEmailCapabilityConfig,
FxaWebhookConfig,
StripeEventConfig,
} from '@fxa/payments/webhooks';
import { StatsDConfig } from '@fxa/shared/metrics/statsd';
import { FirestoreConfig } from 'libs/shared/db/firestore/src/lib/firestore.config';
import { FxaOAuthConfig } from '@fxa/payments/auth';
Expand Down Expand Up @@ -78,4 +82,9 @@ export class RootConfig {
@ValidateNested()
@IsDefined()
public readonly googleIapClientConfig!: Partial<GoogleIapClientConfig>;

@Type(() => AuthServerEmailCapabilityConfig)
@ValidateNested()
@IsDefined()
public readonly authServerEmailCapabilityConfig!: Partial<AuthServerEmailCapabilityConfig>;
}
2 changes: 2 additions & 0 deletions apps/payments/next/app/[locale]/subscriptions/manage/en.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ subscription-management-page-banner-warning-link-no-payment-method = Add a payme
subscription-management-subscriptions-heading = Subscriptions
subscription-management-free-trial-heading = Free trials
subscription-management-your-free-trials-aria = Your free trials
subscription-management-entitlements-heading = Services included with your account
subscription-management-your-entitlements-aria = Services included with your account

# Heading for mobile only quick links menu
subscription-management-jump-to-heading = Jump to
Expand Down
40 changes: 40 additions & 0 deletions apps/payments/next/app/[locale]/subscriptions/manage/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { SubPlatPaymentMethodType } from '@fxa/payments/customer';
import {
Banner,
BannerVariant,
BusinessEntitlementContent,
formatPlanInterval,
FreeTrialContent,
getCardIcon,
Expand Down Expand Up @@ -76,6 +77,7 @@ export default async function Manage({
appleIapSubscriptions,
googleIapSubscriptions,
trialSubscriptions,
entitlements,
} = await getSubManPageContentAction(
{ ...resolvedParams },
{ ...resolvedSearchParams },
Expand Down Expand Up @@ -268,6 +270,44 @@ export default async function Manage({
</nav>
)}

{entitlements && entitlements.length > 0 && (
<section
id="entitlements"
className="scroll-mt-16"
aria-labelledby="entitlements-heading"
>
<h2
id="entitlements-heading"
className="font-bold px-4 pt-8 pb-4 text-lg tablet:px-6"
>
{l10n.getString(
'subscription-management-entitlements-heading',
'Services included with your account'
)}
</h2>
<ul
aria-label={l10n.getString(
'subscription-management-your-entitlements-aria',
'Services included with your account'
)}
>
{entitlements.map((entitlement, index) => (
<li
key={`${entitlement.clientId}-${index}`}
aria-labelledby={`${entitlement.clientId}-entitlement-information`}
className="leading-6 pb-4 last:pb-0"
>
<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">
<div className="flex flex-col px-4 tablet:px-0 tablet:flex-row tablet:items-start">
<BusinessEntitlementContent entitlement={entitlement} />
</div>
</div>
</li>
))}
</ul>
</section>
)}

{trialSubscriptions.length > 0 && (
<section
id="free-trial"
Expand Down
102 changes: 102 additions & 0 deletions libs/payments/management/src/lib/businessEntitlements.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import {
ServiceResultFactory,
ServicesWithCapabilitiesQueryFactory,
ServicesWithCapabilitiesResult,
ServicesWithCapabilitiesResultUtil,
} from '@fxa/shared/cms';

import { buildBusinessEntitlementsForPage } from './businessEntitlements';

describe('buildBusinessEntitlementsForPage', () => {
function catalog(
services: Parameters<typeof ServiceResultFactory>[0][] = []
): ServicesWithCapabilitiesResultUtil {
const result = ServicesWithCapabilitiesQueryFactory({
services: services.map((s) => ServiceResultFactory(s)),
});
return new ServicesWithCapabilitiesResultUtil(
result as ServicesWithCapabilitiesResult
);
}

it('returns an empty list when there are no entitlements', () => {
expect(buildBusinessEntitlementsForPage({}, catalog(), [])).toEqual([]);
});

it('emits one card per clientId with sorted capabilities', () => {
const result = buildBusinessEntitlementsForPage(
{ vpn: ['pro', 'mobile'] },
catalog([
{
oauthClientId: 'vpn',
internalName: 'Mozilla VPN',
description: 'Secure your connection.',
},
]),
[]
);

expect(result).toEqual([
{
clientId: 'vpn',
displayName: 'Mozilla VPN',
description: 'Secure your connection.',
capabilities: ['mobile', 'pro'],
},
]);
});

it('falls back to clientId and null description when the catalog has no entry', () => {
const result = buildBusinessEntitlementsForPage(
{ 'unknown-client': ['some-cap'] },
catalog([]),
[]
);

expect(result).toEqual([
{
clientId: 'unknown-client',
displayName: 'unknown-client',
description: null,
capabilities: ['some-cap'],
},
]);
});

it('hides entitlements when the user is already subscribed (case-insensitive)', () => {
const result = buildBusinessEntitlementsForPage(
{ VPN: ['pro'], relay: ['premium'] },
catalog([
{ oauthClientId: 'vpn', internalName: 'Mozilla VPN' },
{ oauthClientId: 'relay', internalName: 'Firefox Relay' },
]),
['Vpn']
);

expect(result.map((e) => e.clientId)).toEqual(['relay']);
});

it('returns deterministic order sorted by clientId', () => {
const result = buildBusinessEntitlementsForPage(
{ relay: ['premium'], vpn: ['pro'], mdn: ['plus'] },
catalog([]),
[]
);

expect(result.map((e) => e.clientId)).toEqual(['mdn', 'relay', 'vpn']);
});

it('skips clientIds whose entitlement has no capabilities', () => {
const result = buildBusinessEntitlementsForPage(
{ vpn: [] },
catalog([{ oauthClientId: 'vpn' }]),
[]
);

expect(result).toEqual([]);
});
});
47 changes: 47 additions & 0 deletions libs/payments/management/src/lib/businessEntitlements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import type { ServicesWithCapabilitiesResultUtil } from '@fxa/shared/cms';

export interface BusinessEntitlementContent {
clientId: string;
displayName: string;
description: string | null;
capabilities: string[];
}

/**
* Pure helper: from the user's email-resolved `{ clientId → capabilities[] }`
* map, produce the list of entitlement cards to render on the subscription
* management page. Skips any clientId already covered by an active
* subscription/trial/IAP. Sorted by clientId for stable rendering.
*/
export function buildBusinessEntitlementsForPage(
entitlementMap: Record<string, readonly string[]>,
serviceCatalog: ServicesWithCapabilitiesResultUtil,
subscribedClientIds: Iterable<string>
): BusinessEntitlementContent[] {
const excluded = new Set<string>();
for (const id of subscribedClientIds) {
if (id) excluded.add(id.toLowerCase());
}

const out: BusinessEntitlementContent[] = [];
for (const [rawClientId, capabilities] of Object.entries(entitlementMap)) {
const clientId = rawClientId.toLowerCase();
if (excluded.has(clientId)) continue;
if (!capabilities || capabilities.length === 0) continue;

const service = serviceCatalog.findServiceByClientId(clientId);
out.push({
clientId,
displayName: service?.internalName ?? rawClientId,
description: service?.description ?? null,
capabilities: [...capabilities].sort(),
});
}

out.sort((a, b) => a.clientId.localeCompare(b.clientId));
return out;
}
Loading
Loading