0
Fork 0
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:
Sag 2023-07-19 14:26:42 +02:00 committed by GitHub
parent 5b7bca1f1e
commit 8a32941ae8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 396 additions and 2 deletions

View file

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

View file

@ -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]

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