Ghost/ghost/members-importer/test/MembersCSVImporterStripeUtils.test.js
Kevin Ansfield ae1ac83fc5
Fixed members import-with-tier alpha creating unexpected invoices (#20695)
ref https://linear.app/tryghost/issue/ONC-199

The `updateSubscriptionItemPrice()` method in our Stripe library used by the importer when moving a subscription over to a Ghost product/price was setting `proration_behavior: 'always_invoice'`. This resulted in invoices being created when changing the subscription (even though no prices were changing as far as the customer is concerned) and in some cases where a customer previously had a one-off discount the customer was incorrectly charged the proration difference because the discount was no longer applied to the new invoice.

- updated `updateSubscriptionItemPrice()` to accept an `options` param allowing the `proration_behavior` property passed to the Stripe API to be overridden on a per-call basis
- updated the `forceStripeSubscriptionToProduct()` method used by the importer to pass an options object with `prorationBehavior: 'none'` when updating the subscription item price so that no invoice and no unexpected charges occur when importing
2024-07-31 11:16:25 +01:00

404 lines
17 KiB
JavaScript

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,
{prorationBehavior: 'none'}
).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,
{prorationBehavior: 'none'}
).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();
});
});
});