Added donations API (#17495)

refs https://github.com/TryGhost/Product/issues/3648

- Refactored Members API RouterController.createCheckoutSession: Split the method into smaller parts so we can reuse individual parts for the upcoming donation checkout session.
- Wired up donation checkout creation
- Added donation events
This commit is contained in:
Simon Backx 2023-07-31 18:00:52 +02:00 committed by GitHub
parent 18cf5dd582
commit 841e52ccfe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 921 additions and 147 deletions

View File

@ -41,7 +41,7 @@ const COMMAND_ADMIN = {
const COMMAND_TYPESCRIPT = {
name: 'ts',
command: 'nx watch --projects=ghost/collections,ghost/in-memory-repository,ghost/mail-events,ghost/model-to-domain-event-interceptor,ghost/post-revisions,ghost/nql-filter-expansions -- nx run \\$NX_PROJECT_NAME:build:ts',
command: 'nx watch --projects=ghost/collections,ghost/in-memory-repository,ghost/mail-events,ghost/model-to-domain-event-interceptor,ghost/post-revisions,ghost/nql-filter-expansions,ghost/donations -- nx run \\$NX_PROJECT_NAME:build:ts',
cwd: path.resolve(__dirname, '../../'),
prefixColor: 'cyan',
env: {}

View File

@ -327,6 +327,7 @@ async function initServices({config}) {
const mediaInliner = require('./server/services/media-inliner');
const collections = require('./server/services/collections');
const mailEvents = require('./server/services/mail-events');
const donationService = require('./server/services/donations');
const urlUtils = require('./shared/url-utils');
@ -365,7 +366,8 @@ async function initServices({config}) {
slackNotifications.init(),
collections.init(),
mediaInliner.init(),
mailEvents.init()
mailEvents.init(),
donationService.init()
]);
debug('End: Services');

View File

@ -0,0 +1,34 @@
const errors = require('@tryghost/errors');
const ghostBookshelf = require('./base');
const DonationPaymentEvent = ghostBookshelf.Model.extend({
tableName: 'donation_payment_events',
member() {
return this.belongsTo('Member', 'member_id', 'id');
},
postAttribution() {
return this.belongsTo('Post', 'attribution_id', 'id');
},
userAttribution() {
return this.belongsTo('User', 'attribution_id', 'id');
},
tagAttribution() {
return this.belongsTo('Tag', 'attribution_id', 'id');
}
}, {
async edit() {
throw new errors.IncorrectUsageError({message: 'Cannot edit DonationPaymentEvent'});
},
async destroy() {
throw new errors.IncorrectUsageError({message: 'Cannot destroy DonationPaymentEvent'});
}
});
module.exports = {
DonationPaymentEvent: ghostBookshelf.model('DonationPaymentEvent', DonationPaymentEvent)
};

View File

@ -0,0 +1,19 @@
const {DonationPaymentEvent: DonationPaymentEventModel} = require('../../models');
class DonationServiceWrapper {
repository;
init() {
if (this.repository) {
return;
}
const {DonationBookshelfRepository} = require('@tryghost/donations');
this.repository = new DonationBookshelfRepository({
DonationPaymentEventModel
});
}
}
module.exports = DonationServiceWrapper;

View File

@ -0,0 +1,3 @@
const DonationServiceWrapper = require('./DonationServiceWrapper');
module.exports = new DonationServiceWrapper();

View File

@ -214,7 +214,8 @@ function createApiInstance(config) {
labsService: labsService,
newslettersService: newslettersService,
memberAttributionService: memberAttributionService.service,
emailSuppressionList
emailSuppressionList,
settingsCache
});
return membersApiInstance;

View File

@ -258,20 +258,28 @@ const createSessionFromMagicLink = async function (req, res, next) {
}
}
if (action === 'signin') {
const referrer = req.query.r;
const siteUrl = urlUtils.getSiteUrl();
// If a custom referrer/redirect was passed, redirect the user to that URL
const referrer = req.query.r;
const siteUrl = urlUtils.getSiteUrl();
if (referrer && referrer.startsWith(siteUrl)) {
const redirectUrl = new URL(referrer);
redirectUrl.searchParams.set('success', true);
if (referrer && referrer.startsWith(siteUrl)) {
const redirectUrl = new URL(referrer);
// Copy search params
searchParams.forEach((value, key) => {
redirectUrl.searchParams.set(key, value);
});
redirectUrl.searchParams.set('success', 'true');
if (action === 'signin') {
// Not sure if we can delete this, this is a legacy param
redirectUrl.searchParams.set('action', 'signin');
return res.redirect(redirectUrl.href);
}
return res.redirect(redirectUrl.href);
}
// Do a standard 302 redirect to the homepage, with success=true
searchParams.set('success', true);
searchParams.set('success', 'true');
res.redirect(`${urlUtils.getSubdir()}/?${searchParams.toString()}`);
} catch (err) {
logging.warn(err.message);

View File

@ -9,6 +9,7 @@ const events = require('../../lib/common/events');
const models = require('../../models');
const {getConfig} = require('./config');
const settingsHelpers = require('../settings-helpers');
const donationService = require('../donations');
async function configureApi() {
const cfg = getConfig({settingsHelpers, config, urlUtils});
@ -54,7 +55,8 @@ module.exports = new StripeService({
value: data.secret
}]);
}
}
},
donationService
});
module.exports.init = async function init() {

View File

@ -80,6 +80,7 @@
"@tryghost/database-info": "0.3.17",
"@tryghost/debug": "0.1.24",
"@tryghost/domain-events": "0.0.0",
"@tryghost/donations": "0.0.0",
"@tryghost/dynamic-routing-events": "0.0.0",
"@tryghost/email-analytics-provider-mailgun": "0.0.0",
"@tryghost/email-analytics-service": "0.0.0",

View File

@ -144,7 +144,7 @@ describe('Members Service Middleware', function () {
res.redirect.firstCall.args[0].should.eql('https://custom.com/paid/');
});
it('redirects member to referrer param path on signup if it is on the site', async function () {
it('redirects member to referrer param path on signin if it is on the site', async function () {
req.url = '/members?token=test&action=signin&r=https%3A%2F%2Fsite.com%2Fblah%2Fmy-post%2F';
req.query = {token: 'test', action: 'signin', r: 'https://site.com/blah/my-post/#comment-123'};
@ -157,7 +157,23 @@ describe('Members Service Middleware', function () {
// Check behavior
next.calledOnce.should.be.false();
res.redirect.calledOnce.should.be.true();
res.redirect.firstCall.args[0].should.eql('https://site.com/blah/my-post/?success=true&action=signin#comment-123');
res.redirect.firstCall.args[0].should.eql('https://site.com/blah/my-post/?action=signin&success=true#comment-123');
});
it('redirects member to referrer param path on signup if it is on the site', async function () {
req.url = '/members?token=test&action=signup&r=https%3A%2F%2Fsite.com%2Fblah%2Fmy-post%2F';
req.query = {token: 'test', action: 'signup', r: 'https://site.com/blah/my-post/#comment-123'};
// Fake token handling failure
membersService.ssr.exchangeTokenForSession.resolves({});
// Call the middleware
await membersMiddleware.createSessionFromMagicLink(req, res, next);
// Check behavior
next.calledOnce.should.be.false();
res.redirect.calledOnce.should.be.true();
res.redirect.firstCall.args[0].should.eql('https://site.com/blah/my-post/?action=signup&success=true#comment-123');
});
it('does not redirect to referrer param if it is external', async function () {

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/ts'
]
};

21
ghost/donations/README.md Normal file
View File

@ -0,0 +1,21 @@
# Donations
## Usage
## Develop
This is a monorepo package.
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.
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests

View File

@ -0,0 +1,32 @@
{
"name": "@tryghost/donations",
"version": "0.0.0",
"repository": "https://github.com/TryGhost/Ghost/tree/main/packages/donations",
"author": "Ghost Foundation",
"private": true,
"main": "build/index.js",
"types": "build/index.d.ts",
"scripts": {
"dev": "tsc --watch --preserveWatchOutput --sourceMap",
"build": "tsc",
"build:ts": "yarn build",
"prepare": "tsc",
"test:unit": "NODE_ENV=testing c8 --src src --all --reporter text --reporter cobertura mocha -r ts-node/register './test/**/*.test.ts'",
"test": "yarn test:types && yarn test:unit",
"test:types": "tsc --noEmit",
"lint:code": "eslint src/ --ext .ts --cache",
"lint": "yarn lint:code && yarn lint:test",
"lint:test": "eslint -c test/.eslintrc.js test/ --ext .ts --cache"
},
"files": [
"build"
],
"devDependencies": {
"c8": "8.0.1",
"mocha": "10.2.0",
"sinon": "15.2.0",
"ts-node": "10.9.1",
"typescript": "5.1.6"
},
"dependencies": {}
}

View File

@ -0,0 +1,48 @@
import {DonationPaymentEvent} from './DonationPaymentEvent';
import {DonationRepository} from './DonationRepository';
type BookshelfModelInstance = unknown;
type BookshelfOptions = unknown;
type BookshelfModel<T extends BookshelfModelInstance> = {
add(data: Partial<T>, unfilteredOptions?: BookshelfOptions): Promise<T>;
};
type DonationEventModelInstance = BookshelfModelInstance & {
name: string | null;
email: string;
member_id: string | null;
amount: number;
currency: string;
attribution_id: string | null;
attribution_url: string | null;
attribution_type: string | null;
referrer_source: string | null;
referrer_medium: string | null;
referrer_url: string | null;
}
type DonationPaymentEventModel = BookshelfModel<DonationEventModelInstance>;
export class DonationBookshelfRepository implements DonationRepository {
#Model: DonationPaymentEventModel;
constructor({DonationPaymentEventModel}: {DonationPaymentEventModel: DonationPaymentEventModel}) {
this.#Model = DonationPaymentEventModel;
}
async save(event: DonationPaymentEvent) {
await this.#Model.add({
name: event.name,
email: event.email,
member_id: event.memberId,
amount: event.amount,
currency: event.currency,
attribution_id: event.attributionId,
attribution_url: event.attributionUrl,
attribution_type: event.attributionType,
referrer_source: event.referrerSource,
referrer_medium: event.referrerMedium,
referrer_url: event.referrerUrl,
});
}
}

View File

@ -0,0 +1,39 @@
export class DonationPaymentEvent {
timestamp: Date;
name: string | null;
email: string;
memberId: string | null;
amount: number;
currency: string;
attributionId: string | null;
attributionUrl: string | null;
attributionType: string | null;
referrerSource: string | null;
referrerMedium: string | null;
referrerUrl: string | null;
constructor(data: Omit<DonationPaymentEvent, 'timestamp'>, timestamp: Date) {
this.timestamp = timestamp;
this.name = data.name;
this.email = data.email;
this.memberId = data.memberId;
this.amount = data.amount;
this.currency = data.currency;
this.attributionId = data.attributionId;
this.attributionUrl = data.attributionUrl;
this.attributionType = data.attributionType;
this.referrerSource = data.referrerSource;
this.referrerMedium = data.referrerMedium;
this.referrerUrl = data.referrerUrl;
}
static create(data: Omit<DonationPaymentEvent, 'timestamp'>, timestamp?: Date) {
return new DonationPaymentEvent(
data,
timestamp ?? new Date()
);
}
}

View File

@ -0,0 +1,5 @@
import {DonationPaymentEvent} from "./DonationPaymentEvent";
export type DonationRepository = {
save(event: DonationPaymentEvent): Promise<void>;
}

View File

@ -0,0 +1,3 @@
export * from './DonationPaymentEvent';
export * from './DonationRepository';
export * from './DonationBookshelfRepository';

View File

@ -0,0 +1,7 @@
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

View File

@ -0,0 +1,8 @@
import assert from 'assert/strict';
describe('Hello world', function () {
it('Runs a test', function () {
// TODO: Write me!
assert.ok(require('../'));
});
});

View File

@ -0,0 +1,110 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
"incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": ["es2019"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
"rootDir": "src", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
"resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "build", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"include": ["src/**/*"]
}

View File

@ -53,7 +53,7 @@ class MagicLink {
* @param {string} options.email - The email to send magic link to
* @param {TokenData} options.tokenData - The data for token
* @param {string} [options.type='signin'] - The type to be passed to the url and content generator functions
* @param {string} [options.referrer=null] - The referrer of the request, if exists
* @param {string} [options.referrer=null] - The referrer of the request, if exists. The member will be redirected back to this URL after signin.
* @returns {Promise<{token: Token, info: SentMessageInfo}>}
*/
async sendMagicLink(options) {
@ -84,13 +84,14 @@ class MagicLink {
* @param {object} options
* @param {TokenData} options.tokenData - The data for token
* @param {string} [options.type='signin'] - The type to be passed to the url and content generator functions. This type will also get stored in the token data.
* @param {string} [options.referrer=null] - The referrer of the request, if exists. The member will be redirected back to this URL after signin.
* @returns {Promise<URL>} - signin URL
*/
async getMagicLink(options) {
const type = options.type ?? 'signin';
const token = await this.tokenProvider.create({...options.tokenData, type});
return this.getSigninURL(token, type);
return this.getSigninURL(token, type, options.referrer);
}
/**

View File

@ -14,7 +14,8 @@ const messages = {
unableToCheckout: 'Unable to initiate checkout session',
inviteOnly: 'This site is invite-only, contact the owner for access.',
memberNotFound: 'No member exists with this e-mail address.',
memberNotFoundSignUp: 'No member exists with this e-mail address. Please sign up first.'
memberNotFoundSignUp: 'No member exists with this e-mail address. Please sign up first.',
invalidType: 'Invalid checkout type.'
};
module.exports = class RouterController {
@ -139,93 +140,14 @@ module.exports = class RouterController {
res.end(JSON.stringify(sessionInfo));
}
async createCheckoutSession(req, res) {
let ghostPriceId = req.body.priceId;
const tierId = req.body.tierId;
let cadence = req.body.cadence;
const identity = req.body.identity;
const offerId = req.body.offerId;
const metadata = req.body.metadata ?? {};
if (!ghostPriceId && !offerId && !tierId && !cadence) {
throw new BadRequestError({
message: tpl(messages.badRequest)
});
}
if (offerId && (ghostPriceId || (tierId && cadence))) {
throw new BadRequestError({
message: tpl(messages.badRequest)
});
}
if (ghostPriceId && tierId && cadence) {
throw new BadRequestError({
message: tpl(messages.badRequest)
});
}
if (tierId && !cadence) {
throw new BadRequestError({
message: tpl(messages.badRequest)
});
}
if (cadence && cadence !== 'month' && cadence !== 'year') {
throw new BadRequestError({
message: tpl(messages.badRequest)
});
}
let tier;
let offer;
let member;
let options = {};
if (offerId) {
offer = await this._offersAPI.getOffer({id: offerId});
tier = await this._tiersService.api.read(offer.tier.id);
cadence = offer.cadence;
// Attach offer information to stripe metadata for free trial offers
// free trial offers don't have associated stripe coupons
metadata.offer = offer.id;
} else {
offer = null;
tier = await this._tiersService.api.read(tierId);
}
if (tier.status === 'archived') {
throw new NoPermissionError({
message: tpl(messages.tierArchived)
});
}
if (identity) {
try {
const claims = await this._tokenService.decodeToken(identity);
const email = claims && claims.sub;
if (email) {
member = await this._memberRepository.get({
email
}, {
withRelated: ['stripeCustomers', 'products']
});
}
} catch (err) {
throw new UnauthorizedError({err});
}
} else if (req.body.customerEmail) {
member = await this._memberRepository.get({
email: req.body.customerEmail
}, {
withRelated: ['stripeCustomers', 'products']
});
}
async _setAttributionMetadata(metadata) {
// Don't allow to set the source manually
delete metadata.attribution_id;
delete metadata.attribution_url;
delete metadata.attribution_type;
delete metadata.referrer_source;
delete metadata.referrer_medium;
delete metadata.referrer_url;
if (metadata.urlHistory) {
// The full attribution history doesn't fit in the Stripe metadata (can't store objects + limited to 50 keys and 500 chars values)
@ -260,31 +182,121 @@ module.exports = class RouterController {
metadata.referrer_url = attribution.referrerUrl;
}
}
}
options.successUrl = req.body.successUrl;
options.cancelUrl = req.body.cancelUrl;
options.email = req.body.customerEmail;
/**
* Read the passed tier, offer and cadence from the request body and return the corresponding objects, or throws if validation fails
* @returns
*/
async _getSubscriptionCheckoutData(body) {
const ghostPriceId = body.priceId;
const tierId = body.tierId;
const offerId = body.offerId;
if (!member && req.body.customerEmail && !req.body.successUrl) {
options.successUrl = await this._magicLinkService.getMagicLink({
tokenData: {
email: req.body.customerEmail,
attribution: {
id: metadata.attribution_id ?? null,
type: metadata.attribution_type ?? null,
url: metadata.attribution_url ?? null
}
},
type: 'signup'
let cadence = body.cadence;
let tier;
let offer;
// Validate basic input
if (!ghostPriceId && !offerId && !tierId && !cadence) {
throw new BadRequestError({
message: tpl(messages.badRequest)
});
}
const restrictCheckout = member?.get('status') === 'paid';
if (offerId && (ghostPriceId || (tierId && cadence))) {
throw new BadRequestError({
message: tpl(messages.badRequest)
});
}
if (ghostPriceId && tierId && cadence) {
throw new BadRequestError({
message: tpl(messages.badRequest)
});
}
if (tierId && !cadence) {
throw new BadRequestError({
message: tpl(messages.badRequest)
});
}
if (cadence && cadence !== 'month' && cadence !== 'year') {
throw new BadRequestError({
message: tpl(messages.badRequest)
});
}
// Fetch tier and offer
if (offerId) {
offer = await this._offersAPI.getOffer({id: offerId});
tier = await this._tiersService.api.read(offer.tier.id);
cadence = offer.cadence;
} else {
offer = null;
tier = await this._tiersService.api.read(tierId);
}
if (tier.status === 'archived') {
throw new NoPermissionError({
message: tpl(messages.tierArchived)
});
}
return {
tier,
offer,
cadence
};
}
/**
*
* @param {object} options
* @param {object} options.tier
* @param {object} [options.offer]
* @param {string} options.cadence
* @param {string} options.successUrl URL to redirect to after successful checkout
* @param {string} options.cancelUrl URL to redirect to after cancelled checkout
* @param {string} [options.email] Email address of the customer
* @param {object} [options.member] Currently authenticated member OR member associated with the email address
* @param {boolean} options.isAuthenticated
* @param {object} options.metadata Metadata to be passed to Stripe
* @returns
*/
async _createSubscriptionCheckoutSession(options) {
if (options.offer) {
// Attach offer information to stripe metadata for free trial offers
// free trial offers don't have associated stripe coupons
options.metadata.offer = options.offer.id;
}
if (!options.member && options.email) {
// Create a signup link if there is no member with this email address
options.successUrl = await this._magicLinkService.getMagicLink({
tokenData: {
email: options.email,
attribution: {
id: options.metadata.attribution_id ?? null,
type: options.metadata.attribution_type ?? null,
url: options.metadata.attribution_url ?? null
}
},
type: 'signup',
// Redirect to the original success url after sign up
referrer: options.successUrl
});
}
const restrictCheckout = options.member?.get('status') === 'paid';
if (restrictCheckout) {
if (!identity && req.body.customerEmail) {
// This member is already subscribed to a paid tier
// We don't want to create a duplicate subscription
if (!options.isAuthenticated && options.email) {
try {
await this._sendEmailWithMagicLink({email: req.body.customerEmail, requestedType: 'signin'});
await this._sendEmailWithMagicLink({email: options.email, requestedType: 'signin'});
} catch (err) {
logging.warn(err);
}
@ -296,19 +308,9 @@ module.exports = class RouterController {
}
try {
const paymentLink = await this._paymentsService.getPaymentLink({
tier,
cadence,
offer,
member,
metadata,
options
});
res.writeHead(200, {
'Content-Type': 'application/json'
});
const paymentLink = await this._paymentsService.getPaymentLink(options);
return res.end(JSON.stringify({url: paymentLink}));
return {url: paymentLink};
} catch (err) {
throw new BadRequestError({
err,
@ -317,6 +319,111 @@ module.exports = class RouterController {
}
}
/**
*
* @param {object} options
* @param {string} options.successUrl URL to redirect to after successful checkout
* @param {string} options.cancelUrl URL to redirect to after cancelled checkout
* @param {string} [options.email] Email address of the customer
* @param {object} [options.member] Currently authenticated member OR member associated with the email address
* @param {boolean} options.isAuthenticated
* @param {object} options.metadata Metadata to be passed to Stripe
* @returns
*/
async _createDonationCheckoutSession(options) {
try {
const paymentLink = await this._paymentsService.getDonationPaymentLink(options);
return {url: paymentLink};
} catch (err) {
throw new BadRequestError({
err,
message: tpl(messages.unableToCheckout)
});
}
}
async createCheckoutSession(req, res) {
const type = req.body.type ?? 'subscription';
const metadata = req.body.metadata ?? {};
const identity = req.body.identity;
const membersEnabled = true;
// Check this checkout type is supported
if (typeof type !== 'string' || !['subscription', 'donation'].includes(type)) {
throw new BadRequestError({
message: tpl(messages.invalidType)
});
}
// Optional authentication
let member;
let isAuthenticated = false;
if (membersEnabled) {
if (identity) {
try {
const claims = await this._tokenService.decodeToken(identity);
const email = claims && claims.sub;
if (email) {
member = await this._memberRepository.get({
email
}, {
withRelated: ['stripeCustomers', 'products']
});
isAuthenticated = true;
}
} catch (err) {
throw new UnauthorizedError({err});
}
} else if (req.body.customerEmail) {
member = await this._memberRepository.get({
email: req.body.customerEmail
}, {
withRelated: ['stripeCustomers', 'products']
});
}
}
// Store attribution data in the metadata
await this._setAttributionMetadata(metadata);
// Build options
const options = {
successUrl: req.body.successUrl,
cancelUrl: req.body.cancelUrl,
email: req.body.customerEmail,
member,
metadata,
isAuthenticated
};
let response;
if (type === 'subscription') {
if (!membersEnabled) {
throw new BadRequestError({
message: tpl(messages.badRequest)
});
}
// Get selected tier, offer and cadence
const data = await this._getSubscriptionCheckoutData(req.body);
// Check the checkout session
response = await this._createSubscriptionCheckoutSession({
...options,
...data
});
} else if (type === 'donation') {
response = await this._createDonationCheckoutSession(options);
}
res.writeHead(200, {
'Content-Type': 'application/json'
});
return res.end(JSON.stringify(response));
}
async sendMagicLink(req, res) {
const {email, autoRedirect} = req.body;
let {emailType, redirect} = req.body;

View File

@ -69,7 +69,8 @@ module.exports = function MembersAPI({
labsService,
newslettersService,
memberAttributionService,
emailSuppressionList
emailSuppressionList,
settingsCache
}) {
const tokenService = new TokenService({
privateKey,
@ -159,7 +160,8 @@ module.exports = function MembersAPI({
StripeCustomer,
Offer,
offersAPI,
stripeAPIService
stripeAPIService,
settingsCache
});
const memberController = new MemberController({

View File

@ -10,6 +10,7 @@ class PaymentsService {
* @param {import('bookshelf').Model} deps.Offer
* @param {import('@tryghost/members-offers/lib/application/OffersAPI')} deps.offersAPI
* @param {import('@tryghost/members-stripe-service/lib/StripeAPI')} deps.stripeAPIService
* @param {{get(key: string): any}} deps.settingsCache
*/
constructor(deps) {
/** @private */
@ -24,6 +25,8 @@ class PaymentsService {
this.offersAPI = deps.offersAPI;
/** @private */
this.stripeAPIService = deps.stripeAPIService;
/** @private */
this.settingsCache = deps.settingsCache;
DomainEvents.subscribe(OfferCreatedEvent, async (event) => {
await this.getCouponForOffer(event.data.offer.id);
@ -57,14 +60,13 @@ class PaymentsService {
* @param {Offer} [params.offer]
* @param {Member} [params.member]
* @param {Object.<string, any>} [params.metadata]
* @param {object} params.options
* @param {string} params.options.successUrl
* @param {string} params.options.cancelUrl
* @param {string} [params.options.email]
* @param {string} params.successUrl
* @param {string} params.cancelUrl
* @param {string} [params.email]
*
* @returns {Promise<URL>}
*/
async getPaymentLink({tier, cadence, offer, member, metadata, options}) {
async getPaymentLink({tier, cadence, offer, member, metadata, successUrl, cancelUrl, email}) {
let coupon = null;
let trialDays = null;
if (offer) {
@ -87,12 +89,10 @@ class PaymentsService {
const price = await this.getPriceForTierCadence(tier, cadence);
const email = options.email || null;
const data = {
metadata,
successUrl: options.successUrl,
cancelUrl: options.cancelUrl,
successUrl: successUrl,
cancelUrl: cancelUrl,
trialDays: trialDays ?? tier.trialDays,
coupon: coupon?.id
};
@ -111,6 +111,35 @@ class PaymentsService {
return session.url;
}
/**
* @param {object} params
* @param {Member} [params.member]
* @param {Object.<string, any>} [params.metadata]
* @param {string} params.successUrl
* @param {string} params.cancelUrl
* @param {string} [params.email]
*
* @returns {Promise<URL>}
*/
async getDonationPaymentLink({member, metadata, successUrl, cancelUrl, email}) {
let customer = null;
if (member) {
customer = await this.getCustomerForMember(member);
}
const data = {
priceId: (await this.getPriceForDonations()).id,
metadata,
successUrl: successUrl,
cancelUrl: cancelUrl,
customer,
customerEmail: !customer && email ? email : null
};
const session = await this.stripeAPIService.createDonationCheckoutSession(data);
return session.url;
}
async getCustomerForMember(member) {
const rows = await this.StripeCustomerModel.where({
member_id: member.id
@ -206,6 +235,144 @@ class PaymentsService {
}
}
/**
* @returns {Promise<{id: string}>}
*/
async getProductForDonations({name}) {
const existingDonationPrices = await this.StripePriceModel
.where({
type: 'donation'
})
.query()
.select('stripe_product_id');
for (const row of existingDonationPrices) {
const product = await this.StripeProductModel
.where({
stripe_product_id: row.stripe_product_id
})
.query()
.select('stripe_product_id')
.first();
if (product) {
// Check active in Stripe
try {
const stripeProduct = await this.stripeAPIService.getProduct(row.stripe_product_id);
if (stripeProduct.active) {
return {id: stripeProduct.id};
}
} catch (err) {
logging.warn(err);
}
}
}
const product = await this.createProductForDonations({name});
return {
id: product.id
};
}
/**
* @returns {Promise<{id: string}>}
*/
async getPriceForDonations() {
const currency = 'usd'; // TODO: we need to use a setting here!
const nickname = 'Support ' + this.settingsCache.get('title');
const price = await this.StripePriceModel
.where({
type: 'donation',
active: true,
currency
})
.query()
.select('stripe_price_id', 'stripe_product_id', 'id', 'nickname')
.first();
if (price) {
if (price.nickname !== nickname) {
// Rename it in Stripe (in case the publication name changed)
try {
await this.stripeAPIService.updatePrice(price.stripe_price_id, {
nickname
});
// Update product too
await this.stripeAPIService.updateProduct(price.stripe_product_id, {
name: nickname
});
await this.StripePriceModel.edit({
nickname
}, {id: price.id});
} catch (err) {
logging.warn(err);
}
}
return {
id: price.stripe_price_id
};
}
const newPrice = await this.createPriceForDonations({
nickname,
currency
});
return {
id: newPrice.id
};
}
/**
* @returns {Promise<import('stripe').default.Price>}
*/
async createPriceForDonations({currency, nickname}) {
const product = await this.getProductForDonations({name: nickname});
// Create the price in Stripe
const price = await this.stripeAPIService.createPrice({
currency,
product: product.id,
custom_unit_amount: {
enabled: true
},
nickname,
type: 'one-time',
active: true
});
// Save it to the database
await this.StripePriceModel.add({
stripe_price_id: price.id,
stripe_product_id: product.id,
active: price.active,
nickname: price.nickname,
currency: price.currency,
amount: 0,
type: 'donation',
interval: null
});
return price;
}
/**
* @returns {Promise<import('stripe').default.Product>}
*/
async createProductForDonations({name}) {
const product = await this.stripeAPIService.createProduct({
name
});
await this.StripeProductModel.add({
product_id: null,
stripe_product_id: product.id
});
return product;
}
/**
* @param {import('@tryghost/tiers').Tier} tier
* @param {'month'|'year'} cadence
@ -220,7 +387,8 @@ class PaymentsService {
currency,
interval: cadence,
amount,
active: true
active: true,
type: 'recurring'
}).query().select('id', 'stripe_price_id');
for (const row of rows) {

View File

@ -1,6 +1,9 @@
// @ts-ignore
const {VersionMismatchError} = require('@tryghost/errors');
// @ts-ignore
const debug = require('@tryghost/debug')('stripe');
const Stripe = require('stripe').Stripe;
// @ts-ignore
const LeakyBucket = require('leaky-bucket');
/* Stripe has the following rate limits:
@ -121,9 +124,10 @@ module.exports = class StripeAPI {
* @param {boolean} options.active
* @param {string} options.nickname
* @param {string} options.currency
* @param {number} options.amount
* @param {number} [options.amount]
* @param {{enabled: boolean;maximum?: number;minimum?: number;preset?: number;}} [options.custom_unit_amount]
* @param {'recurring'|'one-time'} options.type
* @param {Stripe.Price.Recurring.Interval|null} options.interval
* @param {Stripe.Price.Recurring.Interval|null} [options.interval]
*
* @returns {Promise<IPrice>}
*/
@ -135,7 +139,9 @@ module.exports = class StripeAPI {
unit_amount: options.amount,
active: options.active,
nickname: options.nickname,
recurring: options.type === 'recurring' ? {
// @ts-ignore
custom_unit_amount: options.custom_unit_amount, // missing in .d.ts definitions in the Stripe node version we use, but should be supported in Stripe API at this version (:
recurring: options.type === 'recurring' && options.interval ? {
interval: options.interval
} : undefined
});
@ -146,7 +152,7 @@ module.exports = class StripeAPI {
/**
* @param {string} id
* @param {object} options
* @param {boolean} options.active
* @param {boolean} [options.active]
* @param {string} [options.nickname]
*
* @returns {Promise<IPrice>}
@ -482,11 +488,60 @@ module.exports = class StripeAPI {
stripeSessionOptions.customer_email = customerEmail;
}
// @ts-ignore
const session = await this._stripe.checkout.sessions.create(stripeSessionOptions);
return session;
}
/**
* @param {object} options
* @param {Object.<String, any>} options.metadata
* @param {string} options.successUrl
* @param {string} options.cancelUrl
* @param {string} [options.customer]
* @param {string} [options.customerEmail]
*
* @returns {Promise<import('stripe').Stripe.Checkout.Session>}
*/
async createDonationCheckoutSession({priceId, successUrl, cancelUrl, metadata, customer, customerEmail}) {
await this._rateLimitBucket.throttle();
/**
* @type {Stripe.Checkout.SessionCreateParams}
*/
const stripeSessionOptions = {
mode: 'payment',
success_url: successUrl || this._config.checkoutSessionSuccessUrl,
cancel_url: cancelUrl || this._config.checkoutSessionCancelUrl,
automatic_tax: {
enabled: this._config.enableAutomaticTax
},
metadata,
customer: customer ? customer.id : undefined,
customer_email: customer ? undefined : customerEmail,
submit_type: 'donate',
invoice_creation: {
enabled: true,
invoice_data: {
// Make sure we pass the data through to the invoice
metadata: {
ghost_donation: true,
...metadata
}
}
},
line_items: [{
price: priceId,
quantity: 1
}]
};
// @ts-ignore
const session = await this._stripe.checkout.sessions.create(stripeSessionOptions);
return session;
}
/**
* @param {ICustomer} customer
* @param {object} options

View File

@ -8,6 +8,7 @@ const {StripeLiveEnabledEvent, StripeLiveDisabledEvent} = require('./events');
module.exports = class StripeService {
constructor({
membersService,
donationService,
StripeWebhook,
models
}) {
@ -32,6 +33,9 @@ module.exports = class StripeService {
get eventRepository() {
return membersService.api.events;
},
get donationRepository() {
return donationService.repository;
},
sendSignupEmail(email){
return membersService.api.sendEmailWithMagicLink({
email,

View File

@ -1,6 +1,7 @@
const _ = require('lodash');
const logging = require('@tryghost/logging');
const errors = require('@tryghost/errors');
const {DonationPaymentEvent} = require('@tryghost/donations');
module.exports = class WebhookController {
/**
@ -10,6 +11,7 @@ module.exports = class WebhookController {
* @param {any} deps.eventRepository
* @param {any} deps.memberRepository
* @param {any} deps.productRepository
* @param {import('@tryghost/donations').DonationRepository} deps.donationRepository
* @param {any} deps.sendSignupEmail
*/
constructor(deps) {
@ -102,10 +104,38 @@ module.exports = class WebhookController {
}
/**
* @param {import('stripe').Stripe.Invoice} invoice
* @private
*/
async invoiceEvent(invoice) {
if (!invoice.subscription) {
// Check if this is a one time payment, related to a donation
if (invoice.metadata.ghost_donation && invoice.paid) {
// Track a one time payment event
const amount = invoice.amount_paid;
const member = await this.deps.memberRepository.get({
customer_id: invoice.customer
});
const data = DonationPaymentEvent.create({
name: member?.get('name') ?? invoice.customer_name,
email: member?.get('email') ?? invoice.customer_email,
memberId: member?.id ?? null,
amount,
currency: invoice.currency,
// Attribution data
attributionId: invoice.metadata.attribution_id ?? null,
attributionUrl: invoice.metadata.attribution_url ?? null,
attributionType: invoice.metadata.attribution_type ?? null,
referrerSource: invoice.metadata.referrer_source ?? null,
referrerMedium: invoice.metadata.referrer_medium ?? null,
referrerUrl: invoice.metadata.referrer_url ?? null
});
await this.deps.donationRepository.save(data);
}
return;
}
const subscription = await this.api.getSubscription(invoice.subscription, {

View File

@ -7,8 +7,8 @@
"author": "Ghost Foundation",
"license": "MIT",
"workspaces": [
"apps/*",
"ghost/*"
"ghost/*",
"apps/*"
],
"monorepo": {
"public": false,

View File

@ -10628,6 +10628,24 @@ c8@8.0.0:
yargs "^16.2.0"
yargs-parser "^20.2.9"
c8@8.0.1:
version "8.0.1"
resolved "https://registry.yarnpkg.com/c8/-/c8-8.0.1.tgz#bafd60be680e66c5530ee69f621e45b1364af9fd"
integrity sha512-EINpopxZNH1mETuI0DzRA4MZpAUH+IFiRhnmFD3vFr3vdrgxqi3VfE3KL0AIL+zDq8rC9bZqwM/VDmmoe04y7w==
dependencies:
"@bcoe/v8-coverage" "^0.2.3"
"@istanbuljs/schema" "^0.1.3"
find-up "^5.0.0"
foreground-child "^2.0.0"
istanbul-lib-coverage "^3.2.0"
istanbul-lib-report "^3.0.1"
istanbul-reports "^3.1.6"
rimraf "^3.0.2"
test-exclude "^6.0.0"
v8-to-istanbul "^9.0.0"
yargs "^17.7.2"
yargs-parser "^21.1.1"
cac@^6.7.14:
version "6.7.14"
resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959"
@ -18849,6 +18867,15 @@ istanbul-lib-report@^3.0.0:
make-dir "^3.0.0"
supports-color "^7.1.0"
istanbul-lib-report@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d"
integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==
dependencies:
istanbul-lib-coverage "^3.0.0"
make-dir "^4.0.0"
supports-color "^7.1.0"
istanbul-lib-source-maps@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz#895f3a709fcfba34c6de5a42939022f3e4358551"
@ -18866,6 +18893,14 @@ istanbul-reports@^3.0.2, istanbul-reports@^3.1.4, istanbul-reports@^3.1.5:
html-escaper "^2.0.0"
istanbul-lib-report "^3.0.0"
istanbul-reports@^3.1.6:
version "3.1.6"
resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.6.tgz#2544bcab4768154281a2f0870471902704ccaa1a"
integrity sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==
dependencies:
html-escaper "^2.0.0"
istanbul-lib-report "^3.0.0"
istextorbinary@2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/istextorbinary/-/istextorbinary-2.1.0.tgz#dbed2a6f51be2f7475b68f89465811141b758874"
@ -20785,6 +20820,13 @@ make-dir@^3.0.0, make-dir@^3.0.2, make-dir@^3.1.0:
dependencies:
semver "^6.0.0"
make-dir@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e"
integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==
dependencies:
semver "^7.5.3"
make-error@^1.1.1, make-error@^1.3.6:
version "1.3.6"
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"