Merge pull request #16091 from mozilla/FXA-6166

refactor(payments): Utilize new plan-eligibility endpoint
This commit is contained in:
Lisa Chan 2023-11-22 09:23:58 -05:00 коммит произвёл GitHub
Родитель c95d96e582 807f58b386
Коммит d2ce1788db
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
14 изменённых файлов: 352 добавлений и 279 удалений

Просмотреть файл

@ -379,7 +379,9 @@ export class CapabilityService {
targetPlan: AbbrevPlan
): Promise<SubscriptionChangeEligibility> {
if (!this.eligibilityManager)
return [SubscriptionEligibilityResult.INVALID];
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.INVALID,
};
const iapProductIds = iapSubscribedPlans.map((p) => p.product_id);
const planIds = [
...stripeSubscribedPlans.map((p) => p.plan_id),
@ -392,20 +394,32 @@ export class CapabilityService {
);
// No overlap, we can create a new subscription
if (!overlaps.length) return [SubscriptionEligibilityResult.CREATE];
if (!overlaps.length)
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.CREATE,
};
// Users with IAP Offering overlaps should not be allowed to proceed
if (
overlaps.some(
const iapRoadblockPlan = iapSubscribedPlans.find((plan) => {
return overlaps?.some(
(overlap) =>
overlap.type === 'offering' &&
iapProductIds.includes(overlap.offeringProductId)
)
)
return [SubscriptionEligibilityResult.BLOCKED_IAP];
);
});
if (iapRoadblockPlan)
return {
subscriptionEligibilityResult:
SubscriptionEligibilityResult.BLOCKED_IAP,
eligibleSourcePlan: iapRoadblockPlan,
};
// Multiple existing overlapping plans, we can't merge them
if (overlaps.length > 1) return [SubscriptionEligibilityResult.INVALID];
if (overlaps.length > 1)
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.INVALID,
};
const overlap = overlaps[0];
assert(
@ -417,10 +431,15 @@ export class CapabilityService {
);
if (overlap.comparison === OfferingComparison.DOWNGRADE)
return [SubscriptionEligibilityResult.DOWNGRADE, overlapAbbrev];
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.DOWNGRADE,
eligibleSourcePlan: overlapAbbrev,
};
if (!overlapAbbrev || overlapAbbrev.plan_id === targetPlan.plan_id)
return [SubscriptionEligibilityResult.INVALID];
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.INVALID,
};
// Any interval change that is lower than the existing plans interval is
// a downgrade. Otherwise its considered an upgrade.
@ -430,9 +449,15 @@ export class CapabilityService {
{ unit: targetPlan.interval, count: targetPlan.interval_count }
) === IntervalComparison.SHORTER
)
return [SubscriptionEligibilityResult.DOWNGRADE, overlapAbbrev];
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.DOWNGRADE,
eligibleSourcePlan: overlapAbbrev,
};
return [SubscriptionEligibilityResult.UPGRADE, overlapAbbrev];
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.UPGRADE,
eligibleSourcePlan: overlapAbbrev,
};
}
/**
@ -450,10 +475,13 @@ export class CapabilityService {
useFirestoreProductConfigs
);
if (!targetProductSet) return [SubscriptionEligibilityResult.INVALID];
if (!targetProductSet)
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.INVALID,
};
// Lookup whether user holds an IAP subscription with a shared productSet to the target
const iapRoadblock = iapSubscribedPlans.some((abbrevPlan) => {
const iapRoadblockPlan = iapSubscribedPlans.find((abbrevPlan) => {
const { productSet } = productUpgradeFromProductConfig(
abbrevPlan,
useFirestoreProductConfigs
@ -464,7 +492,12 @@ export class CapabilityService {
// Users with an IAP subscription to the productSet that we're trying to subscribe
// to should not be allowed to proceed
if (iapRoadblock) return [SubscriptionEligibilityResult.BLOCKED_IAP];
if (iapRoadblockPlan)
return {
subscriptionEligibilityResult:
SubscriptionEligibilityResult.BLOCKED_IAP,
eligibleSourcePlan: iapRoadblockPlan,
};
const isSubscribedToProductSet = stripeSubscribedPlans.some(
(abbrevPlan) => {
@ -478,7 +511,9 @@ export class CapabilityService {
);
if (!isSubscribedToProductSet)
return [SubscriptionEligibilityResult.CREATE];
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
@ -490,12 +525,21 @@ export class CapabilityService {
);
if (eligibility === SubscriptionUpdateEligibility.UPGRADE)
return [SubscriptionEligibilityResult.UPGRADE, abbrevPlan];
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.UPGRADE,
eligibleSourcePlan: abbrevPlan,
};
if (eligibility === SubscriptionUpdateEligibility.DOWNGRADE)
return [SubscriptionEligibilityResult.DOWNGRADE, abbrevPlan];
return {
subscriptionEligibilityResult:
SubscriptionEligibilityResult.DOWNGRADE,
eligibleSourcePlan: abbrevPlan,
};
}
return [SubscriptionEligibilityResult.INVALID];
return {
subscriptionEligibilityResult: SubscriptionEligibilityResult.INVALID,
};
}
/**

Просмотреть файл

@ -92,12 +92,14 @@ export class MozillaSubscriptionHandler {
const targetPlanId = request.params.planId;
const eligibility = (
await this.capabilityService.getPlanEligibility(uid, targetPlanId)
)[0];
const result = await this.capabilityService.getPlanEligibility(
uid,
targetPlanId
);
return {
eligibility,
eligibility: result.subscriptionEligibilityResult,
currentPlan: result.eligibleSourcePlan,
};
}
}

Просмотреть файл

@ -108,13 +108,14 @@ export class PayPalHandler extends StripeWebhookHandler {
}
// Validate that the user doesn't have conflicting subscriptions, for instance via IAP
const eligibility = (
await this.capabilityService.getPlanEligibility(
customer.metadata.userid,
priceId
)
)[0];
if (eligibility !== SubscriptionEligibilityResult.CREATE) {
const result = await this.capabilityService.getPlanEligibility(
customer.metadata.userid,
priceId
);
if (
result.subscriptionEligibilityResult !==
SubscriptionEligibilityResult.CREATE
) {
throw error.userAlreadySubscribedToProduct();
}

Просмотреть файл

@ -9,6 +9,7 @@ import { SeverityLevel } from '@sentry/types';
import { getAccountCustomerByUid } from 'fxa-shared/db/models/auth';
import {
AbbrevPlan,
SubscriptionChangeEligibility,
SubscriptionEligibilityResult,
SubscriptionUpdateEligibility,
} from 'fxa-shared/subscriptions/types';
@ -219,14 +220,14 @@ export class StripeHandler {
);
}
const eligibility = await this.capabilityService.getPlanEligibility(
uid,
planId
);
const result: SubscriptionChangeEligibility =
await this.capabilityService.getPlanEligibility(uid, planId);
const eligibleForUpgrade =
eligibility[0] === SubscriptionEligibilityResult.UPGRADE;
const isUpgradeForCurrentPlan = eligibility[1]?.plan_id === currentPlan.id;
result.subscriptionEligibilityResult ===
SubscriptionEligibilityResult.UPGRADE;
const isUpgradeForCurrentPlan =
result.eligibleSourcePlan?.plan_id === currentPlan.id;
if (!eligibleForUpgrade || !isUpgradeForCurrentPlan) {
throw error.invalidPlanUpdate();
}
@ -405,13 +406,15 @@ export class StripeHandler {
let isUpgrade = false,
sourcePlan;
if (customer) {
const upgradeResult = await this.capabilityService.getPlanEligibility(
const result = await this.capabilityService.getPlanEligibility(
customer.metadata.userid,
priceId
);
isUpgrade = upgradeResult[0] === SubscriptionUpdateEligibility.UPGRADE;
sourcePlan = upgradeResult[1];
isUpgrade =
result.subscriptionEligibilityResult ===
SubscriptionUpdateEligibility.UPGRADE;
sourcePlan = result.eligibleSourcePlan;
}
const previewInvoice = await this.stripeHelper.previewInvoice({
@ -553,13 +556,14 @@ export class StripeHandler {
}
// Validate that the user doesn't have conflicting subscriptions, for instance via IAP
const eligibility = (
const { subscriptionEligibilityResult } =
await this.capabilityService.getPlanEligibility(
customer.metadata.userid,
priceId
)
)[0];
if (eligibility !== SubscriptionEligibilityResult.CREATE) {
);
if (
subscriptionEligibilityResult !== SubscriptionEligibilityResult.CREATE
) {
throw error.userAlreadySubscribedToProduct();
}

Просмотреть файл

@ -546,7 +546,7 @@ describe('CapabilityService', () => {
});
});
describe('elibility', () => {
describe('eligibility', () => {
const mockPlanTier1ShortInterval = {
plan_id: 'plan_123456',
product_id: 'prod_123456',
@ -612,26 +612,30 @@ describe('CapabilityService', () => {
type: 'offering',
},
]);
const actual = (
const actual =
await capabilityService.eligibilityFromEligibilityManager(
[],
[mockPlanTier1ShortInterval],
mockPlanTier1LongInterval
)
)[0];
assert.equal(actual, SubscriptionEligibilityResult.BLOCKED_IAP);
);
assert.deepEqual(actual, {
subscriptionEligibilityResult:
SubscriptionEligibilityResult.BLOCKED_IAP,
eligibleSourcePlan: mockPlanTier1ShortInterval,
});
});
it('returns create for targetPlan with offering user is not subscribed to', async () => {
mockEligibilityManager.getOfferingOverlap = sinon.fake.resolves([]);
const actual = (
const actual =
await capabilityService.eligibilityFromEligibilityManager(
[],
[],
mockPlanTier1ShortInterval
)
)[0];
assert.equal(actual, SubscriptionEligibilityResult.CREATE);
);
assert.deepEqual(actual, {
subscriptionEligibilityResult: SubscriptionEligibilityResult.CREATE,
});
});
it('returns upgrade for targetPlan with offering user is subscribed to a lower tier of', async () => {
@ -642,14 +646,16 @@ describe('CapabilityService', () => {
type: 'plan',
},
]);
const actual = (
const actual =
await capabilityService.eligibilityFromEligibilityManager(
[mockPlanTier1ShortInterval],
[],
mockPlanTier2LongInterval
)
)[0];
assert.equal(actual, SubscriptionEligibilityResult.UPGRADE);
);
assert.deepEqual(actual, {
subscriptionEligibilityResult: SubscriptionEligibilityResult.UPGRADE,
eligibleSourcePlan: mockPlanTier1ShortInterval,
});
});
it('returns downgrade for targetPlan with offering user is subscribed to a higher tier of', async () => {
@ -660,14 +666,17 @@ describe('CapabilityService', () => {
type: 'plan',
},
]);
const actual = (
const actual =
await capabilityService.eligibilityFromEligibilityManager(
[mockPlanTier2LongInterval],
[],
mockPlanTier1ShortInterval
)
)[0];
assert.equal(actual, SubscriptionEligibilityResult.DOWNGRADE);
);
assert.deepEqual(actual, {
subscriptionEligibilityResult:
SubscriptionEligibilityResult.DOWNGRADE,
eligibleSourcePlan: undefined,
});
});
it('returns upgrade for targetPlan with offering user is subscribed to a higher interval of', async () => {
@ -678,14 +687,16 @@ describe('CapabilityService', () => {
type: 'plan',
},
]);
const actual = (
const actual =
await capabilityService.eligibilityFromEligibilityManager(
[mockPlanTier1ShortInterval],
[],
mockPlanTier1LongInterval
)
)[0];
assert.equal(actual, SubscriptionEligibilityResult.UPGRADE);
);
assert.deepEqual(actual, {
subscriptionEligibilityResult: SubscriptionEligibilityResult.UPGRADE,
eligibleSourcePlan: mockPlanTier1ShortInterval,
});
});
it('returns downgrade for targetPlan with shorter interval but higher tier than user is subscribed to', async () => {
@ -698,14 +709,17 @@ describe('CapabilityService', () => {
]);
Container.set(EligibilityManager, mockEligibilityManager);
capabilityService = new CapabilityService();
const actual = (
const actual =
await capabilityService.eligibilityFromEligibilityManager(
[mockPlanTier1LongInterval],
[],
mockPlanTier2ShortInterval
)
)[0];
assert.equal(actual, SubscriptionEligibilityResult.DOWNGRADE);
);
assert.deepEqual(actual, {
subscriptionEligibilityResult:
SubscriptionEligibilityResult.DOWNGRADE,
eligibleSourcePlan: mockPlanTier1LongInterval,
});
});
it('returns invalid for targetPlan with same offering user is subscribed to', async () => {
@ -716,14 +730,15 @@ describe('CapabilityService', () => {
type: 'plan',
},
]);
const actual = (
const actual =
await capabilityService.eligibilityFromEligibilityManager(
[mockPlanTier1ShortInterval],
[],
mockPlanTier1ShortInterval
)
)[0];
assert.equal(actual, SubscriptionEligibilityResult.INVALID);
);
assert.deepEqual(actual, {
subscriptionEligibilityResult: SubscriptionEligibilityResult.INVALID,
});
});
});
@ -731,67 +746,72 @@ describe('CapabilityService', () => {
it('returns blocked_iap for targetPlan with productSet the user is subscribed to with IAP', async () => {
capabilityService.fetchSubscribedPricesFromAppStore =
sinon.fake.resolves(['plan_123456']);
const actual = (
await capabilityService.eligibilityFromStripeMetadata(
[],
[mockPlanTier2LongInterval],
mockPlanTier1ShortInterval
)
)[0];
assert.equal(actual, SubscriptionEligibilityResult.BLOCKED_IAP);
const actual = await capabilityService.eligibilityFromStripeMetadata(
[],
[mockPlanTier2LongInterval],
mockPlanTier1ShortInterval
);
assert.deepEqual(actual, {
subscriptionEligibilityResult:
SubscriptionEligibilityResult.BLOCKED_IAP,
eligibleSourcePlan: mockPlanTier2LongInterval,
});
});
it('returns create for targetPlan with productSet user is not subscribed to', async () => {
const actual = (
await capabilityService.eligibilityFromStripeMetadata(
[],
[],
mockPlanTier1ShortInterval
)
)[0];
assert.equal(actual, SubscriptionEligibilityResult.CREATE);
const actual = await capabilityService.eligibilityFromStripeMetadata(
[],
[],
mockPlanTier1ShortInterval
);
assert.deepEqual(actual, {
subscriptionEligibilityResult: SubscriptionEligibilityResult.CREATE,
});
});
it('returns upgrade for targetPlan with productSet user is subscribed to a lower tier of', async () => {
capabilityService.fetchSubscribedPricesFromStripe = sinon.fake.resolves(
[mockPlanTier1ShortInterval.plan_id]
);
const actual = (
await capabilityService.eligibilityFromStripeMetadata(
[mockPlanTier1ShortInterval],
[],
mockPlanTier2LongInterval
)
)[0];
assert.equal(actual, SubscriptionEligibilityResult.UPGRADE);
const actual = await capabilityService.eligibilityFromStripeMetadata(
[mockPlanTier1ShortInterval],
[],
mockPlanTier2LongInterval
);
assert.deepEqual(actual, {
subscriptionEligibilityResult: SubscriptionEligibilityResult.UPGRADE,
eligibleSourcePlan: mockPlanTier1ShortInterval,
});
});
it('returns downgrade for targetPlan with productSet user is subscribed to a higher tier of', async () => {
capabilityService.fetchSubscribedPricesFromStripe = sinon.fake.resolves(
[mockPlanTier2LongInterval.plan_id]
);
const actual = (
await capabilityService.eligibilityFromStripeMetadata(
[mockPlanTier2LongInterval],
[],
mockPlanTier1ShortInterval
)
)[0];
assert.equal(actual, SubscriptionEligibilityResult.DOWNGRADE);
const actual = await capabilityService.eligibilityFromStripeMetadata(
[mockPlanTier2LongInterval],
[],
mockPlanTier1ShortInterval
);
assert.deepEqual(actual, {
subscriptionEligibilityResult:
SubscriptionEligibilityResult.DOWNGRADE,
eligibleSourcePlan: mockPlanTier2LongInterval,
});
});
it('returns invalid for targetPlan with no product order', async () => {
capabilityService.fetchSubscribedPricesFromStripe = sinon.fake.resolves(
[mockPlanTier2LongInterval.plan_id]
);
const actual = (
await capabilityService.eligibilityFromStripeMetadata(
[mockPlanTier2LongInterval],
[],
mockPlanNoProductOrder
)
)[0];
assert.equal(actual, SubscriptionEligibilityResult.INVALID);
const actual = await capabilityService.eligibilityFromStripeMetadata(
[mockPlanTier2LongInterval],
[],
mockPlanNoProductOrder
);
assert.deepEqual(actual, {
subscriptionEligibilityResult: SubscriptionEligibilityResult.INVALID,
});
});
});
});

Просмотреть файл

@ -319,7 +319,9 @@ describe('mozilla-subscriptions', () => {
describe('plan-eligibility', () => {
beforeEach(() => {
capabilityService = {
getPlanEligibility: sandbox.stub().resolves(['eligibility', undefined]),
getPlanEligibility: sandbox.stub().resolves({
subscriptionEligibilityResult: 'eligibility',
}),
};
});
@ -334,6 +336,7 @@ describe('plan-eligibility', () => {
);
assert.deepEqual(resp, {
eligibility: 'eligibility',
currentPlan: undefined,
});
});
});

Просмотреть файл

@ -204,10 +204,9 @@ describe('subscriptions payPalRoutes', () => {
beforeEach(() => {
stripeHelper.findCustomerSubscriptionByPlanId =
sinon.fake.returns(undefined);
capabilityService.getPlanEligibility = sinon.fake.resolves([
SubscriptionEligibilityResult.CREATE,
undefined,
]);
capabilityService.getPlanEligibility = sinon.fake.resolves({
subscriptionEligibilityResult: SubscriptionEligibilityResult.CREATE,
});
stripeHelper.cancelSubscription = sinon.fake.resolves({});
payPalHelper.cancelBillingAgreement = sinon.fake.resolves({});
profile.deleteCache = sinon.fake.resolves({});

Просмотреть файл

@ -418,10 +418,9 @@ describe('DirectStripeRoutes', () => {
},
};
mockCapabilityService.getPlanEligibility = sinon.stub();
mockCapabilityService.getPlanEligibility.resolves([
SubscriptionEligibilityResult.CREATE,
undefined,
]);
mockCapabilityService.getPlanEligibility.resolves({
subscriptionEligibilityResult: SubscriptionEligibilityResult.CREATE,
});
mockCapabilityService.getClients = sinon.stub();
mockCapabilityService.getClients.resolves(mockContentfulClients);
Container.set(CapabilityService, mockCapabilityService);
@ -1974,10 +1973,10 @@ describe('DirectStripeRoutes', () => {
VALID_REQUEST.params = { subscriptionId: subscriptionId };
mockCapabilityService.getPlanEligibility = sinon.stub();
mockCapabilityService.getPlanEligibility.resolves([
SubscriptionEligibilityResult.UPGRADE,
plan.id,
]);
mockCapabilityService.getPlanEligibility.resolves({
subscriptionEligibilityResult: SubscriptionEligibilityResult.UPGRADE,
eligibleSourcePlan: subscription2,
});
directStripeRoutesInstance.stripeHelper.changeSubscriptionPlan.resolves();
@ -2013,10 +2012,9 @@ describe('DirectStripeRoutes', () => {
directStripeRoutesInstance.stripeHelper.findAbbrevPlanById.resolves(plan);
mockCapabilityService.getPlanEligibility = sinon.stub();
mockCapabilityService.getPlanEligibility.resolves([
SubscriptionEligibilityResult.UPGRADE,
plan.id,
]);
mockCapabilityService.getPlanEligibility.resolves({
subscriptionEligibilityResult: SubscriptionEligibilityResult.UPGRADE,
});
try {
await directStripeRoutesInstance.updateSubscription(VALID_REQUEST);

Просмотреть файл

@ -6,6 +6,7 @@ import {
LatestInvoiceItems,
} from 'fxa-shared/dto/auth/payments/invoice';
import {
AbbrevPlan,
IapSubscription,
MozillaSubscriptionTypes,
WebSubscription,
@ -129,7 +130,7 @@ export const SELECTED_PLAN: Plan = {
},
};
export const UPGRADE_FROM_PLAN: Plan = {
export const UPGRADE_FROM_PLAN: AbbrevPlan = {
plan_id: 'plan_abc',
product_id: PRODUCT_ID,
product_name: 'Example Product',
@ -139,6 +140,7 @@ export const UPGRADE_FROM_PLAN: Plan = {
interval_count: 1,
active: true,
plan_metadata: null,
plan_name: 'Example Product Monthly',
product_metadata: {
webIconURL: 'http://placekitten.com/49/49?image=9',
webIconBackground: 'lime',
@ -156,7 +158,7 @@ export const UPGRADE_FROM_PLAN: Plan = {
},
};
export const UPGRADE_FROM_PLAN_ARCHIVED: Plan = {
export const UPGRADE_FROM_PLAN_ARCHIVED: AbbrevPlan = {
...UPGRADE_FROM_PLAN,
product_metadata: {
...UPGRADE_FROM_PLAN.product_metadata,

Просмотреть файл

@ -30,14 +30,14 @@ import './index.scss';
import { ProductProps } from '../index';
import { PaymentConsentCheckbox } from '../../../components/PaymentConsentCheckbox';
import { PaymentProviderDetails } from '../../../components/PaymentProviderDetails';
import { WebSubscription } from 'fxa-shared/subscriptions/types';
import { AbbrevPlan, WebSubscription } from 'fxa-shared/subscriptions/types';
import { FirstInvoicePreview } from 'fxa-shared/dto/auth/payments/invoice';
export type SubscriptionUpgradeProps = {
profile: Profile;
customer: Customer;
selectedPlan: Plan;
upgradeFromPlan: Plan;
upgradeFromPlan: AbbrevPlan;
upgradeFromSubscription: WebSubscription;
invoicePreview: FirstInvoicePreview;
discount?: number;

Просмотреть файл

@ -476,10 +476,39 @@ describe('routes/Product', () => {
});
it('offers upgrade if user is already subscribed to another plan in the same product set', async () => {
const apiMocks = initSubscribedApiMocks({
planId: 'plan_upgrade',
planEligibility: 'upgrade',
});
const apiMocks = [
nock(profileServer)
.get('/v1/profile')
.reply(
200,
{ ...MOCK_PROFILE },
{ 'Access-Control-Allow-Origin': '*' }
),
nock(authServer)
.get('/v1/oauth/subscriptions/plans')
.reply(200, MOCK_PLANS, { 'Access-Control-Allow-Origin': '*' }),
nock(authServer)
.get(
'/v1/oauth/mozilla-subscriptions/customer/billing-and-subscriptions'
)
.reply(200, MOCK_CUSTOMER, { 'Access-Control-Allow-Origin': '*' }),
nock(authServer)
.persist()
.get(
`/v1/oauth/mozilla-subscriptions/customer/plan-eligibility/plan_upgrade`
)
.reply(
200,
{ eligibility: 'upgrade', currentPlan: MOCK_PLANS[1] },
{ 'Access-Control-Allow-Origin': '*' }
),
nock(authServer)
.persist()
.post('/v1/oauth/subscriptions/invoice/preview')
.reply(200, mockPreviewInvoiceResponse, {
'Access-Control-Allow-Origin': '*',
}),
];
const { findByTestId } = renderWithLocalizationProvider(
<Subject
{...{
@ -522,7 +551,39 @@ describe('routes/Product', () => {
});
it('does not allow a downgrade', async () => {
const apiMocks = initSubscribedApiMocks({ planId: 'plan_no_downgrade' });
const apiMocks = [
nock(profileServer)
.get('/v1/profile')
.reply(
200,
{ ...MOCK_PROFILE },
{ 'Access-Control-Allow-Origin': '*' }
),
nock(authServer)
.get('/v1/oauth/subscriptions/plans')
.reply(200, MOCK_PLANS, { 'Access-Control-Allow-Origin': '*' }),
nock(authServer)
.get(
'/v1/oauth/mozilla-subscriptions/customer/billing-and-subscriptions'
)
.reply(200, MOCK_CUSTOMER, { 'Access-Control-Allow-Origin': '*' }),
nock(authServer)
.persist()
.get(
`/v1/oauth/mozilla-subscriptions/customer/plan-eligibility/plan_no_downgrade`
)
.reply(
200,
{ eligibility: 'downgrade', currentPlan: MOCK_PLANS[0] },
{ 'Access-Control-Allow-Origin': '*' }
),
nock(authServer)
.persist()
.post('/v1/oauth/subscriptions/invoice/preview')
.reply(200, mockPreviewInvoiceResponse, {
'Access-Control-Allow-Origin': '*',
}),
];
const { findByTestId } = renderWithLocalizationProvider(
<Subject
{...{
@ -562,25 +623,57 @@ describe('routes/Product', () => {
});
it('displays roadblock for an IAP subscribed product', async () => {
const apiMocks = initSubscribedApiMocks({
mockCustomer: {
...MOCK_CUSTOMER_AFTER_SUBSCRIPTION,
subscriptions: [
const apiMocks = [
nock(profileServer)
.get('/v1/profile')
.reply(
200,
{ ...MOCK_PROFILE },
{ 'Access-Control-Allow-Origin': '*' }
),
nock(authServer)
.get('/v1/oauth/subscriptions/plans')
.reply(200, MOCK_PLANS, { 'Access-Control-Allow-Origin': '*' }),
nock(authServer)
.get(
'/v1/oauth/mozilla-subscriptions/customer/billing-and-subscriptions'
)
.reply(
200,
{
_subscription_type: MozillaSubscriptionTypes.IAP_GOOGLE,
product_id: PRODUCT_ID,
auto_renewing: true,
expiry_time_millis: Date.now(),
package_name: 'org.mozilla.cooking.with.foxkeh',
sku: 'org.mozilla.foxkeh.yearly',
product_name: 'Cooking with Foxkeh',
price_id: 'nextlevel',
...MOCK_CUSTOMER_AFTER_SUBSCRIPTION,
subscriptions: [
{
_subscription_type: MozillaSubscriptionTypes.IAP_GOOGLE,
product_id: PRODUCT_ID,
auto_renewing: true,
expiry_time_millis: Date.now(),
package_name: 'org.mozilla.cooking.with.foxkeh',
sku: 'org.mozilla.foxkeh.yearly',
product_name: 'Cooking with Foxkeh',
price_id: 'nextlevel',
},
],
},
],
},
planId: 'nextlevel',
planEligibility: 'blocked_iap',
});
{ 'Access-Control-Allow-Origin': '*' }
),
nock(authServer)
.persist()
.get(
`/v1/oauth/mozilla-subscriptions/customer/plan-eligibility/nextlevel`
)
.reply(
200,
{ eligibility: 'blocked_iap', currentPlan: MOCK_PLANS[1] },
{ 'Access-Control-Allow-Origin': '*' }
),
nock(authServer)
.persist()
.post('/v1/oauth/subscriptions/invoice/preview')
.reply(200, mockPreviewInvoiceResponse, {
'Access-Control-Allow-Origin': '*',
}),
];
const { findByTestId } = renderWithLocalizationProvider(
<Subject
{...{

Просмотреть файл

@ -15,9 +15,7 @@ import { State } from '../../store/state';
import { sequences, SequenceFunctions } from '../../store/sequences';
import { actions, ActionFunctions } from '../../store/actions';
import { selectors, SelectorReturns } from '../../store/selectors';
import { Plan, ProductMetadata } from '../../store/types';
import { metadataFromPlan } from 'fxa-shared/subscriptions/metadata';
import { getSubscriptionUpdateEligibility } from 'fxa-shared/subscriptions/stripe';
import { Plan } from '../../store/types';
import FetchErrorDialogMessage from '../../components/FetchErrorDialogMessage';
import PlanErrorDialog from '../../components/PlanErrorDialog';
@ -27,38 +25,23 @@ import SubscriptionUpgrade from '../Product/SubscriptionUpgrade';
import SubscriptionCreate from '../Product/SubscriptionCreate';
import SubscriptionChangeRoadblock from './SubscriptionChangeRoadblock';
import {
AbbrevPlan,
SubscriptionUpdateEligibility,
WebSubscription,
IapSubscription,
} from 'fxa-shared/subscriptions/types';
import {
isWebSubscription,
isIapSubscription,
} from 'fxa-shared/subscriptions/type-guards';
import { isWebSubscription } from 'fxa-shared/subscriptions/type-guards';
import { findCustomerIapSubscriptionByProductId } from '../../lib/customer';
import IapRoadblock from './IapRoadblock';
import { CouponDetails } from 'fxa-shared/dto/auth/payments/coupon';
import { useParams } from 'react-router-dom';
import DialogMessage from '../../components/DialogMessage';
type PlansByIdType = {
[plan_id: string]: { plan: Plan; metadata: ProductMetadata };
};
const indexPlansById = (plans: State['plans']): PlansByIdType =>
(plans.result || []).reduce(
(acc, plan) => ({
...acc,
[plan.plan_id]: { plan, metadata: metadataFromPlan(plan) },
}),
{}
);
// Check if the customer is subscribed to a product.
const customerIsSubscribedToProduct = (
customerSubscriptions: ProductProps['customerSubscriptions'],
productId: string
) =>
): boolean | null =>
customerSubscriptions &&
customerSubscriptions.some(
(customerSubscription) => customerSubscription.product_id === productId
@ -69,7 +52,7 @@ const customerIsSubscribedToProduct = (
const customerIsSubscribedToPlan = (
customerSubscriptions: ProductProps['customerSubscriptions'],
selectedPlan: Plan
) =>
): boolean | null =>
customerSubscriptions &&
customerSubscriptions.some(
(customerSubscription) =>
@ -77,44 +60,6 @@ const customerIsSubscribedToPlan = (
selectedPlan.plan_id === customerSubscription.plan_id
);
// If the customer has any subscribed plan that matches the productSet for the
// selected plan, determine whether if it's an upgrade or downgrade.
// Otherwise, it's 'invalid'.
const subscriptionUpdateEligibilityResult = (
customerSubscriptions: WebSubscription[],
selectedPlan: Plan,
plansById: PlansByIdType,
useFirestoreProductConfigs: boolean
):
| {
subscriptionUpdateEligibility: SubscriptionUpdateEligibility;
plan: Plan;
subscription: WebSubscription;
}
| typeof SubscriptionUpdateEligibility.INVALID => {
if (customerSubscriptions) {
for (const customerSubscription of customerSubscriptions) {
const subscriptionPlanInfo = plansById[customerSubscription.plan_id];
const subscriptionUpdateEligibility = getSubscriptionUpdateEligibility(
subscriptionPlanInfo.plan,
selectedPlan,
useFirestoreProductConfigs
);
if (
subscriptionUpdateEligibility !== SubscriptionUpdateEligibility.INVALID
) {
return {
subscriptionUpdateEligibility,
plan: subscriptionPlanInfo.plan,
subscription: customerSubscription,
};
}
}
}
return SubscriptionUpdateEligibility.INVALID;
};
export type ProductProps = {
profile: SelectorReturns['profile'];
plans: SelectorReturns['plans'];
@ -174,9 +119,8 @@ export const Product = ({
'(max-width: 429px) and (max-height: 945px) and (orientation: portrait),(max-width: 945px) and (orientation: landscape) and (max-height: 429px)',
matchMediaDefault
);
const plansById = useMemo(() => indexPlansById(plans), [plans]);
const selectedPlan = useMemo(
const selectedPlan: Plan = useMemo(
() => getSelectedPlan(productId, planId, plansByProductId),
[productId, planId, plansByProductId]
);
@ -264,10 +208,8 @@ export const Product = ({
// Only check for upgrade or existing subscription if we have a customer.
if (customer.result && subscriptionChangeEligibility.result !== null) {
const iapSubscription = findCustomerIapSubscriptionByProductId(
customerSubscriptions,
productId
);
const iapSubscription: IapSubscription | null =
findCustomerIapSubscriptionByProductId(customerSubscriptions, productId);
// Note regarding IAP roadblock:
//
@ -283,47 +225,11 @@ export const Product = ({
// else, product is not subscribed to, but on same product set/might be eligible for upgrade
// show iap upgrade contact support error messaging
if (subscriptionChangeEligibility.result.eligibility === 'blocked_iap') {
// Get plan customer is blocked on
const currentPlan = () => {
if (selectedPlan.product_metadata !== null) {
const iapSubscriptions = (customerSubscriptions || []).filter((s) =>
isIapSubscription(s)
) as IapSubscription[];
for (const customerSubscription of iapSubscriptions) {
const subscriptionPlanInfo =
plansById[customerSubscription.price_id];
const currentPlanProductSet: Array<string> =
subscriptionPlanInfo.metadata.productSet || [];
const selectedPlanProductSet: Array<string> = selectedPlan
.product_metadata.productSet
? selectedPlan.product_metadata.productSet.split(',')
: [];
if (
currentPlanProductSet.length !== 0 &&
selectedPlanProductSet.length !== 0
) {
if (
selectedPlanProductSet.some(
(product: string) =>
currentPlanProductSet.indexOf(product) >= 0
)
) {
return subscriptionPlanInfo.plan;
}
}
}
}
return selectedPlan;
};
const currentPlan = subscriptionChangeEligibility.result.currentPlan!;
return (
<IapRoadblock
{...{
currentPlan: currentPlan(),
currentPlan,
selectedPlan,
customer: customer.result,
profile: profile.result,
@ -341,10 +247,8 @@ export const Product = ({
isWebSubscription(s)
) as WebSubscription[];
const alreadySubscribedToSelectedPlan = customerIsSubscribedToPlan(
webSubscriptions,
selectedPlan
);
const alreadySubscribedToSelectedPlan: boolean | null =
customerIsSubscribedToPlan(webSubscriptions, selectedPlan);
if (invoicePreview.error || !invoicePreview.result) {
const ariaLabelledBy = 'product-invoice-preview-error-title';
@ -383,15 +287,11 @@ export const Product = ({
);
}
const planUpdateEligibilityResult = subscriptionUpdateEligibilityResult(
webSubscriptions,
selectedPlan,
plansById,
config.featureFlags.useFirestoreProductConfigs
);
// Not an upgrade or a downgrade.
if (planUpdateEligibilityResult === SubscriptionUpdateEligibility.INVALID) {
if (
subscriptionChangeEligibility.result.eligibility ===
SubscriptionUpdateEligibility.INVALID
) {
if (customerIsSubscribedToProduct(webSubscriptions, productId)) {
return (
<SubscriptionChangeRoadblock
@ -415,7 +315,7 @@ export const Product = ({
}
if (
planUpdateEligibilityResult.subscriptionUpdateEligibility ===
subscriptionChangeEligibility.result.eligibility ===
SubscriptionUpdateEligibility.DOWNGRADE
) {
return (
@ -426,9 +326,14 @@ export const Product = ({
}
if (
planUpdateEligibilityResult.subscriptionUpdateEligibility ===
subscriptionChangeEligibility.result.eligibility ===
SubscriptionUpdateEligibility.UPGRADE
) {
const currentPlan: AbbrevPlan =
subscriptionChangeEligibility.result.currentPlan!;
const currentSubscriptionFromPlan = webSubscriptions.find(
(sub) => sub.plan_id === currentPlan.plan_id
);
return (
<SubscriptionUpgrade
{...{
@ -436,8 +341,8 @@ export const Product = ({
profile: profile.result,
customer: customer.result,
selectedPlan,
upgradeFromPlan: planUpdateEligibilityResult.plan,
upgradeFromSubscription: planUpdateEligibilityResult.subscription,
upgradeFromPlan: currentPlan,
upgradeFromSubscription: currentSubscriptionFromPlan!,
updateSubscriptionPlanAndRefresh,
resetUpdateSubscriptionPlan,
updateSubscriptionPlanStatus,

Просмотреть файл

@ -1,4 +1,5 @@
import {
AbbrevPlan,
MozillaSubscription,
PaypalPaymentError,
SubscriptionEligibilityResult,
@ -94,6 +95,7 @@ export type Customer = {
export type PlanEligibility = {
eligibility: SubscriptionEligibilityResult;
currentPlan?: AbbrevPlan;
};
export interface CreateSubscriptionResult {

Просмотреть файл

@ -220,7 +220,7 @@ export type InvoicePreview = [
proratedInvoice?: Stripe.UpcomingInvoice
];
export type SubscriptionChangeEligibility = [
subscriptionEligibilityResult: SubscriptionEligibilityResult,
eligibleSourcePlan?: AbbrevPlan
];
export type SubscriptionChangeEligibility = {
subscriptionEligibilityResult: SubscriptionEligibilityResult;
eligibleSourcePlan?: AbbrevPlan;
};