Implement member import with tier (#17506)

refs https://github.com/TryGhost/Product/issues/3629
This commit is contained in:
Michael Barrett 2023-08-18 15:24:31 +01:00 committed by GitHub
parent f1b51729fc
commit 3a95caf48f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 930 additions and 53 deletions

View File

@ -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')

View File

@ -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);

View File

@ -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
});
};

View File

@ -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<import('@tryghost/tiers/lib/Tier')>} getDefaultTier - async function returning default Member Tier
* @property {() => Promise<import('@tryghost/tiers/lib/Tier')>} 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<import('@tryghost/tiers/lib/Tier')>} 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<string|null>}
*/
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);
}
};

View File

@ -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<Object>}
*/
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<void>}
*/
async archivePrice(stripePriceId) {
await this._stripeAPIService.updatePrice(stripePriceId, {active: false});
}
};

View File

@ -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));
});
});
});

View File

@ -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();
});
});
});

View File

@ -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
1 id email name note subscribed_to_emails complimentary_plan stripe_customer_id created_at deleted_at labels import_tier
2 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

View File

@ -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
1 id email name note subscribed_to_emails complimentary_plan stripe_customer_id created_at deleted_at labels import_tier
2 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

View File

@ -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
1 id email name note subscribed_to_emails complimentary_plan stripe_customer_id created_at deleted_at labels import_tier
2 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

View File

@ -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
1 id email name note subscribed_to_emails complimentary_plan stripe_customer_id created_at deleted_at labels import_tier
2 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

View File

@ -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);
});
});