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