Wired up the payment service to create stripe checkout sessions

refs https://github.com/TryGhost/Team/issues/2078
This commit is contained in:
Fabien "egg" O'Carroll 2022-10-21 16:28:09 +07:00
parent 31610f9b94
commit 25d8d694a0
7 changed files with 206 additions and 185 deletions

View File

@ -13,6 +13,7 @@ const SingleUseTokenProvider = require('./SingleUseTokenProvider');
const urlUtils = require('../../../shared/url-utils');
const labsService = require('../../../shared/labs');
const offersService = require('../offers');
const tiersService = require('../tiers');
const newslettersService = require('../newsletters');
const memberAttributionService = require('../member-attribution');
@ -198,6 +199,7 @@ function createApiInstance(config) {
MemberFeedback: models.MemberFeedback
},
stripeAPIService: stripeService.api,
tiersService: tiersService,
offersAPI: offersService.api,
labsService: labsService,
newslettersService: newslettersService,

View File

@ -744,14 +744,14 @@ Object {
"id": "sub_123",
"offer": null,
"plan": Object {
"amount": 5000,
"amount": 500,
"currency": "USD",
"id": "173e16a1fffa7d232b398e4a9b08d266a456ae8f3d23e5f11cc608ced6730b12",
"interval": "month",
"nickname": "month",
},
"price": Object {
"amount": 5000,
"amount": 500,
"currency": "USD",
"id": "173e16a1fffa7d232b398e4a9b08d266a456ae8f3d23e5f11cc608ced6730b12",
"interval": "month",
@ -782,7 +782,7 @@ exports[`Members API Can add a subscription 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2510",
"content-length": "2508",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
@ -842,14 +842,14 @@ Object {
"id": "sub_123",
"offer": null,
"plan": Object {
"amount": 5000,
"amount": 500,
"currency": "USD",
"id": "173e16a1fffa7d232b398e4a9b08d266a456ae8f3d23e5f11cc608ced6730b12",
"interval": "month",
"nickname": "month",
},
"price": Object {
"amount": 5000,
"amount": 500,
"currency": "USD",
"id": "173e16a1fffa7d232b398e4a9b08d266a456ae8f3d23e5f11cc608ced6730b12",
"interval": "month",
@ -880,7 +880,7 @@ exports[`Members API Can add a subscription 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2510",
"content-length": "2508",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
@ -2125,14 +2125,14 @@ Object {
"id": "sub_K1cBgJt6sCMu5n",
"offer": null,
"plan": Object {
"amount": 5000,
"amount": 500,
"currency": "USD",
"id": "173e16a1fffa7d232b398e4a9b08d266a456ae8f3d23e5f11cc608ced6730b12",
"interval": "month",
"nickname": "month",
},
"price": Object {
"amount": 5000,
"amount": 500,
"currency": "USD",
"id": "173e16a1fffa7d232b398e4a9b08d266a456ae8f3d23e5f11cc608ced6730b12",
"interval": "month",
@ -2163,7 +2163,7 @@ exports[`Members API Can edit a subscription 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2377",
"content-length": "2375",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",
@ -2223,14 +2223,14 @@ Object {
"id": "sub_K1cBgJt6sCMu5n",
"offer": null,
"plan": Object {
"amount": 5000,
"amount": 500,
"currency": "USD",
"id": "173e16a1fffa7d232b398e4a9b08d266a456ae8f3d23e5f11cc608ced6730b12",
"interval": "month",
"nickname": "month",
},
"price": Object {
"amount": 5000,
"amount": 500,
"currency": "USD",
"id": "173e16a1fffa7d232b398e4a9b08d266a456ae8f3d23e5f11cc608ced6730b12",
"interval": "month",
@ -2261,7 +2261,7 @@ exports[`Members API Can edit a subscription 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2379",
"content-length": "2377",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Version, Origin, Accept-Encoding",

View File

@ -2,8 +2,7 @@
exports[`Create Stripe Checkout Session Does allow to create a checkout session if the customerEmail is not associated with a paid member 1: [body] 1`] = `
Object {
"publicKey": "pk_test_for_stripe",
"sessionId": "cs_123",
"url": "https://site.com",
}
`;
@ -100,8 +99,7 @@ Object {
exports[`Create Stripe Checkout Session Member attribution Does pass post attribution source to session metadata 1: [body] 1`] = `
Object {
"publicKey": "pk_test_for_stripe",
"sessionId": "cs_123",
"url": "https://site.com",
}
`;
@ -117,8 +115,7 @@ Object {
exports[`Create Stripe Checkout Session Member attribution Does pass url attribution source to session metadata 1: [body] 1`] = `
Object {
"publicKey": "pk_test_for_stripe",
"sessionId": "cs_123",
"url": "https://site.com",
}
`;
@ -134,8 +131,7 @@ Object {
exports[`Create Stripe Checkout Session Member attribution Ignores attribution_* values in metadata 1: [body] 1`] = `
Object {
"publicKey": "pk_test_for_stripe",
"sessionId": "cs_123",
"url": "https://site.com",
}
`;

View File

@ -60,12 +60,37 @@ describe('Create Stripe Checkout Session', function () {
const paidTier = tiers.find(tier => tier.type === 'paid');
nock('https://api.stripe.com')
.persist()
.get(/v1\/.*/)
.reply((uri, body) => {
const [match, resource, id] = uri.match(/\/v1\/(\w+)\/(.+)\/?/) || [null];
if (match) {
if (resource === 'products') {
return [200, {
id: id,
active: true
}];
}
if (resource === 'prices') {
return [200, {
id: id,
active: true,
currency: 'usd',
unit_amount: 500
}];
}
}
return [500];
});
nock('https://api.stripe.com')
.persist()
.post(/v1\/.*/)
.reply((uri, body) => {
if (uri === '/v1/checkout/sessions') {
return [200, {id: 'cs_123'}];
return [200, {id: 'cs_123', url: 'https://site.com'}];
}
return [500];
@ -92,6 +117,31 @@ describe('Create Stripe Checkout Session', function () {
const paidTier = tiers.find(tier => tier.type === 'paid');
nock('https://api.stripe.com')
.persist()
.get(/v1\/.*/)
.reply((uri, body) => {
const [match, resource, id] = uri.match(/\/v1\/(\w+)\/(.+)\/?/) || [null];
if (match) {
if (resource === 'products') {
return [200, {
id: id,
active: true
}];
}
if (resource === 'prices') {
return [200, {
id: id,
active: true,
currency: 'usd',
unit_amount: 500
}];
}
}
return [500];
});
const scope = nock('https://api.stripe.com')
.persist()
.post(/v1\/.*/)
@ -101,10 +151,11 @@ describe('Create Stripe Checkout Session', function () {
should(parsed.get('metadata[attribution_url]')).eql('/test');
should(parsed.get('metadata[attribution_type]')).eql('url');
should(parsed.get('metadata[attribution_id]')).be.null();
return [200, {id: 'cs_123'}];
return [200, {id: 'cs_123', url: 'https://site.com'}];
}
throw new Error('Should not get called');
return [500];
});
await membersAgent.post('/api/create-stripe-checkout-session/')
@ -136,6 +187,31 @@ describe('Create Stripe Checkout Session', function () {
const paidTier = tiers.find(tier => tier.type === 'paid');
nock('https://api.stripe.com')
.persist()
.get(/v1\/.*/)
.reply((uri, body) => {
const [match, resource, id] = uri.match(/\/v1\/(\w+)\/(.+)\/?/) || [null];
if (match) {
if (resource === 'products') {
return [200, {
id: id,
active: true
}];
}
if (resource === 'prices') {
return [200, {
id: id,
active: true,
currency: 'usd',
unit_amount: 500
}];
}
}
return [500];
});
const scope = nock('https://api.stripe.com')
.persist()
.post(/v1\/.*/)
@ -145,10 +221,11 @@ describe('Create Stripe Checkout Session', function () {
should(parsed.get('metadata[attribution_url]')).eql(url);
should(parsed.get('metadata[attribution_type]')).eql('post');
should(parsed.get('metadata[attribution_id]')).eql(post.id);
return [200, {id: 'cs_123'}];
return [200, {id: 'cs_123', url: 'https://site.com'}];
}
throw new Error('Should not get called');
return [500];
});
await membersAgent.post('/api/create-stripe-checkout-session/')
@ -177,6 +254,31 @@ describe('Create Stripe Checkout Session', function () {
const paidTier = tiers.find(tier => tier.type === 'paid');
nock('https://api.stripe.com')
.persist()
.get(/v1\/.*/)
.reply((uri, body) => {
const [match, resource, id] = uri.match(/\/v1\/(\w+)\/(.+)\/?/) || [null];
if (match) {
if (resource === 'products') {
return [200, {
id: id,
active: true
}];
}
if (resource === 'prices') {
return [200, {
id: id,
active: true,
currency: 'usd',
unit_amount: 500
}];
}
}
return [500];
});
const scope = nock('https://api.stripe.com')
.persist()
.post(/v1\/.*/)
@ -186,10 +288,11 @@ describe('Create Stripe Checkout Session', function () {
should(parsed.get('metadata[attribution_url]')).be.null();
should(parsed.get('metadata[attribution_type]')).be.null();
should(parsed.get('metadata[attribution_id]')).be.null();
return [200, {id: 'cs_123'}];
return [200, {id: 'cs_123', url: 'https://site.com'}];
}
throw new Error('Should not get called');
return [500];
});
await membersAgent.post('/api/create-stripe-checkout-session/')

View File

@ -588,7 +588,7 @@ DataGenerator.Content = {
active: true,
nickname: 'Monthly',
currency: 'USD',
amount: 5000,
amount: 500,
type: 'recurring',
interval: 'month'
},
@ -624,6 +624,17 @@ DataGenerator.Content = {
amount: 15000,
type: 'recurring',
interval: 'year'
},
{
id: ObjectId().toHexString(),
stripe_price_id: '173e16a1fffa7d232b398e4a9b08d266a456ae8f3d23e5f11cc608ced6730b13',
stripe_product_id: '109c85c734fb9992e7bc30a26af66c22f5c94d8dc62e0a33cb797be902c06b2d',
active: true,
nickname: 'Yearly',
currency: 'USD',
amount: 5000,
type: 'recurring',
interval: 'year'
}
],
stripe_products: [
@ -1674,7 +1685,8 @@ DataGenerator.forKnex = (function () {
createBasic(DataGenerator.Content.stripe_prices[0]),
createBasic(DataGenerator.Content.stripe_prices[1]),
createBasic(DataGenerator.Content.stripe_prices[2]),
createBasic(DataGenerator.Content.stripe_prices[3])
createBasic(DataGenerator.Content.stripe_prices[3]),
createBasic(DataGenerator.Content.stripe_prices[4])
];
const stripe_customer_subscriptions = [

View File

@ -61,6 +61,7 @@ module.exports = function MembersAPI({
Comment,
MemberFeedback
},
tiersService,
stripeAPIService,
offersAPI,
labsService,
@ -169,7 +170,7 @@ module.exports = function MembersAPI({
const routerController = new RouterController({
offersAPI,
paymentsService,
productRepository,
tiersService,
memberRepository,
StripePrice,
allowSelfSignup,

View File

@ -1,7 +1,7 @@
const tpl = require('@tryghost/tpl');
const logging = require('@tryghost/logging');
const _ = require('lodash');
const {BadRequestError, NoPermissionError, NotFoundError, UnauthorizedError} = require('@tryghost/errors');
const {BadRequestError, NoPermissionError, UnauthorizedError} = require('@tryghost/errors');
const errors = require('@tryghost/errors');
const messages = {
@ -24,7 +24,6 @@ module.exports = class RouterController {
* @param {object} deps
* @param {any} deps.offersAPI
* @param {any} deps.paymentsService
* @param {any} deps.productRepository
* @param {any} deps.memberRepository
* @param {any} deps.StripePrice
* @param {() => boolean} deps.allowSelfSignup
@ -38,7 +37,7 @@ module.exports = class RouterController {
constructor({
offersAPI,
paymentsService,
productRepository,
tiersService,
memberRepository,
StripePrice,
allowSelfSignup,
@ -51,7 +50,7 @@ module.exports = class RouterController {
}) {
this._offersAPI = offersAPI;
this._paymentsService = paymentsService;
this._productRepository = productRepository;
this._tiersService = tiersService;
this._memberRepository = memberRepository;
this._StripePrice = StripePrice;
this._allowSelfSignup = allowSelfSignup;
@ -178,32 +177,45 @@ module.exports = class RouterController {
});
}
let couponId = null;
let trialDays;
let tier;
let offer;
let member;
let options = {};
if (offerId) {
const offer = await this._offersAPI.getOffer({id: offerId});
const tier = (await this._productRepository.get(offer.tier)).toJSON();
offer = await this._offersAPI.getOffer({id: offerId});
tier = await this._tiersService.api.read(offer.tier.id);
} else {
offer = null;
tier = await this._tiersService.api.read(tierId);
}
if (offer.status === 'archived') {
throw new NoPermissionError({
message: tpl(messages.offerArchived)
});
}
if (tier.status === 'archived') {
throw new NoPermissionError({
message: tpl(messages.tierArchived)
});
}
if (offer.cadence === 'month') {
ghostPriceId = tier.monthly_price_id;
} else {
ghostPriceId = tier.yearly_price_id;
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});
}
// Free trial offers don't have a stripe coupon
if (offer.type === 'trial') {
trialDays = offer.amount;
} else {
const coupon = await this._paymentsService.getCouponForOffer(offerId);
couponId = coupon.id;
}
metadata.offer = offer.id;
} else if (req.body.customerEmail) {
member = await this._memberRepository.get({
email: req.body.customerEmail
}, {
withRelated: ['stripeCustomers', 'products']
});
}
// Don't allow to set the source manually
@ -245,106 +257,25 @@ module.exports = class RouterController {
}
}
if (!ghostPriceId) {
const tier = await this._productRepository.get({id: tierId});
if (tier) {
if (cadence === 'month') {
ghostPriceId = tier.get('monthly_price_id');
} else {
ghostPriceId = tier.get('yearly_price_id');
}
}
}
const price = await this._StripePrice.findOne({
id: ghostPriceId
});
if (!price) {
throw new NotFoundError({
message: tpl(messages.notFound)
});
}
const priceId = price.get('stripe_price_id');
const product = await this._productRepository.get({stripe_price_id: priceId});
if (this.labsService.isSet('freeTrial') && !trialDays) {
trialDays = product.get('trial_days');
}
if (product.get('active') !== true) {
throw new NoPermissionError({
message: tpl(messages.tierArchived)
});
}
let member = null;
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']});
}
let successUrl = req.body.successUrl;
let cancelUrl = req.body.cancelUrl;
options.successUrl = req.body.successUrl;
options.cancelUrl = req.body.cancelUrl;
if (!member && req.body.customerEmail && !req.body.successUrl) {
const memberExistsForCustomer = await this._memberRepository.get({email: req.body.customerEmail});
if (!memberExistsForCustomer) {
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'
});
}
}
if (!member) {
const customer = null;
const session = await this._stripeAPIService.createCheckoutSession(priceId, customer, {
coupon: couponId,
successUrl,
cancelUrl,
trialDays,
customerEmail: req.body.customerEmail,
metadata: metadata
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'
});
const publicKey = this._stripeAPIService.getPublicKey();
const sessionInfo = {
publicKey,
sessionId: session.id
};
res.writeHead(200, {
'Content-Type': 'application/json'
});
return res.end(JSON.stringify(sessionInfo));
}
let restrictCheckout = false;
if (!this.labsService.isSet('compExpiring')) {
restrictCheckout = member.related('products').length !== 0;
} else {
restrictCheckout = member.get('status') === 'paid';
}
const restrictCheckout = member?.get('status') === 'paid';
if (restrictCheckout) {
if (!identity && req.body.customerEmail) {
try {
@ -359,44 +290,20 @@ module.exports = class RouterController {
});
}
let stripeCustomer;
for (const customer of member.related('stripeCustomers').models) {
try {
const fetchedCustomer = await this._stripeAPIService.getCustomer(customer.get('customer_id'));
if (!fetchedCustomer.deleted) {
stripeCustomer = fetchedCustomer;
break;
}
} catch (err) {
logging.info('Ignoring error for fetching customer for checkout');
}
}
if (!stripeCustomer) {
stripeCustomer = await this._stripeAPIService.createCustomer({email: member.get('email')});
}
try {
const session = await this._stripeAPIService.createCheckoutSession(priceId, stripeCustomer, {
coupon: couponId,
successUrl,
cancelUrl,
trialDays,
metadata: metadata
const paymentLink = await this._paymentsService.getPaymentLink({
tier,
cadence,
offer,
member,
metadata,
options
});
const publicKey = this._stripeAPIService.getPublicKey();
const sessionInfo = {
publicKey,
sessionId: session.id
};
res.writeHead(200, {
'Content-Type': 'application/json'
});
return res.end(JSON.stringify(sessionInfo));
return res.end(JSON.stringify({url: paymentLink}));
} catch (err) {
throw new BadRequestError({
err,
@ -451,7 +358,7 @@ module.exports = class RouterController {
tokenData.attribution = await this._memberAttributionService.getAttribution(req.body.urlHistory);
await this._sendEmailWithMagicLink({email, tokenData, requestedType: emailType, referrer: referer});
res.writeHead(201);
return res.end('Created.');
}
@ -464,7 +371,7 @@ module.exports = class RouterController {
res.writeHead(201);
return res.end('Created.');
}
throw new errors.BadRequestError({
message: this._allowSelfSignup() ? tpl(messages.memberNotFoundSignUp) : tpl(messages.memberNotFound)
});