mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-03 23:00:14 -05:00
Added initial Offers module
refs https://github.com/TryGhost/Team/issues/1083 This is the initial scaffolding for setting up Offers in Ghost
This commit is contained in:
parent
b03221401e
commit
5674036902
17 changed files with 855 additions and 0 deletions
10
ghost/offers/.eslintrc.js
Normal file
10
ghost/offers/.eslintrc.js
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
module.exports = {
|
||||||
|
parser: '@babel/eslint-parser',
|
||||||
|
parserOptions: {
|
||||||
|
requireConfigFile: false
|
||||||
|
},
|
||||||
|
plugins: ['ghost'],
|
||||||
|
extends: [
|
||||||
|
'plugin:ghost/node'
|
||||||
|
]
|
||||||
|
};
|
21
ghost/offers/LICENSE
Normal file
21
ghost/offers/LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2013-2021 Ghost Foundation
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
34
ghost/offers/README.md
Normal file
34
ghost/offers/README.md
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
# Offers
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
`npm install @tryghost/offers --save`
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
`yarn add @tryghost/offers`
|
||||||
|
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
This is a mono repository, managed with [lerna](https://lernajs.io/).
|
||||||
|
|
||||||
|
Follow the instructions for the top-level repo.
|
||||||
|
1. `git clone` this repo & `cd` into it as usual
|
||||||
|
2. Run `yarn` to install top-level dependencies.
|
||||||
|
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
- `yarn dev`
|
||||||
|
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
- `yarn lint` run just eslint
|
||||||
|
- `yarn test` run lint and tests
|
||||||
|
|
||||||
|
|
27
ghost/offers/index.js
Normal file
27
ghost/offers/index.js
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
const OfferRepository = require('./lib/OfferRepository');
|
||||||
|
const OffersAPI = require('./lib/OffersAPI');
|
||||||
|
|
||||||
|
class OffersModule {
|
||||||
|
/**
|
||||||
|
* @param {OffersAPI} offersAPI
|
||||||
|
*/
|
||||||
|
constructor(offersAPI) {
|
||||||
|
this.api = offersAPI;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} deps
|
||||||
|
* @param {import('@tryghost/express-dynamic-redirects')} deps.redirectManager
|
||||||
|
* @param {import('@tryghost/members-stripe-service')} deps.stripeAPIService
|
||||||
|
* @param {any} deps.OfferModel
|
||||||
|
*
|
||||||
|
* @returns {OffersModule}
|
||||||
|
*/
|
||||||
|
static create(deps) {
|
||||||
|
const repository = new OfferRepository(deps.OfferModel, deps.stripeAPIService, deps.redirectManager);
|
||||||
|
const offersAPI = new OffersAPI(repository);
|
||||||
|
return new OffersModule(offersAPI);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = OffersModule;
|
14
ghost/offers/jsconfig.json
Normal file
14
ghost/offers/jsconfig.json
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"include": [
|
||||||
|
"index.js",
|
||||||
|
"lib/**/*.js"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"checkJs": true,
|
||||||
|
"module": "commonjs",
|
||||||
|
"target": "es2018",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"strictFunctionTypes": true,
|
||||||
|
"noImplicitAny": true
|
||||||
|
}
|
||||||
|
}
|
311
ghost/offers/lib/Offer.js
Normal file
311
ghost/offers/lib/Offer.js
Normal file
|
@ -0,0 +1,311 @@
|
||||||
|
const errors = require('./errors');
|
||||||
|
const ObjectID = require('bson-objectid').default;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} OfferProps
|
||||||
|
* @prop {ObjectID} id
|
||||||
|
* @prop {string} name
|
||||||
|
* @prop {string} code
|
||||||
|
* @prop {string} display_title
|
||||||
|
* @prop {string} display_description
|
||||||
|
* @prop {'month'|'year'} cadence
|
||||||
|
* @prop {'percent'|'amount'} type
|
||||||
|
* @prop {number} amount
|
||||||
|
* @prop {string} currency
|
||||||
|
* @prop {OfferTier} tier
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} UniqueChecker
|
||||||
|
* @prop {(code: string) => Promise<boolean>} isUniqueCode
|
||||||
|
* @prop {(code: string) => Promise<boolean>} isUniqueName
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} TierProps
|
||||||
|
* @prop {ObjectID} id
|
||||||
|
* @prop {string} name
|
||||||
|
*/
|
||||||
|
|
||||||
|
class OfferTier {
|
||||||
|
get id() {
|
||||||
|
return this.props.id.toHexString();
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return this.props.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {TierProps} props
|
||||||
|
*/
|
||||||
|
constructor(props) {
|
||||||
|
this.props = props;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} data
|
||||||
|
* @returns {OfferTier}
|
||||||
|
*/
|
||||||
|
static create(data) {
|
||||||
|
let id;
|
||||||
|
|
||||||
|
if (data.id instanceof ObjectID) {
|
||||||
|
id = data.id;
|
||||||
|
} else if (typeof data.id === 'string') {
|
||||||
|
id = new ObjectID(data.id);
|
||||||
|
} else {
|
||||||
|
id = new ObjectID();
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = data.name;
|
||||||
|
|
||||||
|
return new OfferTier({
|
||||||
|
id,
|
||||||
|
name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Offer {
|
||||||
|
get id() {
|
||||||
|
return this.props.id.toHexString();
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return this.props.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get code() {
|
||||||
|
return this.props.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
get currency() {
|
||||||
|
return this.props.currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} code
|
||||||
|
* @param {UniqueChecker} uniqueChecker
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async updateCode(code, uniqueChecker) {
|
||||||
|
if (code === this.props.code) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!await uniqueChecker.isUniqueCode(code)) {
|
||||||
|
throw new errors.InvalidOfferCode({
|
||||||
|
message: 'Offer `code` must be unique.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.changed.code.push(this.props.code);
|
||||||
|
this.props.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
* @param {UniqueChecker} uniqueChecker
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async updateName(name, uniqueChecker) {
|
||||||
|
if (name === this.props.name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!await uniqueChecker.isUniqueName(name)) {
|
||||||
|
throw new errors.InvalidOfferNameError({
|
||||||
|
message: 'Offer `name` must be unique.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.changed.name.push(this.props.name);
|
||||||
|
this.props.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get oldCodes() {
|
||||||
|
return this.changed.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
get codeChanged() {
|
||||||
|
return this.changed.code.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get displayTitle() {
|
||||||
|
return this.props.display_title;
|
||||||
|
}
|
||||||
|
|
||||||
|
set displayTitle(value) {
|
||||||
|
this.props.display_title = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get displayDescription() {
|
||||||
|
return this.props.display_description;
|
||||||
|
}
|
||||||
|
|
||||||
|
set displayDescription(value) {
|
||||||
|
this.props.display_description = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get tier() {
|
||||||
|
return this.props.tier;
|
||||||
|
}
|
||||||
|
|
||||||
|
get cadence() {
|
||||||
|
return this.props.cadence;
|
||||||
|
}
|
||||||
|
|
||||||
|
get type() {
|
||||||
|
return this.props.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
get amount() {
|
||||||
|
if (this.type === 'percent' && this.props.amount === 314) {
|
||||||
|
return 3.14;
|
||||||
|
}
|
||||||
|
return this.props.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isNew() {
|
||||||
|
return !!this.options.isNew;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
* @param {OfferProps} props
|
||||||
|
* @param {object} options
|
||||||
|
* @param {boolean} options.isNew
|
||||||
|
*/
|
||||||
|
constructor(props, options) {
|
||||||
|
/** @private */
|
||||||
|
this.props = props;
|
||||||
|
/** @private */
|
||||||
|
this.options = options;
|
||||||
|
/** @private */
|
||||||
|
this.changed = {
|
||||||
|
code: [],
|
||||||
|
name: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} data
|
||||||
|
* @param {UniqueChecker} uniqueChecker
|
||||||
|
* @returns {Promise<Offer>}
|
||||||
|
*/
|
||||||
|
static async create(data, uniqueChecker) {
|
||||||
|
let isNew = false;
|
||||||
|
let id;
|
||||||
|
|
||||||
|
if (data.id instanceof ObjectID) {
|
||||||
|
id = data.id;
|
||||||
|
} else if (typeof data.id === 'string') {
|
||||||
|
id = new ObjectID(data.id);
|
||||||
|
} else {
|
||||||
|
id = new ObjectID();
|
||||||
|
isNew = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.name || typeof data.name !== 'string') {
|
||||||
|
throw new errors.InvalidOfferNameError({
|
||||||
|
message: 'Offer `name` must be a string.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.name.length > 191) {
|
||||||
|
throw new errors.InvalidOfferNameError({
|
||||||
|
message: 'Offer `name` can be a maximum of 191 characters.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
if (!await uniqueChecker.isUniqueName(data.name)) {
|
||||||
|
throw new errors.InvalidOfferNameError({
|
||||||
|
message: 'Offer `name` must be unique.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const name = data.name;
|
||||||
|
|
||||||
|
if (!data.display_title || typeof data.display_title !== 'string') {
|
||||||
|
throw new errors.InvalidOfferDisplayTitle({
|
||||||
|
message: 'Offer `display_title` must be a string.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.display_title.length > 191) {
|
||||||
|
throw new errors.InvalidOfferDisplayTitle({
|
||||||
|
message: 'Offer `display_title` can be a maximum of 191 characters.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const title = data.display_title;
|
||||||
|
|
||||||
|
if (!data.display_description || typeof data.display_description !== 'string') {
|
||||||
|
throw new errors.InvalidOfferDisplayDescription({
|
||||||
|
message: 'Offer `display_description` must be a string.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.display_description.length > 191) {
|
||||||
|
throw new errors.InvalidOfferDisplayDescription({
|
||||||
|
message: 'Offer `display_description` can be a maximum of 191 characters.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const description = data.display_description;
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
if (!await uniqueChecker.isUniqueCode(data.code)) {
|
||||||
|
throw new errors.InvalidOfferCode({
|
||||||
|
message: 'Offer `code` must be unique.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = data.code;
|
||||||
|
|
||||||
|
if (data.type !== 'percent') {
|
||||||
|
throw new errors.InvalidOfferType({
|
||||||
|
message: 'Offer `type` must be "percent".'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = data.type;
|
||||||
|
|
||||||
|
if (data.type === 'percent') {
|
||||||
|
if (data.amount < 0 || data.amount > 100 && data.amount !== 314) {
|
||||||
|
throw new errors.InvalidOfferAmount({
|
||||||
|
message: 'Offer `amount` must be an integer between 0 and 100.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(data.amount)) {
|
||||||
|
throw new errors.InvalidOfferAmount({
|
||||||
|
message: 'Offer `amount` must be an integer between 0 and 100.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const amount = data.amount;
|
||||||
|
|
||||||
|
if (data.cadence !== 'month' && data.cadence !== 'year') {
|
||||||
|
throw new errors.InvalidOfferCadence({
|
||||||
|
message: 'Offer `cadence` must be one of "month" or "year".'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cadence = data.cadence;
|
||||||
|
const currency = data.currency;
|
||||||
|
|
||||||
|
const tier = OfferTier.create(data.tier);
|
||||||
|
|
||||||
|
return new Offer({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
code,
|
||||||
|
display_title: title,
|
||||||
|
display_description: description,
|
||||||
|
type,
|
||||||
|
amount,
|
||||||
|
cadence,
|
||||||
|
currency,
|
||||||
|
tier
|
||||||
|
}, {isNew});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Offer;
|
52
ghost/offers/lib/OfferMapper.js
Normal file
52
ghost/offers/lib/OfferMapper.js
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* @typedef {import('./Offer')} Offer
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} OfferDTO
|
||||||
|
* @prop {string} id
|
||||||
|
* @prop {string} name
|
||||||
|
* @prop {string} code
|
||||||
|
*
|
||||||
|
* @prop {string} display_title
|
||||||
|
* @prop {string} display_description
|
||||||
|
*
|
||||||
|
* @prop {'percent'|'amount'} type
|
||||||
|
*
|
||||||
|
* @prop {'month'|'year'} cadence
|
||||||
|
* @prop {number} amount
|
||||||
|
*
|
||||||
|
* @prop {boolean} currency_restriction
|
||||||
|
* @prop {string} currency
|
||||||
|
*
|
||||||
|
* @prop {object} tier
|
||||||
|
* @prop {string} tier.id
|
||||||
|
* @prop {string} tier.name
|
||||||
|
*/
|
||||||
|
|
||||||
|
class OfferMapper {
|
||||||
|
/**
|
||||||
|
* @param {Offer} offer
|
||||||
|
* @returns {OfferDTO}
|
||||||
|
*/
|
||||||
|
static toDTO(offer) {
|
||||||
|
return {
|
||||||
|
id: offer.id,
|
||||||
|
name: offer.name,
|
||||||
|
code: offer.code,
|
||||||
|
display_title: offer.displayTitle,
|
||||||
|
display_description: offer.displayDescription,
|
||||||
|
type: offer.type,
|
||||||
|
cadence: offer.cadence,
|
||||||
|
amount: offer.amount,
|
||||||
|
currency_restriction: offer.type === 'amount',
|
||||||
|
currency: offer.type === 'amount' ? offer.currency : null,
|
||||||
|
tier: {
|
||||||
|
id: offer.tier.id,
|
||||||
|
name: offer.tier.name
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = OfferMapper;
|
156
ghost/offers/lib/OfferRepository.js
Normal file
156
ghost/offers/lib/OfferRepository.js
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
const Offer = require('./Offer');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} OfferRepositoryOptions
|
||||||
|
* @prop {import('knex').Transaction} transacting
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {any} json
|
||||||
|
* @returns {Offer.OfferProps}
|
||||||
|
*/
|
||||||
|
function toDomain(json) {
|
||||||
|
return {
|
||||||
|
id: json.id,
|
||||||
|
name: json.name,
|
||||||
|
code: json.code,
|
||||||
|
display_title: json.portal_title,
|
||||||
|
display_description: json.portal_description,
|
||||||
|
type: json.discount_type,
|
||||||
|
amount: json.discount_amount,
|
||||||
|
cadence: json.interval,
|
||||||
|
currency: json.currency,
|
||||||
|
tier: {
|
||||||
|
id: json.product.id,
|
||||||
|
name: json.product.name
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class OfferRepository {
|
||||||
|
/**
|
||||||
|
* @param {{forge: (data: object) => import('bookshelf').Model<Offer.OfferProps>}} OfferModel
|
||||||
|
* @param {import('@tryghost/members-stripe-service')} stripeAPIService
|
||||||
|
* @param {import('@tryghost/express-dynamic-redirects')} redirectManager
|
||||||
|
*/
|
||||||
|
constructor(OfferModel, stripeAPIService, redirectManager) {
|
||||||
|
/** @private */
|
||||||
|
this.OfferModel = OfferModel;
|
||||||
|
/** @private */
|
||||||
|
this.stripeAPIService = stripeAPIService;
|
||||||
|
/** @private */
|
||||||
|
this.redirectManager = redirectManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param {(t: import('knex').Transaction) => Promise<T>} cb
|
||||||
|
* @returns {Promise<T>}
|
||||||
|
*/
|
||||||
|
async createTransaction(cb) {
|
||||||
|
return this.OfferModel.transaction(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
* @param {OfferRepositoryOptions} options
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async existsByName(name, options) {
|
||||||
|
const model = await this.OfferModel.findOne({name}, options);
|
||||||
|
if (!model) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} code
|
||||||
|
* @param {OfferRepositoryOptions} options
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async existsByCode(code, options) {
|
||||||
|
const model = await this.OfferModel.findOne({code}, options);
|
||||||
|
if (!model) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
* @param {OfferRepositoryOptions} options
|
||||||
|
* @returns {Promise<Offer>}
|
||||||
|
*/
|
||||||
|
async getById(id, options) {
|
||||||
|
const model = await this.OfferModel.findOne({id}, {
|
||||||
|
...options,
|
||||||
|
withRelated: ['product']
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = model.toJSON();
|
||||||
|
|
||||||
|
return Offer.create(toDomain(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {OfferRepositoryOptions} options
|
||||||
|
* @returns {Promise<Offer[]>}
|
||||||
|
*/
|
||||||
|
async getAll(options) {
|
||||||
|
const models = await this.OfferModel.findAll({
|
||||||
|
...options,
|
||||||
|
withRelated: ['product']
|
||||||
|
});
|
||||||
|
return Promise.all(models.toJSON().map(toDomain).map(Offer.create));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Offer} offer
|
||||||
|
* @param {OfferRepositoryOptions} options
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async save(offer, options) {
|
||||||
|
const model = this.OfferModel.forge({
|
||||||
|
id: offer.id,
|
||||||
|
name: offer.name,
|
||||||
|
code: offer.code,
|
||||||
|
portal_title: offer.displayTitle,
|
||||||
|
portal_description: offer.displayDescription,
|
||||||
|
discount_type: offer.type,
|
||||||
|
discount_amount: offer.amount,
|
||||||
|
interval: offer.cadence,
|
||||||
|
product_id: offer.tier.id,
|
||||||
|
duration: 'once'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (offer.codeChanged) {
|
||||||
|
offer.oldCodes.forEach((code) => {
|
||||||
|
this.redirectManager.removeRedirect(code);
|
||||||
|
});
|
||||||
|
this.redirectManager.addRedirect(offer.code, `/#/portal/offers/${offer.id}`, {
|
||||||
|
permanent: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offer.isNew) {
|
||||||
|
/** @type {import('stripe').Stripe.CouponCreateParams} */
|
||||||
|
const coupon = {
|
||||||
|
name: offer.name,
|
||||||
|
duration: 'once'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (offer.type === 'percent') {
|
||||||
|
coupon.percent_off = offer.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const couponData = await this.stripeAPIService.createCoupon(coupon);
|
||||||
|
model.set('stripe_coupon_id', couponData.id);
|
||||||
|
await model.save(null, {method: 'insert', ...options});
|
||||||
|
} else {
|
||||||
|
await model.save(null, {method: 'update', ...options});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = OfferRepository;
|
86
ghost/offers/lib/OffersAPI.js
Normal file
86
ghost/offers/lib/OffersAPI.js
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
const Offer = require('./Offer');
|
||||||
|
const OfferMapper = require('./OfferMapper');
|
||||||
|
const UniqueChecker = require('./UniqueChecker');
|
||||||
|
|
||||||
|
class OffersAPI {
|
||||||
|
/**
|
||||||
|
* @param {import('./OfferRepository')} repository
|
||||||
|
*/
|
||||||
|
constructor(repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} data
|
||||||
|
*
|
||||||
|
* @returns {Promise<OfferMapper.OfferDTO>}
|
||||||
|
*/
|
||||||
|
async createOffer(data) {
|
||||||
|
return this.repository.createTransaction(async (transaction) => {
|
||||||
|
const options = {transacting: transaction};
|
||||||
|
const uniqueChecker = new UniqueChecker(this.repository, transaction);
|
||||||
|
|
||||||
|
const offer = await Offer.create(data, uniqueChecker);
|
||||||
|
|
||||||
|
await this.repository.save(offer, options);
|
||||||
|
|
||||||
|
return OfferMapper.toDTO(offer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} data
|
||||||
|
* @param {string} data.id
|
||||||
|
* @param {string} [data.name]
|
||||||
|
* @param {string} [data.title]
|
||||||
|
* @param {string} [data.description]
|
||||||
|
* @param {string} [data.code]
|
||||||
|
*
|
||||||
|
* @returns {Promise<OfferMapper.OfferDTO>}
|
||||||
|
*/
|
||||||
|
async updateOffer(data) {
|
||||||
|
return await this.repository.createTransaction(async (transaction) => {
|
||||||
|
const options = {transacting: transaction};
|
||||||
|
const uniqueChecker = new UniqueChecker(this.repository, transaction);
|
||||||
|
|
||||||
|
const offer = await this.repository.getById(data.id, options);
|
||||||
|
|
||||||
|
if (data.name) {
|
||||||
|
offer.updateName(data.name, uniqueChecker);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.code) {
|
||||||
|
offer.updateCode(data.code, uniqueChecker);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.title) {
|
||||||
|
offer.displayTitle = data.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.description) {
|
||||||
|
offer.displayDescription = data.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.repository.save(offer, options);
|
||||||
|
|
||||||
|
transaction.commit();
|
||||||
|
|
||||||
|
return OfferMapper.toDTO(offer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<OfferMapper.OfferDTO[]>}
|
||||||
|
*/
|
||||||
|
async listOffers() {
|
||||||
|
return await this.repository.createTransaction(async (transaction) => {
|
||||||
|
const options = {transacting: transaction};
|
||||||
|
|
||||||
|
const offers = await this.repository.getAll(options);
|
||||||
|
|
||||||
|
return offers.map(OfferMapper.toDTO);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = OffersAPI;
|
30
ghost/offers/lib/UniqueChecker.js
Normal file
30
ghost/offers/lib/UniqueChecker.js
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
class UniqueChecker {
|
||||||
|
/**
|
||||||
|
* @param {import('./OfferRepository')} repository
|
||||||
|
* @param {import('knex').Transaction} transaction
|
||||||
|
*/
|
||||||
|
constructor(repository, transaction) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.options = {
|
||||||
|
transacting: transaction
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} code
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async isUniqueCode(code) {
|
||||||
|
return await this.repository.existsByCode(code, this.options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async isUniqueName(name) {
|
||||||
|
return await this.repository.existsByName(name, this.options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = UniqueChecker;
|
37
ghost/offers/lib/errors.js
Normal file
37
ghost/offers/lib/errors.js
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
const {GhostError} = require('@tryghost/errors');
|
||||||
|
|
||||||
|
class InvalidPropError extends GhostError {
|
||||||
|
static message = 'Invalid Offer property';
|
||||||
|
/** @param {any} options */
|
||||||
|
constructor(options) {
|
||||||
|
super({
|
||||||
|
statusCode: 400
|
||||||
|
});
|
||||||
|
this.errorType = this.constructor.name;
|
||||||
|
this.message = options.message || this.constructor.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InvalidOfferNameError extends InvalidPropError {
|
||||||
|
static message = 'Invalid offer name!';
|
||||||
|
}
|
||||||
|
class InvalidOfferDisplayTitle extends InvalidPropError {}
|
||||||
|
class InvalidOfferDisplayDescription extends InvalidPropError {}
|
||||||
|
class InvalidOfferCode extends InvalidPropError {}
|
||||||
|
class InvalidOfferType extends InvalidPropError {}
|
||||||
|
class InvalidOfferAmount extends InvalidPropError {}
|
||||||
|
class InvalidOfferCurrency extends InvalidPropError {}
|
||||||
|
class InvalidOfferTierName extends InvalidPropError {}
|
||||||
|
class InvalidOfferCadence extends InvalidPropError {}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
InvalidOfferNameError,
|
||||||
|
InvalidOfferDisplayTitle,
|
||||||
|
InvalidOfferDisplayDescription,
|
||||||
|
InvalidOfferCode,
|
||||||
|
InvalidOfferType,
|
||||||
|
InvalidOfferAmount,
|
||||||
|
InvalidOfferCurrency,
|
||||||
|
InvalidOfferCadence,
|
||||||
|
InvalidOfferTierName
|
||||||
|
};
|
29
ghost/offers/package.json
Normal file
29
ghost/offers/package.json
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"name": "@tryghost/members-offers",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"repository": "https://github.com/TryGhost/Members/tree/main/packages/offers",
|
||||||
|
"author": "Ghost Foundation",
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "echo \"Implement me!\"",
|
||||||
|
"test": "NODE_ENV=testing c8 --check-coverage mocha './test/**/*.test.js'",
|
||||||
|
"lint": "eslint . --ext .js --cache",
|
||||||
|
"posttest": "yarn lint"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"index.js",
|
||||||
|
"lib"
|
||||||
|
],
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/eslint-parser": "^7.15.7",
|
||||||
|
"c8": "7.9.0",
|
||||||
|
"mocha": "9.1.2",
|
||||||
|
"should": "13.2.3",
|
||||||
|
"sinon": "11.1.2"
|
||||||
|
},
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
6
ghost/offers/test/.eslintrc.js
Normal file
6
ghost/offers/test/.eslintrc.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: ['ghost'],
|
||||||
|
extends: [
|
||||||
|
'plugin:ghost/test'
|
||||||
|
]
|
||||||
|
};
|
10
ghost/offers/test/hello.test.js
Normal file
10
ghost/offers/test/hello.test.js
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
// Switch these lines once there are useful utils
|
||||||
|
// const testUtils = require('./utils');
|
||||||
|
require('./utils');
|
||||||
|
|
||||||
|
describe('Hello world', function () {
|
||||||
|
it('Runs a test', function () {
|
||||||
|
// TODO: Write me!
|
||||||
|
'hello'.should.eql('hello');
|
||||||
|
});
|
||||||
|
});
|
11
ghost/offers/test/utils/assertions.js
Normal file
11
ghost/offers/test/utils/assertions.js
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
/**
|
||||||
|
* Custom Should Assertions
|
||||||
|
*
|
||||||
|
* Add any custom assertions to this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Example Assertion
|
||||||
|
// should.Assertion.add('ExampleAssertion', function () {
|
||||||
|
// this.params = {operator: 'to be a valid Example Assertion'};
|
||||||
|
// this.obj.should.be.an.Object;
|
||||||
|
// });
|
11
ghost/offers/test/utils/index.js
Normal file
11
ghost/offers/test/utils/index.js
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
/**
|
||||||
|
* Test Utilities
|
||||||
|
*
|
||||||
|
* Shared utils for writing tests
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Require overrides - these add globals for tests
|
||||||
|
require('./overrides');
|
||||||
|
|
||||||
|
// Require assertions - adds custom should assertions
|
||||||
|
require('./assertions');
|
10
ghost/offers/test/utils/overrides.js
Normal file
10
ghost/offers/test/utils/overrides.js
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
// This file is required before any test is run
|
||||||
|
|
||||||
|
// Taken from the should wiki, this is how to make should global
|
||||||
|
// Should is a global in our eslint test config
|
||||||
|
global.should = require('should').noConflict();
|
||||||
|
should.extend();
|
||||||
|
|
||||||
|
// Sinon is a simple case
|
||||||
|
// Sinon is a global in our eslint test config
|
||||||
|
global.sinon = require('sinon');
|
Loading…
Add table
Reference in a new issue