diff --git a/ghost/core/core/server/services/members/service.js b/ghost/core/core/server/services/members/service.js index fdacfc2ce9..c6a6b843a9 100644 --- a/ghost/core/core/server/services/members/service.js +++ b/ghost/core/core/server/services/members/service.js @@ -4,7 +4,7 @@ const tpl = require('@tryghost/tpl'); const MembersSSR = require('@tryghost/members-ssr'); const db = require('../../data/db'); const MembersConfigProvider = require('./MembersConfigProvider'); -const MembersCSVImporter = require('@tryghost/members-importer'); +const makeMembersCSVImporter = require('@tryghost/members-importer'); const MembersStats = require('./stats/MembersStats'); const memberJobs = require('./jobs'); const logging = require('@tryghost/logging'); @@ -41,30 +41,43 @@ const membersStats = new MembersStats({ }); let membersApi; -let verificationTrigger; -const membersImporter = new MembersCSVImporter({ - storagePath: config.getContentPath('data'), - getTimezone: () => settingsCache.get('timezone'), - getMembersRepository: async () => { - const api = await module.exports.api; - return api.members; - }, - getDefaultTier: () => { - return tiersService.api.readDefaultTier(); - }, - sendEmail: ghostMailer.send.bind(ghostMailer), - isSet: flag => labsService.isSet(flag), - addJob: jobsService.addJob.bind(jobsService), - knex: db.knex, - urlFor: urlUtils.urlFor.bind(urlUtils), - context: { - importer: true - } -}); +const initMembersCSVImporter = ({stripeAPIService}) => { + return makeMembersCSVImporter({ + storagePath: config.getContentPath('data'), + getTimezone: () => settingsCache.get('timezone'), + getMembersRepository: async () => { + const api = await module.exports.api; + return api.members; + }, + getDefaultTier: () => { + return tiersService.api.readDefaultTier(); + }, + getTierByName: async (name) => { + const tiers = await tiersService.api.browse({ + filter: `name:'${name}'` + }); -const processImport = async (options) => { - return await membersImporter.process({...options, verificationTrigger}); + if (tiers.data.length > 0) { + // It is possible that there are multiple tiers with the same name so return the last one in the array - + // `tiersService.api.browse` returns all tiers, but without any ordering applied, so we assume that + // the last one in the array is the most recently created + return tiers.data.pop(); + } + + return null; + }, + sendEmail: ghostMailer.send.bind(ghostMailer), + isSet: flag => labsService.isSet(flag), + addJob: jobsService.addJob.bind(jobsService), + knex: db.knex, + urlFor: urlUtils.urlFor.bind(urlUtils), + context: { + importer: true + }, + stripeAPIService, + productRepository: membersApi.productRepository + }); }; const initVerificationTrigger = () => { @@ -133,9 +146,14 @@ module.exports = { getMembersApi: () => module.exports.api }); - verificationTrigger = initVerificationTrigger(); + const verificationTrigger = initVerificationTrigger(); module.exports.verificationTrigger = verificationTrigger; + const membersCSVImporter = initMembersCSVImporter({stripeAPIService: stripeService.api}); + module.exports.processImport = async (options) => { + return await membersCSVImporter.process({...options, verificationTrigger}); + }; + if (!env?.startsWith('testing')) { const membersMigrationJobName = 'members-migrations'; if (!(await jobsService.hasExecutedSuccessfully(membersMigrationJobName))) { @@ -168,7 +186,7 @@ module.exports = { stripeConnect: require('./stripe-connect'), - processImport: processImport, + processImport: null, stats: membersStats, export: require('./exporter/query') diff --git a/ghost/members-api/lib/repositories/ProductRepository.js b/ghost/members-api/lib/repositories/ProductRepository.js index c1612b4452..d2e5a882d5 100644 --- a/ghost/members-api/lib/repositories/ProductRepository.js +++ b/ghost/members-api/lib/repositories/ProductRepository.js @@ -633,6 +633,7 @@ class ProductRepository { } } + await product.related('stripeProducts').fetch(options); await product.related('stripePrices').fetch(options); await product.related('monthlyPrice').fetch(options); await product.related('yearlyPrice').fetch(options); diff --git a/ghost/members-importer/index.js b/ghost/members-importer/index.js index 7810f809c7..f5d7e74763 100644 --- a/ghost/members-importer/index.js +++ b/ghost/members-importer/index.js @@ -1 +1,30 @@ -module.exports = require('./lib/MembersCSVImporter'); +const MembersCSVImporter = require('./lib/MembersCSVImporter'); +const MembersCSVImporterStripeUtils = require('./lib/MembersCSVImporterStripeUtils'); + +/** + * @typedef {import('./lib/MembersCSVImporter').MembersCSVImporterOptions} MembersCSVImporterOptions + */ + +/** + * @typedef {Object} MakeImporterDeps + * @property {Object} stripeAPIService - Instance of StripeAPIService + * @property {Object} productRepository - Instance of ProductRepository + */ + +/** + * Make an instance of MembersCSVImporter + * + * @param {MakeImporterDeps & MembersCSVImporterOptions} deps + * @returns {MembersCSVImporter} + */ +module.exports = function makeImporter(deps) { + const stripeUtils = new MembersCSVImporterStripeUtils({ + stripeAPIService: deps.stripeAPIService, + productRepository: deps.productRepository + }); + + return new MembersCSVImporter({ + ...deps, + stripeUtils + }); +}; diff --git a/ghost/members-importer/lib/MembersCSVImporter.js b/ghost/members-importer/lib/MembersCSVImporter.js index 61f0b81e26..fde1c9955c 100644 --- a/ghost/members-importer/lib/MembersCSVImporter.js +++ b/ghost/members-importer/lib/MembersCSVImporter.js @@ -8,7 +8,9 @@ const emailTemplate = require('./email-template'); const logging = require('@tryghost/logging'); const messages = { - filenameCollision: 'Filename already exists, please try again.' + filenameCollision: 'Filename already exists, please try again.', + freeMemberNotAllowedImportTier: 'You cannot import a free member with a specified tier.', + invalidImportTier: '"{tier}" is not a valid tier.' }; // The key should correspond to a member model field (unless it's a special purpose field like 'complimentary_plan') @@ -25,31 +27,40 @@ const DEFAULT_CSV_HEADER_MAPPING = { import_tier: 'import_tier' }; +/** + * @typedef {Object} MembersCSVImporterOptions + * @property {string} storagePath - The path to store CSV's in before importing + * @property {Function} getTimezone - function returning currently configured timezone + * @property {() => Object} getMembersRepository - member model access instance for data access and manipulation + * @property {() => Promise} getDefaultTier - async function returning default Member Tier + * @property {() => Promise} getTierByName - async function returning Member Tier by name + * @property {Function} sendEmail - function sending an email + * @property {(string) => boolean} isSet - Method checking if specific feature is enabled + * @property {({job, offloaded, name}) => void} addJob - Method registering an async job + * @property {Object} knex - An instance of the Ghost Database connection + * @property {Function} urlFor - function generating urls + * @property {Object} context + * @property {Object} stripeUtils - An instance of MembersCSVImporterStripeUtils + */ + module.exports = class MembersCSVImporter { /** - * @param {Object} options - * @param {string} options.storagePath - The path to store CSV's in before importing - * @param {Function} options.getTimezone - function returning currently configured timezone - * @param {() => Object} options.getMembersRepository - member model access instance for data access and manipulation - * @param {() => Promise} options.getDefaultTier - async function returning default Member Tier - * @param {Function} options.sendEmail - function sending an email - * @param {(string) => boolean} options.isSet - Method checking if specific feature is enabled - * @param {({job, offloaded, name}) => void} options.addJob - Method registering an async job - * @param {Object} options.knex - An instance of the Ghost Database connection - * @param {Function} options.urlFor - function generating urls - * @param {Object} options.context + * @param {MembersCSVImporterOptions} options */ - constructor({storagePath, getTimezone, getMembersRepository, getDefaultTier, sendEmail, isSet, addJob, knex, urlFor, context}) { + constructor({storagePath, getTimezone, getMembersRepository, getDefaultTier, getTierByName, sendEmail, isSet, addJob, knex, urlFor, context, stripeUtils}) { this._storagePath = storagePath; this._getTimezone = getTimezone; this._getMembersRepository = getMembersRepository; this._getDefaultTier = getDefaultTier; + this._getTierByName = getTierByName; this._sendEmail = sendEmail; this._isSet = isSet; this._addJob = addJob; this._knex = knex; this._urlFor = urlFor; this._context = context; + this._stripeUtils = stripeUtils; + this._tierIdCache = new Map(); } /** @@ -113,6 +124,14 @@ module.exports = class MembersCSVImporter { const defaultTier = await this._getDefaultTier(); const membersRepository = await this._getMembersRepository(); + // Clear tier ID cache before each import in-case tiers have been updated since last import + this._tierIdCache.clear(); + + // Keep track of any Stripe prices created as a result of an import tier being specified so that they + // can be archived after the import has completed - This ensures the created Stripe prices cannot be re-used + // for future subscriptions + const archivableStripePriceIds = []; + const result = await rows.reduce(async (resultPromise, row) => { const resultAccumulator = await resultPromise; @@ -172,6 +191,17 @@ module.exports = class MembersCSVImporter { })); } + let importTierId; + if (row.import_tier) { + importTierId = await this.#getTierIdByName(row.import_tier); + + if (!importTierId) { + throw new errors.DataImportError({ + message: tpl(messages.invalidImportTier, {tier: row.import_tier}) + }); + } + } + if (row.stripe_customer_id && typeof row.stripe_customer_id === 'string') { let stripeCustomerId; @@ -183,18 +213,37 @@ module.exports = class MembersCSVImporter { } if (stripeCustomerId) { + if (row.import_tier) { + const {isNewStripePrice, stripePriceId} = await this._stripeUtils.forceStripeSubscriptionToProduct({ + customer_id: stripeCustomerId, + product_id: importTierId + }, options); + + if (isNewStripePrice) { + archivableStripePriceIds.push(stripePriceId); + } + } + await membersRepository.linkStripeCustomer({ customer_id: stripeCustomerId, member_id: member.id }, options); } } else if (row.complimentary_plan) { - await membersRepository.update({ - products: [{id: defaultTier.id.toString()}] - }, { + const products = []; + + if (row.import_tier) { + products.push({id: importTierId}); + } else { + products.push({id: defaultTier.id.toString()}); + } + + await membersRepository.update({products}, { ...options, id: member.id }); + } else if (row.import_tier) { + throw new errors.DataImportError({message: tpl(messages.freeMemberNotAllowedImportTier)}); } await trx.commit(); @@ -220,6 +269,10 @@ module.exports = class MembersCSVImporter { errors: [] })); + await Promise.all( + archivableStripePriceIds.map(stripePriceId => this._stripeUtils.archivePrice(stripePriceId)) + ); + return { total: result.imported + result.errors.length, ...result @@ -372,4 +425,24 @@ module.exports = class MembersCSVImporter { }; } } + + /** + * Retrieve the ID of a tier, querying by its name, and cache the result + * + * @param {string} name + * @returns {Promise} + */ + async #getTierIdByName(name) { + if (!this._tierIdCache.has(name)) { + const tier = await this._getTierByName(name); + + if (!tier) { + return null; + } + + this._tierIdCache.set(name, tier.id.toString()); + } + + return this._tierIdCache.get(name); + } }; diff --git a/ghost/members-importer/lib/MembersCSVImporterStripeUtils.js b/ghost/members-importer/lib/MembersCSVImporterStripeUtils.js new file mode 100644 index 0000000000..82f19adb40 --- /dev/null +++ b/ghost/members-importer/lib/MembersCSVImporterStripeUtils.js @@ -0,0 +1,192 @@ +const {DataImportError} = require('@tryghost/errors'); +const tpl = require('@tryghost/tpl'); + +const messages = { + productNotFound: 'Cannot find Product {id}', + noStripeConnection: 'Cannot {action} without a Stripe Connection', + forceNoCustomer: 'Cannot find Stripe customer to update subscription', + forceNoExistingSubscription: 'Cannot update subscription when customer does not have an existing subscription', + forceTooManySubscriptions: 'Cannot update subscription when customer has multiple subscriptions', + forceTooManySubscriptionItems: 'Cannot update subscription when existing subscription has multiple items', + forceExistingSubscriptionNotRecurring: 'Cannot update subscription when existing subscription is not recurring' +}; + +module.exports = class MembersCSVImporterStripeUtils { + /** + * @param {Object} stripeAPIService + * @param {Object} productRepository + */ + constructor({ + stripeAPIService, + productRepository + }) { + this._stripeAPIService = stripeAPIService; + this._productRepository = productRepository; + } + + /** + * Force a Stripe customer to be subscribed to a specific Ghost product + * + * This will either: + * + * Create a new price on the Stripe product that is associated with the Ghost product, then update + * the customer's Stripe subscription to use the new price. The new price will be created with the details of the + * existing price of the item in customer's Stripe subscription + * + * or + * + * Update the customer's stripe subscription to use an existing price on the Stripe product that matches the + * details of the existing price of the item in customer's Stripe subscription + * + * If there is no Stripe product associated with the Ghost product, one will be created + * + * This method should be used in-conjunction with `MembersRepository.linkSubscription` to ensure + * that the changes made in Stripe are reflected in Ghost - This is not executed as part of this to allow for + * flexibility and reduce duplication + * + * @param {Object} data + * @param {String} data.customer_id - Stripe customer ID + * @param {String} data.product_id - Ghost product ID + * @param {Object} options + * @returns {Promise} + */ + async forceStripeSubscriptionToProduct(data, options) { + if (!this._stripeAPIService.configured) { + throw new DataImportError({ + message: tpl(messages.noStripeConnection, {action: 'force subscription to product'}) + }); + } + + // Retrieve customer's existing subscription information + const stripeCustomer = await this._stripeAPIService.getCustomer(data.customer_id); + + // Subscription can only be forced if the customer exists + if (!stripeCustomer) { + throw new DataImportError({message: tpl(messages.forceNoCustomer)}); + } + + // Subscription can only be forced if the customer has an existing subscription + if (stripeCustomer.subscriptions.data.length === 0) { + throw new DataImportError({message: tpl(messages.forceNoExistingSubscription)}); + } + + // Subscription can only be forced if the customer does not have multiple subscriptions + if (stripeCustomer.subscriptions.data.length > 1) { + throw new DataImportError({message: tpl(messages.forceTooManySubscriptions)}); + } + + const stripeSubscription = stripeCustomer.subscriptions.data[0]; + + // Subscription can only be forced if the existing subscription does not have multiple items + if (stripeSubscription.items.data.length > 1) { + throw new DataImportError({message: tpl(messages.forceTooManySubscriptionItems)}); + } + + const stripeSubscriptionItem = stripeSubscription.items.data[0]; + const stripeSubscriptionItemPrice = stripeSubscriptionItem.price; + const stripeSubscriptionItemPriceCurrency = stripeSubscriptionItemPrice.currency; + const stripeSubscriptionItemPriceAmount = stripeSubscriptionItemPrice.unit_amount; + const stripeSubscriptionItemPriceType = stripeSubscriptionItemPrice.type; + const stripeSubscriptionItemPriceInterval = stripeSubscriptionItemPrice.recurring?.interval || null; + + // Subscription can only be forced if the existing subscription has a recurring interval + if (!stripeSubscriptionItemPriceInterval) { + throw new DataImportError({message: tpl(messages.forceExistingSubscriptionNotRecurring)}); + } + + // Retrieve Ghost product + let ghostProduct = await this._productRepository.get( + {id: data.product_id}, + {...options, withRelated: ['stripePrices', 'stripeProducts']} + ); + + if (!ghostProduct) { + throw new DataImportError({message: tpl(messages.productNotFound, {id: data.product_id})}); + } + + // If there is not a Stripe product associated with the Ghost product, ensure one is created before continuing + if (!ghostProduct.related('stripeProducts').first()) { + // Even though we are not updating any information on the product, calling `ProductRepository.update` + // will ensure that the product gets created in Stripe + ghostProduct = await this._productRepository.update({ + id: data.product_id, + name: ghostProduct.get('name'), + // Providing the pricing details will ensure the relevant prices for the Ghost product are created + // on the Stripe product + monthly_price: { + amount: ghostProduct.get('monthly_price'), + currency: ghostProduct.get('currency') + }, + yearly_price: { + amount: ghostProduct.get('yearly_price'), + currency: ghostProduct.get('currency') + } + }, options); + } + + // Find price on Ghost product matching stripe subscription item price details + const ghostProductPrice = ghostProduct.related('stripePrices').find((price) => { + return price.get('currency') === stripeSubscriptionItemPriceCurrency && + price.get('amount') === stripeSubscriptionItemPriceAmount && + price.get('type') === stripeSubscriptionItemPriceType && + price.get('interval') === stripeSubscriptionItemPriceInterval; + }); + + let stripePriceId; + let isNewStripePrice = false; + + if (!ghostProductPrice) { + // If there is not a matching price, create one on the associated Stripe product using the existing + // subscription item price details and update the stripe subscription to use it + const stripeProduct = ghostProduct.related('stripeProducts').first(); + + const newStripePrice = await this._stripeAPIService.createPrice({ + product: stripeProduct.get('stripe_product_id'), + active: true, + nickname: stripeSubscriptionItemPriceInterval === 'month' ? 'Monthly' : 'Yearly', + currency: stripeSubscriptionItemPriceCurrency, + amount: stripeSubscriptionItemPriceAmount, + type: stripeSubscriptionItemPriceType, + interval: stripeSubscriptionItemPriceInterval + }); + + await this._stripeAPIService.updateSubscriptionItemPrice( + stripeSubscription.id, + stripeSubscriptionItem.id, + newStripePrice.id + ); + + stripePriceId = newStripePrice.id; + isNewStripePrice = true; + } else { + // If there is a matching price, and the subscription is not already using it, + // update the subscription to use it + stripePriceId = ghostProductPrice.get('stripe_price_id'); + + if (stripeSubscriptionItem.price.id !== stripePriceId) { + await this._stripeAPIService.updateSubscriptionItemPrice( + stripeSubscription.id, + stripeSubscriptionItem.id, + stripePriceId + ); + } + } + + // If there is a matching price, and the subscription is already using it, nothing else needs to be done + + return { + stripePriceId, + isNewStripePrice + }; + } + + /** + * Archive a price in Stripe + * + * @param {Number} stripePriceId + * @returns {Promise} + */ + async archivePrice(stripePriceId) { + await this._stripeAPIService.updatePrice(stripePriceId, {active: false}); + } +}; diff --git a/ghost/members-importer/test/importer.test.js b/ghost/members-importer/test/MembersCSVImporter.test.js similarity index 76% rename from ghost/members-importer/test/importer.test.js rename to ghost/members-importer/test/MembersCSVImporter.test.js index 155d91d4af..06ff3ebe30 100644 --- a/ghost/members-importer/test/importer.test.js +++ b/ghost/members-importer/test/MembersCSVImporter.test.js @@ -8,16 +8,17 @@ const assert = require('assert/strict'); const fs = require('fs-extra'); const path = require('path'); const sinon = require('sinon'); -const MembersCSVImporter = require('..'); +const MembersCSVImporter = require('../lib/MembersCSVImporter'); const csvPath = path.join(__dirname, '/fixtures/'); -describe('Importer', function () { +describe('MembersCSVImporter', function () { let fsWriteSpy; let memberCreateStub; let knexStub; let sendEmailStub; let membersRepositoryStub; + let stripeUtilsStub; let defaultTierId; const defaultAllowedFields = { @@ -28,7 +29,8 @@ describe('Importer', function () { created_at: 'created_at', complimentary_plan: 'complimentary_plan', stripe_customer_id: 'stripe_customer_id', - labels: 'labels' + labels: 'labels', + import_tier: 'import_tier' }; beforeEach(function () { @@ -45,7 +47,7 @@ describe('Importer', function () { sinon.restore(); }); - const buildMockImporterInstance = () => { + const buildMockImporterInstance = (deps = {}) => { defaultTierId = new ObjectID(); const defaultTierDummy = new Tier({ id: defaultTierId @@ -63,15 +65,17 @@ describe('Importer', function () { linkStripeCustomer: sinon.stub().resolves(null), getCustomerIdByEmail: sinon.stub().resolves('cus_mock_123456') }; - knexStub = { transaction: sinon.stub().resolves({ rollback: () => {}, commit: () => {} }) }; - sendEmailStub = sinon.stub(); + stripeUtilsStub = { + forceStripeSubscriptionToProduct: sinon.stub().resolves({}), + archivePrice: sinon.stub().resolves() + }; return new MembersCSVImporter({ storagePath: csvPath, @@ -87,7 +91,9 @@ describe('Importer', function () { addJob: sinon.stub(), knex: knexStub, urlFor: sinon.stub(), - context: {importer: true} + context: {importer: true}, + stripeUtils: stripeUtilsStub, + ...deps }); }; @@ -295,7 +301,7 @@ describe('Importer', function () { it('performs import on a single csv file', async function () { const importer = buildMockImporterInstance(); - const result = await importer.perform(`${csvPath}/single-column-with-header.csv`, defaultAllowedFields); + const result = await importer.perform(`${csvPath}/single-column-with-header.csv`); assert.equal(membersRepositoryStub.create.args[0][0].email, 'jbloggs@example.com'); assert.deepEqual(membersRepositoryStub.create.args[0][0].labels, []); @@ -383,7 +389,7 @@ describe('Importer', function () { .withArgs({email: 'jbloggs@example.com'}) .resolves(member); - await importer.perform(`${csvPath}/subscribed-to-emails-header.csv`, defaultAllowedFields); + await importer.perform(`${csvPath}/subscribed-to-emails-header.csv`); assert.deepEqual(membersRepositoryStub.update.args[0][0].newsletters, newsletters); }); @@ -404,7 +410,7 @@ describe('Importer', function () { .withArgs({email: 'jbloggs@example.com'}) .resolves(member); - await importer.perform(`${csvPath}/subscribed-to-emails-header.csv`, defaultAllowedFields); + await importer.perform(`${csvPath}/subscribed-to-emails-header.csv`); assert.deepEqual(membersRepositoryStub.update.args[0][0].subscribed, false); }); @@ -435,10 +441,144 @@ describe('Importer', function () { .withArgs({email: 'test@example.com'}) .resolves(member); - await importer.perform(`${csvPath}/subscribed-to-emails-header.csv`, defaultAllowedFields); + await importer.perform(`${csvPath}/subscribed-to-emails-header.csv`); assert.equal(membersRepositoryStub.update.args[0][0].subscribed, false); assert.equal(membersRepositoryStub.update.args[0][0].newsletters, undefined); }); + + it('does not import a free member with an import tier', async function () { + const tier = { + id: { + toString: () => 'abc123' + }, + name: 'Premium Tier' + }; + const getTierByNameStub = sinon.stub(); + + getTierByNameStub.withArgs(tier.name).resolves(tier); + + const importer = buildMockImporterInstance({ + getTierByName: getTierByNameStub + }); + + const result = await importer.perform(`${csvPath}/free-member-import-tier.csv`); + + assert.equal(result.total, 1); + assert.equal(result.imported, 0); + assert.equal(result.errors.length, 1); + assert.equal(result.errors[0].error, 'You cannot import a free member with a specified tier.'); + }); + + it('imports a comped member with an import tier', async function () { + const tier = { + id: { + toString: () => 'abc123' + }, + name: 'Premium Tier' + }; + const getTierByNameStub = sinon.stub(); + + getTierByNameStub.withArgs(tier.name).resolves(tier); + + const importer = buildMockImporterInstance({ + getTierByName: getTierByNameStub + }); + + const result = await importer.perform(`${csvPath}/comped-member-import-tier.csv`); + + assert.equal(result.total, 1); + assert.equal(result.imported, 1); + assert.equal(result.errors.length, 0); + assert.ok(membersRepositoryStub.update.calledOnce); + assert.deepEqual( + membersRepositoryStub.update.getCall(0).args[0], + {products: [{id: tier.id.toString()}]} + ); + }); + + it('does not import a comped member with an invalid import tier', async function () { + const tier = { + id: { + toString: () => 'abc123' + }, + name: 'Premium Tier' + }; + const getTierByNameStub = sinon.stub(); + + getTierByNameStub.withArgs(tier.name).resolves(tier); + + const importer = buildMockImporterInstance({ + getTierByName: getTierByNameStub + }); + + const result = await importer.perform(`${csvPath}/comped-member-invalid-import-tier.csv`); + + assert.equal(result.total, 1); + assert.equal(result.imported, 0); + assert.equal(result.errors.length, 1); + assert.equal(result.errors[0].error, '"Invalid Tier" is not a valid tier.'); + }); + + it('imports a paid member with an import tier', async function () { + const tier = { + id: { + toString: () => 'abc123' + }, + name: 'Premium Tier' + }; + const getTierByNameStub = sinon.stub(); + + getTierByNameStub.withArgs(tier.name).resolves(tier); + + const importer = buildMockImporterInstance({ + getTierByName: getTierByNameStub + }); + + const result = await importer.perform(`${csvPath}/paid-member-import-tier.csv`); + + assert.equal(result.total, 1); + assert.equal(result.imported, 1); + assert.equal(result.errors.length, 0); + assert.ok(stripeUtilsStub.forceStripeSubscriptionToProduct.calledOnce); + assert.deepEqual( + stripeUtilsStub.forceStripeSubscriptionToProduct.getCall(0).args[0], + { + customer_id: 'cus_MdR9tqW6bAreiq', + product_id: tier.id.toString() + } + ); + }); + + it('archives any Stripe prices created due to an import tier being specified', async function () { + const tier = { + id: { + toString: () => 'abc123' + }, + name: 'Premium Tier' + }; + const getTierByNameStub = sinon.stub(); + + getTierByNameStub.withArgs(tier.name).resolves(tier); + + const newStripePriceId = 'price_123'; + + const importer = buildMockImporterInstance({ + getTierByName: getTierByNameStub + }); + + stripeUtilsStub.forceStripeSubscriptionToProduct.resolves({ + isNewStripePrice: true, + stripePriceId: newStripePriceId + }); + + const result = await importer.perform(`${csvPath}/paid-member-import-tier.csv`); + + assert.equal(result.total, 1); + assert.equal(result.imported, 1); + assert.equal(result.errors.length, 0); + assert.ok(stripeUtilsStub.archivePrice.calledOnce); + assert.ok(stripeUtilsStub.archivePrice.calledWith(newStripePriceId)); + }); }); }); diff --git a/ghost/members-importer/test/MembersCSVImporterStripeUtils.test.js b/ghost/members-importer/test/MembersCSVImporterStripeUtils.test.js new file mode 100644 index 0000000000..9b1176613d --- /dev/null +++ b/ghost/members-importer/test/MembersCSVImporterStripeUtils.test.js @@ -0,0 +1,401 @@ +const sinon = require('sinon'); +const {DataImportError} = require('@tryghost/errors'); +const MembersCSVImporterStripeUtils = require('../lib/MembersCSVImporterStripeUtils'); + +describe('MembersCSVImporterStripeUtils', function () { + const CUSTOMER_ID = 'abc123'; + const PRODUCT_ID = 'def456'; + const OPTIONS = {}; + let stripeCustomer, stripeCustomerSubscriptionItem, ghostProduct; + + beforeEach(function () { + stripeCustomer = { + subscriptions: { + data: [ + { + id: 'sub_1', + items: { + data: [ + { + id: 'sub_1_item_1', + price: { + id: 'sub_1_item_1_price_1', + currency: 'usd', + unit_amount: 500, + type: 'recurring', + recurring: { + interval: 'month' + } + } + } + ] + } + } + ] + } + }; + stripeCustomerSubscriptionItem = stripeCustomer.subscriptions.data[0].items.data[0]; + ghostProduct = { + id: PRODUCT_ID, + stripe_product_id: stripeCustomerSubscriptionItem.id, + name: 'Premium Tier', + monthly_price: stripeCustomerSubscriptionItem.price.unit_amount, + yearly_price: stripeCustomerSubscriptionItem.price.unit_amount * 10, + currency: stripeCustomerSubscriptionItem.price.currency + }; + }); + + /** + * Returns a stubbed Stripe API service + * + * @param {Object} options + * @param {Boolean} [options.configured] - Whether the Stripe API service is configured, defaults to true + * @param {Boolean} [options.resolveCustomer] - Whether the Stripe API service should resolve the customer, defaults to true + * @returns {Object} + */ + const getStripeApiServiceStub = ({ + configured = true, + resolveCustomer = true + } = {}) => { + const stripeAPIServiceStub = { + configured, + getCustomer: sinon.stub().resolves(null), + updateSubscriptionItemPrice: sinon.stub().resolves(), + createPrice: sinon.stub().resolves(), + updatePrice: sinon.stub().resolves() + }; + + if (resolveCustomer) { + stripeAPIServiceStub.getCustomer.withArgs(CUSTOMER_ID).resolves(stripeCustomer); + } + + return stripeAPIServiceStub; + }; + + /** + * Returns a stubbed product repository + * + * @param {Object} options + * @param {String} [options.ghostProductStripePriceId] - The Stripe price ID of the Ghost product, defaults to the Stripe price ID of the Stripe customer's existing subscription + * @param {Boolean} [options.resolveGhostProductPrice] - Whether the product repository should resolve the Ghost product price, defaults to true + * @param {Boolean} [options.resolveStripeProduct] - Whether the product repository should resolve the Stripe product, defaults to true + * @returns {Object} + */ + const getProductRepositoryStub = ({ + ghostProductStripePriceId = stripeCustomerSubscriptionItem.price.id, + resolveGhostProductPrice = true, + resolveStripeProduct = true + } = {}) => { + // Ghost product price + const priceStub = { + get: sinon.stub().returns(null) + }; + + priceStub.get.withArgs('stripe_price_id').returns(ghostProductStripePriceId); + + // Ghost product + const productStub = { + related: sinon.stub().returns(null), + get: key => ghostProduct[key] + }; + + productStub.related.withArgs('stripeProducts').returns({ + first: sinon.stub().returns(resolveStripeProduct ? productStub : null) + }); + + productStub.related.withArgs('stripePrices').returns({ + find: sinon.stub().returns(resolveGhostProductPrice ? priceStub : null) + }); + + // Product repository + const productRepositoryStub = { + get: sinon.stub().resolves(null), + update: sinon.stub().resolves(productStub) + }; + + productRepositoryStub.get.withArgs( + {id: PRODUCT_ID}, + {...OPTIONS, withRelated: ['stripePrices', 'stripeProducts']} + ).resolves(productStub); + + return productRepositoryStub; + }; + + describe('forceSubscriptionToProduct', function () { + it('rejects when there is no Stripe connection', async function () { + const stripeAPIServiceStub = getStripeApiServiceStub({configured: false}); + const membersCSVImporterStripeUtils = new MembersCSVImporterStripeUtils({ + stripeAPIService: stripeAPIServiceStub + }); + + await membersCSVImporterStripeUtils.forceStripeSubscriptionToProduct({}, OPTIONS).should.be.rejectedWith( + DataImportError, + {message: 'Cannot force subscription to product without a Stripe Connection'} + ); + }); + + it('rejects when the Stripe customer cannot be retrieved', async function () { + const stripeAPIServiceStub = getStripeApiServiceStub({resolveCustomer: false}); + const membersCSVImporterStripeUtils = new MembersCSVImporterStripeUtils({ + stripeAPIService: stripeAPIServiceStub + }); + + await membersCSVImporterStripeUtils.forceStripeSubscriptionToProduct({ + customer_id: CUSTOMER_ID + }, OPTIONS).should.be.rejectedWith( + DataImportError, + {message: 'Cannot find Stripe customer to update subscription'} + ); + }); + + it('rejects when the Stripe customer has no existing subscription', async function () { + const stripeAPIServiceStub = getStripeApiServiceStub(); + + stripeCustomer.subscriptions.data = []; + + const membersCSVImporterStripeUtils = new MembersCSVImporterStripeUtils({ + stripeAPIService: stripeAPIServiceStub + }); + + await membersCSVImporterStripeUtils.forceStripeSubscriptionToProduct({ + customer_id: CUSTOMER_ID + }, OPTIONS).should.be.rejectedWith( + DataImportError, + {message: 'Cannot update subscription when customer does not have an existing subscription'} + ); + }); + + it('rejects when the Stripe customer has multiple subscriptions', async function () { + const stripeAPIServiceStub = getStripeApiServiceStub(); + + stripeCustomer.subscriptions.data.push({ + id: 'sub_2', + items: { + data: [ + { + id: 'sub_2_item_1', + price: { + id: 'sub_2_item_1_price_1', + recurring: { + interval: 'month' + } + } + } + ] + } + }); + + const membersCSVImporterStripeUtils = new MembersCSVImporterStripeUtils({ + stripeAPIService: stripeAPIServiceStub + }); + + await membersCSVImporterStripeUtils.forceStripeSubscriptionToProduct({ + customer_id: CUSTOMER_ID + }, OPTIONS).should.be.rejectedWith( + DataImportError, + {message: 'Cannot update subscription when customer has multiple subscriptions'} + ); + }); + + it('rejects when the Stripe customer has subscription with multiple items', async function () { + const stripeAPIServiceStub = getStripeApiServiceStub(); + + stripeCustomer.subscriptions.data[0].items.data.push({ + id: 'sub_1_item_1', + price: { + id: 'sub_1_item_1_price_2', + recurring: { + interval: 'month' + } + } + }); + + const membersCSVImporterStripeUtils = new MembersCSVImporterStripeUtils({ + stripeAPIService: stripeAPIServiceStub + }); + + await membersCSVImporterStripeUtils.forceStripeSubscriptionToProduct({ + customer_id: CUSTOMER_ID + }, OPTIONS).should.be.rejectedWith( + DataImportError, + {message: 'Cannot update subscription when existing subscription has multiple items'} + ); + }); + + it('rejects when the Stripe customer has subscription that is not recurring', async function () { + const stripeAPIServiceStub = getStripeApiServiceStub(); + + delete stripeCustomer.subscriptions.data[0].items.data[0].price.recurring; + + const membersCSVImporterStripeUtils = new MembersCSVImporterStripeUtils({ + stripeAPIService: stripeAPIServiceStub + }); + + await membersCSVImporterStripeUtils.forceStripeSubscriptionToProduct({ + customer_id: CUSTOMER_ID + }, OPTIONS).should.be.rejectedWith( + DataImportError, + {message: 'Cannot update subscription when existing subscription is not recurring'} + ); + }); + + it('rejects when the Ghost product can not be retrieved', async function () { + const stripeAPIServiceStub = getStripeApiServiceStub(); + const productRepositoryStub = { + get: sinon.stub().resolves({}) // Ensure truthy value is resolved + }; + + productRepositoryStub.get.withArgs( + {id: PRODUCT_ID}, + {...OPTIONS, withRelated: ['stripePrices', 'stripeProducts']} + ).resolves(null); + + const membersCSVImporterStripeUtils = new MembersCSVImporterStripeUtils({ + stripeAPIService: stripeAPIServiceStub, + productRepository: productRepositoryStub + }); + + await membersCSVImporterStripeUtils.forceStripeSubscriptionToProduct({ + customer_id: CUSTOMER_ID, + product_id: PRODUCT_ID + }, OPTIONS).should.be.rejectedWith( + DataImportError, + {message: `Cannot find Product ${PRODUCT_ID}`} + ); + }); + + it('does not update the Stripe customer\'s subscription if they already have a subscription to the Ghost product', async function () { + const stripeAPIServiceStub = getStripeApiServiceStub(); + const productRepositoryStub = getProductRepositoryStub(); + const membersCSVImporterStripeUtils = new MembersCSVImporterStripeUtils({ + stripeAPIService: stripeAPIServiceStub, + productRepository: productRepositoryStub + }); + const result = await membersCSVImporterStripeUtils.forceStripeSubscriptionToProduct({ + customer_id: CUSTOMER_ID, + product_id: PRODUCT_ID + }, OPTIONS); + + result.stripePriceId.should.equal(stripeCustomerSubscriptionItem.price.id); + result.isNewStripePrice.should.be.false(); + + stripeAPIServiceStub.updateSubscriptionItemPrice.calledOnce.should.be.false(); + }); + + it('updates the Stripe customer\'s subscription if they already have a subscription, but to some other Ghost product', async function () { + const GHOST_PRODUCT_STRIPE_PRICE_ID = 'some_other_ghost_product'; + const stripeAPIServiceStub = getStripeApiServiceStub(); + const productRepositoryStub = getProductRepositoryStub({ + ghostProductStripePriceId: GHOST_PRODUCT_STRIPE_PRICE_ID + }); + const membersCSVImporterStripeUtils = new MembersCSVImporterStripeUtils({ + stripeAPIService: stripeAPIServiceStub, + productRepository: productRepositoryStub + }); + const result = await membersCSVImporterStripeUtils.forceStripeSubscriptionToProduct({ + customer_id: CUSTOMER_ID, + product_id: PRODUCT_ID + }, OPTIONS); + + result.stripePriceId.should.equal(GHOST_PRODUCT_STRIPE_PRICE_ID); + result.isNewStripePrice.should.be.false(); + + stripeAPIServiceStub.updateSubscriptionItemPrice.calledOnce.should.be.true(); + stripeAPIServiceStub.updateSubscriptionItemPrice.calledWithExactly( + stripeCustomer.subscriptions.data[0].id, + stripeCustomerSubscriptionItem.id, + GHOST_PRODUCT_STRIPE_PRICE_ID + ).should.be.true(); + }); + + it('creates a new price on the Stripe product matching the Stripe customer\'s existing subscription and updates the subscription', async function () { + const stripeAPIServiceStub = getStripeApiServiceStub(); + const stripeSubscriptionItem = stripeCustomerSubscriptionItem; + const NEW_STRIPE_PRICE_ID = 'new_stripe_price_id'; + + stripeAPIServiceStub.createPrice.withArgs({ + product: stripeSubscriptionItem.id, + active: true, + nickname: 'Monthly', + currency: stripeSubscriptionItem.price.currency, + amount: stripeSubscriptionItem.price.unit_amount, + type: stripeSubscriptionItem.price.type, + interval: stripeSubscriptionItem.price.recurring.interval + }).resolves({ + id: NEW_STRIPE_PRICE_ID + }); + + const productRepositoryStub = getProductRepositoryStub({ + resolveGhostProductPrice: false + }); + const membersCSVImporterStripeUtils = new MembersCSVImporterStripeUtils({ + stripeAPIService: stripeAPIServiceStub, + productRepository: productRepositoryStub + }); + const result = await membersCSVImporterStripeUtils.forceStripeSubscriptionToProduct({ + customer_id: CUSTOMER_ID, + product_id: PRODUCT_ID + }, OPTIONS); + + // Assert new price was created + result.stripePriceId.should.equal(NEW_STRIPE_PRICE_ID); + result.isNewStripePrice.should.be.true(); + + // Assert subscription was updated + stripeAPIServiceStub.updateSubscriptionItemPrice.calledOnce.should.be.true(); + stripeAPIServiceStub.updateSubscriptionItemPrice.calledWithExactly( + stripeCustomer.subscriptions.data[0].id, + stripeCustomerSubscriptionItem.id, + NEW_STRIPE_PRICE_ID + ).should.be.true(); + }); + + it('creates a new product in Stripe if one does not already existing for the Ghost product', async function () { + const stripeAPIServiceStub = getStripeApiServiceStub(); + const productRepositoryStub = getProductRepositoryStub({ + resolveStripeProduct: false + }); + const membersCSVImporterStripeUtils = new MembersCSVImporterStripeUtils({ + stripeAPIService: stripeAPIServiceStub, + productRepository: productRepositoryStub + }); + + await membersCSVImporterStripeUtils.forceStripeSubscriptionToProduct({ + customer_id: CUSTOMER_ID, + product_id: PRODUCT_ID + }, OPTIONS); + + productRepositoryStub.update.calledOnce.should.be.true(); + productRepositoryStub.update.calledWithExactly( + { + id: PRODUCT_ID, + name: ghostProduct.name, + monthly_price: { + amount: ghostProduct.monthly_price, + currency: ghostProduct.currency + }, + yearly_price: { + amount: ghostProduct.yearly_price, + currency: ghostProduct.currency + } + }, + OPTIONS + ).should.be.true(); + }); + }); + + describe('archivePrice', function () { + it('archives a Stripe price', async function () { + const stripeAPIServiceStub = getStripeApiServiceStub(); + const membersCSVImporterStripeUtils = new MembersCSVImporterStripeUtils({ + stripeAPIService: stripeAPIServiceStub + }); + const stripePriceId = 'price_123'; + + await membersCSVImporterStripeUtils.archivePrice(stripePriceId); + + stripeAPIServiceStub.updatePrice.calledOnce.should.be.true(); + stripeAPIServiceStub.updatePrice.calledWithExactly(stripePriceId, {active: false}).should.be.true(); + }); + }); +}); diff --git a/ghost/members-importer/test/fixtures/comped-member-import-tier.csv b/ghost/members-importer/test/fixtures/comped-member-import-tier.csv new file mode 100644 index 0000000000..1ac8dc74a8 --- /dev/null +++ b/ghost/members-importer/test/fixtures/comped-member-import-tier.csv @@ -0,0 +1,3 @@ +id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels,import_tier +634e48e056ef99c6a7af5850,compedmember@example.com,Some Comped Member,This is a comped member,true,true,,2023-07-26T11:50:00.000Z,,Some Import Label,Premium Tier + diff --git a/ghost/members-importer/test/fixtures/comped-member-invalid-import-tier.csv b/ghost/members-importer/test/fixtures/comped-member-invalid-import-tier.csv new file mode 100644 index 0000000000..8462f89add --- /dev/null +++ b/ghost/members-importer/test/fixtures/comped-member-invalid-import-tier.csv @@ -0,0 +1,3 @@ +id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels,import_tier +634e48e056ef99c6a7af5850,compedmember@example.com,Some Comped Member,This is a comped member,true,true,,2023-07-26T11:50:00.000Z,,Some Import Label,Invalid Tier + diff --git a/ghost/members-importer/test/fixtures/free-member-import-tier.csv b/ghost/members-importer/test/fixtures/free-member-import-tier.csv new file mode 100644 index 0000000000..fe15645ec2 --- /dev/null +++ b/ghost/members-importer/test/fixtures/free-member-import-tier.csv @@ -0,0 +1,3 @@ +id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels,import_tier +634e48e056ef99c6a7af5850,freemember@example.com,Some Free Member,This is a free member,true,false,,2023-07-26T11:50:00.000Z,,Some Import Label,Premium Tier + diff --git a/ghost/members-importer/test/fixtures/paid-member-import-tier.csv b/ghost/members-importer/test/fixtures/paid-member-import-tier.csv new file mode 100644 index 0000000000..a3efc22c0d --- /dev/null +++ b/ghost/members-importer/test/fixtures/paid-member-import-tier.csv @@ -0,0 +1,3 @@ +id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels,import_tier +634e48e056ef99c6a7af5850,paidmember@example.com,Some Free Member,This is a paid member,true,,cus_MdR9tqW6bAreiq,2023-07-26T11:50:00.000Z,,Some Import Label,Premium Tier + diff --git a/ghost/members-importer/test/index.test.js b/ghost/members-importer/test/index.test.js new file mode 100644 index 0000000000..9d773fa506 --- /dev/null +++ b/ghost/members-importer/test/index.test.js @@ -0,0 +1,11 @@ +const assert = require('assert/strict'); +const MembersCSVImporter = require('../lib/MembersCSVImporter'); +const makeImporter = require('..'); + +describe('makeImporter', function (){ + it('should return an instance of MembersCSVImporter', function (){ + const importer = makeImporter({}); + + assert.ok(importer instanceof MembersCSVImporter); + }); +});