From 9d11d7553625d5689a53ef434ae80d41e1238b79 Mon Sep 17 00:00:00 2001 From: Reino Muhl <10620585+StaberindeZA@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:59:37 -0400 Subject: [PATCH] feat(subplat): add free access program to billing-and-subscriptions API Because: * Relying parties fetch current subscriptions via GET v1/billing-and-subscriptions, but Free Access Program grants were not represented. This commit: * Adds a `free_access` subscription type sourcing product identity from the related offering. * Filters grants by the projection's per-client capabilities. * Gates the code behind a FreeAccessProgramConfig feature flag (default off). Closes #PAY-3790 --- apps/payments/api/.env | 3 + apps/payments/api/src/app/app.module.ts | 2 + apps/payments/api/src/config/index.ts | 6 + libs/payments/api-server/src/index.ts | 1 + ...lling-and-subscriptions.controller.spec.ts | 6 + .../lib/billing-and-subscriptions.schema.ts | 22 ++ .../billing-and-subscriptions.service.spec.ts | 275 ++++++++++++++++++ .../lib/billing-and-subscriptions.service.ts | 94 ++++-- .../src/lib/free-access-program.config.ts | 26 ++ .../transformToFreeAccessSubscription.spec.ts | 32 ++ .../util/transformToFreeAccessSubscription.ts | 21 ++ .../src/lib/account.manager.in.spec.ts | 20 ++ .../account/src/lib/account.manager.ts | 10 + .../account/src/lib/account.repository.ts | 16 + 14 files changed, 513 insertions(+), 21 deletions(-) create mode 100644 libs/payments/api-server/src/lib/free-access-program.config.ts create mode 100644 libs/payments/api-server/src/lib/util/transformToFreeAccessSubscription.spec.ts create mode 100644 libs/payments/api-server/src/lib/util/transformToFreeAccessSubscription.ts diff --git a/apps/payments/api/.env b/apps/payments/api/.env index 110d27ce7ce..2f9c7671fdc 100644 --- a/apps/payments/api/.env +++ b/apps/payments/api/.env @@ -109,3 +109,6 @@ SENTRY_CONFIG__SAMPLE_RATE=1 SENTRY_CONFIG__TRACES_SAMPLE_RATE=0 SWAGGER_UI=false + +# Free Access Program +FREE_ACCESS_PROGRAM_CONFIG__ENABLED=false diff --git a/apps/payments/api/src/app/app.module.ts b/apps/payments/api/src/app/app.module.ts index d8d1fe9b9be..d7f9ef9528f 100644 --- a/apps/payments/api/src/app/app.module.ts +++ b/apps/payments/api/src/app/app.module.ts @@ -72,6 +72,7 @@ import { AccountManager } from '@fxa/shared/account/account'; import { CartManager } from '@fxa/payments/cart'; import { CmsContentValidationManager, + FreeAccessProgramConfigurationManager, MeteringConfigurationManager, ProductConfigurationManager, StrapiClient, @@ -124,6 +125,7 @@ import { PaymentsMetricsAggregatorService } from '@fxa/payments/metrics-aggregat BillingAndSubscriptionsService, CapabilityManager, ProductConfigurationManager, + FreeAccessProgramConfigurationManager, CartManager, SubscriptionEventsService, PaymentsGleanFactory, diff --git a/apps/payments/api/src/config/index.ts b/apps/payments/api/src/config/index.ts index d5aa0a8cff6..bf644f32bdf 100644 --- a/apps/payments/api/src/config/index.ts +++ b/apps/payments/api/src/config/index.ts @@ -7,6 +7,7 @@ import { AppleIapClientConfig, GoogleIapClientConfig } from '@fxa/payments/iap'; import { PaymentsGleanConfig } from '@fxa/payments/metrics'; import { PaypalClientConfig } from '@fxa/payments/paypal'; import { StripeConfig } from '@fxa/payments/stripe'; +import { FreeAccessProgramConfig } from '@fxa/payments/api-server'; import { StrapiClientConfig } from '@fxa/shared/cms'; import { MySQLConfig } from '@fxa/shared/db/mysql/core'; import { FxaWebhookConfig, StripeEventConfig } from '@fxa/payments/webhooks'; @@ -99,4 +100,9 @@ export class RootConfig { @IsBoolean() @IsDefined() public readonly swaggerUi!: boolean; + + @Type(() => FreeAccessProgramConfig) + @ValidateNested() + @IsDefined() + public readonly freeAccessProgramConfig!: Partial; } diff --git a/libs/payments/api-server/src/index.ts b/libs/payments/api-server/src/index.ts index 4824a9c329c..4484fa7e89b 100644 --- a/libs/payments/api-server/src/index.ts +++ b/libs/payments/api-server/src/index.ts @@ -8,3 +8,4 @@ export * from './lib/billing-and-subscriptions.schema'; export * from './lib/billing-and-subscriptions.factories'; export * from './lib/util/openapi'; export * from './lib/util/response-validation.interceptor'; +export * from './lib/free-access-program.config'; diff --git a/libs/payments/api-server/src/lib/billing-and-subscriptions.controller.spec.ts b/libs/payments/api-server/src/lib/billing-and-subscriptions.controller.spec.ts index eeac3b6bff0..ea1759870be 100644 --- a/libs/payments/api-server/src/lib/billing-and-subscriptions.controller.spec.ts +++ b/libs/payments/api-server/src/lib/billing-and-subscriptions.controller.spec.ts @@ -41,16 +41,19 @@ import { StripeClient, } from '@fxa/payments/stripe'; import { + FreeAccessProgramConfigurationManager, MockStrapiClientConfigProvider, ProductConfigurationManager, StrapiClient, } from '@fxa/shared/cms'; +import { AccountManager } from '@fxa/shared/account/account'; import { MockFirestoreProvider } from '@fxa/shared/db/firestore'; import { MockAccountDatabaseNestFactory } from '@fxa/shared/db/mysql/account'; import { MockStatsDProvider } from '@fxa/shared/metrics/statsd'; import { BillingAndSubscriptionsController } from './billing-and-subscriptions.controller'; import { BillingAndSubscriptionsService } from './billing-and-subscriptions.service'; +import { MockFreeAccessProgramConfigProvider } from './free-access-program.config'; const FxaOAuthUserFactory = ( override?: Partial @@ -79,6 +82,9 @@ describe('BillingAndSubscriptionsController', () => { ProductManager, CapabilityManager, ProductConfigurationManager, + FreeAccessProgramConfigurationManager, + MockFreeAccessProgramConfigProvider, + AccountManager, AppleIapPurchaseManager, AppleIapClient, MockAppleIapClientConfigProvider, diff --git a/libs/payments/api-server/src/lib/billing-and-subscriptions.schema.ts b/libs/payments/api-server/src/lib/billing-and-subscriptions.schema.ts index 5483d89afd7..16199cae811 100644 --- a/libs/payments/api-server/src/lib/billing-and-subscriptions.schema.ts +++ b/libs/payments/api-server/src/lib/billing-and-subscriptions.schema.ts @@ -131,6 +131,27 @@ export const appleIapSubscriptionSchema = z.object({ */ export type AppleIapSubscription = z.infer; +/** + * Zod schema for a Free Access Program grant. + * + * A Free Access Program customer is not subscribed to a Stripe Price, so the + * product identity is sourced from the related offering. There is no price, + * subscription id, or billing interval behind a free grant, so none of those + * fields are reported — only the product the grant provides access to. + */ +export const freeAccessSubscriptionSchema = z.object({ + _subscription_type: z.literal('free_access'), + offering_api_identifier: z.string(), + product_id: z.string(), +}); + +/** + * A Free Access Program grant. + */ +export type FreeAccessSubscription = z.infer< + typeof freeAccessSubscriptionSchema +>; + /** * Zod schema for a subscription returned by the billing-and-subscriptions * endpoint, discriminated on `_subscription_type`. @@ -139,6 +160,7 @@ export const subscriptionSchema = z.discriminatedUnion('_subscription_type', [ webSubscriptionSchema, googleIapSubscriptionSchema, appleIapSubscriptionSchema, + freeAccessSubscriptionSchema, ]); /** diff --git a/libs/payments/api-server/src/lib/billing-and-subscriptions.service.spec.ts b/libs/payments/api-server/src/lib/billing-and-subscriptions.service.spec.ts index fa76d1a1bce..18aca52d076 100644 --- a/libs/payments/api-server/src/lib/billing-and-subscriptions.service.spec.ts +++ b/libs/payments/api-server/src/lib/billing-and-subscriptions.service.spec.ts @@ -51,10 +51,12 @@ import { StripeSubscriptionItemFactory, } from '@fxa/payments/stripe'; import { + FreeAccessProgramConfigurationManager, MockStrapiClientConfigProvider, ProductConfigurationManager, StrapiClient, } from '@fxa/shared/cms'; +import { AccountManager } from '@fxa/shared/account/account'; import { MockFirestoreProvider } from '@fxa/shared/db/firestore'; import { MockAccountDatabaseNestFactory } from '@fxa/shared/db/mysql/account'; import { MockStatsDProvider } from '@fxa/shared/metrics/statsd'; @@ -65,6 +67,7 @@ import { IapOfferingFactory, } from './billing-and-subscriptions.factories'; import { BillingAndSubscriptionsService } from './billing-and-subscriptions.service'; +import { FreeAccessProgramConfig } from './free-access-program.config'; const UID = 'abc123'; const CLIENT_ID = 'client_xyz'; @@ -81,12 +84,18 @@ describe('BillingAndSubscriptionsService', () => { let productManager: ProductManager; let capabilityManager: CapabilityManager; let productConfigurationManager: ProductConfigurationManager; + let freeAccessProgramConfigurationManager: FreeAccessProgramConfigurationManager; + let accountManager: AccountManager; let appleIapPurchaseManager: AppleIapPurchaseManager; let googleIapPurchaseManager: GoogleIapPurchaseManager; let logger: { log: jest.Mock; error: jest.Mock }; + // Mutable so individual tests can toggle the feature flag; reset in beforeEach. + const mockFreeAccessProgramConfig = { enabled: true }; + beforeEach(async () => { logger = { log: jest.fn(), error: jest.fn() }; + mockFreeAccessProgramConfig.enabled = true; const moduleRef = await Test.createTestingModule({ providers: [ @@ -100,6 +109,12 @@ describe('BillingAndSubscriptionsService', () => { ProductManager, CapabilityManager, ProductConfigurationManager, + FreeAccessProgramConfigurationManager, + { + provide: FreeAccessProgramConfig, + useValue: mockFreeAccessProgramConfig, + }, + AccountManager, AppleIapPurchaseManager, AppleIapClient, MockAppleIapClientConfigProvider, @@ -133,12 +148,23 @@ describe('BillingAndSubscriptionsService', () => { productManager = moduleRef.get(ProductManager); capabilityManager = moduleRef.get(CapabilityManager); productConfigurationManager = moduleRef.get(ProductConfigurationManager); + freeAccessProgramConfigurationManager = moduleRef.get( + FreeAccessProgramConfigurationManager + ); + accountManager = moduleRef.get(AccountManager); appleIapPurchaseManager = moduleRef.get(AppleIapPurchaseManager); googleIapPurchaseManager = moduleRef.get(GoogleIapPurchaseManager); // Default: no IAP unless overridden. jest.spyOn(appleIapPurchaseManager, 'getForUser').mockResolvedValue([]); jest.spyOn(googleIapPurchaseManager, 'getForUser').mockResolvedValue([]); + // Default: no Free Access Program membership unless overridden. + jest + .spyOn(accountManager, 'getPrimaryEmailByUid') + .mockResolvedValue(undefined); + jest + .spyOn(freeAccessProgramConfigurationManager, 'getCachedProjection') + .mockResolvedValue({}); }); describe('get', () => { @@ -744,5 +770,254 @@ describe('BillingAndSubscriptionsService', () => { expect(loggedError.message).toContain('interval=fortnightly'); expect(loggedError.message).toContain('storeId=google_sku'); }); + + describe('free access program', () => { + const FAP_EMAIL = 'User@Example.com'; + + // Minimal stand-in for EligibilityContentByOfferingResultUtil: the service + // only reads stripeProductId from the offering. + const mockOffering = (stripeProductId: string) => + ({ + getOffering: () => ({ stripeProductId }), + } as never); + + it('returns a free_access subscription for a FAP-only customer', async () => { + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockRejectedValue( + new AccountCustomerNotFoundError(UID, new Error('not found')) + ); + // Projection is keyed by lowercased email; primary email is mixed-case. + jest + .spyOn(accountManager, 'getPrimaryEmailByUid') + .mockResolvedValue(FAP_EMAIL); + jest + .spyOn(freeAccessProgramConfigurationManager, 'getCachedProjection') + .mockResolvedValue({ + 'user@example.com': { + capabilities: { [CLIENT_ID]: ['vpn'] }, + offeringApiIdentifiers: ['vpn'], + }, + }); + jest + .spyOn(productConfigurationManager, 'getEligibilityContentByOffering') + .mockResolvedValue(mockOffering('prod_vpn')); + + const result = await service.get({ uid: UID, clientId: CLIENT_ID }); + + expect(result.subscriptions).toHaveLength(1); + expect(result.subscriptions[0]).toEqual({ + _subscription_type: 'free_access', + offering_api_identifier: 'vpn', + product_id: 'prod_vpn', + }); + }); + + it('resolves the FAP offering for the lowercased primary email', async () => { + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockRejectedValue( + new AccountCustomerNotFoundError(UID, new Error('not found')) + ); + jest + .spyOn(accountManager, 'getPrimaryEmailByUid') + .mockResolvedValue(FAP_EMAIL); + jest + .spyOn(freeAccessProgramConfigurationManager, 'getCachedProjection') + .mockResolvedValue({ + 'user@example.com': { + capabilities: { [CLIENT_ID]: ['vpn'] }, + offeringApiIdentifiers: ['vpn'], + }, + }); + const getOfferingSpy = jest + .spyOn(productConfigurationManager, 'getEligibilityContentByOffering') + .mockResolvedValue(mockOffering('prod_vpn')); + + await service.get({ uid: UID, clientId: CLIENT_ID }); + + expect(getOfferingSpy).toHaveBeenCalledWith('vpn'); + }); + + it('filters out a FAP offering when the client lacks its capability', async () => { + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockRejectedValue( + new AccountCustomerNotFoundError(UID, new Error('not found')) + ); + jest + .spyOn(accountManager, 'getPrimaryEmailByUid') + .mockResolvedValue(FAP_EMAIL); + // Projection grants capabilities to a different client only. + jest + .spyOn(freeAccessProgramConfigurationManager, 'getCachedProjection') + .mockResolvedValue({ + 'user@example.com': { + capabilities: { other_client: ['vpn'] }, + offeringApiIdentifiers: ['vpn'], + }, + }); + const getOfferingSpy = jest.spyOn( + productConfigurationManager, + 'getEligibilityContentByOffering' + ); + + const result = await service.get({ uid: UID, clientId: CLIENT_ID }); + + expect(result.subscriptions).toHaveLength(0); + // The offering is never resolved when the client has no FAP capability. + expect(getOfferingSpy).not.toHaveBeenCalled(); + }); + + it('does not throw NotFound for a FAP-only customer with no matching client', async () => { + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockRejectedValue( + new AccountCustomerNotFoundError(UID, new Error('not found')) + ); + jest + .spyOn(accountManager, 'getPrimaryEmailByUid') + .mockResolvedValue(FAP_EMAIL); + jest + .spyOn(freeAccessProgramConfigurationManager, 'getCachedProjection') + .mockResolvedValue({ + 'user@example.com': { + capabilities: { other_client: ['vpn'] }, + offeringApiIdentifiers: ['vpn'], + }, + }); + + const result = await service.get({ uid: UID, clientId: CLIENT_ID }); + + expect(result.subscriptions).toEqual([]); + }); + + it('returns both a web subscription and a free_access subscription', async () => { + const customer = StripeResponseFactory( + StripeCustomerFactory({ + id: STRIPE_CUSTOMER_ID, + currency: 'usd', + }) + ); + const price = StripePriceFactory({ + id: 'price_web', + currency: 'usd', + recurring: StripePriceRecurringFactory({ interval: 'month' }), + }); + const sub = StripeSubscriptionFactory({ + id: 'sub_web', + status: 'active', + items: { + object: 'list', + data: [ + StripeSubscriptionItemFactory({ + price: { ...price, product: 'prod_web' }, + }), + ], + has_more: false, + url: '', + }, + }); + + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockResolvedValue({ + uid: UID, + stripeCustomerId: STRIPE_CUSTOMER_ID, + } as never); + jest.spyOn(customerManager, 'retrieve').mockResolvedValue(customer); + jest + .spyOn(subscriptionManager, 'listActiveForCustomer') + .mockResolvedValue([sub]); + jest + .spyOn(paymentMethodManager, 'retrieve') + .mockResolvedValue( + StripeResponseFactory(StripeCardPaymentMethodFactory()) + ); + jest + .spyOn(accountManager, 'getPrimaryEmailByUid') + .mockResolvedValue(FAP_EMAIL); + jest + .spyOn(freeAccessProgramConfigurationManager, 'getCachedProjection') + .mockResolvedValue({ + 'user@example.com': { + capabilities: { [CLIENT_ID]: ['vpn'] }, + offeringApiIdentifiers: ['vpn'], + }, + }); + jest + .spyOn(productConfigurationManager, 'getEligibilityContentByOffering') + .mockResolvedValue(mockOffering('prod_vpn')); + // The web price grants to the client; the FAP offering is filtered via + // the projection capabilities. + jest + .spyOn(capabilityManager, 'priceIdsToClientCapabilities') + .mockResolvedValue({ [CLIENT_ID]: ['cap'] }); + + const result = await service.get({ uid: UID, clientId: CLIENT_ID }); + + const types = result.subscriptions.map((s) => s._subscription_type); + expect(types).toEqual(['web', 'free_access']); + }); + + it('returns a free_access subscription per matching offering', async () => { + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockRejectedValue( + new AccountCustomerNotFoundError(UID, new Error('not found')) + ); + jest + .spyOn(accountManager, 'getPrimaryEmailByUid') + .mockResolvedValue(FAP_EMAIL); + jest + .spyOn(freeAccessProgramConfigurationManager, 'getCachedProjection') + .mockResolvedValue({ + 'user@example.com': { + capabilities: { [CLIENT_ID]: ['vpn', 'relay'] }, + offeringApiIdentifiers: ['vpn', 'relay'], + }, + }); + jest + .spyOn(productConfigurationManager, 'getEligibilityContentByOffering') + .mockImplementation(async (apiIdentifier: string) => + apiIdentifier === 'vpn' + ? mockOffering('prod_vpn') + : mockOffering('prod_relay') + ); + + const result = await service.get({ uid: UID, clientId: CLIENT_ID }); + + expect(result.subscriptions).toHaveLength(2); + expect( + result.subscriptions.map((s) => + s._subscription_type === 'free_access' + ? s.offering_api_identifier + : null + ) + ).toEqual(['vpn', 'relay']); + }); + + it('does not run any free access program code when the feature is disabled', async () => { + mockFreeAccessProgramConfig.enabled = false; + // FAP-only customer: no Stripe customer and no IAP purchases. + jest + .spyOn(accountCustomerManager, 'getAccountCustomerByUid') + .mockRejectedValue( + new AccountCustomerNotFoundError(UID, new Error('not found')) + ); + const emailSpy = jest.spyOn(accountManager, 'getPrimaryEmailByUid'); + const projectionSpy = jest.spyOn( + freeAccessProgramConfigurationManager, + 'getCachedProjection' + ); + + // With the feature off, a FAP-only customer is treated as unknown. + await expect( + service.get({ uid: UID, clientId: CLIENT_ID }) + ).rejects.toMatchObject({ response: { errno: 176 } }); + expect(emailSpy).not.toHaveBeenCalled(); + expect(projectionSpy).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/libs/payments/api-server/src/lib/billing-and-subscriptions.service.ts b/libs/payments/api-server/src/lib/billing-and-subscriptions.service.ts index 243b51104d4..c7740aa1d5b 100644 --- a/libs/payments/api-server/src/lib/billing-and-subscriptions.service.ts +++ b/libs/payments/api-server/src/lib/billing-and-subscriptions.service.ts @@ -30,18 +30,25 @@ import { type StripeProduct, type StripeSubscription, } from '@fxa/payments/stripe'; +import { AccountManager } from '@fxa/shared/account/account'; import { SanitizeExceptions } from '@fxa/shared/error'; -import { ProductConfigurationManager } from '@fxa/shared/cms'; +import { + type FreeAccessProjection, + FreeAccessProgramConfigurationManager, + ProductConfigurationManager, +} from '@fxa/shared/cms'; import type { BillingAndSubscriptionsResponse, Subscription, } from './billing-and-subscriptions.schema'; +import { FreeAccessProgramConfig } from './free-access-program.config'; import { buildBillingDetails } from './util/buildBillingDetails'; import { buildIapPriceInfoMap } from './util/buildIapPriceInfoMap'; import { hasSubscriptionRequiringPaymentMethod } from './util/hasSubscriptionRequiringPaymentMethod'; import { mapPriceInfo } from './util/mapPriceInfo'; import { transformToAppleIapSubscription } from './util/transformToAppleIapSubscription'; +import { transformToFreeAccessSubscription } from './util/transformToFreeAccessSubscription'; import { transformToGoogleIapSubscription } from './util/transformToGoogleIapSubscription'; import { transformToWebSubscription } from './util/transformToWebSubscription'; @@ -53,6 +60,8 @@ const UNKNOWN_SUBSCRIPTION_CUSTOMER_ERRNO = 176; @Injectable() export class BillingAndSubscriptionsService { constructor( + private readonly freeAccessProgramConfig: FreeAccessProgramConfig, + private readonly accountManager: AccountManager, private readonly accountCustomerManager: AccountCustomerManager, private readonly customerManager: CustomerManager, private readonly subscriptionManager: SubscriptionManager, @@ -62,6 +71,7 @@ export class BillingAndSubscriptionsService { private readonly productManager: ProductManager, private readonly capabilityManager: CapabilityManager, private readonly productConfigurationManager: ProductConfigurationManager, + private readonly freeAccessProgramConfigurationManager: FreeAccessProgramConfigurationManager, private readonly appleIapPurchaseManager: AppleIapPurchaseManager, private readonly googleIapPurchaseManager: GoogleIapPurchaseManager ) {} @@ -94,19 +104,37 @@ export class BillingAndSubscriptionsService { } } - const [activeStripeSubscriptions, googleIapPurchases, appleIapPurchases] = - await Promise.all([ - stripeCustomer - ? this.subscriptionManager.listActiveForCustomer(stripeCustomer.id) - : Promise.resolve([]), - this.googleIapPurchaseManager.getForUser(uid), - this.appleIapPurchaseManager.getForUser(uid), - ]); + const [ + activeStripeSubscriptions, + googleIapPurchases, + appleIapPurchases, + primaryEmail, + freeAccessProjection, + ] = await Promise.all([ + stripeCustomer + ? this.subscriptionManager.listActiveForCustomer(stripeCustomer.id) + : Promise.resolve([]), + this.googleIapPurchaseManager.getForUser(uid), + this.appleIapPurchaseManager.getForUser(uid), + // Only resolve Free Access Program data when the feature is enabled. + this.freeAccessProgramConfig.enabled + ? this.accountManager.getPrimaryEmailByUid(uid) + : Promise.resolve(undefined), + this.freeAccessProgramConfig.enabled + ? this.freeAccessProgramConfigurationManager.getCachedProjection() + : Promise.resolve({}), + ]); + + // The Free Access Program projection is keyed by lowercased primary email. + const freeAccessEntry = primaryEmail + ? freeAccessProjection[primaryEmail.toLowerCase()] + : undefined; if ( !stripeCustomer && googleIapPurchases.length === 0 && - appleIapPurchases.length === 0 + appleIapPurchases.length === 0 && + !freeAccessEntry ) { throw new NotFoundException({ errno: UNKNOWN_SUBSCRIPTION_CUSTOMER_ERRNO, @@ -120,11 +148,11 @@ export class BillingAndSubscriptionsService { activeStripeSubscriptions.length === 0 ? undefined : activeStripeSubscriptions.some( - (sub) => - this.subscriptionManager.getPaymentProvider(sub) === 'paypal' - ) - ? 'paypal' - : 'stripe'; + (sub) => + this.subscriptionManager.getPaymentProvider(sub) === 'paypal' + ) + ? 'paypal' + : 'stripe'; const defaultPaymentMethodId = stripeCustomer.invoice_settings.default_payment_method; @@ -162,8 +190,9 @@ export class BillingAndSubscriptionsService { for (const sub of activeStripeSubscriptions) { const price = getPriceFromSubscription(sub); - const clients = - await this.capabilityManager.priceIdsToClientCapabilities([price.id]); + const clients = await this.capabilityManager.priceIdsToClientCapabilities( + [price.id] + ); if (!(clientId in clients)) { continue; } @@ -230,8 +259,9 @@ export class BillingAndSubscriptionsService { if (!iap) { continue; } - const clients = - await this.capabilityManager.priceIdsToClientCapabilities([iap.priceId]); + const clients = await this.capabilityManager.priceIdsToClientCapabilities( + [iap.priceId] + ); if (!(clientId in clients)) { continue; } @@ -243,14 +273,36 @@ export class BillingAndSubscriptionsService { if (!iap) { continue; } - const clients = - await this.capabilityManager.priceIdsToClientCapabilities([iap.priceId]); + const clients = await this.capabilityManager.priceIdsToClientCapabilities( + [iap.priceId] + ); if (!(clientId in clients)) { continue; } subscriptions.push(transformToAppleIapSubscription(purchase, iap)); } + // Free Access Program grants have no Stripe subscription or Price. The + // projection already maps client ids to the capabilities granted for this + // customer, so filter on it directly rather than re-deriving from Stripe + // prices. Product identity is sourced from the related offering; price data + // is dummy since there is no real subscription price. + if (freeAccessEntry && clientId.toLowerCase() in freeAccessEntry.capabilities) { + for (const apiIdentifier of freeAccessEntry.offeringApiIdentifiers) { + const offering = ( + await this.productConfigurationManager.getEligibilityContentByOffering( + apiIdentifier + ) + ).getOffering(); + subscriptions.push( + transformToFreeAccessSubscription({ + offeringApiIdentifier: apiIdentifier, + productId: offering.stripeProductId, + }) + ); + } + } + return { ...billingDetails, subscriptions, diff --git a/libs/payments/api-server/src/lib/free-access-program.config.ts b/libs/payments/api-server/src/lib/free-access-program.config.ts new file mode 100644 index 00000000000..9774ee45df8 --- /dev/null +++ b/libs/payments/api-server/src/lib/free-access-program.config.ts @@ -0,0 +1,26 @@ +/* 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 { Provider } from '@nestjs/common'; +import { Transform } from 'class-transformer'; +import { IsBoolean } from 'class-validator'; + +export class FreeAccessProgramConfig { + @Transform(({ value }) => { + if (value === 'true' || value === true) return true; + if (value === 'false' || value === false) return false; + return value; + }) + @IsBoolean() + public readonly enabled: boolean; +} + +export const MockFreeAccessProgramConfig = { + enabled: true, +} satisfies FreeAccessProgramConfig; + +export const MockFreeAccessProgramConfigProvider = { + provide: FreeAccessProgramConfig, + useValue: MockFreeAccessProgramConfig, +} satisfies Provider; diff --git a/libs/payments/api-server/src/lib/util/transformToFreeAccessSubscription.spec.ts b/libs/payments/api-server/src/lib/util/transformToFreeAccessSubscription.spec.ts new file mode 100644 index 00000000000..00afb791139 --- /dev/null +++ b/libs/payments/api-server/src/lib/util/transformToFreeAccessSubscription.spec.ts @@ -0,0 +1,32 @@ +/* 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 { transformToFreeAccessSubscription } from './transformToFreeAccessSubscription'; + +describe('transformToFreeAccessSubscription', () => { + it('maps offering product identity into a free_access subscription', () => { + const result = transformToFreeAccessSubscription({ + offeringApiIdentifier: 'vpn', + productId: 'prod_vpn', + }); + + expect(result).toEqual({ + _subscription_type: 'free_access', + offering_api_identifier: 'vpn', + product_id: 'prod_vpn', + }); + }); + + it('does not report any billing data for a free grant', () => { + const result = transformToFreeAccessSubscription({ + offeringApiIdentifier: 'vpn', + productId: 'prod_vpn', + }); + + expect(result).not.toHaveProperty('price_id'); + expect(result).not.toHaveProperty('plan_id'); + expect(result).not.toHaveProperty('priceInfo'); + expect(result).not.toHaveProperty('subscription_id'); + }); +}); diff --git a/libs/payments/api-server/src/lib/util/transformToFreeAccessSubscription.ts b/libs/payments/api-server/src/lib/util/transformToFreeAccessSubscription.ts new file mode 100644 index 00000000000..c70c3f601d2 --- /dev/null +++ b/libs/payments/api-server/src/lib/util/transformToFreeAccessSubscription.ts @@ -0,0 +1,21 @@ +/* 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 { FreeAccessSubscription } from '../billing-and-subscriptions.schema'; + +/** + * Build a `free_access` subscription entry from the related offering's product + * identity. A free grant has no Stripe subscription or Price, so no billing + * data (price, interval, subscription id) is reported. + */ +export const transformToFreeAccessSubscription = (args: { + offeringApiIdentifier: string; + productId: string; +}): FreeAccessSubscription => { + return { + _subscription_type: 'free_access', + offering_api_identifier: args.offeringApiIdentifier, + product_id: args.productId, + }; +}; diff --git a/libs/shared/account/account/src/lib/account.manager.in.spec.ts b/libs/shared/account/account/src/lib/account.manager.in.spec.ts index 5b672653bac..b85fd8fbe06 100644 --- a/libs/shared/account/account/src/lib/account.manager.in.spec.ts +++ b/libs/shared/account/account/src/lib/account.manager.in.spec.ts @@ -66,4 +66,24 @@ describe('accountManager', () => { // // Validate that session is verified, and unverified session no longer exists. // }); }); + + describe('getPrimaryEmailByUid', () => { + it('returns the primary email for an existing account', async () => { + const email = faker.internet.email(); + const uid = await accountManager.createAccountStub(email, 1, 'en-US'); + + const result = await accountManager.getPrimaryEmailByUid(uid); + expect(result).toBe(email); + }); + + it('returns undefined for an unknown uid', async () => { + const unknownUid = faker.string.hexadecimal({ + length: 32, + prefix: '', + }); + + const result = await accountManager.getPrimaryEmailByUid(unknownUid); + expect(result).toBeUndefined(); + }); + }); }); diff --git a/libs/shared/account/account/src/lib/account.manager.ts b/libs/shared/account/account/src/lib/account.manager.ts index 8a55d3d0343..343e69d6ef4 100644 --- a/libs/shared/account/account/src/lib/account.manager.ts +++ b/libs/shared/account/account/src/lib/account.manager.ts @@ -10,6 +10,7 @@ import { AccountDbProvider } from '@fxa/shared/db/mysql/account'; import { createAccount, getAccounts, + getPrimaryEmailByUid, verifyAccountSession, VerificationMethods, } from './account.repository'; @@ -54,6 +55,15 @@ export class AccountManager { return getAccounts(this.db, bufferUids); } + /** + * Return the current primary email address for an account, or undefined if + * the account has no primary email row (e.g. unknown uid). + */ + async getPrimaryEmailByUid(uid: string): Promise { + const row = await getPrimaryEmailByUid(this.db, uuidTransformer.to(uid)); + return row?.email; + } + async verifySession( uid: string, sessionTokenId: string, diff --git a/libs/shared/account/account/src/lib/account.repository.ts b/libs/shared/account/account/src/lib/account.repository.ts index 76880d0fe80..9752a8322c4 100644 --- a/libs/shared/account/account/src/lib/account.repository.ts +++ b/libs/shared/account/account/src/lib/account.repository.ts @@ -71,6 +71,22 @@ export function getAccounts(db: AccountDatabase, uids: Buffer[]) { .execute(); } +/** + * Get the current primary email address for an account by uid. + * + * Returns the row from the `emails` table flagged as primary — this is the + * user's current primary email, which may differ from the immutable signup + * email stored on the `accounts` table. + */ +export function getPrimaryEmailByUid(db: AccountDatabase, uid: Buffer) { + return db + .selectFrom('emails') + .select('email') + .where('uid', '=', uid) + .where('isPrimary', '=', true) + .executeTakeFirst(); +} + /** See session_token.js in auth server for master list. */ export enum VerificationMethods { email = 0,