Added support for Tier events

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

These events are all required for other parts of the Ghost system to stay in
sync. The events on each Tier object will be dispatched by the TierRepository
once they've been persisted.

TierCreatedEvent - generate Stripe Products & Prices
TierNameChangeEvent - update Stripe Products
TierPriceChangeEvent - update Stripe Products & Prices
TierArchivedEvent - update the Portal settings for visible tiers
                  - disable Stripe Products & Prices
TierActivatedEvent - enable Stripe Products & Prices
This commit is contained in:
Fabien "egg" O'Carroll 2022-10-20 17:25:19 +07:00
parent d1d3c1401f
commit 5f928794c3
8 changed files with 305 additions and 6 deletions

View File

@ -1,7 +1,16 @@
const ObjectID = require('bson-objectid').default; const ObjectID = require('bson-objectid').default;
const {ValidationError} = require('@tryghost/errors'); const {ValidationError} = require('@tryghost/errors');
const TierActivatedEvent = require('./TierActivatedEvent');
const TierArchivedEvent = require('./TierArchivedEvent');
const TierCreatedEvent = require('./TierCreatedEvent');
const TierNameChangeEvent = require('./TierNameChangeEvent');
const TierPriceChangeEvent = require('./TierPriceChangeEvent');
module.exports = class Tier { module.exports = class Tier {
/** @type {BaseEvent[]} */
events = [];
/** @type {ObjectID} */ /** @type {ObjectID} */
#id; #id;
get id() { get id() {
@ -20,7 +29,12 @@ module.exports = class Tier {
return this.#name; return this.#name;
} }
set name(value) { set name(value) {
this.#name = validateName(value); const newName = validateName(value);
if (newName === this.#name) {
return;
}
this.events.push(TierNameChangeEvent.create({tier: this}));
this.#name = newName;
} }
/** @type {string[]} */ /** @type {string[]} */
@ -56,7 +70,16 @@ module.exports = class Tier {
return this.#status; return this.#status;
} }
set status(value) { set status(value) {
this.#status = validateStatus(value); const newStatus = validateStatus(value);
if (newStatus === this.#status) {
return;
}
if (newStatus === 'active') {
this.events.push(TierActivatedEvent.create({tier: this}));
} else {
this.events.push(TierArchivedEvent.create({tier: this}));
}
this.#status = newStatus;
} }
/** @type {'public'|'none'} */ /** @type {'public'|'none'} */
@ -110,6 +133,30 @@ module.exports = class Tier {
this.#yearlyPrice = validateYearlyPrice(value, this.#type); this.#yearlyPrice = validateYearlyPrice(value, this.#type);
} }
updatePricing({currency, monthlyPrice, yearlyPrice}) {
if (this.#type !== 'paid') {
throw new ValidationError({
message: 'Cannot set pricing for free tiers'
});
}
const newCurrency = validateCurrency(currency, this.#type);
const newMonthlyPrice = validateMonthlyPrice(monthlyPrice, this.#type);
const newYearlyPrice = validateYearlyPrice(yearlyPrice, this.#type);
if (newCurrency === this.#currency && newMonthlyPrice === this.#monthlyPrice && newYearlyPrice === this.#yearlyPrice) {
return;
}
this.#currency = newCurrency;
this.#monthlyPrice = newMonthlyPrice;
this.#yearlyPrice = newYearlyPrice;
this.events.push(TierPriceChangeEvent.create({
tier: this
}));
}
/** @type {Date} */ /** @type {Date} */
#createdAt; #createdAt;
get createdAt() { get createdAt() {
@ -169,7 +216,9 @@ module.exports = class Tier {
*/ */
static async create(data) { static async create(data) {
let id; let id;
let isNew = false;
if (!data.id) { if (!data.id) {
isNew = true;
id = new ObjectID(); id = new ObjectID();
} else if (typeof data.id === 'string') { } else if (typeof data.id === 'string') {
id = ObjectID.createFromHexString(data.id); id = ObjectID.createFromHexString(data.id);
@ -197,7 +246,7 @@ module.exports = class Tier {
let updatedAt = validateUpdatedAt(data.updatedAt); let updatedAt = validateUpdatedAt(data.updatedAt);
let benefits = validateBenefits(data.benefits); let benefits = validateBenefits(data.benefits);
return new Tier({ const tier = new Tier({
id, id,
slug, slug,
name, name,
@ -214,6 +263,12 @@ module.exports = class Tier {
updated_at: updatedAt, updated_at: updatedAt,
benefits benefits
}); });
if (isNew) {
tier.events.push(TierCreatedEvent.create({tier}));
}
return tier;
} }
}; };

View File

@ -0,0 +1,30 @@
/**
* @typedef {object} TierActivatedEventData
* @prop {Tier} tier
*/
class TierActivatedEvent {
/** @type {TierActivatedEventData} */
data;
/** @type {Date} */
timestamp;
/**
* @param {TierActivatedEvent} data
* @param {Date} timestamp
*/
constructor(data, timestamp) {
this.data = data;
this.timestamp = timestamp;
}
/**
* @param {TierActivatedEvent} data
* @param {Date} [timestamp]
*/
static create(data, timestamp = new Date()) {
return new TierActivatedEvent(data, timestamp);
}
}
module.exports = TierActivatedEvent;

View File

@ -0,0 +1,30 @@
/**
* @typedef {object} TierArchivedEventData
* @prop {Tier} tier
*/
class TierArchivedEvent {
/** @type {TierArchivedEventData} */
data;
/** @type {Date} */
timestamp;
/**
* @param {TierArchivedEvent} data
* @param {Date} timestamp
*/
constructor(data, timestamp) {
this.data = data;
this.timestamp = timestamp;
}
/**
* @param {TierArchivedEvent} data
* @param {Date} [timestamp]
*/
static create(data, timestamp = new Date()) {
return new TierArchivedEvent(data, timestamp);
}
}
module.exports = TierArchivedEvent;

View File

@ -0,0 +1,30 @@
/**
* @typedef {object} TierCreatedEventData
* @prop {Tier} tier
*/
class TierCreatedEvent {
/** @type {TierCreatedEventData} */
data;
/** @type {Date} */
timestamp;
/**
* @param {TierCreatedEvent} data
* @param {Date} timestamp
*/
constructor(data, timestamp) {
this.data = data;
this.timestamp = timestamp;
}
/**
* @param {TierCreatedEvent} data
* @param {Date} [timestamp]
*/
static create(data, timestamp = new Date()) {
return new TierCreatedEvent(data, timestamp);
}
}
module.exports = TierCreatedEvent;

View File

@ -0,0 +1,30 @@
/**
* @typedef {object} TierNameChangeEventData
* @prop {Tier} tier
*/
class TierNameChangeEvent {
/** @type {TierNameChangeEventData} */
data;
/** @type {Date} */
timestamp;
/**
* @param {TierNameChangeEvent} data
* @param {Date} timestamp
*/
constructor(data, timestamp) {
this.data = data;
this.timestamp = timestamp;
}
/**
* @param {TierNameChangeEvent} data
* @param {Date} [timestamp]
*/
static create(data, timestamp = new Date()) {
return new TierNameChangeEvent(data, timestamp);
}
}
module.exports = TierNameChangeEvent;

View File

@ -0,0 +1,30 @@
/**
* @typedef {object} TierPriceChangeEventData
* @prop {Tier} tier
*/
class TierPriceChangeEvent {
/** @type {TierPriceChangeEventData} */
data;
/** @type {Date} */
timestamp;
/**
* @param {TierPriceChangeEvent} data
* @param {Date} timestamp
*/
constructor(data, timestamp) {
this.data = data;
this.timestamp = timestamp;
}
/**
* @param {TierPriceChangeEvent} data
* @param {Date} [timestamp]
*/
static create(data, timestamp = new Date()) {
return new TierPriceChangeEvent(data, timestamp);
}
}
module.exports = TierPriceChangeEvent;

View File

@ -92,9 +92,6 @@ module.exports = class TiersAPI {
'visibility', 'visibility',
'active', 'active',
'trialDays', 'trialDays',
'currency',
'monthlyPrice',
'yearlyPrice',
'welcomePageURL' 'welcomePageURL'
]; ];
@ -104,6 +101,12 @@ module.exports = class TiersAPI {
} }
} }
tier.updatePricing({
currency: data.currency || tier.currency,
monthlyPrice: data.monthlyPrice || tier.monthlyPrice,
yearlyPrice: data.yearlyPrice || tier.yearlyPrice
});
await this.#repository.save(tier); await this.#repository.save(tier);
return tier; return tier;

View File

@ -1,6 +1,10 @@
const assert = require('assert'); const assert = require('assert');
const ObjectID = require('bson-objectid'); const ObjectID = require('bson-objectid');
const Tier = require('../lib/Tier'); const Tier = require('../lib/Tier');
const TierActivatedEvent = require('../lib/TierActivatedEvent');
const TierArchivedEvent = require('../lib/TierArchivedEvent');
const TierNameChangeEvent = require('../lib/TierNameChangeEvent');
const TierPriceChangeEvent = require('../lib/TierPriceChangeEvent');
async function assertError(fn, checkError) { async function assertError(fn, checkError) {
let error; let error;
@ -160,5 +164,92 @@ describe('Tier', function () {
tier.yearlyPrice = 'one hundred'; tier.yearlyPrice = 'one hundred';
}); });
}); });
it('Can change name and adds an event', async function () {
const tier = await Tier.create(validInput);
tier.name = 'New name';
assert(tier.events.find((event) => {
return event instanceof TierNameChangeEvent;
}));
});
it('Can update pricing information and adds an event', async function () {
const tier = await Tier.create(validInput);
tier.updatePricing({
currency: 'eur',
monthlyPrice: 1000,
yearlyPrice: 6000
});
assert(tier.currency === 'EUR');
assert(tier.monthlyPrice === 1000);
assert(tier.yearlyPrice === 6000);
assert(tier.events.find((event) => {
return event instanceof TierPriceChangeEvent;
}));
});
it('Can archive tier and adds an event', async function () {
const tier = await Tier.create(validInput);
tier.status = 'archived';
assert(tier.events.find((event) => {
return event instanceof TierArchivedEvent;
}));
});
it('Can activate tier and adds an event', async function () {
const tier = await Tier.create({...validInput, status: 'archived'});
tier.status = 'active';
assert(tier.events.find((event) => {
return event instanceof TierActivatedEvent;
}));
});
it('Does not add event if values not changed', async function () {
const tier = await Tier.create(validInput);
tier.status = 'active';
assert(!tier.events.find((event) => {
return event instanceof TierActivatedEvent;
}));
tier.name = 'Tier Name';
assert(!tier.events.find((event) => {
return event instanceof TierNameChangeEvent;
}));
tier.updatePricing({
currency: tier.currency,
monthlyPrice: tier.monthlyPrice,
yearlyPrice: tier.yearlyPrice
});
assert(!tier.events.find((event) => {
return event instanceof TierPriceChangeEvent;
}));
});
it('Cannot set pricing data on a free tier', async function () {
const tier = await Tier.create({
...validInput,
type: 'free',
currency: null,
monthlyPrice: null,
yearlyPrice: null,
trialDays: null
});
assertError(() => {
tier.updatePricing({
currency: 'usd',
monthlyPrice: 1000,
yearlyPrice: 10000
});
});
});
}); });
}); });