From 46e281241ea04c0e78debb62bab2616bb35017fe Mon Sep 17 00:00:00 2001 From: Rishabh Date: Fri, 4 Jun 2021 13:12:52 +0530 Subject: [PATCH] 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. --- ghost/admin/app/adapters/product.js | 6 +- .../components/gh-members-payments-setting.js | 100 ++++----- .../app/controllers/settings/membership.js | 207 +++++++----------- ghost/admin/app/models/product.js | 3 +- ghost/admin/app/transforms/stripe-price.js | 14 +- 5 files changed, 130 insertions(+), 200 deletions(-) diff --git a/ghost/admin/app/adapters/product.js b/ghost/admin/app/adapters/product.js index 21811abc08..b2f4c6a8a2 100644 --- a/ghost/admin/app/adapters/product.js +++ b/ghost/admin/app/adapters/product.js @@ -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(); } }); diff --git a/ghost/admin/app/components/gh-members-payments-setting.js b/ghost/admin/app/components/gh-members-payments-setting.js index eb51dfe30a..7f947c7a62 100644 --- a/ghost/admin/app/components/gh-members-payments-setting.js +++ b/ghost/admin/app/components/gh-members-payments-setting.js @@ -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); diff --git a/ghost/admin/app/controllers/settings/membership.js b/ghost/admin/app/controllers/settings/membership.js index ad9d2536ee..5e4a15f9f4 100644 --- a/ghost/admin/app/controllers/settings/membership.js +++ b/ghost/admin/app/controllers/settings/membership.js @@ -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; diff --git a/ghost/admin/app/models/product.js b/ghost/admin/app/models/product.js index 6fd1ed6400..cdffe2b61b 100644 --- a/ghost/admin/app/models/product.js +++ b/ghost/admin/app/models/product.js @@ -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') }); diff --git a/ghost/admin/app/transforms/stripe-price.js b/ghost/admin/app/transforms/stripe-price.js index a3ecaa4fdc..67a9e990bd 100644 --- a/ghost/admin/app/transforms/stripe-price.js +++ b/ghost/admin/app/transforms/stripe-price.js @@ -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; } } });