Wired new membership tiers UI to API

refs https://github.com/TryGhost/Team/issues/712
closes https://github.com/TryGhost/Team/issues/717

The product API is updated to support `monthly/yearly_price` on each product instead of using list of stripe prices. This change updates the handling of membership settings to use the updated API instead of `stripe_prices` property.
This commit is contained in:
Rishabh 2021-06-04 13:12:52 +05:30 committed by Rishabh Garg
parent 6165441c30
commit 46e281241e
5 changed files with 130 additions and 200 deletions

View File

@ -12,14 +12,10 @@ export default ApplicationAdapter.extend({
return this._super(...arguments);
},
urlForDeleteRecord(id, modelName, snapshot) {
urlForDeleteRecord() {
let url = this._super(...arguments);
let parsedUrl = new URL(url);
if (snapshot && snapshot.adapterOptions && snapshot.adapterOptions.cancel) {
parsedUrl.searchParams.set('cancel', 'true');
}
return parsedUrl.toString();
}
});

View File

@ -250,32 +250,46 @@ export default Component.extend({
});
},
updatePortalPlans(monthlyPriceId, yearlyPriceId) {
let portalPlans = ['free'];
if (monthlyPriceId) {
portalPlans.push(monthlyPriceId);
}
if (yearlyPriceId) {
portalPlans.push(yearlyPriceId);
}
this.settings.set('portalPlans', portalPlans);
},
saveProduct: task(function* () {
let pollTimeout = 0;
while (pollTimeout < RETRY_PRODUCT_SAVE_MAX_POLL) {
yield timeout(RETRY_PRODUCT_SAVE_POLL_LENGTH);
const products = yield this.store.query('product', {include: 'monthly_price, yearly_price'});
this.product = products.firstObject;
if (this.product) {
const yearlyDiscount = this.calculateDiscount(5, 50);
this.product.set('monthlyPrice', {
nickname: 'Monthly',
amount: 500,
active: 1,
description: 'Full access',
currency: 'usd',
interval: 'month',
type: 'recurring'
});
this.product.set('yearlyPrice', {
nickname: 'Yearly',
amount: 5000,
active: 1,
currency: 'usd',
description: yearlyDiscount > 0 ? `${yearlyDiscount}% discount` : 'Full access',
interval: 'year',
type: 'recurring'
});
try {
const updatedProduct = yield this.product.save();
return updatedProduct;
} catch (error) {
if (error.payload?.errors && error.payload.errors[0].code === 'STRIPE_NOT_CONFIGURED') {
pollTimeout += RETRY_PRODUCT_SAVE_POLL_LENGTH;
// no-op: will try saving again as stripe is not ready
continue;
} else {
throw error;
let pollTimeout = 0;
/** To allow Stripe config to be ready in backend, we poll the save product request */
while (pollTimeout < RETRY_PRODUCT_SAVE_MAX_POLL) {
yield timeout(RETRY_PRODUCT_SAVE_POLL_LENGTH);
try {
const updatedProduct = yield this.product.save();
return updatedProduct;
} catch (error) {
if (error.payload?.errors && error.payload.errors[0].code === 'STRIPE_NOT_CONFIGURED') {
pollTimeout += RETRY_PRODUCT_SAVE_POLL_LENGTH;
// no-op: will try saving again as stripe is not ready
continue;
} else {
throw error;
}
}
}
}
@ -289,41 +303,9 @@ export default Component.extend({
try {
let response = yield this.settings.save();
const products = yield this.store.query('product', {include: 'stripe_prices'});
this.product = products.firstObject;
if (this.product) {
const stripePrices = this.product.stripePrices || [];
const yearlyDiscount = this.calculateDiscount(5, 50);
stripePrices.push(
{
nickname: 'Monthly',
amount: 500,
active: 1,
description: 'Full access',
currency: 'usd',
interval: 'month',
type: 'recurring'
},
{
nickname: 'Yearly',
amount: 5000,
active: 1,
currency: 'usd',
description: yearlyDiscount > 0 ? `${yearlyDiscount}% discount` : 'Full access',
interval: 'year',
type: 'recurring'
}
);
this.product.set('stripePrices', stripePrices);
const updatedProduct = yield this.saveProduct.perform();
const monthlyPrice = this.getActivePrice(updatedProduct.stripePrices, 'month', 500, 'usd');
const yearlyPrice = this.getActivePrice(updatedProduct.stripePrices, 'year', 5000, 'usd');
this.updatePortalPlans(monthlyPrice.id, yearlyPrice.id);
this.settings.set('membersMonthlyPriceId', monthlyPrice.id);
this.settings.set('membersYearlyPriceId', yearlyPrice.id);
response = yield this.settings.save();
}
yield this.saveProduct.perform();
this.settings.set('portalPlans', ['free', 'monthly', 'yearly']);
response = yield this.settings.save();
this.set('membersStripeOpen', false);
this.set('stripeConnectSuccess', true);

View File

@ -24,9 +24,11 @@ export default class MembersAccessController extends Controller {
@tracked showLeaveRouteModal = false;
@tracked showPortalSettings = false;
@tracked showStripeConnect = false;
@tracked showProductModal = false;
@tracked product = null;
@tracked stripePrices = [];
@tracked products = null;
@tracked productModel = null;
@tracked paidSignupRedirect;
@tracked freeSignupRedirect;
@tracked stripeMonthlyAmount = 5;
@ -59,10 +61,8 @@ export default class MembersAccessController extends Controller {
get hasChangedPrices() {
if (this.product) {
this.stripePrices = this.product.get('stripePrices') || [];
const activePrices = this.stripePrices.filter(price => !!price.active);
const monthlyPrice = this.getPrice(activePrices, 'monthly');
const yearlyPrice = this.getPrice(activePrices, 'yearly');
const monthlyPrice = this.product.get('monthlyPrice');
const yearlyPrice = this.product.get('yearlyPrice');
if (monthlyPrice?.amount && parseInt(this.stripeMonthlyAmount, 10) !== (monthlyPrice.amount / 100)) {
return true;
@ -77,7 +77,7 @@ export default class MembersAccessController extends Controller {
@action
setup() {
this.fetchDefaultProduct.perform();
this.fetchProducts.perform();
this.updatePortalPreview();
}
@ -176,6 +176,23 @@ export default class MembersAccessController extends Controller {
this.showStripeConnect = false;
}
@action
async openEditProduct(product) {
this.productModel = product;
this.showProductModal = true;
}
@action
async openNewProduct() {
this.productModel = this.store.createRecord('product');
this.showProductModal = true;
}
@action
closeProductModal() {
this.showProductModal = false;
}
@action
openPortalSettings() {
this.saveSettingsTask.perform();
@ -207,22 +224,14 @@ export default class MembersAccessController extends Controller {
}
@action
updatePortalPreview({forceRefresh} = {}) {
updatePortalPreview({forceRefresh} = {forceRefresh: false}) {
// TODO: can these be worked out from settings in membersUtils?
const monthlyPrice = this.stripeMonthlyAmount * 100;
const yearlyPrice = this.stripeYearlyAmount * 100;
let portalPlans = this.settings.get('portalPlans') || [];
const currentMontlyPriceId = this.settings.get('membersMonthlyPriceId');
const currentYearlyPriceId = this.settings.get('membersYearlyPriceId');
let isMonthlyChecked = false;
let isYearlyChecked = false;
if (portalPlans.includes(currentMontlyPriceId)) {
isMonthlyChecked = true;
}
if (portalPlans.includes(currentYearlyPriceId)) {
isYearlyChecked = true;
}
let isMonthlyChecked = portalPlans.includes('monthly');
let isYearlyChecked = portalPlans.includes('yearly');
const newUrl = new URL(this.membersUtils.getPortalPreviewUrl({
button: false,
@ -272,119 +281,20 @@ export default class MembersAccessController extends Controller {
}
}
@action
confirmProductSave() {
return this.fetchProducts.perform();
}
@task
*switchFromNoneTask() {
return yield this.saveSettingsTask.perform({forceRefresh: true});
}
async saveProduct() {
const isStripeConnected = this.settings.get('stripeConnectAccountId');
if (this.product && isStripeConnected) {
const stripePrices = this.product.stripePrices || [];
const monthlyAmount = this.stripeMonthlyAmount * 100;
const yearlyAmount = this.stripeYearlyAmount * 100;
const getActivePrice = (prices, type, amount) => {
return prices.find((price) => {
return (
price.active && price.amount === amount && price.type === 'recurring' &&
price.interval === type && price.currency.toLowerCase() === this.currency.toLowerCase()
);
});
};
const monthlyPrice = getActivePrice(stripePrices, 'month', monthlyAmount);
const yearlyPrice = getActivePrice(stripePrices, 'year', yearlyAmount);
if (!monthlyPrice) {
stripePrices.push(
{
nickname: 'Monthly',
amount: monthlyAmount,
active: 1,
currency: this.currency,
interval: 'month',
type: 'recurring'
}
);
}
if (!yearlyPrice) {
stripePrices.push(
{
nickname: 'Yearly',
amount: this.stripeYearlyAmount * 100,
active: 1,
currency: this.currency,
interval: 'year',
type: 'recurring'
}
);
}
if (monthlyPrice && yearlyPrice) {
this.updatePortalPlans(monthlyPrice.id, yearlyPrice.id);
this.settings.set('membersMonthlyPriceId', monthlyPrice.id);
this.settings.set('membersYearlyPriceId', yearlyPrice.id);
return this.product;
} else {
this.product.set('stripePrices', stripePrices);
const savedProduct = await this.product.save();
const updatedStripePrices = savedProduct.stripePrices || [];
const updatedMonthlyPrice = getActivePrice(updatedStripePrices, 'month', monthlyAmount);
const updatedYearlyPrice = getActivePrice(updatedStripePrices, 'year', yearlyAmount);
this.updatePortalPlans(updatedMonthlyPrice.id, updatedYearlyPrice.id);
this.settings.set('membersMonthlyPriceId', updatedMonthlyPrice.id);
this.settings.set('membersYearlyPriceId', updatedYearlyPrice.id);
return savedProduct;
}
}
}
updatePortalPlans(monthlyPriceId, yearlyPriceId) {
let portalPlans = this.settings.get('portalPlans') || [];
const currentMontlyPriceId = this.settings.get('membersMonthlyPriceId');
const currentYearlyPriceId = this.settings.get('membersYearlyPriceId');
if (portalPlans.includes(currentMontlyPriceId)) {
portalPlans = portalPlans.filter(priceId => priceId !== currentMontlyPriceId);
portalPlans.pushObject(monthlyPriceId);
}
if (portalPlans.includes(currentYearlyPriceId)) {
portalPlans = portalPlans.filter(priceId => priceId !== currentYearlyPriceId);
portalPlans.pushObject(yearlyPriceId);
}
this.settings.set('portalPlans', portalPlans);
}
getPrice(prices, type) {
const monthlyPriceId = this.settings.get('membersMonthlyPriceId');
const yearlyPriceId = this.settings.get('membersYearlyPriceId');
if (type === 'monthly') {
return (
prices.find(price => price.id === monthlyPriceId) ||
prices.find(price => price.nickname === 'Monthly') ||
prices.find(price => price.interval === 'month')
);
}
if (type === 'yearly') {
return (
prices.find(price => price.id === yearlyPriceId) ||
prices.find(price => price.nickname === 'Yearly') ||
prices.find(price => price.interval === 'year')
);
}
return null;
}
@task({drop: true})
*fetchDefaultProduct() {
const products = yield this.store.query('product', {include: 'stripe_prices'});
this.product = products.firstObject;
this.stripePrices = [];
if (this.product) {
this.stripePrices = this.product.get('stripePrices') || [];
const activePrices = this.stripePrices.filter(price => !!price.active);
const monthlyPrice = this.getPrice(activePrices, 'monthly');
const yearlyPrice = this.getPrice(activePrices, 'yearly');
setupPortalProduct(product) {
if (product) {
const monthlyPrice = product.get('monthlyPrice');
const yearlyPrice = product.get('yearlyPrice');
if (monthlyPrice && monthlyPrice.amount) {
this.stripeMonthlyAmount = (monthlyPrice.amount / 100);
this.currency = monthlyPrice.currency;
@ -396,19 +306,27 @@ export default class MembersAccessController extends Controller {
}
}
@task({drop: true})
*fetchProducts() {
this.products = yield this.store.query('product', {include: 'monthly_price,yearly_price'});
this.product = this.products.firstObject;
this.setupPortalProduct(this.product);
}
@task({drop: true})
*saveSettingsTask(options) {
yield this.validateStripePlans({updatePortalPreview: false});
if (this.stripePlanError) {
if (this.stripePlanError && !this.config.get('enableDeveloperExperiments')) {
return;
}
if (this.settings.get('errors').length !== 0) {
return;
}
yield this.saveProduct();
if (!this.config.get('enableDeveloperExperiments')) {
yield this.saveProduct();
}
const result = yield this.settings.save();
this.updatePortalPreview(options);
@ -416,10 +334,37 @@ export default class MembersAccessController extends Controller {
return result;
}
async saveProduct() {
const isStripeConnected = this.settings.get('stripeConnectAccountId');
if (this.product && isStripeConnected) {
const monthlyAmount = this.stripeMonthlyAmount * 100;
const yearlyAmount = this.stripeYearlyAmount * 100;
this.product.set('monthlyPrice', {
nickname: 'Monthly',
amount: monthlyAmount,
active: true,
currency: this.currency,
interval: 'month',
type: 'recurring'
});
this.product.set('yearlyPrice', {
nickname: 'Yearly',
amount: yearlyAmount,
active: true,
currency: this.currency,
interval: 'year',
type: 'recurring'
});
const savedProduct = await this.product.save();
return savedProduct;
}
}
resetPrices() {
const activePrices = this.stripePrices.filter(price => !!price.active);
const monthlyPrice = this.getPrice(activePrices, 'monthly');
const yearlyPrice = this.getPrice(activePrices, 'yearly');
const monthlyPrice = this.product.get('monthlyPrice');
const yearlyPrice = this.product.get('yearlyPrice');
this.stripeMonthlyAmount = monthlyPrice ? (monthlyPrice.amount / 100) : 5;
this.stripeYearlyAmount = yearlyPrice ? (yearlyPrice.amount / 100) : 50;

View File

@ -7,5 +7,6 @@ export default Model.extend(ValidationEngine, {
name: attr('string'),
description: attr('string'),
slug: attr('string'),
stripePrices: attr('stripe-price')
monthlyPrice: attr('stripe-price'),
yearlyPrice: attr('stripe-price')
});

View File

@ -3,10 +3,16 @@ import Transform from '@ember-data/serializer/transform';
import {A as emberA, isArray as isEmberArray} from '@ember/array';
export default Transform.extend({
deserialize(serialized = []) {
const stripePrices = serialized.map(itemDetails => StripePrice.create(itemDetails));
deserialize(serialized) {
if (serialized === null || serialized === undefined) {
return null;
} else if (Array.isArray(serialized)) {
const stripePrices = serialized.map(itemDetails => StripePrice.create(itemDetails));
return emberA(stripePrices);
return emberA(stripePrices);
} else {
return StripePrice.create(serialized);
}
},
serialize(deserialized) {
@ -15,7 +21,7 @@ export default Transform.extend({
return item;
}).compact();
} else {
return [];
return deserialized || null;
}
}
});