Updated offers setup to allow trial values

refs https://github.com/TryGhost/Team/issues/1726

- updates offer setup to allow new `trial` as discount type, was prev only `fixed` and `percent`
- updates offer setup to allow `amount` as free trial days value
- updates offer setup to allow `trial` as discount duration value for trial offers, was prev only `once`/`forever`/`repeating`
This commit is contained in:
Rishabh 2022-08-09 14:39:27 +05:30 committed by Rishabh Garg
parent 27a89d4b0e
commit 66970e5002
10 changed files with 173 additions and 11 deletions

View File

@ -11,7 +11,7 @@
* @prop {string} display_title
* @prop {string} display_description
*
* @prop {'percent'|'fixed'} type
* @prop {'percent'|'fixed'|'trial'} type
*
* @prop {'month'|'year'} cadence
* @prop {number} amount
@ -19,7 +19,7 @@
* @prop {boolean} currency_restriction
* @prop {string} currency
*
* @prop {'once'|'repeating'|'forever'} duration
* @prop {'once'|'repeating'|'forever'|'trial'} duration
* @prop {null|number} duration_in_months
*
* @prop {'active'|'archived'} status

View File

@ -108,7 +108,7 @@ class OfferRepository {
code: json.code,
display_title: json.portal_title,
display_description: json.portal_description,
type: json.discount_type === 'amount' ? 'fixed' : 'percent',
type: json.discount_type === 'amount' ? 'fixed' : json.discount_type,
amount: json.discount_amount,
cadence: json.interval,
currency: json.currency,
@ -192,7 +192,7 @@ class OfferRepository {
code: offer.code.value,
portal_title: offer.displayTitle.value || null,
portal_description: offer.displayDescription.value || null,
discount_type: offer.type.value === 'fixed' ? 'amount' : 'percent',
discount_type: offer.type.value === 'fixed' ? 'amount' : offer.type.value,
discount_amount: offer.amount.value,
interval: offer.cadence.value,
product_id: offer.tier.id,

View File

@ -276,10 +276,19 @@ class Offer {
});
}
//CASE: For offer type trial, the duration can only be `trial`
if (type.value === 'trial' && duration.value.type !== 'trial') {
throw new errors.InvalidOfferDuration({
message: 'Offer `duration` must be "trial" for offer type "trial".'
});
}
let currency = null;
let amount;
if (type.equals(OfferType.Percentage)) {
amount = OfferAmount.OfferPercentageAmount.create(data.amount);
} else if (type.equals(OfferType.Trial)) {
amount = OfferAmount.OfferTrialAmount.create(data.amount);
} else if (type.equals(OfferType.Fixed)) {
amount = OfferAmount.OfferFixedAmount.create(data.amount);
currency = OfferCurrency.create(data.currency);

View File

@ -52,6 +52,31 @@ class OfferFixedAmount extends OfferAmount {
static InvalidOfferAmount = InvalidOfferAmount;
}
class OfferTrialAmount extends OfferAmount {
/** @param {unknown} amount */
static create(amount) {
if (typeof amount !== 'number') {
throw new InvalidOfferAmount({
message: 'Offer `amount` must be an integer greater than 0.'
});
}
if (!Number.isInteger(amount)) {
throw new InvalidOfferAmount({
message: 'Offer `amount` must be a integer greater than 0.'
});
}
if (amount < 0) {
throw new InvalidOfferAmount({
message: 'Offer `amount` must be a integer greater than 0.'
});
}
return new OfferTrialAmount(amount);
}
static InvalidOfferAmount = InvalidOfferAmount;
}
module.exports = OfferAmount;
module.exports.OfferPercentageAmount = OfferPercentageAmount;
module.exports.OfferFixedAmount = OfferFixedAmount;
module.exports.OfferTrialAmount = OfferTrialAmount;

View File

@ -3,7 +3,7 @@ const InvalidOfferDuration = require('../errors').InvalidOfferDuration;
/**
* @typedef {object} BasicDuration
* @prop {'once'|'forever'} type
* @prop {'once'|'forever'|'trial'} type
*/
/**
@ -26,9 +26,9 @@ class OfferDuration extends ValueObject {
message: 'Offer `duration` must be a string.'
});
}
if (type !== 'once' && type !== 'repeating' && type !== 'forever') {
if (type !== 'once' && type !== 'repeating' && type !== 'forever' && type !== 'trial') {
throw new InvalidOfferDuration({
message: 'Offer `duration` must be one of "once", "repeating" or "forever".'
message: 'Offer `duration` must be one of "once", "repeating", "forever" or "trial.'
});
}
if (type !== 'repeating') {

View File

@ -1,7 +1,7 @@
const ValueObject = require('./shared/ValueObject');
const InvalidOfferType = require('../errors').InvalidOfferType;
/** @extends ValueObject<'fixed'|'percent'> */
/** @extends ValueObject<'fixed'|'percent'|'trial'> */
class OfferType extends ValueObject {
/** @param {unknown} type */
static create(type) {
@ -10,9 +10,9 @@ class OfferType extends ValueObject {
message: 'Offer `type` must be a string.'
});
}
if (type !== 'percent' && type !== 'fixed') {
if (type !== 'percent' && type !== 'fixed' && type !== 'trial') {
throw new InvalidOfferType({
message: 'Offer `type` must be one of "percent" or "fixed".'
message: 'Offer `type` must be one of "percent", "fixed" or "trial".'
});
}
@ -24,6 +24,8 @@ class OfferType extends ValueObject {
static Percentage = new OfferType('percent');
static Fixed = new OfferType('fixed');
static Trial = new OfferType('trial');
}
module.exports = OfferType;

View File

@ -41,6 +41,48 @@ describe('Offer', function () {
);
});
it('Creates a valid instance of a trial Offer', async function () {
const offer = await Offer.create({
name: 'My Trial Offer',
code: 'offer-code-trial',
display_title: 'My Offer Title',
display_description: 'My Offer Description',
cadence: 'month',
type: 'trial',
amount: 10,
duration: 'trial',
currency: 'USD',
tier: {
id: ObjectID()
}
}, mockUniqueChecker);
should.ok(
offer instanceof Offer,
'Offer.create should return an instance of Offer'
);
});
it('Throws an error if the duration for trial offer is not right', async function () {
await Offer.create({
name: 'My Trial Offer',
code: 'trial-test',
display_title: 'My Offer Title',
display_description: 'My Offer Description',
cadence: 'month',
type: 'trial',
amount: 10,
duration: 'forever',
currency: 'USD',
tier: {
id: ObjectID()
}
}, mockUniqueChecker).then(() => {
should.fail('Expected an error');
}, (err) => {
should.ok(err);
});
});
it('Throws an error if the code is not unique', async function () {
await Offer.create({
name: 'My Offer',
@ -150,6 +192,27 @@ describe('Offer', function () {
should.equal(offer.currency, null);
});
it('Has a currency of null if the type is trial', async function () {
const data = {
name: 'My Trial Offer',
code: 'offer-code-trial',
display_title: 'My Offer Title',
display_description: 'My Offer Description',
cadence: 'year',
type: 'trial',
amount: 20,
duration: 'trial',
currency: 'USD',
tier: {
id: ObjectID()
}
};
const offer = await Offer.create(data, mockUniqueChecker);
should.equal(offer.currency, null);
});
it('Can handle ObjectID, string and no id', async function () {
const data = {
name: 'My Offer',

View File

@ -1,4 +1,4 @@
const {OfferPercentageAmount, OfferFixedAmount} = require('../../../../lib/domain/models/OfferAmount');
const {OfferPercentageAmount, OfferFixedAmount, OfferTrialAmount} = require('../../../../lib/domain/models/OfferAmount');
describe('OfferAmount', function () {
describe('OfferPercentageAmount', function () {
@ -118,4 +118,58 @@ describe('OfferAmount', function () {
should.ok(typeof cadence.value === 'number');
});
});
describe('OfferTrialAmount', function () {
describe('OfferTrialAmount.create factory', function () {
it('Will only create an OfferTrialAmount containing an integer greater than 0', function () {
try {
OfferTrialAmount.create();
should.fail();
} catch (err) {
should.ok(
err instanceof OfferTrialAmount.InvalidOfferAmount,
'expected an InvalidOfferAmount error'
);
}
try {
OfferTrialAmount.create('1');
should.fail();
} catch (err) {
should.ok(
err instanceof OfferTrialAmount.InvalidOfferAmount,
'expected an InvalidOfferAmount error'
);
}
try {
OfferTrialAmount.create(-1);
should.fail();
} catch (err) {
should.ok(
err instanceof OfferTrialAmount.InvalidOfferAmount,
'expected an InvalidOfferAmount error'
);
}
try {
OfferTrialAmount.create(3.14);
should.fail();
} catch (err) {
should.ok(
err instanceof OfferTrialAmount.InvalidOfferAmount,
'expected an InvalidOfferAmount error'
);
}
OfferTrialAmount.create(200);
});
});
it('Exposes a number on the value property', function () {
const cadence = OfferTrialAmount.create(42);
should.ok(typeof cadence.value === 'number');
});
});
});

View File

@ -5,6 +5,7 @@ describe('OfferDuration', function () {
it('Will only allow creating a once, repeating or forever duration', function () {
OfferDuration.create('once');
OfferDuration.create('forever');
OfferDuration.create('trial');
OfferDuration.create('repeating', 2);
try {

View File

@ -5,6 +5,7 @@ describe('OfferType', function () {
it('Creates an Offer type containing either "fixed" or "percent"', function () {
OfferType.create('fixed');
OfferType.create('percent');
OfferType.create('trial');
try {
OfferType.create('other');
@ -41,4 +42,11 @@ describe('OfferType', function () {
should.ok(OfferType.Fixed.equals(OfferType.create('fixed')));
});
});
describe('OfferType.Trial', function () {
it('Is an OfferType with a value of "trial"', function () {
should.equal(OfferType.Trial.value, 'trial');
should.ok(OfferType.Trial.equals(OfferType.create('trial')));
});
});
});