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:
parent
18cf5dd582
commit
841e52ccfe
2
.github/scripts/dev.js
vendored
2
.github/scripts/dev.js
vendored
@ -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: {}
|
||||
|
@ -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');
|
||||
|
||||
|
34
ghost/core/core/server/models/donation-payment-event.js
Normal file
34
ghost/core/core/server/models/donation-payment-event.js
Normal 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)
|
||||
};
|
@ -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;
|
3
ghost/core/core/server/services/donations/index.js
Normal file
3
ghost/core/core/server/services/donations/index.js
Normal file
@ -0,0 +1,3 @@
|
||||
const DonationServiceWrapper = require('./DonationServiceWrapper');
|
||||
|
||||
module.exports = new DonationServiceWrapper();
|
@ -214,7 +214,8 @@ function createApiInstance(config) {
|
||||
labsService: labsService,
|
||||
newslettersService: newslettersService,
|
||||
memberAttributionService: memberAttributionService.service,
|
||||
emailSuppressionList
|
||||
emailSuppressionList,
|
||||
settingsCache
|
||||
});
|
||||
|
||||
return membersApiInstance;
|
||||
|
@ -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);
|
||||
|
@ -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() {
|
||||
|
@ -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",
|
||||
|
@ -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 () {
|
||||
|
6
ghost/donations/.eslintrc.js
Normal file
6
ghost/donations/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/ts'
|
||||
]
|
||||
};
|
21
ghost/donations/README.md
Normal file
21
ghost/donations/README.md
Normal 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
|
||||
|
32
ghost/donations/package.json
Normal file
32
ghost/donations/package.json
Normal 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": {}
|
||||
}
|
48
ghost/donations/src/DonationBookshelfRepository.ts
Normal file
48
ghost/donations/src/DonationBookshelfRepository.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
39
ghost/donations/src/DonationPaymentEvent.ts
Normal file
39
ghost/donations/src/DonationPaymentEvent.ts
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
5
ghost/donations/src/DonationRepository.ts
Normal file
5
ghost/donations/src/DonationRepository.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import {DonationPaymentEvent} from "./DonationPaymentEvent";
|
||||
|
||||
export type DonationRepository = {
|
||||
save(event: DonationPaymentEvent): Promise<void>;
|
||||
}
|
3
ghost/donations/src/index.ts
Normal file
3
ghost/donations/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './DonationPaymentEvent';
|
||||
export * from './DonationRepository';
|
||||
export * from './DonationBookshelfRepository';
|
7
ghost/donations/test/.eslintrc.js
Normal file
7
ghost/donations/test/.eslintrc.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
]
|
||||
};
|
8
ghost/donations/test/hello.test.ts
Normal file
8
ghost/donations/test/hello.test.ts
Normal 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('../'));
|
||||
});
|
||||
});
|
110
ghost/donations/tsconfig.json
Normal file
110
ghost/donations/tsconfig.json
Normal 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/**/*"]
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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;
|
||||
|
@ -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({
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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, {
|
||||
|
@ -7,8 +7,8 @@
|
||||
"author": "Ghost Foundation",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"ghost/*"
|
||||
"ghost/*",
|
||||
"apps/*"
|
||||
],
|
||||
"monorepo": {
|
||||
"public": false,
|
||||
|
42
yarn.lock
42
yarn.lock
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user