packages/fxa-auth-server/lib/payments/capability.ts (882 lines of code) (raw):
/* 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 { getUidAndEmailByStripeCustomerId } from 'fxa-shared/db/models/auth';
import { commaSeparatedListToArray } from 'fxa-shared/lib/utils';
import { ALL_RPS_CAPABILITIES_KEY } from 'fxa-shared/subscriptions/configuration/base';
import { productUpgradeFromProductConfig } from 'fxa-shared/subscriptions/configuration/utils';
import { metadataFromPlan } from 'fxa-shared/subscriptions/metadata';
import {
ACTIVE_SUBSCRIPTION_STATUSES,
getSubscriptionUpdateEligibility,
} from 'fxa-shared/subscriptions/stripe';
import {
AbbrevPlan,
ClientIdCapabilityMap,
SubscriptionChangeEligibility,
SubscriptionEligibilityResult,
SubscriptionUpdateEligibility,
} from 'fxa-shared/subscriptions/types';
import isEqual from 'lodash/isEqual';
import Stripe from 'stripe';
import Container from 'typedi';
import { CapabilityManager } from '@fxa/payments/capability';
import {
EligibilityManager,
IntervalComparison,
intervalComparison,
OfferingComparison,
type OfferingOverlapResult,
} from '@fxa/payments/eligibility';
import * as Sentry from '@sentry/node';
import { SeverityLevel } from '@sentry/core';
import error from '../error';
import { authEvents } from '../events';
import { AppConfig, AuthLogger, AuthRequest } from '../types';
import { ConfigType } from '../../config';
import { PaymentConfigManager } from './configuration/manager';
import { AppleIAP } from './iap/apple-app-store/apple-iap';
import { AppStoreSubscriptionPurchase } from './iap/apple-app-store/subscription-purchase';
import { PlayBilling } from './iap/google-play/play-billing';
import { PlayStoreSubscriptionPurchase } from './iap/google-play/subscription-purchase';
import { PurchaseQueryError } from './iap/google-play/types';
import { StripeHelper } from './stripe';
import {
clientIdCapabilityMapFromMetadata,
sortClientCapabilities,
} from './utils';
import { ProfileClient } from '@fxa/profile/client';
import { reportSentryError, reportSentryMessage } from '../sentry';
function hex(blob: Buffer | string): string {
if (Buffer.isBuffer(blob)) {
return blob.toString('hex');
}
return blob;
}
// Flatten all the capabilities from a clientId to capability map into a single
// array of capabilities.
function allCapabilities(capabilityMap: ClientIdCapabilityMap): string[] {
return [...new Set(Object.values(capabilityMap).flat())];
}
/**
* Handles capability lookups, capability mapping between Stripe and IAP systems
* and active subscription capability calculations and event emitting.
*/
export class CapabilityService {
private log: AuthLogger;
private appleIap?: AppleIAP;
private playBilling?: PlayBilling;
private stripeHelper: StripeHelper;
private profileClient: ProfileClient;
private paymentConfigManager?: PaymentConfigManager;
private capabilityManager?: CapabilityManager;
private eligibilityManager?: EligibilityManager;
private config: ConfigType;
constructor() {
// TODO: the mock stripeHelper here fixes this specific instance when
// stripe isn't configured, but we should have a better strategy
// in general as this helper becomes more pervasive
this.stripeHelper = Container.has(StripeHelper)
? Container.get(StripeHelper)
: ({
iapPurchasesToPriceIds: () => Promise.resolve([]),
fetchCustomer: () => Promise.resolve(null),
allAbbrevPlans: () => Promise.resolve([]),
} as unknown as StripeHelper);
this.profileClient = Container.get(ProfileClient);
if (Container.has(PlayBilling)) {
this.playBilling = Container.get(PlayBilling);
}
if (Container.has(AppleIAP)) {
this.appleIap = Container.get(AppleIAP);
}
if (Container.has(PaymentConfigManager)) {
this.paymentConfigManager = Container.get(PaymentConfigManager);
}
if (Container.has(CapabilityManager)) {
this.capabilityManager = Container.get(CapabilityManager);
}
if (Container.has(EligibilityManager)) {
this.eligibilityManager = Container.get(EligibilityManager);
}
this.config = Container.get(AppConfig);
this.log = Container.get(AuthLogger);
// Register the event handlers for capability changes.
authEvents.on(
'account:capabilitiesAdded',
this.broadcastCapabilitiesAdded.bind(this)
);
authEvents.on(
'account:capabilitiesRemoved',
this.broadcastCapabilitiesRemoved.bind(this)
);
}
/**
* Create a price id changeset, by returning a priorPriceIds that is a disjoint
* set from the currentPriceIds given the affectedPriceIds.
*
* Due to the asynchronous nature of Stripe, Google, and Apple, we have no method
* that lets us know with certainty what price ids the user had active before the
* incoming event that triggered this change. To compensate for this, we assume
* that whatever the new current state of a users price ids are, that they were
* different than before this event. This does imply that we may broadcast that a
* user has had a capability removed or added multiple times even if they already
* had it, but relying parties can handle this gracefully.
*/
private createPriceIdChangeset({
currentPriceIds,
affectedPriceIds,
}: {
currentPriceIds: string[];
affectedPriceIds: string[];
}) {
const priorPriceIds = new Set([...currentPriceIds, ...affectedPriceIds]);
for (const affectedPriceId of affectedPriceIds) {
if (currentPriceIds.includes(affectedPriceId)) {
// Remove the price id from the prior list for processing to assume that it
// was previously inactive and ensure we broadcast a change.
priorPriceIds.delete(affectedPriceId);
}
}
return [...priorPriceIds];
}
/**
* Handle a Stripe Webhook subscription change.
*
* This handles broadcasting and refreshing the subscription capabilities for
* the price ids that were possibly updated.
*
* Stripe supports aligned subscriptions such that a single subscription can
* include multiple items for multiple products.
*/
public async stripeUpdate({
sub,
uid,
}: {
sub: Stripe.Subscription;
uid?: string | null;
}) {
if (typeof sub.customer !== 'string') {
throw error.internalValidationError(
'stripeUpdate',
{
subscriptionId: sub.id,
},
new Error('Subscription customer was not a string.')
);
}
if (!uid) {
({ uid } = await getUidAndEmailByStripeCustomerId(sub.customer));
}
if (!uid) {
// There's nothing to do if we can't find the user. We don't report it
// as we expect this to occur in the case of a deleted user.
return;
}
// Stripe subscriptions from events do not have price expanded, we filter
// by price being the non-expanded string for type checks.
const affectedPriceIds = sub.items.data.map((item) => item.price.id);
if (affectedPriceIds.length === 0) {
return;
}
const currentPriceIds = await this.subscribedPriceIds(uid);
const priorPriceIds = this.createPriceIdChangeset({
currentPriceIds,
affectedPriceIds,
});
return Promise.all([
this.profileClient.deleteCache(uid),
this.processPriceIdDiff({
uid,
priorPriceIds,
currentPriceIds,
}),
]);
}
/**
* Handle a Google Play or Apple App Store purchase change.
*
* This handles broadcasting and refreshing the subscription capabilities for
* the price ids that were possibly updated.
*/
public async iapUpdate(
uid: string,
purchase: PlayStoreSubscriptionPurchase | AppStoreSubscriptionPurchase
) {
const affectedPriceId = (
await this.stripeHelper.iapPurchasesToPriceIds([purchase])
).shift();
if (!affectedPriceId) {
// Purchase is not mapped to a price id.
return;
}
const currentPriceIds = await this.subscribedPriceIds(uid);
const priorPriceIds = this.createPriceIdChangeset({
currentPriceIds,
affectedPriceIds: [affectedPriceId],
});
return Promise.all([
this.profileClient.deleteCache(uid),
this.processPriceIdDiff({
uid,
priorPriceIds,
currentPriceIds,
}),
]);
}
/**
* Return a map of capabilities to client ids for the user.
*/
public async subscriptionCapabilities(
uid: string
): Promise<ClientIdCapabilityMap> {
const subscribedPrices = await this.subscribedPriceIds(uid);
return this.planIdsToClientCapabilities(subscribedPrices);
}
/**
* Return a list of all price ids with an active subscription.
*/
async subscribedPriceIds(uid: string) {
const [
subscribedStripePrices,
subscribedPlayPrices,
subscribedAppStorePrices,
] = await Promise.all([
this.fetchSubscribedPricesFromStripe(uid),
this.fetchSubscribedPricesFromPlay(uid),
this.fetchSubscribedPricesFromAppStore(uid),
]);
return [
...new Set([
...subscribedStripePrices,
...subscribedPlayPrices,
...subscribedAppStorePrices,
]),
];
}
async allAbbrevPlansByPlanId(): Promise<Record<string, AbbrevPlan>> {
const allPlans = await this.stripeHelper.allAbbrevPlans();
// Create a map of planId: abbrevPlan for speed/ease of lookup later without iterating
const allPlansByPlanId: Record<string, AbbrevPlan> = allPlans.reduce(
(acc, plan) => {
return {
...acc,
[plan.plan_id]: plan,
};
},
{}
);
return allPlansByPlanId;
}
async getAllSubscribedAbbrevPlans(
uid: string,
allPlansByPlanId: { [key: string]: AbbrevPlan }
) {
// Fetch all user's subscriptions from all sources
const [stripeSubscriptions, appleIapSubscriptions, playIapSubscriptions] =
await Promise.all([
this.fetchSubscribedPricesFromStripe(uid),
this.fetchSubscribedPricesFromAppStore(uid),
this.fetchSubscribedPricesFromPlay(uid),
]);
const getAbbrevPlansFromPlanIds = (planIds: string[]) => {
return planIds.map((planId) => allPlansByPlanId[planId]).filter(Boolean);
};
const stripeSubscribedPlans =
getAbbrevPlansFromPlanIds(stripeSubscriptions);
const iapSubscribedPlans = [
...getAbbrevPlansFromPlanIds(appleIapSubscriptions),
...getAbbrevPlansFromPlanIds(playIapSubscriptions),
];
return [stripeSubscribedPlans, iapSubscribedPlans];
}
/**
* Determine the subscription eligibility path for a user for a given plan,
* considering existing IAP subscriptions in the process.
*
* This method compares the Stripe Metadata provided eligibility results with
* the Eligibility Managers results if it is defined. Otherwise it returns the
* Stripe Metadata results.
*
* Will throw an error if the targetPlanId does not match with a known plan
*/
public async getPlanEligibility(
uid: string,
targetPlanId: string,
useFirestoreProductConfigs = false
): Promise<SubscriptionChangeEligibility> {
const allPlansByPlanId = await this.allAbbrevPlansByPlanId();
const targetPlan = allPlansByPlanId[targetPlanId];
if (!targetPlan) throw error.unknownSubscriptionPlan(targetPlanId);
const [stripeSubscribedPlans, iapSubscribedPlans] =
await this.getAllSubscribedAbbrevPlans(uid, allPlansByPlanId);
return this.getSubscribedPlanEligibility(
stripeSubscribedPlans,
iapSubscribedPlans,
targetPlan,
useFirestoreProductConfigs,
uid
);
}
public async getSubscribedPlanEligibility(
stripeSubscribedPlans: AbbrevPlan[],
iapSubscribedPlans: AbbrevPlan[],
targetPlan: AbbrevPlan,
useFirestoreProductConfigs = false,
uid: string | undefined = undefined
): Promise<SubscriptionChangeEligibility> {
const cmsEnabled = this.config.cms.enabled;
if (cmsEnabled) {
if (!this.eligibilityManager) {
throw error.internalValidationError(
'eligibilityResult',
{},
new Error('CapabilityManager not found.')
);
} else {
try {
const eligibilityManagerResult =
await this.eligibilityFromEligibilityManager(
stripeSubscribedPlans,
iapSubscribedPlans,
targetPlan
);
return eligibilityManagerResult;
} catch (err) {
throw error.internalValidationError(
'subscriptions.getPlanEligibility',
{},
err
);
}
}
}
// TODO: will be removed in FXA-8918
const stripeEligibilityResult = await this.eligibilityFromStripeMetadata(
stripeSubscribedPlans,
iapSubscribedPlans,
targetPlan,
useFirestoreProductConfigs
);
if (!this.eligibilityManager) return stripeEligibilityResult;
try {
const eligibilityManagerResult =
await this.eligibilityFromEligibilityManager(
stripeSubscribedPlans,
iapSubscribedPlans,
targetPlan
);
if (isEqual(stripeEligibilityResult, eligibilityManagerResult))
return stripeEligibilityResult;
this.log.error(`capability.getPlanEligibility.eligibilityMismatch`, {
stripeSubscribedPlans,
iapSubscribedPlans,
eligibilityManagerResult,
stripeEligibilityResult,
uid,
targetPlanId: targetPlan.plan_id,
});
Sentry.withScope((scope) => {
scope.setContext('getPlanEligibility', {
stripeSubscribedPlans,
iapSubscribedPlans,
eligibilityManagerResult,
stripeEligibilityResult,
uid,
targetPlanId: targetPlan.plan_id,
});
reportSentryMessage(
`Eligibility mismatch for ${uid} on ${targetPlan.plan_id}`,
'error' as SeverityLevel
);
});
} catch (err) {
this.log.error('subscriptions.getPlanEligibility', { error: err });
reportSentryError(err);
}
return stripeEligibilityResult;
// END TODO: will be removed in FXA-8918
}
/**
* Utilizes the EligibilityManager to determine if a user is eligible to
* subscribe to a plan and then maps the evaluation to the same subscription
* change eligilibity results as the Stripe Metadata based evaluation.
*/
async eligibilityFromEligibilityManager(
stripeSubscribedPlans: AbbrevPlan[],
iapSubscribedPlans: AbbrevPlan[],
targetPlan: AbbrevPlan
): Promise<SubscriptionChangeEligibility> {
if (!this.eligibilityManager)
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.INVALID,
};
const stripePlanIds = stripeSubscribedPlans.map((p) => p.plan_id);
const stripeOverlaps = await this.eligibilityManager.getOfferingOverlap({
priceIds: stripePlanIds,
targetPriceId: targetPlan.plan_id,
});
const iapPlanIds = iapSubscribedPlans.map((p) => p.plan_id);
const iapOverlaps = await this.eligibilityManager.getOfferingOverlap({
priceIds: iapPlanIds,
targetPriceId: targetPlan.plan_id,
});
const overlaps = [...stripeOverlaps, ...iapOverlaps];
// No overlap, we can create a new subscription
if (!overlaps.length)
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.CREATE,
};
// Users with IAP Offering overlaps should not be allowed to proceed
const iapRoadblockPlan = iapSubscribedPlans.find((plan) => {
return iapOverlaps.some((overlap) => plan.plan_id === overlap.priceId);
});
if (iapRoadblockPlan)
return {
subscriptionEligibilityResult:
SubscriptionEligibilityResult.BLOCKED_IAP,
eligibleSourcePlan: iapRoadblockPlan,
};
const overlapResults = stripeOverlaps.map((overlap) =>
this.compareOverlap(overlap, targetPlan, stripeSubscribedPlans)
);
if (overlapResults.length === 1) {
return overlapResults[0];
}
// All overlaps must be the same. We do not support multi-direcitonal upgrade/downgrade
const allSame = overlapResults.every(
(result) =>
result.subscriptionEligibilityResult ===
overlapResults[0].subscriptionEligibilityResult
);
const isInvalid =
overlapResults[0].subscriptionEligibilityResult ===
SubscriptionEligibilityResult.INVALID;
if (!allSame || isInvalid) {
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.INVALID,
};
}
const sourceForUpgrade =
overlapResults.reduce<SubscriptionChangeEligibility | null>(
(highest, el) => {
const currentAmount = el.eligibleSourcePlan?.amount || 0;
const highestAmount = highest?.eligibleSourcePlan?.amount || 0;
if (!highestAmount || currentAmount > highestAmount) {
return el;
}
return highest;
},
null
);
// This condition should not be possible
if (!sourceForUpgrade) {
Sentry.captureMessage(
'CapabilityService.eligibilityFromEligibilityManager: No source for upgrade found',
{
extra: {
overlaps,
targetPlan,
stripePlanIds,
},
}
);
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.INVALID,
};
}
const redundantOverlaps = overlapResults.filter(
(result) =>
result.eligibleSourcePlan?.plan_id !==
sourceForUpgrade.eligibleSourcePlan?.plan_id
);
return {
...sourceForUpgrade,
redundantOverlaps,
};
}
compareOverlap(
overlap: OfferingOverlapResult,
targetPlan: AbbrevPlan,
subscribedPrices: AbbrevPlan[]
): SubscriptionChangeEligibility {
const overlapAbbrev = subscribedPrices.find(
(p) => p.plan_id === overlap.priceId
);
if (overlap.comparison === OfferingComparison.DOWNGRADE)
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.DOWNGRADE,
eligibleSourcePlan: overlapAbbrev,
};
if (!overlapAbbrev || overlapAbbrev.plan_id === targetPlan.plan_id)
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.INVALID,
};
// Any interval change that is lower than the existing plans interval is
// a downgrade. Otherwise its considered an upgrade.
if (
intervalComparison(
{ unit: overlapAbbrev.interval, count: overlapAbbrev.interval_count },
{ unit: targetPlan.interval, count: targetPlan.interval_count }
) === IntervalComparison.SHORTER
)
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.DOWNGRADE,
eligibleSourcePlan: overlapAbbrev,
};
if (
overlap.comparison === OfferingComparison.UPGRADE ||
intervalComparison(
{ unit: overlapAbbrev.interval, count: overlapAbbrev.interval_count },
{ unit: targetPlan.interval, count: targetPlan.interval_count }
) === IntervalComparison.LONGER
)
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.UPGRADE,
eligibleSourcePlan: overlapAbbrev,
};
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.INVALID,
};
}
/**
* Utilizes Stripe Metadata to determine if a user is eligible to subscribe to
* a plan.
*/
async eligibilityFromStripeMetadata(
stripeSubscribedPlans: AbbrevPlan[],
iapSubscribedPlans: AbbrevPlan[],
targetPlan: AbbrevPlan,
useFirestoreProductConfigs = false
): Promise<SubscriptionChangeEligibility> {
const { productSet: targetProductSet } = productUpgradeFromProductConfig(
targetPlan,
useFirestoreProductConfigs
);
if (!targetProductSet)
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.INVALID,
};
// Lookup whether user holds an IAP subscription with a shared productSet to the target
const iapRoadblockPlan = iapSubscribedPlans.find((abbrevPlan) => {
const { productSet } = productUpgradeFromProductConfig(
abbrevPlan,
useFirestoreProductConfigs
);
return productSet?.some((name) => targetProductSet.includes(name));
});
// Users with an IAP subscription to the productSet that we're trying to subscribe
// to should not be allowed to proceed
if (iapRoadblockPlan)
return {
subscriptionEligibilityResult:
SubscriptionEligibilityResult.BLOCKED_IAP,
eligibleSourcePlan: iapRoadblockPlan,
};
const isSubscribedToProductSet = stripeSubscribedPlans.some(
(abbrevPlan) => {
const { productSet } = productUpgradeFromProductConfig(
abbrevPlan,
useFirestoreProductConfigs
);
return productSet?.some((name) => targetProductSet.includes(name));
}
);
if (!isSubscribedToProductSet)
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.CREATE,
};
// Use the upgradeEligibility helper to check if any of our existing plans are
// elegible for an upgrade and if so the user can upgrade that existing plan to the desired plan
for (const abbrevPlan of stripeSubscribedPlans) {
const eligibility = getSubscriptionUpdateEligibility(
abbrevPlan,
targetPlan,
useFirestoreProductConfigs
);
if (eligibility === SubscriptionUpdateEligibility.UPGRADE)
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.UPGRADE,
eligibleSourcePlan: abbrevPlan,
};
if (eligibility === SubscriptionUpdateEligibility.DOWNGRADE)
return {
subscriptionEligibilityResult:
SubscriptionEligibilityResult.DOWNGRADE,
eligibleSourcePlan: abbrevPlan,
};
}
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.INVALID,
};
}
/**
* Diff a list of prior price ids to the list of current price ids
* and emit the necessary events for added/removed capabilities.
*/
public async processPriceIdDiff(options: {
uid: string;
priorPriceIds: string[];
currentPriceIds: string[];
}) {
const { uid, priorPriceIds, currentPriceIds } = options;
// Calculate and announce capability changes.
const [priorClientCapabilities, currentClientCapabilities] =
await Promise.all([
this.planIdsToClientCapabilities(priorPriceIds),
this.planIdsToClientCapabilities(currentPriceIds),
]);
const [priorCapabilities, currentCapabilities] = [
allCapabilities(priorClientCapabilities),
allCapabilities(currentClientCapabilities),
];
const newCapabilities = currentCapabilities.filter(
(capability) => !priorCapabilities.includes(capability)
);
const removedCapabilities = priorCapabilities.filter(
(capability) => !currentCapabilities.includes(capability)
);
if (newCapabilities.length > 0) {
this.broadcastCapabilitiesAdded({
uid,
capabilities: newCapabilities,
});
}
if (removedCapabilities.length > 0) {
this.broadcastCapabilitiesRemoved({
uid,
capabilities: removedCapabilities,
});
}
return {
newCapabilities,
removedCapabilities,
};
}
/**
* Broadcast the capabilities that are active via SQS.
*/
private broadcastCapabilitiesAdded(options: {
uid: string;
capabilities: string[];
request?: AuthRequest;
eventCreatedAt?: number;
}) {
const { uid, capabilities, request, eventCreatedAt } = options;
this.log.notifyAttachedServices(
'subscription:update',
request ?? ({} as AuthRequest),
{
uid,
// This number needs to be in seconds.
eventCreatedAt: eventCreatedAt ?? Math.floor(Date.now() / 1000),
isActive: true,
productCapabilities: capabilities,
}
);
}
/**
* Broadcast the capabilities that are not active via SQS.
*/
private broadcastCapabilitiesRemoved(options: {
uid: string;
capabilities: string[];
request?: AuthRequest;
eventCreatedAt?: number;
}) {
const { uid, capabilities, request, eventCreatedAt } = options;
this.log.notifyAttachedServices(
'subscription:update',
request ?? ({} as AuthRequest),
{
uid,
// This number needs to be in seconds.
eventCreatedAt: eventCreatedAt ?? Math.floor(Date.now() / 1000),
isActive: false,
productCapabilities: capabilities,
}
);
}
/**
* Given a `ClientIdCapabilityMap`, return an array of the capabilities
* for the provided client id.
*/
public determineClientVisibleSubscriptionCapabilities(
clientIdRaw: Buffer | string | null,
allCapabilities: Record<string, string[]>
) {
if (!allCapabilities) {
return undefined;
}
const clientId =
clientIdRaw === null ? null : hex(clientIdRaw).toLowerCase();
let capabilitiesToReveal;
if (clientId === null) {
capabilitiesToReveal = new Set(
Object.values(allCapabilities).reduce(
(acc, curr) => [...curr, ...acc],
[]
)
);
} else {
capabilitiesToReveal = new Set([
...(allCapabilities[ALL_RPS_CAPABILITIES_KEY] || []),
...(allCapabilities[clientId] || []),
]);
}
return capabilitiesToReveal.size > 0
? Array.from(capabilitiesToReveal).sort()
: undefined;
}
/**
* Fetch the list of subscription purchases from Google Play and return
* the ids of the products purchased.
*/
private async fetchSubscribedPricesFromPlay(uid: string): Promise<string[]> {
if (!this.playBilling) {
return [];
}
try {
const allPurchases =
await this.playBilling.userManager.queryCurrentSubscriptions(uid);
const purchases = allPurchases.filter((purchase) =>
purchase.isEntitlementActive()
);
return purchases.length === 0
? []
: this.stripeHelper.iapPurchasesToPriceIds(purchases);
} catch (err) {
if (err.name === PurchaseQueryError.OTHER_ERROR) {
this.log.error('Failed to query purchases from Google Play', {
uid,
err,
});
}
return [];
}
}
public async fetchSubscribedPricesFromAppStore(
uid: string
): Promise<string[]> {
if (!this.appleIap) {
return [];
}
try {
const allPurchases =
await this.appleIap.purchaseManager.queryCurrentSubscriptionPurchases(
uid
);
const purchases = allPurchases.filter((purchase) =>
purchase.isEntitlementActive()
);
return purchases.length === 0
? []
: this.stripeHelper.iapPurchasesToPriceIds(purchases);
} catch (err) {
if (err.name === PurchaseQueryError.OTHER_ERROR) {
this.log.error('Failed to query purchases from Apple App Store', {
uid,
err,
});
}
return [];
}
}
/**
* Fetch the list of ids of prices purchased from Stripe.
*/
private async fetchSubscribedPricesFromStripe(
uid: string
): Promise<string[]> {
const customer = await this.stripeHelper.fetchCustomer(uid, [
'subscriptions',
]);
const subscriptions = customer?.subscriptions?.data;
if (!subscriptions) {
return [];
}
const subscribedPrices = subscriptions
.filter((sub) => ACTIVE_SUBSCRIPTION_STATUSES.includes(sub.status))
.flatMap((sub) => sub.items.data)
.map(({ price: { id: priceId } }) => priceId as string);
return subscribedPrices;
}
/**
* Fetch the mergedConfig of plans that are configured and subscribed to.
*/
private async configuredSubscribedMergedConfigs(subscribedPrices: string[]) {
if (!this.paymentConfigManager) return [];
const allPlans = (await this.paymentConfigManager.allPlans()).filter(
(plan) => subscribedPrices.includes(plan.stripePriceId ?? '')
);
return allPlans.map(this.paymentConfigManager.getMergedConfig);
}
/**
* Fetch the list of capabilities for the given plan ids from Stripe.
*/
// TODO: will be removed in FXA-8918
private async planIdsToClientCapabilitiesFromStripe(
subscribedPrices: string[]
): Promise<ClientIdCapabilityMap> {
let result: ClientIdCapabilityMap = {};
// Run through all plans and collect capabilities for subscribed products
for (const price of await this.stripeHelper.allAbbrevPlans()) {
if (!subscribedPrices.includes(price.plan_id)) {
continue;
}
// Add the capabilities for this price's plan and product
result = ClientIdCapabilityMap.merge(
result,
clientIdCapabilityMapFromMetadata(price.product_metadata)
);
result = ClientIdCapabilityMap.merge(
result,
clientIdCapabilityMapFromMetadata(price.plan_metadata || {})
);
}
for (const mergedConfigPlan of await this.configuredSubscribedMergedConfigs(
subscribedPrices
)) {
// Add the capabilities for this price
result = ClientIdCapabilityMap.merge(
result,
mergedConfigPlan.capabilities || {}
);
}
return result;
}
/**
* Retrieve the client capabilities from Stripe
*/
// TODO: will be removed in FXA-8918
async getClientsFromStripe() {
let result: ClientIdCapabilityMap = {};
const planConfigs = await this.stripeHelper.allMergedPlanConfigs();
const capabilitiesForAll: string[] = [];
for (const plan of await this.stripeHelper.allAbbrevPlans()) {
const metadata = metadataFromPlan(plan);
const pConfig = planConfigs?.[plan.plan_id] || {};
capabilitiesForAll.push(
...commaSeparatedListToArray(metadata.capabilities || ''),
...(pConfig.capabilities?.[ALL_RPS_CAPABILITIES_KEY] || [])
);
result = ClientIdCapabilityMap.merge(
result,
clientIdCapabilityMapFromMetadata(metadata || {}, 'capabilities:')
);
if (pConfig.capabilities) {
Object.keys(pConfig.capabilities)
.filter((x) => x !== ALL_RPS_CAPABILITIES_KEY)
.forEach(
(clientId) =>
(result[clientId] = (result[clientId] || []).concat(
pConfig.capabilities?.[clientId]
))
);
}
}
return Object.entries(result).map(([clientId, capabilities]) => {
// Merge dupes with Set
const capabilitySet = new Set([...capabilitiesForAll, ...capabilities]);
const sortedCapabilities = Array.from(capabilitySet).sort();
return {
clientId,
capabilities: sortedCapabilities,
};
});
}
/**
* Retrieve the client capabilities
*/
async getClients() {
const cmsEnabled = this.config.cms.enabled;
if (cmsEnabled) {
if (!this.capabilityManager) {
throw error.internalValidationError(
'getClients',
{},
new Error('CapabilityManager not found.')
);
} else {
try {
const clientsFromCMS = await this.capabilityManager.getClients();
return clientsFromCMS;
} catch (err) {
throw error.internalValidationError(
'subscriptions.getClients',
{},
err
);
}
}
}
// TODO: will be removed in FXA-8918
const clientsFromStripe = await this.getClientsFromStripe();
if (!this.capabilityManager) return clientsFromStripe;
try {
const clientsFromCMS = await this.capabilityManager.getClients();
clientsFromCMS.sort((a, b) => a.clientId.localeCompare(b.clientId));
clientsFromStripe.sort((a, b) => a.clientId.localeCompare(b.clientId));
if (isEqual(clientsFromCMS, clientsFromStripe)) return clientsFromCMS;
this.log.error(`capability.getClients.clientsMismatch`, {
cms: clientsFromCMS,
stripe: clientsFromStripe,
});
Sentry.withScope((scope) => {
scope.setContext('getClients', {
cms: clientsFromCMS,
stripe: clientsFromStripe,
});
reportSentryMessage(
`CapabilityService.getClients - Returned Stripe as clients did not match.`,
'error' as SeverityLevel
);
});
} catch (err) {
this.log.error('subscriptions.getClients', { error: err });
reportSentryError(err);
}
return clientsFromStripe;
// END TODO: will be removed in FXA-8918
}
/**
* Fetch the list of capabilities for the given plan ids
*/
async planIdsToClientCapabilities(
subscribedPrices: string[]
): Promise<ClientIdCapabilityMap> {
const cmsEnabled = this.config.cms.enabled;
if (cmsEnabled) {
if (!this.capabilityManager) {
throw error.internalValidationError(
'planIdsToClientCapabilities',
{},
new Error('CapabilityManager not found.')
);
} else {
try {
const cmsCapabilities =
await this.capabilityManager.priceIdsToClientCapabilities(
subscribedPrices
);
return cmsCapabilities;
} catch (err) {
throw error.internalValidationError(
'subscriptions.planIdsToClientCapabilities',
{},
err
);
}
}
}
// TODO: will be removed in FXA-8918
const stripeCapabilities =
await this.planIdsToClientCapabilitiesFromStripe(subscribedPrices);
if (!this.capabilityManager) return stripeCapabilities;
try {
const cmsCapabilities =
await this.capabilityManager.priceIdsToClientCapabilities(
subscribedPrices
);
if (
isEqual(
sortClientCapabilities(cmsCapabilities),
sortClientCapabilities(stripeCapabilities)
)
) {
return cmsCapabilities;
}
this.log.error(`capability.planIdsToClientCapabilities.mismatch`, {
subscribedPrices,
cms: cmsCapabilities,
stripe: stripeCapabilities,
});
Sentry.withScope((scope) => {
scope.setContext('planIdsToClientCapabilities', {
subscribedPrices,
cms: cmsCapabilities,
stripe: stripeCapabilities,
});
reportSentryMessage(
`CapabilityService.planIdsToClientCapabilities - Returned Stripe as plan ids to client capabilities did not match.`,
'error' as SeverityLevel
);
});
} catch (err) {
this.log.error('subscriptions.planIdsToClientCapabilities', {
error: err,
});
reportSentryError(err);
}
return stripeCapabilities;
// END TODO: will be removed in FXA-8918
}
}