Implement member import with tier (#17506)
refs https://github.com/TryGhost/Product/issues/3629
This commit is contained in:
parent
f1b51729fc
commit
3a95caf48f
@ -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')
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
});
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
192
ghost/members-importer/lib/MembersCSVImporterStripeUtils.js
Normal file
192
ghost/members-importer/lib/MembersCSVImporterStripeUtils.js
Normal 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});
|
||||
}
|
||||
};
|
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
3
ghost/members-importer/test/fixtures/comped-member-import-tier.csv
vendored
Normal file
3
ghost/members-importer/test/fixtures/comped-member-import-tier.csv
vendored
Normal 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
|
||||
|
|
3
ghost/members-importer/test/fixtures/comped-member-invalid-import-tier.csv
vendored
Normal file
3
ghost/members-importer/test/fixtures/comped-member-invalid-import-tier.csv
vendored
Normal 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
|
||||
|
|
3
ghost/members-importer/test/fixtures/free-member-import-tier.csv
vendored
Normal file
3
ghost/members-importer/test/fixtures/free-member-import-tier.csv
vendored
Normal 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
|
||||
|
|
3
ghost/members-importer/test/fixtures/paid-member-import-tier.csv
vendored
Normal file
3
ghost/members-importer/test/fixtures/paid-member-import-tier.csv
vendored
Normal 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
|
||||
|
|
11
ghost/members-importer/test/index.test.js
Normal file
11
ghost/members-importer/test/index.test.js
Normal 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);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user