Stripe: upgrade module and API, switch to Checkout (#12785)

* upgrade stripe module

* switch stripe api to latest version

* fix api version in tests

* start upgrading client and server

* client: switch to redirect

* implement checkout session creation for gems, start implementing webhooks

* stripe: start refactoring one time payments

* working gems and gift payments

* start adding support for subscriptions

* stripe: migrate subscriptions and fix cancelling sub

* allow upgrading group plans

* remove console.log statements

* group plans: upgrade from static page / create new one

* fix #11885, correct group plan modal title

* silence more stripe webhooks

* fix group plans redirects

* implement editing payment method

* start cleaning up code

* fix(stripe): update in-code docs, fix eslint issues

* subscriptions tests

* remove and skip old tests

* skip integration tests

* fix client build

* stripe webhooks: throw error if request fails

* subscriptions: correctly pass groupId

* remove console.log

* stripe: add unit tests for one time payments

* wip: stripe checkout tests

* stripe createCheckoutSession unit tests

* stripe createCheckoutSession unit tests

* stripe createCheckoutSession unit tests (editing card)

* fix existing webhooks tests

* add new webhooks tests

* add more webhooks tests

* fix lint

* stripe integration tests

* better error handling when retrieving customer from stripe

* client: remove unused strings and improve error handling

* payments: limit gift message length (server)

* payments: limit gift message length (client)

* fix redirects when payment is cancelled

* add back "subUpdateCard" string

* fix redirects when editing a sub card, use proper names for products, check subs when gifting
This commit is contained in:
Matteo Pagliazzi
2020-12-14 15:59:17 +01:00
committed by GitHub
parent 7072fbdd06
commit 6d34319455
53 changed files with 2457 additions and 1661 deletions

View File

@@ -1,235 +1,484 @@
import stripeModule from 'stripe';
import { model as User } from '../../../../../../website/server/models/user';
import stripePayments from '../../../../../../website/server/libs/payments/stripe';
import payments from '../../../../../../website/server/libs/payments/payments';
import nconf from 'nconf';
import common from '../../../../../../website/common';
import apiError from '../../../../../../website/server/libs/apiError';
import * as subscriptions from '../../../../../../website/server/libs/payments/stripe/subscriptions';
import * as oneTimePayments from '../../../../../../website/server/libs/payments/stripe/oneTimePayments';
import {
createCheckoutSession,
createEditCardCheckoutSession,
} from '../../../../../../website/server/libs/payments/stripe/checkout';
import {
generateGroup,
} from '../../../../../helpers/api-unit.helper';
import { model as User } from '../../../../../../website/server/models/user';
import { model as Group } from '../../../../../../website/server/models/group';
import * as gems from '../../../../../../website/server/libs/payments/gems';
const { i18n } = common;
describe('stripe - checkout', () => {
const subKey = 'basic_3mo';
const stripe = stripeModule('test');
let stripeChargeStub; let paymentBuyGemsStub; let
paymentCreateSubscritionStub;
let user; let gift; let groupId; let email; let headers; let coupon; let customerIdResponse; let
token; const gemsBlockKey = '21gems'; const gemsBlock = common.content.gems[gemsBlockKey];
beforeEach(() => {
user = new User();
user.profile.name = 'sender';
user.purchased.plan.customerId = 'customer-id';
user.purchased.plan.planId = subKey;
user.purchased.plan.lastBillingDate = new Date();
token = 'test-token';
customerIdResponse = 'example-customerIdResponse';
const stripCustomerResponse = {
id: customerIdResponse,
};
stripeChargeStub = sinon.stub(stripe.charges, 'create').resolves(stripCustomerResponse);
paymentBuyGemsStub = sinon.stub(payments, 'buyGems').resolves({});
paymentCreateSubscritionStub = sinon.stub(payments, 'createSubscription').resolves({});
describe('Stripe - Checkout', () => {
const stripe = stripeModule('test', {
apiVersion: '2020-08-27',
});
const BASE_URL = nconf.get('BASE_URL');
const redirectUrls = {
success_url: `${BASE_URL}/redirect/stripe-success-checkout`,
cancel_url: `${BASE_URL}/redirect/stripe-error-checkout`,
};
afterEach(() => {
stripe.charges.create.restore();
payments.buyGems.restore();
payments.createSubscription.restore();
});
describe('createCheckoutSession', () => {
let user;
const sessionId = 'session-id';
it('should error if there is no token', async () => {
await expect(stripePayments.checkout({
user,
gift,
groupId,
email,
headers,
coupon,
}, stripe))
.to.eventually.be.rejected.and.to.eql({
httpCode: 400,
message: 'Missing req.body.id',
name: 'BadRequest',
beforeEach(() => {
user = new User();
sandbox.stub(stripe.checkout.sessions, 'create').returns(sessionId);
sandbox.stub(gems, 'validateGiftMessage');
});
it('gems', async () => {
const amount = 999;
const gemsBlockKey = '21gems';
sandbox.stub(oneTimePayments, 'getOneTimePaymentInfo').returns({
amount,
gemsBlock: common.content.gems[gemsBlockKey],
});
});
it('should error if gem amount is too low', async () => {
const receivingUser = new User();
receivingUser.save();
gift = {
type: 'gems',
gems: {
amount: 0,
uuid: receivingUser._id,
},
};
const res = await createCheckoutSession({ user, gemsBlock: gemsBlockKey }, stripe);
expect(res).to.equal(sessionId);
await expect(stripePayments.checkout({
token,
user,
gift,
groupId,
email,
headers,
coupon,
}, stripe))
.to.eventually.be.rejected.and.to.eql({
httpCode: 400,
message: 'Amount must be at least 1.',
name: 'BadRequest',
const metadata = {
type: 'gems',
userId: user._id,
gift: undefined,
sub: undefined,
gemsBlock: gemsBlockKey,
};
expect(gems.validateGiftMessage).to.not.be.called;
expect(oneTimePayments.getOneTimePaymentInfo).to.be.calledOnce;
expect(oneTimePayments.getOneTimePaymentInfo).to.be.calledWith(gemsBlockKey, undefined, user);
expect(stripe.checkout.sessions.create).to.be.calledOnce;
expect(stripe.checkout.sessions.create).to.be.calledWith({
payment_method_types: ['card'],
metadata,
line_items: [{
price_data: {
product_data: {
name: common.i18n.t('nGems', { nGems: 21 }),
},
unit_amount: amount,
currency: 'usd',
},
quantity: 1,
}],
mode: 'payment',
...redirectUrls,
});
});
it('should error if user cannot get gems', async () => {
gift = undefined;
sinon.stub(user, 'canGetGems').resolves(false);
await expect(stripePayments.checkout({
token,
user,
gemsBlock: gemsBlockKey,
gift,
groupId,
email,
headers,
coupon,
}, stripe)).to.eventually.be.rejected.and.to.eql({
httpCode: 401,
message: i18n.t('groupPolicyCannotGetGems'),
name: 'NotAuthorized',
});
});
it('should error if the gems block is invalid', async () => {
gift = undefined;
await expect(stripePayments.checkout({
token,
user,
gemsBlock: 'invalid',
gift,
groupId,
email,
headers,
coupon,
}, stripe)).to.eventually.be.rejected.and.to.eql({
httpCode: 400,
message: apiError('invalidGemsBlock'),
name: 'BadRequest',
});
});
it('should purchase gems', async () => {
gift = undefined;
sinon.stub(user, 'canGetGems').resolves(true);
await stripePayments.checkout({
token,
user,
gemsBlock: gemsBlockKey,
gift,
groupId,
email,
headers,
coupon,
}, stripe);
expect(stripeChargeStub).to.be.calledOnce;
expect(stripeChargeStub).to.be.calledWith({
amount: 499,
currency: 'usd',
card: token,
});
expect(paymentBuyGemsStub).to.be.calledOnce;
expect(paymentBuyGemsStub).to.be.calledWith({
user,
customerId: customerIdResponse,
paymentMethod: 'Stripe',
gift,
gemsBlock,
});
expect(user.canGetGems).to.be.calledOnce;
user.canGetGems.restore();
});
it('gems gift', async () => {
const receivingUser = new User();
await receivingUser.save();
it('should gift gems', async () => {
const receivingUser = new User();
await receivingUser.save();
gift = {
type: 'gems',
uuid: receivingUser._id,
gems: {
amount: 16,
},
};
await stripePayments.checkout({
token,
user,
gift,
groupId,
email,
headers,
coupon,
}, stripe);
expect(stripeChargeStub).to.be.calledOnce;
expect(stripeChargeStub).to.be.calledWith({
amount: '400',
currency: 'usd',
card: token,
});
expect(paymentBuyGemsStub).to.be.calledOnce;
expect(paymentBuyGemsStub).to.be.calledWith({
user,
customerId: customerIdResponse,
paymentMethod: 'Gift',
gift,
gemsBlock: undefined,
});
});
it('should gift a subscription', async () => {
const receivingUser = new User();
receivingUser.save();
gift = {
type: 'subscription',
subscription: {
key: subKey,
const gift = {
type: 'gems',
uuid: receivingUser._id,
},
};
gems: {
amount: 4,
},
};
const amount = 100;
sandbox.stub(oneTimePayments, 'getOneTimePaymentInfo').returns({
amount,
gemsBlock: null,
});
await stripePayments.checkout({
token,
user,
gift,
groupId,
email,
headers,
coupon,
}, stripe);
const res = await createCheckoutSession({ user, gift }, stripe);
expect(res).to.equal(sessionId);
gift.member = receivingUser;
expect(stripeChargeStub).to.be.calledOnce;
expect(stripeChargeStub).to.be.calledWith({
amount: '1500',
currency: 'usd',
card: token,
const metadata = {
type: 'gift-gems',
userId: user._id,
gift: JSON.stringify(gift),
sub: undefined,
gemsBlock: undefined,
};
expect(gems.validateGiftMessage).to.be.calledOnce;
expect(gems.validateGiftMessage).to.be.calledWith(gift, user);
expect(oneTimePayments.getOneTimePaymentInfo).to.be.calledOnce;
expect(oneTimePayments.getOneTimePaymentInfo).to.be.calledWith(undefined, gift, user);
expect(stripe.checkout.sessions.create).to.be.calledOnce;
expect(stripe.checkout.sessions.create).to.be.calledWith({
payment_method_types: ['card'],
metadata,
line_items: [{
price_data: {
product_data: {
name: common.i18n.t('nGemsGift', { nGems: 4 }),
},
unit_amount: amount,
currency: 'usd',
},
quantity: 1,
}],
mode: 'payment',
...redirectUrls,
});
});
expect(paymentCreateSubscritionStub).to.be.calledOnce;
expect(paymentCreateSubscritionStub).to.be.calledWith({
user,
customerId: customerIdResponse,
paymentMethod: 'Gift',
gift,
gemsBlock: undefined,
it('subscription gift', async () => {
const receivingUser = new User();
await receivingUser.save();
const subKey = 'basic_3mo';
const gift = {
type: 'subscription',
uuid: receivingUser._id,
subscription: {
key: subKey,
},
};
const amount = 1500;
sandbox.stub(oneTimePayments, 'getOneTimePaymentInfo').returns({
amount,
gemsBlock: null,
subscription: common.content.subscriptionBlocks[subKey],
});
const res = await createCheckoutSession({ user, gift }, stripe);
expect(res).to.equal(sessionId);
const metadata = {
type: 'gift-sub',
userId: user._id,
gift: JSON.stringify(gift),
sub: undefined,
gemsBlock: undefined,
};
expect(oneTimePayments.getOneTimePaymentInfo).to.be.calledOnce;
expect(oneTimePayments.getOneTimePaymentInfo).to.be.calledWith(undefined, gift, user);
expect(stripe.checkout.sessions.create).to.be.calledOnce;
expect(stripe.checkout.sessions.create).to.be.calledWith({
payment_method_types: ['card'],
metadata,
line_items: [{
price_data: {
product_data: {
name: common.i18n.t('nMonthsSubscriptionGift', { nMonths: 3 }),
},
unit_amount: amount,
currency: 'usd',
},
quantity: 1,
}],
mode: 'payment',
...redirectUrls,
});
});
it('subscription', async () => {
const subKey = 'basic_3mo';
const coupon = null;
sandbox.stub(subscriptions, 'checkSubData').returns(undefined);
const sub = common.content.subscriptionBlocks[subKey];
const res = await createCheckoutSession({ user, sub, coupon }, stripe);
expect(res).to.equal(sessionId);
const metadata = {
type: 'subscription',
userId: user._id,
gift: undefined,
sub: JSON.stringify(sub),
};
expect(subscriptions.checkSubData).to.be.calledOnce;
expect(subscriptions.checkSubData).to.be.calledWith(sub, false, coupon);
expect(stripe.checkout.sessions.create).to.be.calledOnce;
expect(stripe.checkout.sessions.create).to.be.calledWith({
payment_method_types: ['card'],
metadata,
line_items: [{
price: sub.key,
quantity: 1,
// @TODO proper copy
}],
mode: 'subscription',
...redirectUrls,
});
});
it('throws if group does not exists', async () => {
const groupId = 'invalid';
sandbox.stub(Group.prototype, 'getMemberCount').resolves(4);
const subKey = 'group_monthly';
const coupon = null;
const sub = common.content.subscriptionBlocks[subKey];
await expect(createCheckoutSession({
user, sub, coupon, groupId,
}, stripe))
.to.eventually.be.rejected.and.to.eql({
httpCode: 404,
name: 'NotFound',
message: i18n.t('groupNotFound'),
});
});
it('group plan', async () => {
const group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
leader: user._id,
});
const groupId = group._id;
await group.save();
sandbox.stub(Group.prototype, 'getMemberCount').resolves(4);
// Add user to group
user.guilds.push(groupId);
await user.save();
const subKey = 'group_monthly';
const coupon = null;
sandbox.stub(subscriptions, 'checkSubData').returns(undefined);
const sub = common.content.subscriptionBlocks[subKey];
const res = await createCheckoutSession({
user, sub, coupon, groupId,
}, stripe);
expect(res).to.equal(sessionId);
const metadata = {
type: 'subscription',
userId: user._id,
gift: undefined,
sub: JSON.stringify(sub),
groupId,
};
expect(Group.prototype.getMemberCount).to.be.calledOnce;
expect(subscriptions.checkSubData).to.be.calledOnce;
expect(subscriptions.checkSubData).to.be.calledWith(sub, true, coupon);
expect(stripe.checkout.sessions.create).to.be.calledOnce;
expect(stripe.checkout.sessions.create).to.be.calledWith({
payment_method_types: ['card'],
metadata,
line_items: [{
price: sub.key,
quantity: 6,
// @TODO proper copy
}],
mode: 'subscription',
...redirectUrls,
});
});
// no gift, sub or gem payment
it('throws if type is invalid', async () => {
await expect(createCheckoutSession({ user }, stripe))
.to.eventually.be.rejected;
});
});
describe('createEditCardCheckoutSession', () => {
let user;
const sessionId = 'session-id';
const customerId = 'customerId';
const subscriptionId = 'subscription-id';
let subscriptionsListStub;
beforeEach(() => {
user = new User();
sandbox.stub(stripe.checkout.sessions, 'create').returns(sessionId);
subscriptionsListStub = sandbox.stub(stripe.subscriptions, 'list');
subscriptionsListStub.resolves({ data: [{ id: subscriptionId }] });
});
it('throws if no valid data is supplied', async () => {
await expect(createEditCardCheckoutSession({}, stripe))
.to.eventually.be.rejected;
});
it('throws if customer does not exists', async () => {
await expect(createEditCardCheckoutSession({ user }, stripe))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: i18n.t('missingSubscription'),
});
});
it('throws if subscription does not exists', async () => {
user.purchased.plan.customerId = customerId;
subscriptionsListStub.resolves({ data: [] });
await expect(createEditCardCheckoutSession({ user }, stripe))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: i18n.t('missingSubscription'),
});
});
it('change card for user subscription', async () => {
user.purchased.plan.customerId = customerId;
const metadata = {
userId: user._id,
type: 'edit-card-user',
};
const res = await createEditCardCheckoutSession({ user }, stripe);
expect(res).to.equal(sessionId);
expect(subscriptionsListStub).to.be.calledOnce;
expect(subscriptionsListStub).to.be.calledWith({ customer: customerId });
expect(stripe.checkout.sessions.create).to.be.calledOnce;
expect(stripe.checkout.sessions.create).to.be.calledWith({
mode: 'setup',
payment_method_types: ['card'],
metadata,
customer: customerId,
setup_intent_data: {
metadata: {
customer_id: customerId,
subscription_id: subscriptionId,
},
},
...redirectUrls,
});
});
it('throws if group does not exists', async () => {
const groupId = 'invalid';
await expect(createEditCardCheckoutSession({ user, groupId }, stripe))
.to.eventually.be.rejected.and.to.eql({
httpCode: 404,
name: 'NotFound',
message: i18n.t('groupNotFound'),
});
});
describe('with group', () => {
let group; let groupId;
beforeEach(async () => {
group = generateGroup({
name: 'test group',
type: 'guild',
privacy: 'public',
leader: user._id,
});
groupId = group._id;
await group.save();
});
it('throws if user is not allowed to change group plan', async () => {
const anotherUser = new User();
anotherUser.guilds.push(groupId);
await anotherUser.save();
await expect(createEditCardCheckoutSession({ user: anotherUser, groupId }, stripe))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: i18n.t('onlyGroupLeaderCanManageSubscription'),
});
});
it('throws if customer does not exists (group)', async () => {
await expect(createEditCardCheckoutSession({ user, groupId }, stripe))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: i18n.t('missingSubscription'),
});
});
it('throws if subscription does not exists (group)', async () => {
group.purchased.plan.customerId = customerId;
subscriptionsListStub.resolves({ data: [] });
await expect(createEditCardCheckoutSession({ user, groupId }, stripe))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: i18n.t('missingSubscription'),
});
});
it('change card for group plans - leader', async () => {
group.purchased.plan.customerId = customerId;
await group.save();
const metadata = {
userId: user._id,
type: 'edit-card-group',
groupId,
};
const res = await createEditCardCheckoutSession({ user, groupId }, stripe);
expect(res).to.equal(sessionId);
expect(subscriptionsListStub).to.be.calledOnce;
expect(subscriptionsListStub).to.be.calledWith({ customer: customerId });
expect(stripe.checkout.sessions.create).to.be.calledOnce;
expect(stripe.checkout.sessions.create).to.be.calledWith({
mode: 'setup',
payment_method_types: ['card'],
metadata,
customer: customerId,
setup_intent_data: {
metadata: {
customer_id: customerId,
subscription_id: subscriptionId,
},
},
...redirectUrls,
});
});
it('change card for group plans - plan owner', async () => {
const anotherUser = new User();
anotherUser.guilds.push(groupId);
await anotherUser.save();
group.purchased.plan.customerId = customerId;
group.purchased.plan.owner = anotherUser._id;
await group.save();
const metadata = {
userId: anotherUser._id,
type: 'edit-card-group',
groupId,
};
const res = await createEditCardCheckoutSession({ user: anotherUser, groupId }, stripe);
expect(res).to.equal(sessionId);
expect(subscriptionsListStub).to.be.calledOnce;
expect(subscriptionsListStub).to.be.calledWith({ customer: customerId });
expect(stripe.checkout.sessions.create).to.be.calledOnce;
expect(stripe.checkout.sessions.create).to.be.calledWith({
mode: 'setup',
payment_method_types: ['card'],
metadata,
customer: customerId,
setup_intent_data: {
metadata: {
customer_id: customerId,
subscription_id: subscriptionId,
},
},
...redirectUrls,
});
});
});
});
});