mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-11 02:12:21 -05:00
✨ Added support for importing Stripe Coupons as Offers (#17415)
closes https://github.com/TryGhost/Product/issues/3595 - when importing paid members with a coupon in Stripe, we currently search for the corresponding offer in our database and attach it to the subscription if found. However, if an offer doesn't exist in the database, we do not create one and don't attach any offer to the subscription - with this change, we now support the creation of a new offer, based on a Stripe coupon, if it didn't exist already
This commit is contained in:
parent
5b7bca1f1e
commit
8a32941ae8
3 changed files with 396 additions and 2 deletions
|
@ -936,7 +936,8 @@ module.exports = class MemberRepository {
|
|||
logging.error(e);
|
||||
}
|
||||
|
||||
let stripeCouponId = subscription.discount && subscription.discount.coupon ? subscription.discount.coupon.id : null;
|
||||
const stripeCoupon = subscription.discount?.coupon;
|
||||
const stripeCouponId = stripeCoupon ? subscription.discount.coupon.id : null;
|
||||
|
||||
// For trial offers, offer id is passed from metadata as there is no stripe coupon
|
||||
let offerId = data.offerId || null;
|
||||
|
@ -948,7 +949,21 @@ module.exports = class MemberRepository {
|
|||
if (offer) {
|
||||
offerId = offer.id;
|
||||
} else {
|
||||
logging.error(`Received an unknown stripe coupon id (${stripeCouponId}) for subscription - ${subscription.id}.`);
|
||||
try {
|
||||
// Create an offer in our database
|
||||
const productId = ghostProduct.get('id');
|
||||
const currency = subscriptionPriceData.currency;
|
||||
const interval = _.get(subscriptionPriceData, 'recurring.interval', '');
|
||||
offer = await this._offerRepository.createFromCoupon(
|
||||
stripeCoupon,
|
||||
{productId, currency, interval, active: false},
|
||||
{transacting: options.transacting}
|
||||
);
|
||||
offerId = offer?.id;
|
||||
} catch (e) {
|
||||
logging.error(`Error when creating an offer from stripe coupon id (${stripeCouponId}) for subscription - ${subscription.id}.`);
|
||||
logging.error(e);
|
||||
}
|
||||
}
|
||||
} else if (offerId) {
|
||||
offer = await this._offerRepository.getById(offerId, {transacting: options.transacting});
|
||||
|
|
|
@ -43,6 +43,14 @@ const mongoTransformer = flowRight(statusTransformer, rejectNonStatusTransformer
|
|||
* @prop {string} filter
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} OfferAdditionalParams
|
||||
* @prop {string} productId — the Ghost Product ID
|
||||
* @prop {string} currency — the currency of the plan
|
||||
* @prop {string} interval — the billing interval of the plan (month, year)
|
||||
* @prop {boolean} active — whether the offer is active upoon creation
|
||||
*/
|
||||
|
||||
class OfferRepository {
|
||||
/**
|
||||
* @param {{forge: (data: object) => import('bookshelf').Model<Offer.OfferProps>}} OfferModel
|
||||
|
@ -179,6 +187,39 @@ class OfferRepository {
|
|||
return Promise.all(offers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('stripe').Stripe.CouponCreateParams} coupon
|
||||
* @param {OfferAdditionalParams} params
|
||||
* @param {BaseOptions} [options]
|
||||
*/
|
||||
async createFromCoupon(coupon, params, options) {
|
||||
const {productId, currency, interval, active} = params;
|
||||
const code = coupon.name && coupon.name.split(' ').map(word => word.toLowerCase()).join('-');
|
||||
|
||||
const data = {
|
||||
active,
|
||||
name: coupon.name,
|
||||
code,
|
||||
product_id: productId,
|
||||
stripe_coupon_id: coupon.id,
|
||||
interval,
|
||||
currency,
|
||||
duration: coupon.duration,
|
||||
duration_in_months: coupon.duration === 'repeating' ? coupon.duration_in_months : null,
|
||||
portal_title: coupon.name
|
||||
};
|
||||
|
||||
if (coupon.percent_off) {
|
||||
data.discount_type = 'percent';
|
||||
data.discount_amount = coupon.percent_off;
|
||||
} else {
|
||||
data.discount_type = 'amount';
|
||||
data.discount_amount = coupon.amount_off;
|
||||
}
|
||||
|
||||
await this.OfferModel.add(data, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Offer} offer
|
||||
* @param {BaseOptions} [options]
|
||||
|
|
338
ghost/offers/test/lib/application/OfferRepository.test.js
Normal file
338
ghost/offers/test/lib/application/OfferRepository.test.js
Normal file
|
@ -0,0 +1,338 @@
|
|||
const sinon = require('sinon');
|
||||
const OfferRepository = require('../../../lib/application/OfferRepository');
|
||||
|
||||
const Offer = {
|
||||
add: sinon.stub()
|
||||
};
|
||||
|
||||
describe('OfferRepository', function () {
|
||||
describe('#createFromCoupon', function () {
|
||||
it('creates a 50% off for 3 months offer', async function () {
|
||||
const coupon = {
|
||||
id: 'coupon-id',
|
||||
name: 'Coupon Name',
|
||||
percent_off: 50,
|
||||
duration: 'repeating',
|
||||
duration_in_months: 3
|
||||
};
|
||||
|
||||
const params = {
|
||||
productId: 'product-id',
|
||||
currency: 'usd',
|
||||
interval: 'month',
|
||||
active: true
|
||||
};
|
||||
|
||||
const options = {
|
||||
transacting: true
|
||||
};
|
||||
|
||||
const expectedData = {
|
||||
active: true,
|
||||
name: 'Coupon Name',
|
||||
code: 'coupon-name',
|
||||
product_id: 'product-id',
|
||||
stripe_coupon_id: 'coupon-id',
|
||||
interval: 'month',
|
||||
currency: 'usd',
|
||||
duration: 'repeating',
|
||||
duration_in_months: 3,
|
||||
portal_title: 'Coupon Name',
|
||||
discount_type: 'percent',
|
||||
discount_amount: 50
|
||||
};
|
||||
|
||||
const offerRepository = new OfferRepository(Offer);
|
||||
await offerRepository.createFromCoupon(coupon, params, options);
|
||||
|
||||
Offer.add.calledWith(expectedData, options).should.be.true();
|
||||
});
|
||||
|
||||
it('creates a 1 USD off for 3 months offer', async function () {
|
||||
const coupon = {
|
||||
id: 'coupon-id',
|
||||
name: 'Coupon Name',
|
||||
amount_off: 1,
|
||||
duration: 'repeating',
|
||||
duration_in_months: 3
|
||||
};
|
||||
|
||||
const params = {
|
||||
productId: 'product-id',
|
||||
currency: 'usd',
|
||||
interval: 'month',
|
||||
active: true
|
||||
};
|
||||
|
||||
const options = {
|
||||
transacting: true
|
||||
};
|
||||
|
||||
const expectedData = {
|
||||
active: true,
|
||||
name: 'Coupon Name',
|
||||
code: 'coupon-name',
|
||||
product_id: 'product-id',
|
||||
stripe_coupon_id: 'coupon-id',
|
||||
interval: 'month',
|
||||
currency: 'usd',
|
||||
duration: 'repeating',
|
||||
duration_in_months: 3,
|
||||
portal_title: 'Coupon Name',
|
||||
discount_type: 'amount',
|
||||
discount_amount: 1
|
||||
};
|
||||
|
||||
const offerRepository = new OfferRepository(Offer);
|
||||
await offerRepository.createFromCoupon(coupon, params, options);
|
||||
|
||||
Offer.add.calledWith(expectedData, options).should.be.true();
|
||||
});
|
||||
|
||||
it('creates a 50% off forever offer', async function () {
|
||||
const coupon = {
|
||||
id: 'coupon-id',
|
||||
name: 'Coupon Name',
|
||||
percent_off: 50,
|
||||
duration: 'forever',
|
||||
duration_in_months: null
|
||||
};
|
||||
|
||||
const params = {
|
||||
productId: 'product-id',
|
||||
currency: 'usd',
|
||||
interval: 'month',
|
||||
active: true
|
||||
};
|
||||
|
||||
const options = {
|
||||
transacting: true
|
||||
};
|
||||
|
||||
const expectedData = {
|
||||
active: true,
|
||||
name: 'Coupon Name',
|
||||
code: 'coupon-name',
|
||||
product_id: 'product-id',
|
||||
stripe_coupon_id: 'coupon-id',
|
||||
interval: 'month',
|
||||
currency: 'usd',
|
||||
duration: 'forever',
|
||||
duration_in_months: null,
|
||||
portal_title: 'Coupon Name',
|
||||
discount_type: 'percent',
|
||||
discount_amount: 50
|
||||
};
|
||||
|
||||
const offerRepository = new OfferRepository(Offer);
|
||||
await offerRepository.createFromCoupon(coupon, params, options);
|
||||
|
||||
Offer.add.calledWith(expectedData, options).should.be.true();
|
||||
});
|
||||
|
||||
it('creates a 1 USD off forever offer', async function () {
|
||||
const coupon = {
|
||||
id: 'coupon-id',
|
||||
name: 'Coupon Name',
|
||||
amount_off: 1,
|
||||
duration: 'forever',
|
||||
duration_in_months: null
|
||||
};
|
||||
|
||||
const params = {
|
||||
productId: 'product-id',
|
||||
currency: 'usd',
|
||||
interval: 'month',
|
||||
active: true
|
||||
};
|
||||
|
||||
const options = {
|
||||
transacting: true
|
||||
};
|
||||
|
||||
const expectedData = {
|
||||
active: true,
|
||||
name: 'Coupon Name',
|
||||
code: 'coupon-name',
|
||||
product_id: 'product-id',
|
||||
stripe_coupon_id: 'coupon-id',
|
||||
interval: 'month',
|
||||
currency: 'usd',
|
||||
duration: 'forever',
|
||||
duration_in_months: null,
|
||||
portal_title: 'Coupon Name',
|
||||
discount_type: 'amount',
|
||||
discount_amount: 1
|
||||
};
|
||||
|
||||
const offerRepository = new OfferRepository(Offer);
|
||||
await offerRepository.createFromCoupon(coupon, params, options);
|
||||
|
||||
Offer.add.calledWith(expectedData, options).should.be.true();
|
||||
});
|
||||
|
||||
it('creates a 50% USD off once yearly offer', async function () {
|
||||
const coupon = {
|
||||
id: 'coupon-id',
|
||||
name: 'Coupon Name',
|
||||
percent_off: 50,
|
||||
duration: 'once',
|
||||
duration_in_months: null
|
||||
};
|
||||
|
||||
const params = {
|
||||
productId: 'product-id',
|
||||
currency: 'usd',
|
||||
interval: 'yearly',
|
||||
active: true
|
||||
};
|
||||
|
||||
const options = {
|
||||
transacting: true
|
||||
};
|
||||
|
||||
const expectedData = {
|
||||
active: true,
|
||||
name: 'Coupon Name',
|
||||
code: 'coupon-name',
|
||||
product_id: 'product-id',
|
||||
stripe_coupon_id: 'coupon-id',
|
||||
interval: 'yearly',
|
||||
currency: 'usd',
|
||||
duration: 'once',
|
||||
duration_in_months: null,
|
||||
portal_title: 'Coupon Name',
|
||||
discount_type: 'percent',
|
||||
discount_amount: 50
|
||||
};
|
||||
|
||||
const offerRepository = new OfferRepository(Offer);
|
||||
await offerRepository.createFromCoupon(coupon, params, options);
|
||||
|
||||
Offer.add.calledWith(expectedData, options).should.be.true();
|
||||
});
|
||||
|
||||
it('creates a 1 USD off once yearly offer', async function () {
|
||||
const coupon = {
|
||||
id: 'coupon-id',
|
||||
name: 'Coupon Name',
|
||||
amount_off: 1,
|
||||
duration: 'once',
|
||||
duration_in_months: null
|
||||
};
|
||||
|
||||
const params = {
|
||||
productId: 'product-id',
|
||||
currency: 'usd',
|
||||
interval: 'yearly',
|
||||
active: true
|
||||
};
|
||||
|
||||
const options = {
|
||||
transacting: true
|
||||
};
|
||||
|
||||
const expectedData = {
|
||||
active: true,
|
||||
name: 'Coupon Name',
|
||||
code: 'coupon-name',
|
||||
product_id: 'product-id',
|
||||
stripe_coupon_id: 'coupon-id',
|
||||
interval: 'yearly',
|
||||
currency: 'usd',
|
||||
duration: 'once',
|
||||
duration_in_months: null,
|
||||
portal_title: 'Coupon Name',
|
||||
discount_type: 'amount',
|
||||
discount_amount: 1
|
||||
};
|
||||
|
||||
const offerRepository = new OfferRepository(Offer);
|
||||
await offerRepository.createFromCoupon(coupon, params, options);
|
||||
|
||||
Offer.add.calledWith(expectedData, options).should.be.true();
|
||||
});
|
||||
|
||||
it('creates a 50% off during one month offer', async function () {
|
||||
const coupon = {
|
||||
id: 'coupon-id',
|
||||
name: 'Coupon Name',
|
||||
percent_off: 50,
|
||||
duration: 'repeating',
|
||||
duration_in_months: 1
|
||||
};
|
||||
|
||||
const params = {
|
||||
productId: 'product-id',
|
||||
currency: 'usd',
|
||||
interval: 'month',
|
||||
active: true
|
||||
};
|
||||
|
||||
const options = {
|
||||
transacting: true
|
||||
};
|
||||
|
||||
const expectedData = {
|
||||
active: true,
|
||||
name: 'Coupon Name',
|
||||
code: 'coupon-name',
|
||||
product_id: 'product-id',
|
||||
stripe_coupon_id: 'coupon-id',
|
||||
interval: 'month',
|
||||
currency: 'usd',
|
||||
duration: 'repeating',
|
||||
duration_in_months: 1,
|
||||
portal_title: 'Coupon Name',
|
||||
discount_type: 'percent',
|
||||
discount_amount: 50
|
||||
};
|
||||
|
||||
const offerRepository = new OfferRepository(Offer);
|
||||
await offerRepository.createFromCoupon(coupon, params, options);
|
||||
|
||||
Offer.add.calledWith(expectedData, options).should.be.true();
|
||||
});
|
||||
|
||||
it('creates a 1 USD off during one month offer', async function () {
|
||||
const coupon = {
|
||||
id: 'coupon-id',
|
||||
name: 'Coupon Name',
|
||||
amount_off: 1,
|
||||
duration: 'repeating',
|
||||
duration_in_months: 1
|
||||
};
|
||||
|
||||
const params = {
|
||||
productId: 'product-id',
|
||||
currency: 'usd',
|
||||
interval: 'month',
|
||||
active: true
|
||||
};
|
||||
|
||||
const options = {
|
||||
transacting: true
|
||||
};
|
||||
|
||||
const expectedData = {
|
||||
active: true,
|
||||
name: 'Coupon Name',
|
||||
code: 'coupon-name',
|
||||
product_id: 'product-id',
|
||||
stripe_coupon_id: 'coupon-id',
|
||||
interval: 'month',
|
||||
currency: 'usd',
|
||||
duration: 'repeating',
|
||||
duration_in_months: 1,
|
||||
portal_title: 'Coupon Name',
|
||||
discount_type: 'amount',
|
||||
discount_amount: 1
|
||||
};
|
||||
|
||||
const offerRepository = new OfferRepository(Offer);
|
||||
await offerRepository.createFromCoupon(coupon, params, options);
|
||||
|
||||
Offer.add.calledWith(expectedData, options).should.be.true();
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Add table
Reference in a new issue