mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-14 05:07:22 +01:00
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:
@@ -71,6 +71,7 @@
|
||||
"SLACK_URL": "https://hooks.slack.com/services/some-url",
|
||||
"STRIPE_API_KEY": "aaaabbbbccccddddeeeeffff00001111",
|
||||
"STRIPE_PUB_KEY": "22223333444455556666777788889999",
|
||||
"STRIPE_WEBHOOKS_ENDPOINT_SECRET": "111111",
|
||||
"TRANSIFEX_SLACK_CHANNEL": "transifex",
|
||||
"WEB_CONCURRENCY": 1,
|
||||
"SKIP_SSL_CHECK_KEY": "key",
|
||||
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -12709,10 +12709,11 @@
|
||||
}
|
||||
},
|
||||
"stripe": {
|
||||
"version": "7.15.0",
|
||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-7.15.0.tgz",
|
||||
"integrity": "sha512-TmouNGv1rIU7cgw7iFKjdQueJSwYKdPRPBuO7eNjrRliZUnsf2bpJqYe+n6ByarUJr38KmhLheVUxDyRawByPQ==",
|
||||
"version": "8.121.0",
|
||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-8.121.0.tgz",
|
||||
"integrity": "sha512-Uswmut57hVdyPrb+EJUTWbrLcTIEL4LS5T6UQZPO5AJNYT0PGHajgY1esQwmV7yVBL+Kgt3y/16zIAY/gAwifg==",
|
||||
"requires": {
|
||||
"@types/node": ">=8.1.0",
|
||||
"qs": "^6.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"remove-markdown": "^0.3.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"short-uuid": "^4.1.0",
|
||||
"stripe": "^7.15.0",
|
||||
"stripe": "^8.121.0",
|
||||
"superagent": "^6.1.0",
|
||||
"universal-analytics": "^0.4.23",
|
||||
"useragent": "^2.1.9",
|
||||
|
||||
@@ -3,6 +3,7 @@ import amzLib from '../../../../../../website/server/libs/payments/amazon';
|
||||
import payments from '../../../../../../website/server/libs/payments/payments';
|
||||
import common from '../../../../../../website/common';
|
||||
import apiError from '../../../../../../website/server/libs/apiError';
|
||||
import * as gems from '../../../../../../website/server/libs/payments/gems';
|
||||
|
||||
const { i18n } = common;
|
||||
|
||||
@@ -88,6 +89,7 @@ describe('Amazon Payments - Checkout', () => {
|
||||
paymentCreateSubscritionStub.resolves({});
|
||||
|
||||
sinon.stub(common, 'uuid').returns('uuid-generated');
|
||||
sandbox.stub(gems, 'validateGiftMessage');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -111,7 +113,10 @@ describe('Amazon Payments - Checkout', () => {
|
||||
if (gift) {
|
||||
expectedArgs.gift = gift;
|
||||
expectedArgs.gemsBlock = undefined;
|
||||
expect(gems.validateGiftMessage).to.be.calledOnce;
|
||||
expect(gems.validateGiftMessage).to.be.calledWith(gift, user);
|
||||
} else {
|
||||
expect(gems.validateGiftMessage).to.not.be.called;
|
||||
expectedArgs.gemsBlock = gemsBlock;
|
||||
}
|
||||
expect(paymentBuyGemsStub).to.be.calledWith(expectedArgs);
|
||||
|
||||
@@ -5,6 +5,7 @@ import applePayments from '../../../../../website/server/libs/payments/apple';
|
||||
import iap from '../../../../../website/server/libs/inAppPurchases';
|
||||
import { model as User } from '../../../../../website/server/models/user';
|
||||
import common from '../../../../../website/common';
|
||||
import * as gems from '../../../../../website/server/libs/payments/gems';
|
||||
|
||||
const { i18n } = common;
|
||||
|
||||
@@ -15,7 +16,7 @@ describe('Apple Payments', () => {
|
||||
let sku; let user; let token; let receipt; let
|
||||
headers;
|
||||
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let paymentBuyGemsStub; let
|
||||
iapGetPurchaseDataStub;
|
||||
iapGetPurchaseDataStub; let validateGiftMessageStub;
|
||||
|
||||
beforeEach(() => {
|
||||
token = 'testToken';
|
||||
@@ -36,6 +37,7 @@ describe('Apple Payments', () => {
|
||||
transactionId: token,
|
||||
}]);
|
||||
paymentBuyGemsStub = sinon.stub(payments, 'buyGems').resolves({});
|
||||
validateGiftMessageStub = sinon.stub(gems, 'validateGiftMessage');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -44,6 +46,7 @@ describe('Apple Payments', () => {
|
||||
iap.isValidated.restore();
|
||||
iap.getPurchaseData.restore();
|
||||
payments.buyGems.restore();
|
||||
gems.validateGiftMessage.restore();
|
||||
});
|
||||
|
||||
it('should throw an error if receipt is invalid', async () => {
|
||||
@@ -143,6 +146,7 @@ describe('Apple Payments', () => {
|
||||
expect(iapIsValidatedStub).to.be.calledOnce;
|
||||
expect(iapIsValidatedStub).to.be.calledWith({});
|
||||
expect(iapGetPurchaseDataStub).to.be.calledOnce;
|
||||
expect(validateGiftMessageStub).to.not.be.called;
|
||||
|
||||
expect(paymentBuyGemsStub).to.be.calledOnce;
|
||||
expect(paymentBuyGemsStub).to.be.calledWith({
|
||||
@@ -180,6 +184,9 @@ describe('Apple Payments', () => {
|
||||
expect(iapIsValidatedStub).to.be.calledWith({});
|
||||
expect(iapGetPurchaseDataStub).to.be.calledOnce;
|
||||
|
||||
expect(validateGiftMessageStub).to.be.calledOnce;
|
||||
expect(validateGiftMessageStub).to.be.calledWith(gift, user);
|
||||
|
||||
expect(paymentBuyGemsStub).to.be.calledOnce;
|
||||
expect(paymentBuyGemsStub).to.be.calledWith({
|
||||
user,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import common from '../../../../../website/common';
|
||||
import { getGemsBlock } from '../../../../../website/server/libs/payments/gems';
|
||||
import {
|
||||
getGemsBlock,
|
||||
validateGiftMessage,
|
||||
} from '../../../../../website/server/libs/payments/gems';
|
||||
import { model as User } from '../../../../../website/server/models/user';
|
||||
|
||||
const { i18n } = common;
|
||||
|
||||
describe('payments/gems', () => {
|
||||
describe('#getGemsBlock', () => {
|
||||
@@ -11,4 +17,50 @@ describe('payments/gems', () => {
|
||||
expect(getGemsBlock('21gems')).to.equal(common.content.gems['21gems']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#validateGiftMessage', () => {
|
||||
let user;
|
||||
let gift;
|
||||
|
||||
beforeEach(() => {
|
||||
user = new User();
|
||||
|
||||
gift = {
|
||||
message: (` // exactly 201 chars
|
||||
A gift message that is over the 200 chars limit.
|
||||
A gift message that is over the 200 chars limit.
|
||||
A gift message that is over the 200 chars limit.
|
||||
A gift message that is over the 200 chars limit. 1
|
||||
`).trim().substring(0, 201),
|
||||
};
|
||||
|
||||
expect(gift.message.length).to.equal(201);
|
||||
});
|
||||
|
||||
it('throws if the gift message is too long', () => {
|
||||
let expectedErr;
|
||||
|
||||
try {
|
||||
validateGiftMessage(gift, user);
|
||||
} catch (err) {
|
||||
expectedErr = err;
|
||||
}
|
||||
|
||||
expect(expectedErr).to.exist;
|
||||
expect(expectedErr).to.eql({
|
||||
httpCode: 400,
|
||||
name: 'BadRequest',
|
||||
message: i18n.t('giftMessageTooLong', { maxGiftMessageLength: 200 }),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not throw if the gift message is not too long', () => {
|
||||
gift.message = gift.message.substring(0, 200);
|
||||
expect(() => validateGiftMessage(gift, user)).to.not.throw;
|
||||
});
|
||||
|
||||
it('does not throw if it is not a gift', () => {
|
||||
expect(() => validateGiftMessage(null, user)).to.not.throw;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import googlePayments from '../../../../../website/server/libs/payments/google';
|
||||
import iap from '../../../../../website/server/libs/inAppPurchases';
|
||||
import { model as User } from '../../../../../website/server/models/user';
|
||||
import common from '../../../../../website/common';
|
||||
import * as gems from '../../../../../website/server/libs/payments/gems';
|
||||
|
||||
const { i18n } = common;
|
||||
|
||||
@@ -15,7 +16,7 @@ describe('Google Payments', () => {
|
||||
let sku; let user; let token; let receipt; let signature; let
|
||||
headers; const gemsBlock = common.content.gems['21gems'];
|
||||
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let
|
||||
paymentBuyGemsStub;
|
||||
paymentBuyGemsStub; let validateGiftMessageStub;
|
||||
|
||||
beforeEach(() => {
|
||||
sku = 'com.habitrpg.android.habitica.iap.21gems';
|
||||
@@ -31,6 +32,7 @@ describe('Google Payments', () => {
|
||||
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
|
||||
.returns(true);
|
||||
paymentBuyGemsStub = sinon.stub(payments, 'buyGems').resolves({});
|
||||
validateGiftMessageStub = sinon.stub(gems, 'validateGiftMessage');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -38,6 +40,7 @@ describe('Google Payments', () => {
|
||||
iap.validate.restore();
|
||||
iap.isValidated.restore();
|
||||
payments.buyGems.restore();
|
||||
gems.validateGiftMessage.restore();
|
||||
});
|
||||
|
||||
it('should throw an error if receipt is invalid', async () => {
|
||||
@@ -89,6 +92,8 @@ describe('Google Payments', () => {
|
||||
user, receipt, signature, headers,
|
||||
});
|
||||
|
||||
expect(validateGiftMessageStub).to.not.be.called;
|
||||
|
||||
expect(iapSetupStub).to.be.calledOnce;
|
||||
expect(iapValidateStub).to.be.calledOnce;
|
||||
expect(iapValidateStub).to.be.calledWith(iap.GOOGLE, {
|
||||
@@ -119,6 +124,9 @@ describe('Google Payments', () => {
|
||||
user, gift, receipt, signature, headers,
|
||||
});
|
||||
|
||||
expect(validateGiftMessageStub).to.be.calledOnce;
|
||||
expect(validateGiftMessageStub).to.be.calledWith(gift, user);
|
||||
|
||||
expect(iapSetupStub).to.be.calledOnce;
|
||||
expect(iapValidateStub).to.be.calledOnce;
|
||||
expect(iapValidateStub).to.be.calledWith(iap.GOOGLE, {
|
||||
|
||||
@@ -22,7 +22,9 @@ describe('Purchasing a group plan for group', () => {
|
||||
|
||||
let plan; let group; let user; let
|
||||
data;
|
||||
const stripe = stripeModule('test');
|
||||
const stripe = stripeModule('test', {
|
||||
apiVersion: '2020-08-27',
|
||||
});
|
||||
const groupLeaderName = 'sender';
|
||||
const groupName = 'test group';
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import paypalPayments from '../../../../../../website/server/libs/payments/paypa
|
||||
import { model as User } from '../../../../../../website/server/models/user';
|
||||
import common from '../../../../../../website/common';
|
||||
import apiError from '../../../../../../website/server/libs/apiError';
|
||||
import * as gems from '../../../../../../website/server/libs/payments/gems';
|
||||
|
||||
const BASE_URL = nconf.get('BASE_URL');
|
||||
const { i18n } = common;
|
||||
@@ -48,6 +49,7 @@ describe('paypal - checkout', () => {
|
||||
.resolves({
|
||||
links: [{ rel: 'approval_url', href: approvalHerf }],
|
||||
});
|
||||
sandbox.stub(gems, 'validateGiftMessage');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -57,6 +59,7 @@ describe('paypal - checkout', () => {
|
||||
it('creates a link for gem purchases', async () => {
|
||||
const link = await paypalPayments.checkout({ user: new User(), gemsBlock: gemsBlockKey });
|
||||
|
||||
expect(gems.validateGiftMessage).to.not.be.called;
|
||||
expect(paypalPaymentCreateStub).to.be.calledOnce;
|
||||
expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('Habitica Gems', 4.99));
|
||||
expect(link).to.eql(approvalHerf);
|
||||
@@ -105,6 +108,7 @@ describe('paypal - checkout', () => {
|
||||
});
|
||||
|
||||
it('creates a link for gifting gems', async () => {
|
||||
const user = new User();
|
||||
const receivingUser = new User();
|
||||
await receivingUser.save();
|
||||
const gift = {
|
||||
@@ -115,14 +119,17 @@ describe('paypal - checkout', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const link = await paypalPayments.checkout({ gift });
|
||||
const link = await paypalPayments.checkout({ user, gift });
|
||||
|
||||
expect(gems.validateGiftMessage).to.be.calledOnce;
|
||||
expect(gems.validateGiftMessage).to.be.calledWith(gift, user);
|
||||
expect(paypalPaymentCreateStub).to.be.calledOnce;
|
||||
expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('Habitica Gems (Gift)', '4.00'));
|
||||
expect(link).to.eql(approvalHerf);
|
||||
});
|
||||
|
||||
it('creates a link for gifting a subscription', async () => {
|
||||
const user = new User();
|
||||
const receivingUser = new User();
|
||||
receivingUser.save();
|
||||
const gift = {
|
||||
@@ -133,7 +140,10 @@ describe('paypal - checkout', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const link = await paypalPayments.checkout({ gift });
|
||||
const link = await paypalPayments.checkout({ user, gift });
|
||||
|
||||
expect(gems.validateGiftMessage).to.be.calledOnce;
|
||||
expect(gems.validateGiftMessage).to.be.calledWith(gift, user);
|
||||
|
||||
expect(paypalPaymentCreateStub).to.be.calledOnce;
|
||||
expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('mo. Habitica Subscription (Gift)', '15.00'));
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import stripeModule from 'stripe';
|
||||
|
||||
import {
|
||||
generateGroup,
|
||||
} from '../../../../../helpers/api-unit.helper';
|
||||
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 common from '../../../../../../website/common';
|
||||
|
||||
const { i18n } = common;
|
||||
|
||||
describe('stripe - cancel subscription', () => {
|
||||
const subKey = 'basic_3mo';
|
||||
const stripe = stripeModule('test');
|
||||
let user; let groupId; let
|
||||
group;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = new User();
|
||||
user.profile.name = 'sender';
|
||||
user.purchased.plan.customerId = 'customer-id';
|
||||
user.purchased.plan.planId = subKey;
|
||||
user.purchased.plan.lastBillingDate = new Date();
|
||||
|
||||
group = generateGroup({
|
||||
name: 'test group',
|
||||
type: 'guild',
|
||||
privacy: 'public',
|
||||
leader: user._id,
|
||||
});
|
||||
group.purchased.plan.customerId = 'customer-id';
|
||||
group.purchased.plan.planId = subKey;
|
||||
await group.save();
|
||||
|
||||
groupId = group._id;
|
||||
});
|
||||
|
||||
it('throws an error if there is no customer id', async () => {
|
||||
user.purchased.plan.customerId = undefined;
|
||||
|
||||
await expect(stripePayments.cancelSubscription({
|
||||
user,
|
||||
groupId: undefined,
|
||||
}))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 401,
|
||||
name: 'NotAuthorized',
|
||||
message: i18n.t('missingSubscription'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws an error if the group is not found', async () => {
|
||||
await expect(stripePayments.cancelSubscription({
|
||||
user,
|
||||
groupId: 'fake-group',
|
||||
}))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 404,
|
||||
name: 'NotFound',
|
||||
message: i18n.t('groupNotFound'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws an error if user is not the group leader', async () => {
|
||||
const nonLeader = new User();
|
||||
nonLeader.guilds.push(groupId);
|
||||
await nonLeader.save();
|
||||
|
||||
await expect(stripePayments.cancelSubscription({
|
||||
user: nonLeader,
|
||||
groupId,
|
||||
}))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 401,
|
||||
name: 'NotAuthorized',
|
||||
message: i18n.t('onlyGroupLeaderCanManageSubscription'),
|
||||
});
|
||||
});
|
||||
|
||||
describe('success', () => {
|
||||
let stripeDeleteCustomerStub; let paymentsCancelSubStub;
|
||||
let stripeRetrieveStub; let subscriptionId; let
|
||||
currentPeriodEndTimeStamp;
|
||||
|
||||
beforeEach(() => {
|
||||
subscriptionId = 'subId';
|
||||
stripeDeleteCustomerStub = sinon.stub(stripe.customers, 'del').resolves({});
|
||||
paymentsCancelSubStub = sinon.stub(payments, 'cancelSubscription').resolves({});
|
||||
|
||||
currentPeriodEndTimeStamp = (new Date()).getTime();
|
||||
stripeRetrieveStub = sinon.stub(stripe.customers, 'retrieve')
|
||||
.resolves({
|
||||
subscriptions: {
|
||||
data: [{
|
||||
id: subscriptionId,
|
||||
current_period_end: currentPeriodEndTimeStamp,
|
||||
}], // eslint-disable-line camelcase
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stripe.customers.del.restore();
|
||||
stripe.customers.retrieve.restore();
|
||||
payments.cancelSubscription.restore();
|
||||
});
|
||||
|
||||
it('cancels a user subscription', async () => {
|
||||
await stripePayments.cancelSubscription({
|
||||
user,
|
||||
groupId: undefined,
|
||||
}, stripe);
|
||||
|
||||
expect(stripeDeleteCustomerStub).to.be.calledOnce;
|
||||
expect(stripeDeleteCustomerStub).to.be.calledWith(user.purchased.plan.customerId);
|
||||
expect(stripeRetrieveStub).to.be.calledOnce;
|
||||
expect(stripeRetrieveStub).to.be.calledWith(user.purchased.plan.customerId);
|
||||
expect(paymentsCancelSubStub).to.be.calledOnce;
|
||||
expect(paymentsCancelSubStub).to.be.calledWith({
|
||||
user,
|
||||
groupId: undefined,
|
||||
nextBill: currentPeriodEndTimeStamp * 1000, // timestamp in seconds
|
||||
paymentMethod: 'Stripe',
|
||||
cancellationReason: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels a group subscription', async () => {
|
||||
await stripePayments.cancelSubscription({
|
||||
user,
|
||||
groupId,
|
||||
}, stripe);
|
||||
|
||||
expect(stripeDeleteCustomerStub).to.be.calledOnce;
|
||||
expect(stripeDeleteCustomerStub).to.be.calledWith(group.purchased.plan.customerId);
|
||||
expect(stripeRetrieveStub).to.be.calledOnce;
|
||||
expect(stripeRetrieveStub).to.be.calledWith(user.purchased.plan.customerId);
|
||||
expect(paymentsCancelSubStub).to.be.calledOnce;
|
||||
expect(paymentsCancelSubStub).to.be.calledWith({
|
||||
user,
|
||||
groupId,
|
||||
nextBill: currentPeriodEndTimeStamp * 1000, // timestamp in seconds
|
||||
paymentMethod: 'Stripe',
|
||||
cancellationReason: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,321 +0,0 @@
|
||||
import stripeModule from 'stripe';
|
||||
import cc from 'coupon-code';
|
||||
|
||||
import {
|
||||
generateGroup,
|
||||
} from '../../../../../helpers/api-unit.helper';
|
||||
import { model as User } from '../../../../../../website/server/models/user';
|
||||
import { model as Coupon } from '../../../../../../website/server/models/coupon';
|
||||
import stripePayments from '../../../../../../website/server/libs/payments/stripe';
|
||||
import payments from '../../../../../../website/server/libs/payments/payments';
|
||||
import common from '../../../../../../website/common';
|
||||
|
||||
const { i18n } = common;
|
||||
|
||||
describe('stripe - checkout with subscription', () => {
|
||||
const subKey = 'basic_3mo';
|
||||
const stripe = stripeModule('test');
|
||||
let user; let group; let data; let gift; let sub;
|
||||
let groupId; let email; let headers; let coupon;
|
||||
let customerIdResponse; let subscriptionId; let
|
||||
token;
|
||||
let spy;
|
||||
let stripeCreateCustomerSpy;
|
||||
let stripePaymentsCreateSubSpy;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = new User();
|
||||
user.profile.name = 'sender';
|
||||
user.purchased.plan.customerId = 'customer-id';
|
||||
user.purchased.plan.planId = subKey;
|
||||
user.purchased.plan.lastBillingDate = new Date();
|
||||
|
||||
group = generateGroup({
|
||||
name: 'test group',
|
||||
type: 'guild',
|
||||
privacy: 'public',
|
||||
leader: user._id,
|
||||
});
|
||||
group.purchased.plan.customerId = 'customer-id';
|
||||
group.purchased.plan.planId = subKey;
|
||||
await group.save();
|
||||
|
||||
sub = {
|
||||
key: 'basic_3mo',
|
||||
};
|
||||
|
||||
data = {
|
||||
user,
|
||||
sub,
|
||||
customerId: 'customer-id',
|
||||
paymentMethod: 'Payment Method',
|
||||
};
|
||||
|
||||
email = 'example@example.com';
|
||||
customerIdResponse = 'test-id';
|
||||
subscriptionId = 'test-sub-id';
|
||||
token = 'test-token';
|
||||
|
||||
spy = sinon.stub(stripe.subscriptions, 'update');
|
||||
spy.resolves;
|
||||
|
||||
stripeCreateCustomerSpy = sinon.stub(stripe.customers, 'create');
|
||||
const stripCustomerResponse = {
|
||||
id: customerIdResponse,
|
||||
subscriptions: {
|
||||
data: [{ id: subscriptionId }],
|
||||
},
|
||||
};
|
||||
stripeCreateCustomerSpy.resolves(stripCustomerResponse);
|
||||
|
||||
stripePaymentsCreateSubSpy = sinon.stub(payments, 'createSubscription');
|
||||
stripePaymentsCreateSubSpy.resolves({});
|
||||
|
||||
data.groupId = group._id;
|
||||
data.sub.quantity = 3;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stripe.subscriptions.update.restore();
|
||||
stripe.customers.create.restore();
|
||||
payments.createSubscription.restore();
|
||||
});
|
||||
|
||||
it('should throw an error if we are missing a token', async () => {
|
||||
await expect(stripePayments.checkout({
|
||||
user,
|
||||
gift,
|
||||
sub,
|
||||
groupId,
|
||||
email,
|
||||
headers,
|
||||
coupon,
|
||||
}))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 400,
|
||||
name: 'BadRequest',
|
||||
message: 'Missing req.body.id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error when coupon code is missing', async () => {
|
||||
sub.discount = 40;
|
||||
|
||||
await expect(stripePayments.checkout({
|
||||
token,
|
||||
user,
|
||||
gift,
|
||||
sub,
|
||||
groupId,
|
||||
email,
|
||||
headers,
|
||||
coupon,
|
||||
}))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 400,
|
||||
name: 'BadRequest',
|
||||
message: i18n.t('couponCodeRequired'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error when coupon code is invalid', async () => {
|
||||
sub.discount = 40;
|
||||
sub.key = 'google_6mo';
|
||||
coupon = 'example-coupon';
|
||||
|
||||
const couponModel = new Coupon();
|
||||
couponModel.event = 'google_6mo';
|
||||
await couponModel.save();
|
||||
|
||||
sinon.stub(cc, 'validate').returns('invalid');
|
||||
|
||||
await expect(stripePayments.checkout({
|
||||
token,
|
||||
user,
|
||||
gift,
|
||||
sub,
|
||||
groupId,
|
||||
email,
|
||||
headers,
|
||||
coupon,
|
||||
}))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 400,
|
||||
name: 'BadRequest',
|
||||
message: i18n.t('invalidCoupon'),
|
||||
});
|
||||
cc.validate.restore();
|
||||
});
|
||||
|
||||
it('subscribes with stripe with a coupon', async () => {
|
||||
sub.discount = 40;
|
||||
sub.key = 'google_6mo';
|
||||
coupon = 'example-coupon';
|
||||
|
||||
const couponModel = new Coupon();
|
||||
couponModel.event = 'google_6mo';
|
||||
const updatedCouponModel = await couponModel.save();
|
||||
|
||||
sinon.stub(cc, 'validate').returns(updatedCouponModel._id);
|
||||
|
||||
await stripePayments.checkout({
|
||||
token,
|
||||
user,
|
||||
gift,
|
||||
sub,
|
||||
groupId,
|
||||
email,
|
||||
headers,
|
||||
coupon,
|
||||
}, stripe);
|
||||
|
||||
expect(stripeCreateCustomerSpy).to.be.calledOnce;
|
||||
expect(stripeCreateCustomerSpy).to.be.calledWith({
|
||||
email,
|
||||
metadata: { uuid: user._id },
|
||||
card: token,
|
||||
plan: sub.key,
|
||||
});
|
||||
|
||||
expect(stripePaymentsCreateSubSpy).to.be.calledOnce;
|
||||
expect(stripePaymentsCreateSubSpy).to.be.calledWith({
|
||||
user,
|
||||
customerId: customerIdResponse,
|
||||
paymentMethod: 'Stripe',
|
||||
sub,
|
||||
headers,
|
||||
groupId: undefined,
|
||||
subscriptionId: undefined,
|
||||
});
|
||||
|
||||
cc.validate.restore();
|
||||
});
|
||||
|
||||
it('subscribes a user', async () => {
|
||||
sub = data.sub;
|
||||
|
||||
await stripePayments.checkout({
|
||||
token,
|
||||
user,
|
||||
gift,
|
||||
sub,
|
||||
groupId,
|
||||
email,
|
||||
headers,
|
||||
coupon,
|
||||
}, stripe);
|
||||
|
||||
expect(stripeCreateCustomerSpy).to.be.calledOnce;
|
||||
expect(stripeCreateCustomerSpy).to.be.calledWith({
|
||||
email,
|
||||
metadata: { uuid: user._id },
|
||||
card: token,
|
||||
plan: sub.key,
|
||||
});
|
||||
|
||||
expect(stripePaymentsCreateSubSpy).to.be.calledOnce;
|
||||
expect(stripePaymentsCreateSubSpy).to.be.calledWith({
|
||||
user,
|
||||
customerId: customerIdResponse,
|
||||
paymentMethod: 'Stripe',
|
||||
sub,
|
||||
headers,
|
||||
groupId: undefined,
|
||||
subscriptionId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('subscribes a group', async () => {
|
||||
token = 'test-token';
|
||||
sub = data.sub;
|
||||
groupId = group._id;
|
||||
email = 'test@test.com';
|
||||
|
||||
// Add user to group
|
||||
user.guilds.push(groupId);
|
||||
await user.save();
|
||||
|
||||
headers = {};
|
||||
|
||||
await stripePayments.checkout({
|
||||
token,
|
||||
user,
|
||||
gift,
|
||||
sub,
|
||||
groupId,
|
||||
email,
|
||||
headers,
|
||||
coupon,
|
||||
}, stripe);
|
||||
|
||||
expect(stripeCreateCustomerSpy).to.be.calledOnce;
|
||||
expect(stripeCreateCustomerSpy).to.be.calledWith({
|
||||
email,
|
||||
metadata: { uuid: user._id },
|
||||
card: token,
|
||||
plan: sub.key,
|
||||
quantity: 3,
|
||||
});
|
||||
|
||||
expect(stripePaymentsCreateSubSpy).to.be.calledOnce;
|
||||
expect(stripePaymentsCreateSubSpy).to.be.calledWith({
|
||||
user,
|
||||
customerId: customerIdResponse,
|
||||
paymentMethod: 'Stripe',
|
||||
sub,
|
||||
headers,
|
||||
groupId,
|
||||
subscriptionId,
|
||||
});
|
||||
});
|
||||
|
||||
it('subscribes a group with the correct number of group members', async () => {
|
||||
token = 'test-token';
|
||||
sub = data.sub;
|
||||
groupId = group._id;
|
||||
email = 'test@test.com';
|
||||
headers = {};
|
||||
|
||||
// Add user to group
|
||||
user.guilds.push(groupId);
|
||||
await user.save();
|
||||
|
||||
user = new User();
|
||||
user.guilds.push(groupId);
|
||||
await user.save();
|
||||
|
||||
group.memberCount = 2;
|
||||
await group.save();
|
||||
|
||||
await stripePayments.checkout({
|
||||
token,
|
||||
user,
|
||||
gift,
|
||||
sub,
|
||||
groupId,
|
||||
email,
|
||||
headers,
|
||||
coupon,
|
||||
}, stripe);
|
||||
|
||||
expect(stripeCreateCustomerSpy).to.be.calledOnce;
|
||||
expect(stripeCreateCustomerSpy).to.be.calledWith({
|
||||
email,
|
||||
metadata: { uuid: user._id },
|
||||
card: token,
|
||||
plan: sub.key,
|
||||
quantity: 4,
|
||||
});
|
||||
|
||||
expect(stripePaymentsCreateSubSpy).to.be.calledOnce;
|
||||
expect(stripePaymentsCreateSubSpy).to.be.calledWith({
|
||||
user,
|
||||
customerId: customerIdResponse,
|
||||
paymentMethod: 'Stripe',
|
||||
sub,
|
||||
headers,
|
||||
groupId,
|
||||
subscriptionId,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
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`,
|
||||
};
|
||||
|
||||
describe('createCheckoutSession', () => {
|
||||
let user;
|
||||
const sessionId = 'session-id';
|
||||
|
||||
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({});
|
||||
sandbox.stub(stripe.checkout.sessions, 'create').returns(sessionId);
|
||||
sandbox.stub(gems, 'validateGiftMessage');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stripe.charges.create.restore();
|
||||
payments.buyGems.restore();
|
||||
payments.createSubscription.restore();
|
||||
it('gems', async () => {
|
||||
const amount = 999;
|
||||
const gemsBlockKey = '21gems';
|
||||
sandbox.stub(oneTimePayments, 'getOneTimePaymentInfo').returns({
|
||||
amount,
|
||||
gemsBlock: common.content.gems[gemsBlockKey],
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
});
|
||||
const res = await createCheckoutSession({ user, gemsBlock: gemsBlockKey }, stripe);
|
||||
expect(res).to.equal(sessionId);
|
||||
|
||||
it('should error if gem amount is too low', async () => {
|
||||
const receivingUser = new User();
|
||||
receivingUser.save();
|
||||
gift = {
|
||||
const metadata = {
|
||||
type: 'gems',
|
||||
gems: {
|
||||
amount: 0,
|
||||
uuid: receivingUser._id,
|
||||
},
|
||||
userId: user._id,
|
||||
gift: undefined,
|
||||
sub: undefined,
|
||||
gemsBlock: gemsBlockKey,
|
||||
};
|
||||
|
||||
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',
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
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',
|
||||
card: token,
|
||||
},
|
||||
quantity: 1,
|
||||
}],
|
||||
mode: 'payment',
|
||||
...redirectUrls,
|
||||
});
|
||||
});
|
||||
|
||||
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('should gift gems', async () => {
|
||||
it('gems gift', async () => {
|
||||
const receivingUser = new User();
|
||||
await receivingUser.save();
|
||||
gift = {
|
||||
|
||||
const gift = {
|
||||
type: 'gems',
|
||||
uuid: receivingUser._id,
|
||||
gems: {
|
||||
amount: 16,
|
||||
amount: 4,
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
const amount = 100;
|
||||
sandbox.stub(oneTimePayments, 'getOneTimePaymentInfo').returns({
|
||||
amount,
|
||||
gemsBlock: null,
|
||||
});
|
||||
|
||||
expect(paymentBuyGemsStub).to.be.calledOnce;
|
||||
expect(paymentBuyGemsStub).to.be.calledWith({
|
||||
user,
|
||||
customerId: customerIdResponse,
|
||||
paymentMethod: 'Gift',
|
||||
gift,
|
||||
const res = await createCheckoutSession({ user, gift }, stripe);
|
||||
expect(res).to.equal(sessionId);
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
it('should gift a subscription', async () => {
|
||||
it('subscription gift', async () => {
|
||||
const receivingUser = new User();
|
||||
receivingUser.save();
|
||||
gift = {
|
||||
await receivingUser.save();
|
||||
const subKey = 'basic_3mo';
|
||||
|
||||
const gift = {
|
||||
type: 'subscription',
|
||||
uuid: receivingUser._id,
|
||||
subscription: {
|
||||
key: subKey,
|
||||
uuid: receivingUser._id,
|
||||
},
|
||||
};
|
||||
|
||||
await stripePayments.checkout({
|
||||
token,
|
||||
user,
|
||||
gift,
|
||||
groupId,
|
||||
email,
|
||||
headers,
|
||||
coupon,
|
||||
}, stripe);
|
||||
|
||||
gift.member = receivingUser;
|
||||
expect(stripeChargeStub).to.be.calledOnce;
|
||||
expect(stripeChargeStub).to.be.calledWith({
|
||||
amount: '1500',
|
||||
currency: 'usd',
|
||||
card: token,
|
||||
const amount = 1500;
|
||||
sandbox.stub(oneTimePayments, 'getOneTimePaymentInfo').returns({
|
||||
amount,
|
||||
gemsBlock: null,
|
||||
subscription: common.content.subscriptionBlocks[subKey],
|
||||
});
|
||||
|
||||
expect(paymentCreateSubscritionStub).to.be.calledOnce;
|
||||
expect(paymentCreateSubscritionStub).to.be.calledWith({
|
||||
user,
|
||||
customerId: customerIdResponse,
|
||||
paymentMethod: 'Gift',
|
||||
gift,
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import stripeModule from 'stripe';
|
||||
|
||||
import {
|
||||
generateGroup,
|
||||
} from '../../../../../helpers/api-unit.helper';
|
||||
import { model as User } from '../../../../../../website/server/models/user';
|
||||
import stripePayments from '../../../../../../website/server/libs/payments/stripe';
|
||||
import common from '../../../../../../website/common';
|
||||
|
||||
const { i18n } = common;
|
||||
|
||||
describe('stripe - edit subscription', () => {
|
||||
const subKey = 'basic_3mo';
|
||||
const stripe = stripeModule('test');
|
||||
let user; let groupId; let group; let
|
||||
token;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = new User();
|
||||
user.profile.name = 'sender';
|
||||
user.purchased.plan.customerId = 'customer-id';
|
||||
user.purchased.plan.planId = subKey;
|
||||
user.purchased.plan.lastBillingDate = new Date();
|
||||
|
||||
group = generateGroup({
|
||||
name: 'test group',
|
||||
type: 'guild',
|
||||
privacy: 'public',
|
||||
leader: user._id,
|
||||
});
|
||||
group.purchased.plan.customerId = 'customer-id';
|
||||
group.purchased.plan.planId = subKey;
|
||||
await group.save();
|
||||
|
||||
groupId = group._id;
|
||||
|
||||
token = 'test-token';
|
||||
});
|
||||
|
||||
it('throws an error if there is no customer id', async () => {
|
||||
user.purchased.plan.customerId = undefined;
|
||||
|
||||
await expect(stripePayments.editSubscription({
|
||||
user,
|
||||
groupId: undefined,
|
||||
}))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 401,
|
||||
name: 'NotAuthorized',
|
||||
message: i18n.t('missingSubscription'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws an error if a token is not provided', async () => {
|
||||
await expect(stripePayments.editSubscription({
|
||||
user,
|
||||
groupId: undefined,
|
||||
}))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 400,
|
||||
name: 'BadRequest',
|
||||
message: 'Missing req.body.id',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws an error if the group is not found', async () => {
|
||||
await expect(stripePayments.editSubscription({
|
||||
token,
|
||||
user,
|
||||
groupId: 'fake-group',
|
||||
}))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 404,
|
||||
name: 'NotFound',
|
||||
message: i18n.t('groupNotFound'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws an error if user is not the group leader', async () => {
|
||||
const nonLeader = new User();
|
||||
nonLeader.guilds.push(groupId);
|
||||
await nonLeader.save();
|
||||
|
||||
await expect(stripePayments.editSubscription({
|
||||
token,
|
||||
user: nonLeader,
|
||||
groupId,
|
||||
}))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 401,
|
||||
name: 'NotAuthorized',
|
||||
message: i18n.t('onlyGroupLeaderCanManageSubscription'),
|
||||
});
|
||||
});
|
||||
|
||||
describe('success', () => {
|
||||
let stripeListSubscriptionStub; let stripeUpdateSubscriptionStub; let
|
||||
subscriptionId;
|
||||
|
||||
beforeEach(() => {
|
||||
subscriptionId = 'subId';
|
||||
stripeListSubscriptionStub = sinon.stub(stripe.subscriptions, 'list')
|
||||
.resolves({
|
||||
data: [{ id: subscriptionId }],
|
||||
});
|
||||
|
||||
stripeUpdateSubscriptionStub = sinon.stub(stripe.subscriptions, 'update').resolves({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stripe.subscriptions.list.restore();
|
||||
stripe.subscriptions.update.restore();
|
||||
});
|
||||
|
||||
it('edits a user subscription', async () => {
|
||||
await stripePayments.editSubscription({
|
||||
token,
|
||||
user,
|
||||
groupId: undefined,
|
||||
}, stripe);
|
||||
|
||||
expect(stripeListSubscriptionStub).to.be.calledOnce;
|
||||
expect(stripeListSubscriptionStub).to.be.calledWith({
|
||||
customer: user.purchased.plan.customerId,
|
||||
});
|
||||
expect(stripeUpdateSubscriptionStub).to.be.calledOnce;
|
||||
expect(stripeUpdateSubscriptionStub).to.be.calledWith(
|
||||
subscriptionId,
|
||||
{ card: token },
|
||||
);
|
||||
});
|
||||
|
||||
it('edits a group subscription', async () => {
|
||||
await stripePayments.editSubscription({
|
||||
token,
|
||||
user,
|
||||
groupId,
|
||||
}, stripe);
|
||||
|
||||
expect(stripeListSubscriptionStub).to.be.calledOnce;
|
||||
expect(stripeListSubscriptionStub).to.be.calledWith({
|
||||
customer: group.purchased.plan.customerId,
|
||||
});
|
||||
expect(stripeUpdateSubscriptionStub).to.be.calledOnce;
|
||||
expect(stripeUpdateSubscriptionStub).to.be.calledWith(
|
||||
subscriptionId,
|
||||
{ card: token },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
316
test/api/unit/libs/payments/stripe/oneTimePayments.test.js
Normal file
316
test/api/unit/libs/payments/stripe/oneTimePayments.test.js
Normal file
@@ -0,0 +1,316 @@
|
||||
import apiError from '../../../../../../website/server/libs/apiError';
|
||||
import common from '../../../../../../website/common';
|
||||
import {
|
||||
getOneTimePaymentInfo,
|
||||
applyGemPayment,
|
||||
} from '../../../../../../website/server/libs/payments/stripe/oneTimePayments';
|
||||
import * as subscriptions from '../../../../../../website/server/libs/payments/stripe/subscriptions';
|
||||
import { model as User } from '../../../../../../website/server/models/user';
|
||||
import payments from '../../../../../../website/server/libs/payments/payments';
|
||||
|
||||
const { i18n } = common;
|
||||
|
||||
describe('Stripe - One Time Payments', () => {
|
||||
describe('getOneTimePaymentInfo', () => {
|
||||
let user;
|
||||
|
||||
beforeEach(() => {
|
||||
user = new User();
|
||||
sandbox.stub(subscriptions, 'checkSubData');
|
||||
});
|
||||
|
||||
describe('gemsBlock', () => {
|
||||
it('returns the gemsBlock and amount', async () => {
|
||||
const { gemsBlock, amount, subscription } = await getOneTimePaymentInfo('21gems', null, user);
|
||||
expect(gemsBlock).to.equal(common.content.gems['21gems']);
|
||||
expect(amount).to.equal(gemsBlock.price);
|
||||
expect(amount).to.equal(499);
|
||||
expect(subscription).to.be.null;
|
||||
expect(subscriptions.checkSubData).to.not.be.called;
|
||||
});
|
||||
|
||||
it('throws if the gemsBlock does not exist', async () => {
|
||||
await expect(getOneTimePaymentInfo('not existant', null, user))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 400,
|
||||
name: 'BadRequest',
|
||||
message: apiError('invalidGemsBlock'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if the user cannot receive gems', async () => {
|
||||
sandbox.stub(user, 'canGetGems').resolves(false);
|
||||
await expect(getOneTimePaymentInfo('21gems', null, user))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 401,
|
||||
name: 'NotAuthorized',
|
||||
message: i18n.t('groupPolicyCannotGetGems'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('gift', () => {
|
||||
it('throws if the receiver does not exist', async () => {
|
||||
const gift = {
|
||||
type: 'gems',
|
||||
uuid: 'invalid',
|
||||
gems: {
|
||||
amount: 3,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(getOneTimePaymentInfo(null, gift, user))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 404,
|
||||
name: 'NotFound',
|
||||
message: i18n.t('userWithIDNotFound', { userId: 'invalid' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if the user cannot receive gems', async () => {
|
||||
const receivingUser = new User();
|
||||
await receivingUser.save();
|
||||
sandbox.stub(User.prototype, 'canGetGems').resolves(false);
|
||||
|
||||
const gift = {
|
||||
type: 'gems',
|
||||
uuid: receivingUser._id,
|
||||
gems: {
|
||||
amount: 2,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(getOneTimePaymentInfo(null, gift, user))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 401,
|
||||
name: 'NotAuthorized',
|
||||
message: i18n.t('groupPolicyCannotGetGems'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if the amount of gems is <= 0', async () => {
|
||||
const receivingUser = new User();
|
||||
await receivingUser.save();
|
||||
const gift = {
|
||||
type: 'gems',
|
||||
uuid: receivingUser._id,
|
||||
gems: {
|
||||
amount: 0,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(getOneTimePaymentInfo(null, gift, user))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 400,
|
||||
name: 'BadRequest',
|
||||
message: i18n.t('badAmountOfGemsToPurchase'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if the subscription block does not exist', async () => {
|
||||
const receivingUser = new User();
|
||||
await receivingUser.save();
|
||||
const gift = {
|
||||
type: 'subscription',
|
||||
uuid: receivingUser._id,
|
||||
subscription: {
|
||||
key: 'invalid',
|
||||
},
|
||||
};
|
||||
|
||||
await expect(getOneTimePaymentInfo(null, gift, user))
|
||||
.to.eventually.throw;
|
||||
});
|
||||
|
||||
it('returns the amount (gems)', async () => {
|
||||
const receivingUser = new User();
|
||||
await receivingUser.save();
|
||||
const gift = {
|
||||
type: 'gems',
|
||||
uuid: receivingUser._id,
|
||||
gems: {
|
||||
amount: 4,
|
||||
},
|
||||
};
|
||||
|
||||
expect(subscriptions.checkSubData).to.not.be.called;
|
||||
|
||||
const { gemsBlock, amount, subscription } = await getOneTimePaymentInfo(null, gift, user);
|
||||
expect(gemsBlock).to.equal(null);
|
||||
expect(amount).to.equal('100');
|
||||
expect(subscription).to.be.null;
|
||||
});
|
||||
|
||||
it('returns the amount (subscription)', async () => {
|
||||
const receivingUser = new User();
|
||||
await receivingUser.save();
|
||||
const gift = {
|
||||
type: 'subscription',
|
||||
uuid: receivingUser._id,
|
||||
subscription: {
|
||||
key: 'basic_3mo',
|
||||
},
|
||||
};
|
||||
const sub = common.content.subscriptionBlocks['basic_3mo']; // eslint-disable-line dot-notation
|
||||
|
||||
const { gemsBlock, amount, subscription } = await getOneTimePaymentInfo(null, gift, user);
|
||||
|
||||
expect(subscriptions.checkSubData).to.be.calledOnce;
|
||||
expect(subscriptions.checkSubData).to.be.calledWith(sub, false, null);
|
||||
|
||||
expect(gemsBlock).to.equal(null);
|
||||
expect(amount).to.equal('1500');
|
||||
expect(Number(amount)).to.equal(sub.price * 100);
|
||||
expect(subscription).to.equal(sub);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyGemPayment', () => {
|
||||
let user;
|
||||
let customerId;
|
||||
let subKey;
|
||||
let userFindByIdStub;
|
||||
let paymentsCreateSubSpy;
|
||||
let paymentBuyGemsStub;
|
||||
|
||||
beforeEach(async () => {
|
||||
subKey = 'basic_3mo';
|
||||
|
||||
user = new User();
|
||||
await user.save();
|
||||
|
||||
customerId = 'test-id';
|
||||
|
||||
paymentsCreateSubSpy = sandbox.stub(payments, 'createSubscription');
|
||||
paymentsCreateSubSpy.resolves({});
|
||||
|
||||
paymentBuyGemsStub = sandbox.stub(payments, 'buyGems');
|
||||
paymentBuyGemsStub.resolves({});
|
||||
});
|
||||
|
||||
it('throws if the user does not exist', async () => {
|
||||
const metadata = { userId: 'invalid' };
|
||||
const session = { metadata, customer: customerId };
|
||||
|
||||
await expect(applyGemPayment(session))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 404,
|
||||
name: 'NotFound',
|
||||
message: i18n.t('userWithIDNotFound', { userId: metadata.userId }),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if the receiving user does not exist', async () => {
|
||||
const metadata = { userId: 'invalid' };
|
||||
const session = { metadata, customer: customerId };
|
||||
|
||||
await expect(applyGemPayment(session))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 404,
|
||||
name: 'NotFound',
|
||||
message: i18n.t('userWithIDNotFound', { userId: metadata.userId }),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if the gems block does not exist', async () => {
|
||||
const gift = {
|
||||
type: 'gems',
|
||||
uuid: 'invalid',
|
||||
gems: {
|
||||
amount: 16,
|
||||
},
|
||||
};
|
||||
|
||||
const metadata = { userId: user._id, gift: JSON.stringify(gift) };
|
||||
const session = { metadata, customer: customerId };
|
||||
|
||||
await expect(applyGemPayment(session))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 404,
|
||||
name: 'NotFound',
|
||||
message: i18n.t('userWithIDNotFound', { userId: 'invalid' }),
|
||||
});
|
||||
});
|
||||
|
||||
describe('with existing user', () => {
|
||||
beforeEach(() => {
|
||||
const execStub = sandbox.stub().resolves(user);
|
||||
userFindByIdStub = sandbox.stub(User, 'findById');
|
||||
userFindByIdStub.withArgs(user._id).returns({ exec: execStub });
|
||||
});
|
||||
|
||||
it('buys gems', async () => {
|
||||
const metadata = { userId: user._id, gemsBlock: '21gems' };
|
||||
const session = { metadata, customer: customerId };
|
||||
|
||||
await applyGemPayment(session);
|
||||
|
||||
expect(paymentBuyGemsStub).to.be.calledOnce;
|
||||
expect(paymentBuyGemsStub).to.be.calledWith({
|
||||
user,
|
||||
customerId,
|
||||
paymentMethod: 'Stripe',
|
||||
gift: undefined,
|
||||
gemsBlock: common.content.gems['21gems'],
|
||||
});
|
||||
});
|
||||
|
||||
it('gift gems', async () => {
|
||||
const receivingUser = new User();
|
||||
const execStub = sandbox.stub().resolves(receivingUser);
|
||||
userFindByIdStub.withArgs(receivingUser._id).returns({ exec: execStub });
|
||||
const gift = {
|
||||
type: 'gems',
|
||||
uuid: receivingUser._id,
|
||||
gems: {
|
||||
amount: 16,
|
||||
},
|
||||
};
|
||||
|
||||
sandbox.stub(JSON, 'parse').returns(gift);
|
||||
const metadata = { userId: user._id, gift: JSON.stringify(gift) };
|
||||
const session = { metadata, customer: customerId };
|
||||
|
||||
await applyGemPayment(session);
|
||||
|
||||
expect(paymentBuyGemsStub).to.be.calledOnce;
|
||||
expect(paymentBuyGemsStub).to.be.calledWith({
|
||||
user,
|
||||
customerId,
|
||||
paymentMethod: 'Gift',
|
||||
gift,
|
||||
gemsBlock: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('gift sub', async () => {
|
||||
const receivingUser = new User();
|
||||
const execStub = sandbox.stub().resolves(receivingUser);
|
||||
userFindByIdStub.withArgs(receivingUser._id).returns({ exec: execStub });
|
||||
const gift = {
|
||||
type: 'subscription',
|
||||
uuid: receivingUser._id,
|
||||
subscription: {
|
||||
key: subKey,
|
||||
},
|
||||
};
|
||||
|
||||
sandbox.stub(JSON, 'parse').returns(gift);
|
||||
const metadata = { userId: user._id, gift: JSON.stringify(gift) };
|
||||
const session = { metadata, customer: customerId };
|
||||
|
||||
await applyGemPayment(session);
|
||||
|
||||
expect(paymentsCreateSubSpy).to.be.calledOnce;
|
||||
expect(paymentsCreateSubSpy).to.be.calledWith({
|
||||
user,
|
||||
customerId,
|
||||
paymentMethod: 'Gift',
|
||||
gift,
|
||||
gemsBlock: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
442
test/api/unit/libs/payments/stripe/subscriptions.test.js
Normal file
442
test/api/unit/libs/payments/stripe/subscriptions.test.js
Normal file
@@ -0,0 +1,442 @@
|
||||
import cc from 'coupon-code';
|
||||
import stripeModule from 'stripe';
|
||||
|
||||
import { model as Coupon } from '../../../../../../website/server/models/coupon';
|
||||
import common from '../../../../../../website/common';
|
||||
import {
|
||||
checkSubData,
|
||||
applySubscription,
|
||||
chargeForAdditionalGroupMember,
|
||||
handlePaymentMethodChange,
|
||||
} from '../../../../../../website/server/libs/payments/stripe/subscriptions';
|
||||
import {
|
||||
generateGroup,
|
||||
} from '../../../../../helpers/api-unit.helper';
|
||||
import { model as User } from '../../../../../../website/server/models/user';
|
||||
import payments from '../../../../../../website/server/libs/payments/payments';
|
||||
import stripePayments from '../../../../../../website/server/libs/payments/stripe';
|
||||
|
||||
const { i18n } = common;
|
||||
|
||||
describe('Stripe - Subscriptions', () => {
|
||||
describe('checkSubData', () => {
|
||||
it('does not throw if the subscription can be used', async () => {
|
||||
const sub = common.content.subscriptionBlocks['basic_3mo']; // eslint-disable-line dot-notation
|
||||
const res = await checkSubData(sub);
|
||||
expect(res).to.equal(undefined);
|
||||
});
|
||||
|
||||
it('throws if the subscription does not exists', async () => {
|
||||
await expect(checkSubData())
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 400,
|
||||
name: 'BadRequest',
|
||||
message: i18n.t('missingSubscriptionCode'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if the subscription can\'t be used', async () => {
|
||||
const sub = common.content.subscriptionBlocks['group_plan_auto']; // eslint-disable-line dot-notation
|
||||
await expect(checkSubData(sub, true))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 400,
|
||||
name: 'BadRequest',
|
||||
message: i18n.t('missingSubscriptionCode'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if the subscription targets a group and an user is making the request', async () => {
|
||||
const sub = common.content.subscriptionBlocks['group_monthly']; // eslint-disable-line dot-notation
|
||||
await expect(checkSubData(sub, false))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 400,
|
||||
name: 'BadRequest',
|
||||
message: i18n.t('missingSubscriptionCode'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if the subscription targets an user and a group is making the request', async () => {
|
||||
const sub = common.content.subscriptionBlocks['basic_3mo']; // eslint-disable-line dot-notation
|
||||
await expect(checkSubData(sub, true))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 400,
|
||||
name: 'BadRequest',
|
||||
message: i18n.t('missingSubscriptionCode'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if the coupon is required but not passed', async () => {
|
||||
const sub = common.content.subscriptionBlocks['google_6mo']; // eslint-disable-line dot-notation
|
||||
await expect(checkSubData(sub, false))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 400,
|
||||
name: 'BadRequest',
|
||||
message: i18n.t('couponCodeRequired'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if the coupon is required but does not exist', async () => {
|
||||
const coupon = 'not-valid';
|
||||
const sub = common.content.subscriptionBlocks['google_6mo']; // eslint-disable-line dot-notation
|
||||
await expect(checkSubData(sub, false, coupon))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 400,
|
||||
name: 'BadRequest',
|
||||
message: i18n.t('invalidCoupon'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if the coupon is required but is invalid', async () => {
|
||||
const couponModel = new Coupon();
|
||||
couponModel.event = 'google_6mo';
|
||||
await couponModel.save();
|
||||
|
||||
sandbox.stub(cc, 'validate').returns('invalid');
|
||||
|
||||
const sub = common.content.subscriptionBlocks['google_6mo']; // eslint-disable-line dot-notation
|
||||
await expect(checkSubData(sub, false, couponModel._id))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 400,
|
||||
name: 'BadRequest',
|
||||
message: i18n.t('invalidCoupon'),
|
||||
});
|
||||
});
|
||||
|
||||
it('works if the coupon is required and valid', async () => {
|
||||
const couponModel = new Coupon();
|
||||
couponModel.event = 'google_6mo';
|
||||
await couponModel.save();
|
||||
|
||||
sandbox.stub(cc, 'validate').returns(couponModel._id);
|
||||
|
||||
const sub = common.content.subscriptionBlocks['google_6mo']; // eslint-disable-line dot-notation
|
||||
await checkSubData(sub, false, couponModel._id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applySubscription', () => {
|
||||
let user; let group; let sub;
|
||||
let groupId;
|
||||
let customerId; let subscriptionId;
|
||||
let subKey;
|
||||
let userFindByIdStub;
|
||||
let stripePaymentsCreateSubSpy;
|
||||
|
||||
beforeEach(async () => {
|
||||
subKey = 'basic_3mo';
|
||||
sub = common.content.subscriptionBlocks[subKey];
|
||||
|
||||
user = new User();
|
||||
await user.save();
|
||||
|
||||
const execStub = sandbox.stub().resolves(user);
|
||||
userFindByIdStub = sandbox.stub(User, 'findById');
|
||||
userFindByIdStub.withArgs(user._id).returns({ exec: execStub });
|
||||
|
||||
group = generateGroup({
|
||||
name: 'test group',
|
||||
type: 'guild',
|
||||
privacy: 'public',
|
||||
leader: user._id,
|
||||
});
|
||||
groupId = group._id;
|
||||
await group.save();
|
||||
|
||||
// Add user to group
|
||||
user.guilds.push(groupId);
|
||||
await user.save();
|
||||
|
||||
customerId = 'test-id';
|
||||
subscriptionId = 'test-sub-id';
|
||||
|
||||
stripePaymentsCreateSubSpy = sandbox.stub(payments, 'createSubscription');
|
||||
stripePaymentsCreateSubSpy.resolves({});
|
||||
});
|
||||
|
||||
it('subscribes a user', async () => {
|
||||
await applySubscription({
|
||||
customer: customerId,
|
||||
subscription: subscriptionId,
|
||||
metadata: {
|
||||
sub: JSON.stringify(sub),
|
||||
userId: user._id,
|
||||
groupId: null,
|
||||
},
|
||||
user,
|
||||
});
|
||||
|
||||
expect(stripePaymentsCreateSubSpy).to.be.calledOnce;
|
||||
expect(stripePaymentsCreateSubSpy).to.be.calledWith({
|
||||
user,
|
||||
customerId,
|
||||
subscriptionId,
|
||||
paymentMethod: 'Stripe',
|
||||
sub: sinon.match({ ...sub }),
|
||||
groupId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('subscribes a group', async () => {
|
||||
sub = common.content.subscriptionBlocks['group_monthly']; // eslint-disable-line dot-notation
|
||||
await applySubscription({
|
||||
customer: customerId,
|
||||
subscription: subscriptionId,
|
||||
metadata: {
|
||||
sub: JSON.stringify(sub),
|
||||
userId: user._id,
|
||||
groupId,
|
||||
},
|
||||
user,
|
||||
});
|
||||
|
||||
expect(stripePaymentsCreateSubSpy).to.be.calledOnce;
|
||||
expect(stripePaymentsCreateSubSpy).to.be.calledWith({
|
||||
user,
|
||||
customerId,
|
||||
subscriptionId,
|
||||
paymentMethod: 'Stripe',
|
||||
sub: sinon.match({ ...sub }),
|
||||
groupId,
|
||||
});
|
||||
});
|
||||
|
||||
it('subscribes a group with multiple users', async () => {
|
||||
const user2 = new User();
|
||||
user2.guilds.push(groupId);
|
||||
await user2.save();
|
||||
|
||||
const execStub2 = sandbox.stub().resolves(user);
|
||||
userFindByIdStub.withArgs(user2._id).returns({ exec: execStub2 });
|
||||
|
||||
group.memberCount = 2;
|
||||
await group.save();
|
||||
|
||||
sub = common.content.subscriptionBlocks['group_monthly']; // eslint-disable-line dot-notation
|
||||
await applySubscription({
|
||||
customer: customerId,
|
||||
subscription: subscriptionId,
|
||||
metadata: {
|
||||
sub: JSON.stringify(sub),
|
||||
userId: user._id,
|
||||
groupId,
|
||||
},
|
||||
user,
|
||||
});
|
||||
|
||||
expect(stripePaymentsCreateSubSpy).to.be.calledOnce;
|
||||
expect(stripePaymentsCreateSubSpy).to.be.calledWith({
|
||||
user,
|
||||
customerId,
|
||||
subscriptionId,
|
||||
paymentMethod: 'Stripe',
|
||||
sub: sinon.match({ ...sub }),
|
||||
groupId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePaymentMethodChange', () => {
|
||||
const stripe = stripeModule('test', {
|
||||
apiVersion: '2020-08-27',
|
||||
});
|
||||
|
||||
it('updates the plan quantity based on the number of group members', async () => {
|
||||
const stripeIntentRetrieveStub = sandbox.stub(stripe.setupIntents, 'retrieve').resolves({
|
||||
payment_method: 1,
|
||||
metadata: {
|
||||
subscription_id: 2,
|
||||
},
|
||||
});
|
||||
const stripeSubUpdateStub = sandbox.stub(stripe.subscriptions, 'update');
|
||||
|
||||
await handlePaymentMethodChange({}, stripe);
|
||||
expect(stripeIntentRetrieveStub).to.be.calledOnce;
|
||||
expect(stripeSubUpdateStub).to.be.calledOnce;
|
||||
expect(stripeSubUpdateStub).to.be.calledWith(2, {
|
||||
default_payment_method: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('chargeForAdditionalGroupMember', () => {
|
||||
const stripe = stripeModule('test', {
|
||||
apiVersion: '2020-08-27',
|
||||
});
|
||||
let stripeUpdateSubStub;
|
||||
const plan = common.content.subscriptionBlocks['group_monthly']; // eslint-disable-line dot-notation
|
||||
|
||||
let user; let group;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = new User();
|
||||
|
||||
group = generateGroup({
|
||||
name: 'test group',
|
||||
type: 'guild',
|
||||
privacy: 'public',
|
||||
leader: user._id,
|
||||
});
|
||||
group.purchased.plan.customerId = 'customer-id';
|
||||
group.purchased.plan.planId = plan.key;
|
||||
group.purchased.plan.subscriptionId = 'sub-id';
|
||||
await group.save();
|
||||
|
||||
stripeUpdateSubStub = sandbox.stub(stripe.subscriptions, 'update').resolves({});
|
||||
});
|
||||
|
||||
it('updates the plan quantity based on the number of group members', async () => {
|
||||
group.memberCount = 4;
|
||||
const newQuantity = group.memberCount + plan.quantity - 1;
|
||||
|
||||
await chargeForAdditionalGroupMember(group, stripe);
|
||||
expect(stripeUpdateSubStub).to.be.calledWithMatch(
|
||||
group.purchased.plan.subscriptionId,
|
||||
sinon.match({
|
||||
plan: group.purchased.plan.planId,
|
||||
quantity: newQuantity,
|
||||
}),
|
||||
);
|
||||
expect(group.purchased.plan.quantity).to.equal(newQuantity);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelSubscription', () => {
|
||||
const subKey = 'basic_3mo';
|
||||
const stripe = stripeModule('test', {
|
||||
apiVersion: '2020-08-27',
|
||||
});
|
||||
let user; let groupId; let
|
||||
group;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = new User();
|
||||
user.profile.name = 'sender';
|
||||
user.purchased.plan.customerId = 'customer-id';
|
||||
user.purchased.plan.planId = subKey;
|
||||
user.purchased.plan.lastBillingDate = new Date();
|
||||
|
||||
group = generateGroup({
|
||||
name: 'test group',
|
||||
type: 'guild',
|
||||
privacy: 'public',
|
||||
leader: user._id,
|
||||
});
|
||||
group.purchased.plan.customerId = 'customer-id';
|
||||
group.purchased.plan.planId = subKey;
|
||||
await group.save();
|
||||
|
||||
groupId = group._id;
|
||||
});
|
||||
|
||||
it('throws an error if there is no customer id', async () => {
|
||||
user.purchased.plan.customerId = undefined;
|
||||
|
||||
await expect(stripePayments.cancelSubscription({
|
||||
user,
|
||||
groupId: undefined,
|
||||
}))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 401,
|
||||
name: 'NotAuthorized',
|
||||
message: i18n.t('missingSubscription'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws an error if the group is not found', async () => {
|
||||
await expect(stripePayments.cancelSubscription({
|
||||
user,
|
||||
groupId: 'fake-group',
|
||||
}))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 404,
|
||||
name: 'NotFound',
|
||||
message: i18n.t('groupNotFound'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws an error if user is not the group leader', async () => {
|
||||
const nonLeader = new User();
|
||||
nonLeader.guilds.push(groupId);
|
||||
await nonLeader.save();
|
||||
|
||||
await expect(stripePayments.cancelSubscription({
|
||||
user: nonLeader,
|
||||
groupId,
|
||||
}))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 401,
|
||||
name: 'NotAuthorized',
|
||||
message: i18n.t('onlyGroupLeaderCanManageSubscription'),
|
||||
});
|
||||
});
|
||||
|
||||
describe('success', () => {
|
||||
let stripeDeleteCustomerStub; let paymentsCancelSubStub;
|
||||
let stripeRetrieveStub; let subscriptionId; let
|
||||
currentPeriodEndTimeStamp;
|
||||
|
||||
beforeEach(() => {
|
||||
subscriptionId = 'subId';
|
||||
stripeDeleteCustomerStub = sinon.stub(stripe.customers, 'del').resolves({});
|
||||
paymentsCancelSubStub = sinon.stub(payments, 'cancelSubscription').resolves({});
|
||||
|
||||
currentPeriodEndTimeStamp = (new Date()).getTime();
|
||||
stripeRetrieveStub = sinon.stub(stripe.customers, 'retrieve')
|
||||
.resolves({
|
||||
subscriptions: {
|
||||
data: [{
|
||||
id: subscriptionId,
|
||||
current_period_end: currentPeriodEndTimeStamp,
|
||||
}], // eslint-disable-line camelcase
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stripe.customers.del.restore();
|
||||
stripe.customers.retrieve.restore();
|
||||
payments.cancelSubscription.restore();
|
||||
});
|
||||
|
||||
it('cancels a user subscription', async () => {
|
||||
await stripePayments.cancelSubscription({
|
||||
user,
|
||||
groupId: undefined,
|
||||
}, stripe);
|
||||
|
||||
expect(stripeDeleteCustomerStub).to.be.calledOnce;
|
||||
expect(stripeDeleteCustomerStub).to.be.calledWith(user.purchased.plan.customerId);
|
||||
expect(stripeRetrieveStub).to.be.calledOnce;
|
||||
expect(stripeRetrieveStub).to.be.calledWith(user.purchased.plan.customerId);
|
||||
expect(paymentsCancelSubStub).to.be.calledOnce;
|
||||
expect(paymentsCancelSubStub).to.be.calledWith({
|
||||
user,
|
||||
groupId: undefined,
|
||||
nextBill: currentPeriodEndTimeStamp * 1000, // timestamp in seconds
|
||||
paymentMethod: 'Stripe',
|
||||
cancellationReason: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels a group subscription', async () => {
|
||||
await stripePayments.cancelSubscription({
|
||||
user,
|
||||
groupId,
|
||||
}, stripe);
|
||||
|
||||
expect(stripeDeleteCustomerStub).to.be.calledOnce;
|
||||
expect(stripeDeleteCustomerStub).to.be.calledWith(group.purchased.plan.customerId);
|
||||
expect(stripeRetrieveStub).to.be.calledOnce;
|
||||
expect(stripeRetrieveStub).to.be.calledWith(user.purchased.plan.customerId);
|
||||
expect(paymentsCancelSubStub).to.be.calledOnce;
|
||||
expect(paymentsCancelSubStub).to.be.calledWith({
|
||||
user,
|
||||
groupId,
|
||||
nextBill: currentPeriodEndTimeStamp * 1000, // timestamp in seconds
|
||||
paymentMethod: 'Stripe',
|
||||
cancellationReason: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import stripeModule from 'stripe';
|
||||
|
||||
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 stripePayments from '../../../../../../website/server/libs/payments/stripe';
|
||||
import payments from '../../../../../../website/server/libs/payments/payments';
|
||||
|
||||
describe('Stripe - Upgrade Group Plan', () => {
|
||||
const stripe = stripeModule('test');
|
||||
let spy; let data; let user; let
|
||||
group;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = new User();
|
||||
user.profile.name = 'sender';
|
||||
|
||||
data = {
|
||||
user,
|
||||
sub: {
|
||||
key: 'basic_3mo', // @TODO: Validate that this is group
|
||||
},
|
||||
customerId: 'customer-id',
|
||||
paymentMethod: 'Payment Method',
|
||||
headers: {
|
||||
'x-client': 'habitica-web',
|
||||
'user-agent': '',
|
||||
},
|
||||
};
|
||||
|
||||
group = generateGroup({
|
||||
name: 'test group',
|
||||
type: 'guild',
|
||||
privacy: 'private',
|
||||
leader: user._id,
|
||||
});
|
||||
await group.save();
|
||||
|
||||
user.guilds.push(group._id);
|
||||
await user.save();
|
||||
|
||||
spy = sinon.stub(stripe.subscriptions, 'update');
|
||||
spy.resolves([]);
|
||||
data.groupId = group._id;
|
||||
data.sub.quantity = 3;
|
||||
stripePayments.setStripeApi(stripe);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stripe.subscriptions.update.restore();
|
||||
});
|
||||
|
||||
it('updates a group plan quantity', async () => {
|
||||
data.paymentMethod = 'Stripe';
|
||||
await payments.createSubscription(data);
|
||||
|
||||
const updatedGroup = await Group.findById(group._id).exec();
|
||||
expect(updatedGroup.purchased.plan.quantity).to.eql(3);
|
||||
|
||||
updatedGroup.memberCount += 1;
|
||||
await updatedGroup.save();
|
||||
|
||||
await stripePayments.chargeForAdditionalGroupMember(updatedGroup);
|
||||
|
||||
expect(spy.calledOnce).to.be.true;
|
||||
expect(updatedGroup.purchased.plan.quantity).to.eql(4);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import stripeModule from 'stripe';
|
||||
|
||||
import nconf from 'nconf';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import moment from 'moment';
|
||||
import {
|
||||
@@ -10,76 +10,104 @@ import stripePayments from '../../../../../../website/server/libs/payments/strip
|
||||
import payments from '../../../../../../website/server/libs/payments/payments';
|
||||
import common from '../../../../../../website/common';
|
||||
import logger from '../../../../../../website/server/libs/logger';
|
||||
import * as oneTimePayments from '../../../../../../website/server/libs/payments/stripe/oneTimePayments';
|
||||
import * as subscriptions from '../../../../../../website/server/libs/payments/stripe/subscriptions';
|
||||
|
||||
const { i18n } = common;
|
||||
|
||||
describe('Stripe - Webhooks', () => {
|
||||
const stripe = stripeModule('test');
|
||||
const stripe = stripeModule('test', {
|
||||
apiVersion: '2020-08-27',
|
||||
});
|
||||
const endpointSecret = nconf.get('STRIPE_WEBHOOKS_ENDPOINT_SECRET');
|
||||
const headers = {};
|
||||
const body = {};
|
||||
|
||||
describe('all events', () => {
|
||||
const eventType = 'account.updated';
|
||||
const event = { id: 123 };
|
||||
const eventRetrieved = { type: eventType };
|
||||
let event;
|
||||
let constructEventStub;
|
||||
|
||||
beforeEach(() => {
|
||||
sinon.stub(stripe.events, 'retrieve').resolves(eventRetrieved);
|
||||
sinon.stub(logger, 'error');
|
||||
event = { type: 'payment_intent.created' };
|
||||
constructEventStub = sandbox.stub(stripe.webhooks, 'constructEvent');
|
||||
constructEventStub.returns(event);
|
||||
sandbox.stub(logger, 'error');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stripe.events.retrieve.restore();
|
||||
logger.error.restore();
|
||||
it('throws if the event can\'t be validated', async () => {
|
||||
const err = new Error('fail');
|
||||
constructEventStub.throws(err);
|
||||
await expect(stripePayments.handleWebhooks({ body: event, headers }, stripe))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 400,
|
||||
name: 'BadRequest',
|
||||
message: `Webhook Error: ${err.message}`,
|
||||
});
|
||||
|
||||
expect(logger.error).to.have.been.calledOnce;
|
||||
const calledWith = logger.error.getCall(0).args;
|
||||
expect(calledWith[0].message).to.equal('Error verifying Stripe webhook');
|
||||
expect(calledWith[1]).to.eql({ err });
|
||||
});
|
||||
|
||||
it('logs an error if an unsupported webhook event is passed', async () => {
|
||||
const error = new Error(`Missing handler for Stripe webhook ${eventType}`);
|
||||
await stripePayments.handleWebhooks({ requestBody: event }, stripe);
|
||||
expect(logger.error).to.have.been.calledOnce;
|
||||
event.type = 'account.updated';
|
||||
await expect(stripePayments.handleWebhooks({ body, headers }, stripe))
|
||||
.to.eventually.be.rejected.and.to.eql({
|
||||
httpCode: 400,
|
||||
name: 'BadRequest',
|
||||
message: `Missing handler for Stripe webhook ${event.type}`,
|
||||
});
|
||||
|
||||
expect(logger.error).to.have.been.calledOnce;
|
||||
const calledWith = logger.error.getCall(0).args;
|
||||
expect(calledWith[0].message).to.equal(error.message);
|
||||
expect(calledWith[1].event).to.equal(eventRetrieved);
|
||||
expect(calledWith[0].message).to.equal('Error handling Stripe webhook');
|
||||
expect(calledWith[1].event).to.eql(event);
|
||||
expect(calledWith[1].err.message).to.eql(`Missing handler for Stripe webhook ${event.type}`);
|
||||
});
|
||||
|
||||
it('retrieves and validates the event from Stripe', async () => {
|
||||
await stripePayments.handleWebhooks({ requestBody: event }, stripe);
|
||||
expect(stripe.events.retrieve).to.have.been.calledOnce;
|
||||
expect(stripe.events.retrieve).to.have.been.calledWith(event.id);
|
||||
await stripePayments.handleWebhooks({ body, headers }, stripe);
|
||||
expect(stripe.webhooks.constructEvent).to.have.been.calledOnce;
|
||||
expect(stripe.webhooks.constructEvent)
|
||||
.to.have.been.calledWith(body, undefined, endpointSecret);
|
||||
});
|
||||
});
|
||||
|
||||
describe('customer.subscription.deleted', () => {
|
||||
const eventType = 'customer.subscription.deleted';
|
||||
let event;
|
||||
let constructEventStub;
|
||||
|
||||
beforeEach(() => {
|
||||
sinon.stub(stripe.customers, 'del').resolves({});
|
||||
sinon.stub(payments, 'cancelSubscription').resolves({});
|
||||
event = { type: eventType };
|
||||
constructEventStub = sandbox.stub(stripe.webhooks, 'constructEvent');
|
||||
constructEventStub.returns(event);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stripe.customers.del.restore();
|
||||
payments.cancelSubscription.restore();
|
||||
beforeEach(() => {
|
||||
sandbox.stub(stripe.customers, 'del').resolves({});
|
||||
sandbox.stub(payments, 'cancelSubscription').resolves({});
|
||||
});
|
||||
|
||||
it('does not do anything if event.request is null (subscription cancelled manually)', async () => {
|
||||
sinon.stub(stripe.events, 'retrieve').resolves({
|
||||
constructEventStub.returns({
|
||||
id: 123,
|
||||
type: eventType,
|
||||
request: 123,
|
||||
});
|
||||
|
||||
await stripePayments.handleWebhooks({ requestBody: {} }, stripe);
|
||||
await stripePayments.handleWebhooks({ body, headers }, stripe);
|
||||
|
||||
expect(stripe.events.retrieve).to.have.been.calledOnce;
|
||||
expect(stripe.webhooks.constructEvent).to.have.been.calledOnce;
|
||||
expect(stripe.customers.del).to.not.have.been.called;
|
||||
expect(payments.cancelSubscription).to.not.have.been.called;
|
||||
stripe.events.retrieve.restore();
|
||||
});
|
||||
|
||||
describe('user subscription', () => {
|
||||
it('throws an error if the user is not found', async () => {
|
||||
const customerId = 456;
|
||||
sinon.stub(stripe.events, 'retrieve').resolves({
|
||||
constructEventStub.returns({
|
||||
id: 123,
|
||||
type: eventType,
|
||||
data: {
|
||||
@@ -93,7 +121,7 @@ describe('Stripe - Webhooks', () => {
|
||||
request: null,
|
||||
});
|
||||
|
||||
await expect(stripePayments.handleWebhooks({ requestBody: {} }, stripe))
|
||||
await expect(stripePayments.handleWebhooks({ body, headers }, stripe))
|
||||
.to.eventually.be.rejectedWith({
|
||||
message: i18n.t('userNotFound'),
|
||||
httpCode: 404,
|
||||
@@ -102,8 +130,6 @@ describe('Stripe - Webhooks', () => {
|
||||
|
||||
expect(stripe.customers.del).to.not.have.been.called;
|
||||
expect(payments.cancelSubscription).to.not.have.been.called;
|
||||
|
||||
stripe.events.retrieve.restore();
|
||||
});
|
||||
|
||||
it('deletes the customer on Stripe and calls payments.cancelSubscription', async () => {
|
||||
@@ -114,7 +140,7 @@ describe('Stripe - Webhooks', () => {
|
||||
subscriber.purchased.plan.paymentMethod = 'Stripe';
|
||||
await subscriber.save();
|
||||
|
||||
sinon.stub(stripe.events, 'retrieve').resolves({
|
||||
constructEventStub.returns({
|
||||
id: 123,
|
||||
type: eventType,
|
||||
data: {
|
||||
@@ -128,7 +154,7 @@ describe('Stripe - Webhooks', () => {
|
||||
request: null,
|
||||
});
|
||||
|
||||
await stripePayments.handleWebhooks({ requestBody: {} }, stripe);
|
||||
await stripePayments.handleWebhooks({ body, headers }, stripe);
|
||||
|
||||
expect(stripe.customers.del).to.have.been.calledOnce;
|
||||
expect(stripe.customers.del).to.have.been.calledWith(customerId);
|
||||
@@ -139,15 +165,13 @@ describe('Stripe - Webhooks', () => {
|
||||
expect(cancelSubscriptionOpts.paymentMethod).to.equal('Stripe');
|
||||
expect(Math.round(moment(cancelSubscriptionOpts.nextBill).diff(new Date(), 'days', true))).to.equal(3);
|
||||
expect(cancelSubscriptionOpts.groupId).to.be.undefined;
|
||||
|
||||
stripe.events.retrieve.restore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('group plan subscription', () => {
|
||||
it('throws an error if the group is not found', async () => {
|
||||
const customerId = 456;
|
||||
sinon.stub(stripe.events, 'retrieve').resolves({
|
||||
constructEventStub.returns({
|
||||
id: 123,
|
||||
type: eventType,
|
||||
data: {
|
||||
@@ -161,7 +185,7 @@ describe('Stripe - Webhooks', () => {
|
||||
request: null,
|
||||
});
|
||||
|
||||
await expect(stripePayments.handleWebhooks({ requestBody: {} }, stripe))
|
||||
await expect(stripePayments.handleWebhooks({ body, headers }, stripe))
|
||||
.to.eventually.be.rejectedWith({
|
||||
message: i18n.t('groupNotFound'),
|
||||
httpCode: 404,
|
||||
@@ -170,8 +194,6 @@ describe('Stripe - Webhooks', () => {
|
||||
|
||||
expect(stripe.customers.del).to.not.have.been.called;
|
||||
expect(payments.cancelSubscription).to.not.have.been.called;
|
||||
|
||||
stripe.events.retrieve.restore();
|
||||
});
|
||||
|
||||
it('throws an error if the group leader is not found', async () => {
|
||||
@@ -187,7 +209,7 @@ describe('Stripe - Webhooks', () => {
|
||||
subscriber.purchased.plan.paymentMethod = 'Stripe';
|
||||
await subscriber.save();
|
||||
|
||||
sinon.stub(stripe.events, 'retrieve').resolves({
|
||||
constructEventStub.returns({
|
||||
id: 123,
|
||||
type: eventType,
|
||||
data: {
|
||||
@@ -201,7 +223,7 @@ describe('Stripe - Webhooks', () => {
|
||||
request: null,
|
||||
});
|
||||
|
||||
await expect(stripePayments.handleWebhooks({ requestBody: {} }, stripe))
|
||||
await expect(stripePayments.handleWebhooks({ body, headers }, stripe))
|
||||
.to.eventually.be.rejectedWith({
|
||||
message: i18n.t('userNotFound'),
|
||||
httpCode: 404,
|
||||
@@ -210,8 +232,6 @@ describe('Stripe - Webhooks', () => {
|
||||
|
||||
expect(stripe.customers.del).to.not.have.been.called;
|
||||
expect(payments.cancelSubscription).to.not.have.been.called;
|
||||
|
||||
stripe.events.retrieve.restore();
|
||||
});
|
||||
|
||||
it('deletes the customer on Stripe and calls payments.cancelSubscription', async () => {
|
||||
@@ -230,7 +250,7 @@ describe('Stripe - Webhooks', () => {
|
||||
subscriber.purchased.plan.paymentMethod = 'Stripe';
|
||||
await subscriber.save();
|
||||
|
||||
sinon.stub(stripe.events, 'retrieve').resolves({
|
||||
constructEventStub.returns({
|
||||
id: 123,
|
||||
type: eventType,
|
||||
data: {
|
||||
@@ -244,7 +264,7 @@ describe('Stripe - Webhooks', () => {
|
||||
request: null,
|
||||
});
|
||||
|
||||
await stripePayments.handleWebhooks({ requestBody: {} }, stripe);
|
||||
await stripePayments.handleWebhooks({ body, headers }, stripe);
|
||||
|
||||
expect(stripe.customers.del).to.have.been.calledOnce;
|
||||
expect(stripe.customers.del).to.have.been.calledWith(customerId);
|
||||
@@ -255,9 +275,65 @@ describe('Stripe - Webhooks', () => {
|
||||
expect(cancelSubscriptionOpts.paymentMethod).to.equal('Stripe');
|
||||
expect(Math.round(moment(cancelSubscriptionOpts.nextBill).diff(new Date(), 'days', true))).to.equal(3);
|
||||
expect(cancelSubscriptionOpts.groupId).to.equal(subscriber._id);
|
||||
|
||||
stripe.events.retrieve.restore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkout.session.completed', () => {
|
||||
const eventType = 'checkout.session.completed';
|
||||
let event;
|
||||
let constructEventStub;
|
||||
const session = {};
|
||||
|
||||
beforeEach(() => {
|
||||
session.metadata = {};
|
||||
event = { type: eventType, data: { object: session } };
|
||||
constructEventStub = sandbox.stub(stripe.webhooks, 'constructEvent');
|
||||
constructEventStub.returns(event);
|
||||
|
||||
sandbox.stub(oneTimePayments, 'applyGemPayment').resolves({});
|
||||
sandbox.stub(subscriptions, 'applySubscription').resolves({});
|
||||
sandbox.stub(subscriptions, 'handlePaymentMethodChange').resolves({});
|
||||
});
|
||||
|
||||
it('handles changing an user sub', async () => {
|
||||
session.metadata.type = 'edit-card-user';
|
||||
|
||||
await stripePayments.handleWebhooks({ body, headers }, stripe);
|
||||
|
||||
expect(stripe.webhooks.constructEvent).to.have.been.calledOnce;
|
||||
expect(subscriptions.handlePaymentMethodChange).to.have.been.calledOnce;
|
||||
expect(subscriptions.handlePaymentMethodChange).to.have.been.calledWith(session);
|
||||
});
|
||||
|
||||
it('handles changing a group sub', async () => {
|
||||
session.metadata.type = 'edit-card-group';
|
||||
|
||||
await stripePayments.handleWebhooks({ body, headers }, stripe);
|
||||
|
||||
expect(stripe.webhooks.constructEvent).to.have.been.calledOnce;
|
||||
expect(subscriptions.handlePaymentMethodChange).to.have.been.calledOnce;
|
||||
expect(subscriptions.handlePaymentMethodChange).to.have.been.calledWith(session);
|
||||
});
|
||||
|
||||
it('applies a subscription', async () => {
|
||||
session.metadata.type = 'subscription';
|
||||
|
||||
await stripePayments.handleWebhooks({ body, headers }, stripe);
|
||||
|
||||
expect(stripe.webhooks.constructEvent).to.have.been.calledOnce;
|
||||
expect(subscriptions.applySubscription).to.have.been.calledOnce;
|
||||
expect(subscriptions.applySubscription).to.have.been.calledWith(session);
|
||||
});
|
||||
|
||||
it('handles a one time payment', async () => {
|
||||
session.metadata.type = 'something else';
|
||||
|
||||
await stripePayments.handleWebhooks({ body, headers }, stripe);
|
||||
|
||||
expect(stripe.webhooks.constructEvent).to.have.been.calledOnce;
|
||||
expect(oneTimePayments.applyGemPayment).to.have.been.calledOnce;
|
||||
expect(oneTimePayments.applyGemPayment).to.have.been.calledWith(session);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
generateUser,
|
||||
} from '../../../../../helpers/api-integration/v3';
|
||||
import stripePayments from '../../../../../../website/server/libs/payments/stripe';
|
||||
import common from '../../../../../../website/common';
|
||||
|
||||
describe('payments - stripe - #createCheckoutSession', () => {
|
||||
const endpoint = '/stripe/checkout-session';
|
||||
let user; const groupId = 'groupId';
|
||||
const gift = {}; const subKey = 'basic_3mo';
|
||||
const gemsBlock = '21gems'; const coupon = 'coupon';
|
||||
let stripeCreateCheckoutSessionStub; const sessionId = 'sessionId';
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await generateUser();
|
||||
stripeCreateCheckoutSessionStub = sinon
|
||||
.stub(stripePayments, 'createCheckoutSession')
|
||||
.resolves({ id: sessionId });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stripePayments.createCheckoutSession.restore();
|
||||
});
|
||||
|
||||
it('works', async () => {
|
||||
const res = await user.post(endpoint, {
|
||||
groupId,
|
||||
gift,
|
||||
sub: subKey,
|
||||
gemsBlock,
|
||||
coupon,
|
||||
});
|
||||
|
||||
expect(res.sessionId).to.equal(sessionId);
|
||||
|
||||
expect(stripeCreateCheckoutSessionStub).to.be.calledOnce;
|
||||
expect(stripeCreateCheckoutSessionStub.args[0][0].user._id).to.eql(user._id);
|
||||
expect(stripeCreateCheckoutSessionStub.args[0][0].groupId).to.eql(groupId);
|
||||
expect(stripeCreateCheckoutSessionStub.args[0][0].gift).to.eql(gift);
|
||||
expect(stripeCreateCheckoutSessionStub.args[0][0].sub)
|
||||
.to.eql(common.content.subscriptionBlocks[subKey]);
|
||||
expect(stripeCreateCheckoutSessionStub.args[0][0].gemsBlock).to.eql(gemsBlock);
|
||||
expect(stripeCreateCheckoutSessionStub.args[0][0].coupon).to.eql(coupon);
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import {
|
||||
generateUser,
|
||||
generateGroup,
|
||||
} from '../../../../../helpers/api-integration/v3';
|
||||
import stripePayments from '../../../../../../website/server/libs/payments/stripe';
|
||||
|
||||
describe('payments - stripe - #checkout', () => {
|
||||
const endpoint = '/stripe/checkout';
|
||||
let user; let
|
||||
group;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await generateUser();
|
||||
});
|
||||
|
||||
it('verifies credentials', async () => {
|
||||
await expect(user.post(
|
||||
`${endpoint}?gemsBlock=4gems`,
|
||||
{ id: 123 },
|
||||
)).to.eventually.be.rejected.and.include({
|
||||
code: 401,
|
||||
error: 'Error',
|
||||
// message: 'Invalid API Key provided: aaaabbbb********************1111',
|
||||
});
|
||||
});
|
||||
|
||||
describe('success', () => {
|
||||
let stripeCheckoutSubscriptionStub;
|
||||
|
||||
beforeEach(async () => {
|
||||
stripeCheckoutSubscriptionStub = sinon.stub(stripePayments, 'checkout').resolves({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stripePayments.checkout.restore();
|
||||
});
|
||||
|
||||
it('creates a user subscription', async () => {
|
||||
user = await generateUser({
|
||||
'profile.name': 'sender',
|
||||
'purchased.plan.customerId': 'customer-id',
|
||||
'purchased.plan.planId': 'basic_3mo',
|
||||
'purchased.plan.lastBillingDate': new Date(),
|
||||
balance: 2,
|
||||
});
|
||||
|
||||
await user.post(endpoint);
|
||||
|
||||
expect(stripeCheckoutSubscriptionStub).to.be.calledOnce;
|
||||
expect(stripeCheckoutSubscriptionStub.args[0][0].user._id).to.eql(user._id);
|
||||
expect(stripeCheckoutSubscriptionStub.args[0][0].groupId).to.eql(undefined);
|
||||
});
|
||||
|
||||
it('creates a group subscription', async () => {
|
||||
user = await generateUser({
|
||||
'profile.name': 'sender',
|
||||
'purchased.plan.customerId': 'customer-id',
|
||||
'purchased.plan.planId': 'basic_3mo',
|
||||
'purchased.plan.lastBillingDate': new Date(),
|
||||
balance: 2,
|
||||
});
|
||||
|
||||
group = await generateGroup(user, {
|
||||
name: 'test group',
|
||||
type: 'guild',
|
||||
privacy: 'public',
|
||||
'purchased.plan.customerId': 'customer-id',
|
||||
'purchased.plan.planId': 'basic_3mo',
|
||||
'purchased.plan.lastBillingDate': new Date(),
|
||||
});
|
||||
|
||||
await user.post(`${endpoint}?groupId=${group._id}`);
|
||||
|
||||
expect(stripeCheckoutSubscriptionStub).to.be.calledOnce;
|
||||
expect(stripeCheckoutSubscriptionStub.args[0][0].user._id).to.eql(user._id);
|
||||
expect(stripeCheckoutSubscriptionStub.args[0][0].groupId).to.eql(group._id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,79 +1,31 @@
|
||||
import {
|
||||
generateUser,
|
||||
generateGroup,
|
||||
translate as t,
|
||||
} from '../../../../../helpers/api-integration/v3';
|
||||
import stripePayments from '../../../../../../website/server/libs/payments/stripe';
|
||||
|
||||
describe('payments - stripe - #subscribeEdit', () => {
|
||||
const endpoint = '/stripe/subscribe/edit';
|
||||
let user; let
|
||||
group;
|
||||
let user; const groupId = 'groupId';
|
||||
let stripeEditSubscriptionStub;
|
||||
const sessionId = 'sessionId';
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await generateUser();
|
||||
});
|
||||
|
||||
it('verifies credentials', async () => {
|
||||
await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({
|
||||
code: 401,
|
||||
error: 'NotAuthorized',
|
||||
message: t('missingSubscription'),
|
||||
});
|
||||
});
|
||||
|
||||
describe('success', () => {
|
||||
let stripeEditSubscriptionStub;
|
||||
|
||||
beforeEach(async () => {
|
||||
stripeEditSubscriptionStub = sinon.stub(stripePayments, 'editSubscription').resolves({});
|
||||
stripeEditSubscriptionStub = sinon
|
||||
.stub(stripePayments, 'createEditCardCheckoutSession')
|
||||
.resolves({ id: sessionId });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stripePayments.editSubscription.restore();
|
||||
stripePayments.createEditCardCheckoutSession.restore();
|
||||
});
|
||||
|
||||
it('cancels a user subscription', async () => {
|
||||
user = await generateUser({
|
||||
'profile.name': 'sender',
|
||||
'purchased.plan.customerId': 'customer-id',
|
||||
'purchased.plan.planId': 'basic_3mo',
|
||||
'purchased.plan.lastBillingDate': new Date(),
|
||||
balance: 2,
|
||||
});
|
||||
|
||||
await user.post(endpoint);
|
||||
it('works', async () => {
|
||||
const res = await user.post(endpoint, { groupId });
|
||||
expect(res.sessionId).to.equal(sessionId);
|
||||
|
||||
expect(stripeEditSubscriptionStub).to.be.calledOnce;
|
||||
expect(stripeEditSubscriptionStub.args[0][0].user._id).to.eql(user._id);
|
||||
expect(stripeEditSubscriptionStub.args[0][0].groupId).to.eql(undefined);
|
||||
});
|
||||
|
||||
it('cancels a group subscription', async () => {
|
||||
user = await generateUser({
|
||||
'profile.name': 'sender',
|
||||
'purchased.plan.customerId': 'customer-id',
|
||||
'purchased.plan.planId': 'basic_3mo',
|
||||
'purchased.plan.lastBillingDate': new Date(),
|
||||
balance: 2,
|
||||
});
|
||||
|
||||
group = await generateGroup(user, {
|
||||
name: 'test group',
|
||||
type: 'guild',
|
||||
privacy: 'public',
|
||||
'purchased.plan.customerId': 'customer-id',
|
||||
'purchased.plan.planId': 'basic_3mo',
|
||||
'purchased.plan.lastBillingDate': new Date(),
|
||||
});
|
||||
|
||||
await user.post(endpoint, {
|
||||
groupId: group._id,
|
||||
});
|
||||
|
||||
expect(stripeEditSubscriptionStub).to.be.calledOnce;
|
||||
expect(stripeEditSubscriptionStub.args[0][0].user._id).to.eql(user._id);
|
||||
expect(stripeEditSubscriptionStub.args[0][0].groupId).to.eql(group._id);
|
||||
});
|
||||
expect(stripeEditSubscriptionStub.args[0][0].groupId).to.eql(groupId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
generateUser,
|
||||
} from '../../../../../helpers/api-integration/v3';
|
||||
import stripePayments from '../../../../../../website/server/libs/payments/stripe';
|
||||
|
||||
describe('payments - stripe - #handleWebhooks', () => {
|
||||
const endpoint = '/stripe/webhooks';
|
||||
let user; const body = '{"key": "val"}';
|
||||
let stripeHandleWebhooksStub;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await generateUser();
|
||||
stripeHandleWebhooksStub = sinon
|
||||
.stub(stripePayments, 'handleWebhooks')
|
||||
.resolves({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stripePayments.handleWebhooks.restore();
|
||||
});
|
||||
|
||||
it('works', async () => {
|
||||
const res = await user.post(endpoint, body);
|
||||
expect(res).to.eql({});
|
||||
|
||||
expect(stripeHandleWebhooksStub).to.be.calledOnce;
|
||||
expect(stripeHandleWebhooksStub.args[0][0].body).to.exist;
|
||||
expect(stripeHandleWebhooksStub.args[0][0].headers).to.exist;
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,7 @@
|
||||
v-if="!group.purchased.plan.dateTerminated
|
||||
&& group.purchased.plan.paymentMethod === 'Stripe'"
|
||||
class="btn btn-primary"
|
||||
@click="showStripeEdit({groupId: group.id})"
|
||||
@click="redirectToStripeEdit({groupId: group.id})"
|
||||
>
|
||||
{{ $t('subUpdateCard') }}
|
||||
</div>
|
||||
|
||||
@@ -202,7 +202,7 @@ export default {
|
||||
|
||||
this.paymentMethod = paymentMethod;
|
||||
if (this.paymentMethod === this.PAYMENTS.STRIPE) {
|
||||
this.showStripe(paymentData);
|
||||
this.redirectToStripe(paymentData);
|
||||
} else if (this.paymentMethod === this.PAYMENTS.AMAZON) {
|
||||
paymentData.type = 'subscription';
|
||||
return paymentData;
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
</div>
|
||||
<b-modal
|
||||
id="group-plan-modal"
|
||||
title="Select Payment"
|
||||
:title="activePage === PAGES.CREATE_GROUP ? 'Create your Group' : 'Select Payment'"
|
||||
size="md"
|
||||
hide-footer="hide-footer"
|
||||
>
|
||||
@@ -524,7 +524,7 @@ export default {
|
||||
}
|
||||
|
||||
if (this.paymentMethod === this.PAYMENTS.STRIPE) {
|
||||
this.showStripe(paymentData);
|
||||
this.redirectToStripe(paymentData);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
</div>
|
||||
<payments-buttons
|
||||
:disabled="!selectedGemsBlock"
|
||||
:stripe-fn="() => showStripe({ gemsBlock: selectedGemsBlock })"
|
||||
:stripe-fn="() => redirectToStripe({ gemsBlock: selectedGemsBlock })"
|
||||
:paypal-fn="() => openPaypal({
|
||||
url: paypalCheckoutLink, type: 'gems', gemsBlock: selectedGemsBlock
|
||||
})"
|
||||
|
||||
@@ -118,7 +118,9 @@
|
||||
class="form-control"
|
||||
rows="3"
|
||||
:placeholder="$t('sendGiftMessagePlaceholder')"
|
||||
:maxlength="MAX_GIFT_MESSAGE_LENGTH"
|
||||
></textarea>
|
||||
<span>{{ gift.message.length || 0 }} / {{ MAX_GIFT_MESSAGE_LENGTH }}</span>
|
||||
<!--include ../formatting-help-->
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -133,7 +135,7 @@
|
||||
<payments-buttons
|
||||
v-else
|
||||
:disabled="!gift.subscription.key && gift.gems.amount < 1"
|
||||
:stripe-fn="() => showStripe({gift, uuid: userReceivingGems._id, receiverName})"
|
||||
:stripe-fn="() => redirectToStripe({gift, uuid: userReceivingGems._id, receiverName})"
|
||||
:paypal-fn="() => openPaypalGift({
|
||||
gift: gift, giftedTo: userReceivingGems._id, receiverName,
|
||||
})"
|
||||
@@ -181,6 +183,7 @@ import planGemLimits from '@/../../common/script/libs/planGemLimits';
|
||||
import paymentsMixin from '@/mixins/payments';
|
||||
import notificationsMixin from '@/mixins/notifications';
|
||||
import paymentsButtons from '@/components/payments/buttons/list';
|
||||
import { MAX_GIFT_MESSAGE_LENGTH } from '@/../../common/script/constants';
|
||||
|
||||
// @TODO: EMAILS.TECH_ASSISTANCE_EMAIL, load from config
|
||||
const TECH_ASSISTANCE_EMAIL = 'admin@habitica.com';
|
||||
@@ -208,6 +211,7 @@ export default {
|
||||
},
|
||||
sendingInProgress: false,
|
||||
userReceivingGems: null,
|
||||
MAX_GIFT_MESSAGE_LENGTH: MAX_GIFT_MESSAGE_LENGTH.toString(),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
<button
|
||||
class="btn btn-primary btn-update-card
|
||||
d-flex justify-content-center align-items-center"
|
||||
@click="showStripeEdit()"
|
||||
@click="redirectToStripeEdit()"
|
||||
>
|
||||
<div
|
||||
v-once
|
||||
|
||||
@@ -27,7 +27,10 @@
|
||||
</b-form-group>
|
||||
<payments-buttons
|
||||
:disabled="!subscription.key"
|
||||
:stripe-fn="() => showStripe({subscription:subscription.key, coupon:subscription.coupon})"
|
||||
:stripe-fn="() => redirectToStripe({
|
||||
subscription: subscription.key,
|
||||
coupon: subscription.coupon,
|
||||
})"
|
||||
:paypal-fn="() => openPaypal({url: paypalPurchaseLink, type: 'subscription'})"
|
||||
:amazon-data="{
|
||||
type: 'subscription',
|
||||
|
||||
@@ -25,6 +25,6 @@ export function setup () { // eslint-disable-line import/prefer-default-export
|
||||
const stripeScript = document.createElement('script');
|
||||
[firstScript] = document.getElementsByTagName('script');
|
||||
stripeScript.async = true;
|
||||
stripeScript.src = '//checkout.stripe.com/v2/checkout.js';
|
||||
stripeScript.src = 'https://js.stripe.com/v3/';
|
||||
firstScript.parentNode.insertBefore(stripeScript, firstScript);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ import subscriptionBlocks from '@/../../common/script/content/subscriptionBlocks
|
||||
import { mapState } from '@/libs/store';
|
||||
import encodeParams from '@/libs/encodeParams';
|
||||
import notificationsMixin from '@/mixins/notifications';
|
||||
import * as Analytics from '@/libs/analytics';
|
||||
import { CONSTANTS, setLocalSetting } from '@/libs/userlocalManager';
|
||||
|
||||
const { STRIPE_PUB_KEY } = process.env;
|
||||
|
||||
const habiticaUrl = `${window.location.protocol}//${window.location.host}`;
|
||||
// const habiticaUrl = `${window.location.protocol}//${window.location.host}`;
|
||||
let stripeInstance = null;
|
||||
|
||||
export default {
|
||||
mixins: [notificationsMixin],
|
||||
@@ -100,7 +100,10 @@ export default {
|
||||
// Listen for changes to local storage, indicating that the payment completed
|
||||
window.addEventListener('storage', localStorageChangeHandled);
|
||||
},
|
||||
showStripe (data) {
|
||||
async redirectToStripe (data) {
|
||||
if (!stripeInstance) {
|
||||
stripeInstance = window.Stripe(STRIPE_PUB_KEY);
|
||||
}
|
||||
if (!this.checkGemAmount(data)) return;
|
||||
|
||||
let sub = false;
|
||||
@@ -113,12 +116,6 @@ export default {
|
||||
|
||||
sub = sub && subscriptionBlocks[sub];
|
||||
|
||||
let amount;
|
||||
if (data.gemsBlock) amount = data.gemsBlock.price;
|
||||
if (sub) amount = sub.price * 100;
|
||||
if (data.gift && data.gift.type === 'gems') amount = (data.gift.gems.amount / 4) * 100;
|
||||
if (data.group) amount = (sub.price + 3 * (data.group.memberCount - 1)) * 100;
|
||||
|
||||
let paymentType;
|
||||
if (sub === false && !data.gift) paymentType = 'gems';
|
||||
if (sub !== false && !data.gift) paymentType = 'subscription';
|
||||
@@ -126,45 +123,29 @@ export default {
|
||||
if (data.gift && data.gift.type === 'gems') paymentType = 'gift-gems';
|
||||
if (data.gift && data.gift.type === 'subscription') paymentType = 'gift-subscription';
|
||||
|
||||
const label = (sub && paymentType !== 'gift-subscription')
|
||||
? this.$t('subscribe')
|
||||
: this.$t('checkout');
|
||||
|
||||
window.StripeCheckout.open({
|
||||
key: STRIPE_PUB_KEY,
|
||||
address: false,
|
||||
amount,
|
||||
name: 'Habitica',
|
||||
description: label,
|
||||
// image: '/apple-touch-icon-144-precomposed.png',
|
||||
panelLabel: label,
|
||||
token: async res => {
|
||||
let url = '/stripe/checkout?a=a'; // just so I can concat &x=x below
|
||||
let url = '/stripe/checkout-session';
|
||||
const postData = {};
|
||||
|
||||
if (data.groupToCreate) {
|
||||
url = '/api/v4/groups/create-plan?a=a';
|
||||
res.groupToCreate = data.groupToCreate;
|
||||
res.paymentType = 'Stripe';
|
||||
url = '/api/v4/groups/create-plan';
|
||||
postData.groupToCreate = data.groupToCreate;
|
||||
postData.paymentType = 'Stripe';
|
||||
}
|
||||
|
||||
if (data.gemsBlock) url += `&gemsBlock=${data.gemsBlock.key}`;
|
||||
if (data.gift) url += `&gift=${this.encodeGift(data.uuid, data.gift)}`;
|
||||
if (data.subscription) url += `&sub=${sub.key}`;
|
||||
if (data.coupon) url += `&coupon=${data.coupon}`;
|
||||
if (data.groupId) url += `&groupId=${data.groupId}`;
|
||||
|
||||
const response = await axios.post(url, res);
|
||||
|
||||
// @TODO handle with normal notifications?
|
||||
const responseStatus = response.status;
|
||||
if (responseStatus >= 400) {
|
||||
window.alert(`Error: ${response.message}`); // eslint-disable-line no-alert
|
||||
return;
|
||||
if (data.gemsBlock) postData.gemsBlock = data.gemsBlock.key;
|
||||
if (data.gift) {
|
||||
data.gift.uuid = data.uuid;
|
||||
postData.gift = data.gift;
|
||||
}
|
||||
if (data.subscription) postData.sub = sub.key;
|
||||
if (data.coupon) postData.coupon = data.coupon;
|
||||
if (data.groupId) postData.groupId = data.groupId;
|
||||
|
||||
const response = await axios.post(url, postData);
|
||||
|
||||
const appState = {
|
||||
paymentMethod: 'stripe',
|
||||
paymentCompleted: true,
|
||||
paymentCompleted: false,
|
||||
paymentType,
|
||||
};
|
||||
if (paymentType === 'subscription') {
|
||||
@@ -172,12 +153,17 @@ export default {
|
||||
} else if (paymentType === 'groupPlan') {
|
||||
appState.subscriptionKey = sub.key;
|
||||
|
||||
// Handle new user signup
|
||||
if (!this.$store.state.isUserLoggedIn) {
|
||||
appState.newSignup = true;
|
||||
}
|
||||
|
||||
if (data.groupToCreate) {
|
||||
appState.newGroup = true;
|
||||
appState.group = pick(data.groupToCreate, ['_id', 'memberCount', 'name']);
|
||||
appState.group = pick(response.data.data.group, ['_id', 'memberCount', 'name', 'type']);
|
||||
} else {
|
||||
appState.newGroup = false;
|
||||
appState.group = pick(data.group, ['_id', 'memberCount', 'name']);
|
||||
appState.group = pick(data.group, ['_id', 'memberCount', 'name', 'type']);
|
||||
}
|
||||
} else if (paymentType.indexOf('gift-') === 0) {
|
||||
appState.gift = data.gift;
|
||||
@@ -186,64 +172,61 @@ export default {
|
||||
appState.gemsBlock = data.gemsBlock;
|
||||
}
|
||||
|
||||
|
||||
setLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE, JSON.stringify(appState));
|
||||
|
||||
const newGroup = response.data.data;
|
||||
if (newGroup && newGroup._id) {
|
||||
// @TODO this does not do anything as we reload just below
|
||||
// @TODO: Just append? or $emit?
|
||||
|
||||
// Handle new user signup
|
||||
if (!this.$store.state.isUserLoggedIn) {
|
||||
Analytics.track({
|
||||
hitType: 'event',
|
||||
eventCategory: 'group-plans-static',
|
||||
eventAction: 'view',
|
||||
eventLabel: 'paid-with-stripe',
|
||||
try {
|
||||
const checkoutSessionResult = await stripeInstance.redirectToCheckout({
|
||||
sessionId: response.data.data.sessionId,
|
||||
});
|
||||
|
||||
window.location.assign(`${habiticaUrl}/group-plans/${newGroup._id}/task-information?showGroupOverview=true`);
|
||||
return;
|
||||
if (checkoutSessionResult.error) {
|
||||
console.error(checkoutSessionResult.error); // eslint-disable-line
|
||||
alert(`Error while redirecting to Stripe: ${checkoutSessionResult.error.message}`);
|
||||
throw checkoutSessionResult.error;
|
||||
}
|
||||
|
||||
this.user.guilds.push(newGroup._id);
|
||||
window.location.assign(`${habiticaUrl}/group-plans/${newGroup._id}/task-information`);
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error('Error while redirecting to Stripe', err); // eslint-disable-line
|
||||
alert(`Error while redirecting to Stripe: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (data.groupId) {
|
||||
window.location.assign(`${habiticaUrl}/group-plans/${data.groupId}/task-information`);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload(true);
|
||||
},
|
||||
});
|
||||
},
|
||||
showStripeEdit (config) {
|
||||
async redirectToStripeEdit (config) {
|
||||
if (!stripeInstance) {
|
||||
stripeInstance = window.Stripe(STRIPE_PUB_KEY);
|
||||
}
|
||||
|
||||
let groupId;
|
||||
if (config && config.groupId) {
|
||||
groupId = config.groupId;
|
||||
}
|
||||
|
||||
window.StripeCheckout.open({
|
||||
key: STRIPE_PUB_KEY,
|
||||
address: false,
|
||||
name: this.$t('subUpdateTitle'),
|
||||
description: this.$t('subUpdateDescription'),
|
||||
panelLabel: this.$t('subUpdateCard'),
|
||||
token: async data => {
|
||||
data.groupId = groupId;
|
||||
const url = '/stripe/subscribe/edit';
|
||||
const response = await axios.post(url, data);
|
||||
const appState = {
|
||||
paymentMethod: 'stripe',
|
||||
isStripeEdit: true,
|
||||
paymentCompleted: false,
|
||||
paymentType: groupId ? 'groupPlan' : 'subscription',
|
||||
groupId,
|
||||
};
|
||||
|
||||
// Success
|
||||
window.location.reload(true);
|
||||
// error
|
||||
window.alert(response.message); // eslint-disable-line no-alert
|
||||
},
|
||||
const response = await axios.post('/stripe/subscribe/edit', {
|
||||
groupId,
|
||||
});
|
||||
|
||||
setLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE, JSON.stringify(appState));
|
||||
|
||||
try {
|
||||
const checkoutSessionResult = await stripeInstance.redirectToCheckout({
|
||||
sessionId: response.data.data.sessionId,
|
||||
});
|
||||
if (checkoutSessionResult.error) {
|
||||
console.error(checkoutSessionResult.error); // eslint-disable-line
|
||||
alert(`Error while redirecting to Stripe: ${checkoutSessionResult.error.message}`);
|
||||
throw checkoutSessionResult.error;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error while redirecting to Stripe', err); // eslint-disable-line
|
||||
alert(`Error while redirecting to Stripe: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
checkGemAmount (data) {
|
||||
const isGem = data && data.gift && data.gift.type === 'gems';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CONSTANTS, getLocalSetting, setLocalSetting } from '@/libs/userlocalManager';
|
||||
import * as Analytics from '@/libs/analytics';
|
||||
|
||||
export default function (to, from, next) {
|
||||
const { redirect } = to.params;
|
||||
@@ -13,9 +14,105 @@ export default function (to, from, next) {
|
||||
setLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE, JSON.stringify(newAppState));
|
||||
}
|
||||
window.close();
|
||||
break;
|
||||
return null;
|
||||
}
|
||||
case 'stripe-success-checkout': {
|
||||
const appState = getLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE);
|
||||
if (appState) {
|
||||
const newAppState = JSON.parse(appState);
|
||||
newAppState.paymentCompleted = true;
|
||||
setLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE, JSON.stringify(newAppState));
|
||||
|
||||
if (newAppState.isStripeEdit) {
|
||||
if (newAppState.paymentType === 'subscription') {
|
||||
return next({ name: 'subscription' });
|
||||
}
|
||||
|
||||
if (newAppState.paymentType === 'groupPlan') {
|
||||
return next({
|
||||
name: 'groupPlanBilling',
|
||||
params: { groupId: newAppState.groupId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newGroup = newAppState.group;
|
||||
if (newGroup && newGroup._id) {
|
||||
// Handle new user signup
|
||||
if (newAppState.newSignup === true) {
|
||||
Analytics.track({
|
||||
hitType: 'event',
|
||||
eventCategory: 'group-plans-static',
|
||||
eventAction: 'view',
|
||||
eventLabel: 'paid-with-stripe',
|
||||
});
|
||||
|
||||
return next({
|
||||
name: 'groupPlanDetailTaskInformation',
|
||||
params: { groupId: newGroup._id },
|
||||
query: { showGroupOverview: 'true' },
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
name: 'groupPlanDetailTaskInformation',
|
||||
params: { groupId: newGroup._id },
|
||||
});
|
||||
}
|
||||
}
|
||||
return next({ name: 'tasks' });
|
||||
}
|
||||
case 'stripe-error-checkout': {
|
||||
const appState = getLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE);
|
||||
if (appState) {
|
||||
const newAppState = JSON.parse(appState);
|
||||
const {
|
||||
paymentType,
|
||||
gift,
|
||||
newGroup,
|
||||
group,
|
||||
isStripeEdit,
|
||||
groupId,
|
||||
} = newAppState;
|
||||
|
||||
if (paymentType === 'subscription') {
|
||||
return next({ name: 'subscription' });
|
||||
}
|
||||
|
||||
if (paymentType === 'groupPlan') {
|
||||
if (isStripeEdit) {
|
||||
return next({
|
||||
name: 'groupPlanBilling',
|
||||
params: { groupId },
|
||||
});
|
||||
}
|
||||
|
||||
if (newGroup) {
|
||||
return next({ name: 'groupPlan' });
|
||||
}
|
||||
|
||||
if (group.type === 'party') {
|
||||
return next({
|
||||
name: 'party',
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
name: 'guild',
|
||||
params: { groupId: group._id },
|
||||
});
|
||||
}
|
||||
if (paymentType.indexOf('gift-') === 0) {
|
||||
return next({ name: 'userProfile', params: { userId: gift.uuid } });
|
||||
}
|
||||
if (paymentType === 'gems') {
|
||||
return next({ name: 'tasks', query: { openGemsModal: true } });
|
||||
}
|
||||
}
|
||||
|
||||
return next({ name: 'tasks' });
|
||||
}
|
||||
default:
|
||||
next({ name: 'notFound' });
|
||||
return next({ name: 'notFound' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +425,11 @@ router.beforeEach((to, from, next) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (to.name === 'tasks' && to.query.openGemsModal === 'true') {
|
||||
setTimeout(() => router.app.$emit('bv::show::modal', 'buy-gems'), 500);
|
||||
return next({ name: 'tasks' });
|
||||
}
|
||||
|
||||
if ((to.name === 'stats' || to.name === 'achievements' || to.name === 'profile') && from.name !== null) {
|
||||
router.app.$emit('habitica:show-profile', {
|
||||
startingPage: to.name,
|
||||
|
||||
@@ -135,6 +135,7 @@
|
||||
"sendGiftMessagePlaceholder": "Personal message (optional)",
|
||||
"sendGiftSubscription": "<%= months %> Month(s): $<%= price %> USD",
|
||||
"gemGiftsAreOptional": "Please note that Habitica will never require you to gift gems to other players. Begging people for gems is a <strong>violation of the Community Guidelines</strong>, and all such instances should be reported to <%= hrefTechAssistanceEmail %>.",
|
||||
"giftMessageTooLong": "The maximum length for gift messages is <%= maxGiftMessageLength %>.",
|
||||
"battleWithFriends": "Battle Monsters With Friends",
|
||||
"startAParty": "Start a Party",
|
||||
"partyUpName": "Party Up",
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
"namedHatchingPotion": "<%= type %> Hatching Potion",
|
||||
"buyGems": "Buy Gems",
|
||||
"purchaseGems": "Purchase Gems",
|
||||
"nGems": "<%= nGems %> Gems",
|
||||
"nGemsGift": "<%= nGems %> Gems (Gift)",
|
||||
"nMonthsSubscriptionGift": "<%= nMonths %> Month(s) Subscription (Gift)",
|
||||
"items": "Items",
|
||||
"AZ": "A-Z",
|
||||
"sort": "Sort",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"giftSubscriptionText4": "Thanks for supporting Habitica!",
|
||||
"organization": "Organization",
|
||||
"groupPlans": "Group Plans",
|
||||
"subscribe": "Subscribe",
|
||||
"nowSubscribed": "You are now subscribed to Habitica!",
|
||||
"cancelSub": "Cancel Subscription",
|
||||
"cancelSubInfoGoogle": "Please go to the \"Account\" > \"Subscriptions\" section of the Google Play Store app to cancel your subscription or to see your subscription's termination date if you have already cancelled it. This screen is not able to show you whether your subscription has been cancelled.",
|
||||
@@ -125,7 +124,6 @@
|
||||
"mysterySetwondercon": "Wondercon",
|
||||
"subUpdateCard": "Update Credit Card",
|
||||
"subUpdateTitle": "Update",
|
||||
"subUpdateDescription": "Update the card to be charged.",
|
||||
"notEnoughHourglasses": "You don't have enough Mystic Hourglasses.",
|
||||
"backgroundAlreadyOwned": "Background already owned.",
|
||||
"petsAlreadyOwned": "Pet already owned.",
|
||||
|
||||
@@ -11,6 +11,8 @@ export const MAX_SUMMARY_SIZE_FOR_CHALLENGES = 250;
|
||||
export const MIN_SHORTNAME_SIZE_FOR_CHALLENGES = 3;
|
||||
export const MAX_MESSAGE_LENGTH = 3000;
|
||||
|
||||
export const MAX_GIFT_MESSAGE_LENGTH = 200;
|
||||
|
||||
export const CHAT_FLAG_LIMIT_FOR_HIDING = 2; // hide posts that have this many flags
|
||||
export const CHAT_FLAG_FROM_MOD = 5; // a flag from a moderator counts as this many flags
|
||||
// a shadow-muted user's post starts with this many flags
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
SUPPORTED_SOCIAL_NETWORKS,
|
||||
TAVERN_ID,
|
||||
MAX_MESSAGE_LENGTH,
|
||||
MAX_GIFT_MESSAGE_LENGTH,
|
||||
} from './constants';
|
||||
import content from './content/index';
|
||||
import * as count from './count';
|
||||
@@ -111,6 +112,7 @@ api.constants = {
|
||||
CHAT_FLAG_FROM_SHADOW_MUTE,
|
||||
MINIMUM_PASSWORD_LENGTH,
|
||||
MAX_MESSAGE_LENGTH,
|
||||
MAX_GIFT_MESSAGE_LENGTH,
|
||||
};
|
||||
// TODO Move these under api.constants
|
||||
api.maxLevel = MAX_LEVEL;
|
||||
|
||||
@@ -198,8 +198,7 @@ api.createGroupPlan = {
|
||||
const results = await Promise.all([user.save(), group.save()]);
|
||||
const savedGroup = results[1];
|
||||
|
||||
// Analytics
|
||||
const analyticsObject = {
|
||||
res.analytics.track('join group', {
|
||||
uuid: user._id,
|
||||
hitType: 'event',
|
||||
category: 'behavior',
|
||||
@@ -207,27 +206,31 @@ api.createGroupPlan = {
|
||||
groupType: savedGroup.type,
|
||||
privacy: savedGroup.privacy,
|
||||
headers: req.headers,
|
||||
});
|
||||
|
||||
// do not remove chat flags data as we've just created the group
|
||||
const groupResponse = savedGroup.toJSON();
|
||||
// the leader is the authenticated user
|
||||
groupResponse.leader = {
|
||||
_id: user._id,
|
||||
profile: { name: user.profile.name },
|
||||
};
|
||||
res.analytics.track('join group', analyticsObject);
|
||||
|
||||
if (req.body.paymentType === 'Stripe') {
|
||||
const token = req.body.id;
|
||||
const gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
|
||||
const sub = req.query.sub ? common.content.subscriptionBlocks[req.query.sub] : false;
|
||||
const groupId = savedGroup._id;
|
||||
const { email } = req.body;
|
||||
const { headers } = req;
|
||||
const { coupon } = req.query;
|
||||
const {
|
||||
gift, sub: subKey, gemsBlock, coupon,
|
||||
} = req.body;
|
||||
|
||||
await stripePayments.checkout({
|
||||
token,
|
||||
user,
|
||||
gift,
|
||||
sub,
|
||||
groupId,
|
||||
email,
|
||||
headers,
|
||||
coupon,
|
||||
const sub = subKey ? common.content.subscriptionBlocks[subKey] : false;
|
||||
const groupId = savedGroup._id;
|
||||
|
||||
const session = await stripePayments.createCheckoutSession({
|
||||
user, gemsBlock, gift, sub, groupId, coupon, headers: req.headers,
|
||||
});
|
||||
|
||||
res.respond(200, {
|
||||
sessionId: session.id,
|
||||
group: groupResponse,
|
||||
});
|
||||
} else if (req.body.paymentType === 'Amazon') {
|
||||
const { billingAgreementId } = req.body;
|
||||
@@ -246,19 +249,9 @@ api.createGroupPlan = {
|
||||
groupId,
|
||||
headers,
|
||||
});
|
||||
|
||||
res.respond(201, groupResponse);
|
||||
}
|
||||
|
||||
// Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833
|
||||
// await Q.ninvoke(savedGroup, 'populate', ['leader', nameFields]);
|
||||
// doc.populate doesn't return a promise
|
||||
const response = savedGroup.toJSON();
|
||||
// the leader is the authenticated user
|
||||
response.leader = {
|
||||
_id: user._id,
|
||||
profile: { name: user.profile.name },
|
||||
};
|
||||
|
||||
res.respond(201, response); // do not remove chat flags data as we've just created the group
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -8,37 +8,37 @@ const api = {};
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /stripe/checkout Stripe checkout
|
||||
* @api {post} /stripe/checkout-session Create a Stripe Checkout Session
|
||||
* @apiName StripeCheckout
|
||||
* @apiGroup Payments
|
||||
*
|
||||
* @apiParam {String} id Body parameter - The token
|
||||
* @apiParam {String} email Body parameter - the customer email
|
||||
* @apiParam {String} gift Query parameter - stringified json object, gift
|
||||
* @apiParam {String} sub Query parameter - subscription, possible values are:
|
||||
* basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo
|
||||
* @apiParam {String} coupon Query parameter - coupon for the matching subscription,
|
||||
* required only for certain subscriptions
|
||||
* @apiParam (Body) {String} [gemsBlock] If purchasing a gem block, its key
|
||||
* @apiParam (Body) {Object} [gift] The gift object
|
||||
* @apiParam (Body) {String} [sub] If purchasing a subscription, its key
|
||||
* @apiParam (Body) {UUID} [groupId] If purchasing a group plan, the group id
|
||||
* @apiParam (Body) {String} [coupon] Subscription Coupon
|
||||
*
|
||||
* @apiSuccess {Object} data Empty object
|
||||
* @apiSuccess {String} data.sessionId The created checkout session id
|
||||
* */
|
||||
api.checkout = {
|
||||
api.createCheckoutSession = {
|
||||
method: 'POST',
|
||||
url: '/stripe/checkout',
|
||||
url: '/stripe/checkout-session',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
// @TODO: These quer params need to be changed to body
|
||||
const token = req.body.id;
|
||||
const { user } = res.locals;
|
||||
const gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
|
||||
const sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false;
|
||||
const { groupId, coupon, gemsBlock } = req.query;
|
||||
const {
|
||||
gift, sub: subKey, gemsBlock, coupon, groupId,
|
||||
} = req.body;
|
||||
|
||||
await stripePayments.checkout({
|
||||
token, user, gemsBlock, gift, sub, groupId, coupon, headers: req.headers,
|
||||
const sub = subKey ? shared.content.subscriptionBlocks[subKey] : false;
|
||||
|
||||
const session = await stripePayments.createCheckoutSession({
|
||||
user, gemsBlock, gift, sub, groupId, coupon,
|
||||
});
|
||||
|
||||
res.respond(200, {});
|
||||
res.respond(200, {
|
||||
sessionId: session.id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -48,22 +48,23 @@ api.checkout = {
|
||||
* @apiName StripeSubscribeEdit
|
||||
* @apiGroup Payments
|
||||
*
|
||||
* @apiParam {String} id Body parameter - The token
|
||||
* @apiParam (Body) {UUID} [groupId] If editing a group plan, the group id
|
||||
*
|
||||
* @apiSuccess {Object} data Empty object
|
||||
* @apiSuccess {String} data.sessionId The created checkout session id
|
||||
* */
|
||||
api.subscribeEdit = {
|
||||
method: 'POST',
|
||||
url: '/stripe/subscribe/edit',
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
const token = req.body.id;
|
||||
const { groupId } = req.body;
|
||||
const { user } = res.locals;
|
||||
|
||||
await stripePayments.editSubscription({ token, groupId, user });
|
||||
const session = await stripePayments.createEditCardCheckoutSession({ groupId, user });
|
||||
|
||||
res.respond(200, {});
|
||||
res.respond(200, {
|
||||
sessionId: session.id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -72,6 +73,9 @@ api.subscribeEdit = {
|
||||
* @api {get} /stripe/subscribe/cancel Cancel Stripe subscription
|
||||
* @apiName StripeSubscribeCancel
|
||||
* @apiGroup Payments
|
||||
*
|
||||
* @apiParam (Body) {UUID} [groupId] If editing a group plan, the group id
|
||||
*
|
||||
* */
|
||||
api.subscribeCancel = {
|
||||
method: 'GET',
|
||||
@@ -91,11 +95,20 @@ api.subscribeCancel = {
|
||||
},
|
||||
};
|
||||
|
||||
// NOTE: due to Stripe requirements on validating webhooks, the body is not json parsed
|
||||
// for this route, see website/server/middlewares/index.js
|
||||
|
||||
/**
|
||||
* @apiIgnore Payments are considered part of the private API
|
||||
* @api {post} /stripe/webhooks Stripe Webhooks handler
|
||||
* @apiName StripeHandleWebhooks
|
||||
* @apiGroup Payments
|
||||
* */
|
||||
api.handleWebhooks = {
|
||||
method: 'POST',
|
||||
url: '/stripe/webhooks',
|
||||
async handler (req, res) {
|
||||
await stripePayments.handleWebhooks({ requestBody: req.body });
|
||||
await stripePayments.handleWebhooks({ body: req.body, headers: req.headers });
|
||||
|
||||
return res.respond(200, {});
|
||||
},
|
||||
|
||||
@@ -151,6 +151,7 @@ function removeTerminatedSubscription (user) {
|
||||
_.merge(plan, {
|
||||
planId: null,
|
||||
customerId: null,
|
||||
subscriptionId: null,
|
||||
paymentMethod: null,
|
||||
});
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { // eslint-disable-line import/no-cycle
|
||||
basicFields as basicGroupFields,
|
||||
} from '../../models/group';
|
||||
import { model as Coupon } from '../../models/coupon';
|
||||
import { getGemsBlock } from './gems'; // eslint-disable-line import/no-cycle
|
||||
import { getGemsBlock, validateGiftMessage } from './gems'; // eslint-disable-line import/no-cycle
|
||||
|
||||
// TODO better handling of errors
|
||||
|
||||
@@ -117,6 +117,7 @@ api.checkout = async function checkout (options = {}) {
|
||||
|
||||
if (gift) {
|
||||
gift.member = await User.findById(gift.uuid).exec();
|
||||
validateGiftMessage(gift, user);
|
||||
|
||||
if (gift.type === this.constants.GIFT_TYPE_GEMS) {
|
||||
if (gift.gems.amount <= 0) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import moment from 'moment';
|
||||
import shared from '../../../common';
|
||||
import iap from '../inAppPurchases';
|
||||
import payments from './payments';
|
||||
import { getGemsBlock } from './gems';
|
||||
import { getGemsBlock, validateGiftMessage } from './gems';
|
||||
import {
|
||||
NotAuthorized,
|
||||
BadRequest,
|
||||
@@ -28,6 +28,7 @@ api.verifyGemPurchase = async function verifyGemPurchase (options) {
|
||||
} = options;
|
||||
|
||||
if (gift) {
|
||||
validateGiftMessage(gift, user);
|
||||
gift.member = await User.findById(gift.uuid).exec();
|
||||
}
|
||||
|
||||
@@ -232,6 +233,7 @@ api.noRenewSubscribe = async function noRenewSubscribe (options) {
|
||||
};
|
||||
|
||||
if (gift) {
|
||||
validateGiftMessage(gift, user);
|
||||
gift.member = await User.findById(gift.uuid).exec();
|
||||
gift.subscription = sub;
|
||||
data.gift = gift;
|
||||
|
||||
@@ -62,6 +62,17 @@ async function buyGemGift (data) {
|
||||
await data.gift.member.save();
|
||||
}
|
||||
|
||||
const { MAX_GIFT_MESSAGE_LENGTH } = shared.constants;
|
||||
export function validateGiftMessage (gift, user) {
|
||||
if (gift.message && gift.message.length > MAX_GIFT_MESSAGE_LENGTH) {
|
||||
throw new BadRequest(shared.i18n.t(
|
||||
'giftMessageTooLong',
|
||||
{ maxGiftMessageLength: MAX_GIFT_MESSAGE_LENGTH },
|
||||
user.preferences.language,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
export function getGemsBlock (gemsBlock) {
|
||||
const block = shared.content.gems[gemsBlock];
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '../errors';
|
||||
import { model as IapPurchaseReceipt } from '../../models/iapPurchaseReceipt';
|
||||
import { model as User } from '../../models/user';
|
||||
import { getGemsBlock } from './gems';
|
||||
import { getGemsBlock, validateGiftMessage } from './gems';
|
||||
|
||||
const api = {};
|
||||
|
||||
@@ -28,6 +28,7 @@ api.verifyGemPurchase = async function verifyGemPurchase (options) {
|
||||
|
||||
if (gift) {
|
||||
gift.member = await User.findById(gift.uuid).exec();
|
||||
validateGiftMessage(gift, user);
|
||||
}
|
||||
const receiver = gift ? gift.member : user;
|
||||
const receiverCanGetGems = await receiver.canGetGems();
|
||||
@@ -214,6 +215,7 @@ api.noRenewSubscribe = async function noRenewSubscribe (options) {
|
||||
};
|
||||
|
||||
if (gift) {
|
||||
validateGiftMessage(gift, user);
|
||||
gift.member = await User.findById(gift.uuid).exec();
|
||||
gift.subscription = sub;
|
||||
data.gift = gift;
|
||||
|
||||
@@ -8,7 +8,7 @@ import paypal from 'paypal-rest-sdk';
|
||||
import cc from 'coupon-code';
|
||||
import shared from '../../../common';
|
||||
import payments from './payments'; // eslint-disable-line import/no-cycle
|
||||
import { getGemsBlock } from './gems'; // eslint-disable-line import/no-cycle
|
||||
import { getGemsBlock, validateGiftMessage } from './gems'; // eslint-disable-line import/no-cycle
|
||||
import { model as Coupon } from '../../models/coupon';
|
||||
import { model as User } from '../../models/user'; // eslint-disable-line import/no-cycle
|
||||
import { // eslint-disable-line import/no-cycle
|
||||
@@ -87,6 +87,8 @@ api.checkout = async function checkout (options = {}) {
|
||||
const member = await User.findById(gift.uuid).exec();
|
||||
gift.member = member;
|
||||
|
||||
validateGiftMessage(gift, user);
|
||||
|
||||
if (gift.type === 'gems') {
|
||||
if (gift.gems.amount <= 0) {
|
||||
throw new BadRequest(i18n.t('badAmountOfGemsToPurchase'));
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
import moment from 'moment';
|
||||
|
||||
import logger from '../logger';
|
||||
import {
|
||||
BadRequest,
|
||||
NotAuthorized,
|
||||
NotFound,
|
||||
} from '../errors';
|
||||
import payments from './payments'; // eslint-disable-line import/no-cycle
|
||||
import { model as User } from '../../models/user'; // eslint-disable-line import/no-cycle
|
||||
import { // eslint-disable-line import/no-cycle
|
||||
model as Group,
|
||||
basicFields as basicGroupFields,
|
||||
} from '../../models/group';
|
||||
import shared from '../../../common';
|
||||
import stripeConstants from './stripe/constants';
|
||||
import { checkout } from './stripe/checkout'; // eslint-disable-line import/no-cycle
|
||||
import { getStripeApi, setStripeApi } from './stripe/api';
|
||||
|
||||
const { i18n } = shared;
|
||||
|
||||
const api = {};
|
||||
|
||||
api.constants = { ...stripeConstants };
|
||||
|
||||
api.setStripeApi = setStripeApi;
|
||||
|
||||
/**
|
||||
* Allows for purchasing a user subscription, group subscription or gems with Stripe
|
||||
*
|
||||
* @param options
|
||||
* @param options.token The stripe token generated on the front end
|
||||
* @param options.user The user object who is purchasing
|
||||
* @param options.gift The gift details if any
|
||||
* @param options.sub The subscription data to purchase
|
||||
* @param options.groupId The id of the group purchasing a subscription
|
||||
* @param options.email The email enter by the user on the Stripe form
|
||||
* @param options.headers The request headers to store on analytics
|
||||
* @return undefined
|
||||
*/
|
||||
api.checkout = checkout;
|
||||
|
||||
/**
|
||||
* Edits a subscription created by Stripe
|
||||
*
|
||||
* @param options
|
||||
* @param options.token The stripe token generated on the front end
|
||||
* @param options.user The user object who is purchasing
|
||||
* @param options.groupId The id of the group purchasing a subscription
|
||||
*
|
||||
* @return undefined
|
||||
*/
|
||||
api.editSubscription = async function editSubscription (options, stripeInc) {
|
||||
const { token, groupId, user } = options;
|
||||
let customerId;
|
||||
|
||||
// @TODO: We need to mock this, but curently we don't have correct
|
||||
// Dependency Injection. And the Stripe Api doesn't seem to be a singleton?
|
||||
let stripeApi = getStripeApi();
|
||||
if (stripeInc) stripeApi = stripeInc;
|
||||
|
||||
if (groupId) {
|
||||
const groupFields = basicGroupFields.concat(' purchased');
|
||||
const group = await Group.getGroup({
|
||||
user, groupId, populateLeader: false, groupFields,
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
throw new NotFound(i18n.t('groupNotFound'));
|
||||
}
|
||||
|
||||
const allowedManagers = [group.leader, group.purchased.plan.owner];
|
||||
|
||||
if (allowedManagers.indexOf(user._id) === -1) {
|
||||
throw new NotAuthorized(i18n.t('onlyGroupLeaderCanManageSubscription'));
|
||||
}
|
||||
customerId = group.purchased.plan.customerId;
|
||||
} else {
|
||||
customerId = user.purchased.plan.customerId;
|
||||
}
|
||||
|
||||
if (!customerId) throw new NotAuthorized(i18n.t('missingSubscription'));
|
||||
if (!token) throw new BadRequest('Missing req.body.id');
|
||||
|
||||
// @TODO: Handle Stripe Error response
|
||||
const subscriptions = await stripeApi.subscriptions.list({ customer: customerId });
|
||||
const subscriptionId = subscriptions.data[0].id;
|
||||
await stripeApi.subscriptions.update(subscriptionId, { card: token });
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancels a subscription created by Stripe
|
||||
*
|
||||
* @param options
|
||||
* @param options.user The user object who is purchasing
|
||||
* @param options.groupId The id of the group purchasing a subscription
|
||||
* @param options.cancellationReason A text string to control sending an email
|
||||
*
|
||||
* @return undefined
|
||||
*/
|
||||
api.cancelSubscription = async function cancelSubscription (options, stripeInc) {
|
||||
const { groupId, user, cancellationReason } = options;
|
||||
let customerId;
|
||||
|
||||
// @TODO: We need to mock this, but curently we don't have correct
|
||||
// Dependency Injection. And the Stripe Api doesn't seem to be a singleton?
|
||||
let stripeApi = getStripeApi();
|
||||
if (stripeInc) stripeApi = stripeInc;
|
||||
|
||||
if (groupId) {
|
||||
const groupFields = basicGroupFields.concat(' purchased');
|
||||
const group = await Group.getGroup({
|
||||
user, groupId, populateLeader: false, groupFields,
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
throw new NotFound(i18n.t('groupNotFound'));
|
||||
}
|
||||
|
||||
const allowedManagers = [group.leader, group.purchased.plan.owner];
|
||||
|
||||
if (allowedManagers.indexOf(user._id) === -1) {
|
||||
throw new NotAuthorized(i18n.t('onlyGroupLeaderCanManageSubscription'));
|
||||
}
|
||||
customerId = group.purchased.plan.customerId;
|
||||
} else {
|
||||
customerId = user.purchased.plan.customerId;
|
||||
}
|
||||
|
||||
if (!customerId) throw new NotAuthorized(i18n.t('missingSubscription'));
|
||||
|
||||
// @TODO: Handle error response
|
||||
const customer = await stripeApi.customers.retrieve(customerId).catch(err => err);
|
||||
let nextBill = moment().add(30, 'days').unix() * 1000;
|
||||
|
||||
if (customer && (customer.subscription || customer.subscriptions)) {
|
||||
let { subscription } = customer;
|
||||
if (!subscription && customer.subscriptions) {
|
||||
[subscription] = customer.subscriptions.data;
|
||||
}
|
||||
await stripeApi.customers.del(customerId);
|
||||
|
||||
if (subscription && subscription.current_period_end) {
|
||||
nextBill = subscription.current_period_end * 1000; // timestamp in seconds
|
||||
}
|
||||
}
|
||||
|
||||
await payments.cancelSubscription({
|
||||
user,
|
||||
groupId,
|
||||
nextBill,
|
||||
paymentMethod: this.constants.PAYMENT_METHOD,
|
||||
cancellationReason,
|
||||
});
|
||||
};
|
||||
|
||||
api.chargeForAdditionalGroupMember = async function chargeForAdditionalGroupMember (group) {
|
||||
const stripeApi = getStripeApi();
|
||||
const plan = shared.content.subscriptionBlocks.group_monthly;
|
||||
|
||||
await stripeApi.subscriptions.update(
|
||||
group.purchased.plan.subscriptionId,
|
||||
{
|
||||
plan: plan.key,
|
||||
quantity: group.memberCount + plan.quantity - 1,
|
||||
},
|
||||
);
|
||||
|
||||
group.purchased.plan.quantity = group.memberCount + plan.quantity - 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle webhooks from stripes
|
||||
*
|
||||
* @param options
|
||||
* @param options.user The user object who is purchasing
|
||||
* @param options.groupId The id of the group purchasing a subscription
|
||||
*
|
||||
* @return undefined
|
||||
*/
|
||||
api.handleWebhooks = async function handleWebhooks (options, stripeInc) {
|
||||
const { requestBody } = options;
|
||||
|
||||
// @TODO: We need to mock this, but curently we don't have correct
|
||||
// Dependency Injection. And the Stripe Api doesn't seem to be a singleton?
|
||||
let stripeApi = getStripeApi();
|
||||
if (stripeInc) stripeApi = stripeInc;
|
||||
|
||||
// Verify the event by fetching it from Stripe
|
||||
const event = await stripeApi.events.retrieve(requestBody.id);
|
||||
|
||||
switch (event.type) {
|
||||
case 'customer.subscription.deleted': {
|
||||
// event.request !== null means that the user itself cancelled the subscrioption,
|
||||
// the cancellation on our side has been already handled
|
||||
if (event.request !== null) break;
|
||||
|
||||
const subscription = event.data.object;
|
||||
const customerId = subscription.customer;
|
||||
const isGroupSub = shared.content.subscriptionBlocks[subscription.plan.id].target === 'group';
|
||||
|
||||
let user;
|
||||
let groupId;
|
||||
|
||||
if (isGroupSub) {
|
||||
const groupFields = basicGroupFields.concat(' purchased');
|
||||
const group = await Group.findOne({
|
||||
'purchased.plan.customerId': customerId,
|
||||
'purchased.plan.paymentMethod': this.constants.PAYMENT_METHOD,
|
||||
}).select(groupFields).exec();
|
||||
|
||||
if (!group) throw new NotFound(i18n.t('groupNotFound'));
|
||||
groupId = group._id;
|
||||
|
||||
user = await User.findById(group.leader).exec();
|
||||
} else {
|
||||
user = await User.findOne({
|
||||
'purchased.plan.customerId': customerId,
|
||||
'purchased.plan.paymentMethod': this.constants.PAYMENT_METHOD,
|
||||
}).exec();
|
||||
}
|
||||
|
||||
if (!user) throw new NotFound(i18n.t('userNotFound'));
|
||||
|
||||
await stripeApi.customers.del(customerId);
|
||||
|
||||
await payments.cancelSubscription({
|
||||
user,
|
||||
groupId,
|
||||
paymentMethod: this.constants.PAYMENT_METHOD,
|
||||
// Give three extra days to allow the user to resubscribe without losing benefits
|
||||
nextBill: moment().add({ days: 3 }).toDate(),
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
logger.error(new Error(`Missing handler for Stripe webhook ${event.type}`), { event });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default api;
|
||||
@@ -1,7 +1,9 @@
|
||||
import stripeModule from 'stripe';
|
||||
import nconf from 'nconf';
|
||||
|
||||
let stripe = stripeModule(nconf.get('STRIPE_API_KEY'));
|
||||
let stripe = stripeModule(nconf.get('STRIPE_API_KEY'), {
|
||||
apiVersion: '2020-08-27',
|
||||
});
|
||||
|
||||
function setStripeApi (stripeInc) {
|
||||
stripe = stripeInc;
|
||||
|
||||
@@ -1,161 +1,187 @@
|
||||
import cc from 'coupon-code';
|
||||
import nconf from 'nconf';
|
||||
|
||||
import { getStripeApi } from './api';
|
||||
import { model as User } from '../../../models/user'; // eslint-disable-line import/no-cycle
|
||||
import { model as Coupon } from '../../../models/coupon';
|
||||
import {
|
||||
NotAuthorized,
|
||||
NotFound,
|
||||
} from '../../errors';
|
||||
import { // eslint-disable-line import/no-cycle
|
||||
model as Group,
|
||||
basicFields as basicGroupFields,
|
||||
} from '../../../models/group';
|
||||
import shared from '../../../../common';
|
||||
import {
|
||||
BadRequest,
|
||||
NotAuthorized,
|
||||
} from '../../errors';
|
||||
import payments from '../payments'; // eslint-disable-line import/no-cycle
|
||||
import { getGemsBlock } from '../gems'; // eslint-disable-line import/no-cycle
|
||||
import stripeConstants from './constants';
|
||||
import { getOneTimePaymentInfo } from './oneTimePayments'; // eslint-disable-line import/no-cycle
|
||||
import { checkSubData } from './subscriptions'; // eslint-disable-line import/no-cycle
|
||||
import { validateGiftMessage } from '../gems'; // eslint-disable-line import/no-cycle
|
||||
|
||||
function getGiftAmount (gift) {
|
||||
if (gift.type === 'subscription') {
|
||||
return `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`;
|
||||
}
|
||||
const BASE_URL = nconf.get('BASE_URL');
|
||||
|
||||
if (gift.gems.amount <= 0) {
|
||||
throw new BadRequest(shared.i18n.t('badAmountOfGemsToPurchase'));
|
||||
}
|
||||
|
||||
return `${(gift.gems.amount / 4) * 100}`;
|
||||
}
|
||||
|
||||
async function buyGems (gemsBlock, gift, user, token, stripeApi) {
|
||||
let amount;
|
||||
|
||||
if (gift) {
|
||||
amount = getGiftAmount(gift);
|
||||
} else {
|
||||
amount = gemsBlock.price;
|
||||
}
|
||||
|
||||
if (!gift || gift.type === 'gems') {
|
||||
const receiver = gift ? gift.member : user;
|
||||
const receiverCanGetGems = await receiver.canGetGems();
|
||||
if (!receiverCanGetGems) throw new NotAuthorized(shared.i18n.t('groupPolicyCannotGetGems', receiver.preferences.language));
|
||||
}
|
||||
|
||||
const response = await stripeApi.charges.create({
|
||||
amount,
|
||||
currency: 'usd',
|
||||
card: token,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function buySubscription (sub, coupon, email, user, token, groupId, stripeApi) {
|
||||
if (sub.discount) {
|
||||
if (!coupon) throw new BadRequest(shared.i18n.t('couponCodeRequired'));
|
||||
coupon = await Coupon // eslint-disable-line no-param-reassign
|
||||
.findOne({ _id: cc.validate(coupon), event: sub.key }).exec();
|
||||
if (!coupon) throw new BadRequest(shared.i18n.t('invalidCoupon'));
|
||||
}
|
||||
|
||||
const customerObject = {
|
||||
email,
|
||||
metadata: { uuid: user._id },
|
||||
card: token,
|
||||
plan: sub.key,
|
||||
};
|
||||
|
||||
if (groupId) {
|
||||
customerObject.quantity = sub.quantity;
|
||||
const groupFields = basicGroupFields.concat(' purchased');
|
||||
const group = await Group.getGroup({
|
||||
user, groupId, populateLeader: false, groupFields,
|
||||
});
|
||||
const membersCount = await group.getMemberCount();
|
||||
customerObject.quantity = membersCount + sub.quantity - 1;
|
||||
}
|
||||
|
||||
const response = await stripeApi.customers.create(customerObject);
|
||||
|
||||
let subscriptionId;
|
||||
if (groupId) subscriptionId = response.subscriptions.data[0].id;
|
||||
|
||||
return { subResponse: response, subId: subscriptionId };
|
||||
}
|
||||
|
||||
async function applyGemPayment (user, response, gemsBlock, gift) {
|
||||
let method = 'buyGems';
|
||||
const data = {
|
||||
user,
|
||||
customerId: response.id,
|
||||
paymentMethod: stripeConstants.PAYMENT_METHOD,
|
||||
gemsBlock,
|
||||
gift,
|
||||
};
|
||||
|
||||
if (gift) {
|
||||
if (gift.type === 'subscription') method = 'createSubscription';
|
||||
data.paymentMethod = 'Gift';
|
||||
}
|
||||
|
||||
await payments[method](data);
|
||||
}
|
||||
|
||||
export async function checkout (options, stripeInc) {
|
||||
export async function createCheckoutSession (options, stripeInc) {
|
||||
const {
|
||||
token,
|
||||
user,
|
||||
gift,
|
||||
gemsBlock,
|
||||
gemsBlock: gemsBlockKey,
|
||||
sub,
|
||||
groupId,
|
||||
email,
|
||||
headers,
|
||||
coupon,
|
||||
} = options;
|
||||
let response;
|
||||
let subscriptionId;
|
||||
|
||||
// @TODO: We need to mock this, but curently we don't have correct
|
||||
// Dependency Injection. And the Stripe Api doesn't seem to be a singleton?
|
||||
let stripeApi = getStripeApi();
|
||||
if (stripeInc) stripeApi = stripeInc;
|
||||
|
||||
if (!token) throw new BadRequest('Missing req.body.id');
|
||||
let type = 'gems';
|
||||
if (gift) {
|
||||
type = gift.type === 'gems' ? 'gift-gems' : 'gift-sub';
|
||||
validateGiftMessage(gift, user);
|
||||
} else if (sub) {
|
||||
type = 'subscription';
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
type,
|
||||
userId: user._id,
|
||||
gift: gift ? JSON.stringify(gift) : undefined,
|
||||
sub: sub ? JSON.stringify(sub) : undefined,
|
||||
};
|
||||
|
||||
let lineItems;
|
||||
|
||||
if (type === 'subscription') {
|
||||
let quantity = 1;
|
||||
|
||||
if (groupId) {
|
||||
quantity = sub.quantity;
|
||||
const groupFields = basicGroupFields.concat(' purchased');
|
||||
const group = await Group.getGroup({
|
||||
user, groupId, populateLeader: false, groupFields,
|
||||
});
|
||||
if (!group) {
|
||||
throw new NotFound(shared.i18n.t('groupNotFound', user.preferences.language));
|
||||
}
|
||||
const membersCount = await group.getMemberCount();
|
||||
quantity = membersCount + sub.quantity - 1;
|
||||
metadata.groupId = groupId;
|
||||
}
|
||||
|
||||
await checkSubData(sub, Boolean(groupId), coupon);
|
||||
|
||||
lineItems = [{
|
||||
price: sub.key,
|
||||
quantity,
|
||||
}];
|
||||
} else {
|
||||
const {
|
||||
amount,
|
||||
gemsBlock,
|
||||
subscription,
|
||||
} = await getOneTimePaymentInfo(gemsBlockKey, gift, user);
|
||||
|
||||
metadata.gemsBlock = gemsBlock ? gemsBlock.key : undefined;
|
||||
|
||||
let productName;
|
||||
|
||||
if (gift) {
|
||||
const member = await User.findById(gift.uuid).exec();
|
||||
gift.member = member;
|
||||
}
|
||||
|
||||
let block;
|
||||
if (!sub && !gift) {
|
||||
block = getGemsBlock(gemsBlock);
|
||||
}
|
||||
|
||||
if (sub) {
|
||||
const { subId, subResponse } = await buySubscription(
|
||||
sub, coupon, email, user, token, groupId, stripeApi,
|
||||
);
|
||||
subscriptionId = subId;
|
||||
response = subResponse;
|
||||
if (gift.type === 'subscription') {
|
||||
productName = shared.i18n.t('nMonthsSubscriptionGift', { nMonths: subscription.months }, user.preferences.language);
|
||||
} else {
|
||||
response = await buyGems(block, gift, user, token, stripeApi);
|
||||
productName = shared.i18n.t('nGemsGift', { nGems: gift.gems.amount }, user.preferences.language);
|
||||
}
|
||||
} else {
|
||||
productName = shared.i18n.t('nGems', { nGems: gemsBlock.gems }, user.preferences.language);
|
||||
}
|
||||
|
||||
if (sub) {
|
||||
await payments.createSubscription({
|
||||
user,
|
||||
customerId: response.id,
|
||||
paymentMethod: this.constants.PAYMENT_METHOD,
|
||||
sub,
|
||||
headers,
|
||||
groupId,
|
||||
subscriptionId,
|
||||
lineItems = [{
|
||||
price_data: {
|
||||
product_data: {
|
||||
name: productName,
|
||||
},
|
||||
unit_amount: amount,
|
||||
currency: 'usd',
|
||||
},
|
||||
quantity: 1,
|
||||
}];
|
||||
}
|
||||
|
||||
const session = await stripeApi.checkout.sessions.create({
|
||||
payment_method_types: ['card'],
|
||||
metadata,
|
||||
line_items: lineItems,
|
||||
mode: type === 'subscription' ? 'subscription' : 'payment',
|
||||
success_url: `${BASE_URL}/redirect/stripe-success-checkout`,
|
||||
cancel_url: `${BASE_URL}/redirect/stripe-error-checkout`,
|
||||
});
|
||||
} else {
|
||||
await applyGemPayment(user, response, block, gift);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function createEditCardCheckoutSession (options, stripeInc) {
|
||||
const {
|
||||
user,
|
||||
groupId,
|
||||
} = options;
|
||||
|
||||
// @TODO: We need to mock this, but curently we don't have correct
|
||||
// Dependency Injection. And the Stripe Api doesn't seem to be a singleton?
|
||||
let stripeApi = getStripeApi();
|
||||
if (stripeInc) stripeApi = stripeInc;
|
||||
|
||||
const type = groupId ? 'edit-card-group' : 'edit-card-user';
|
||||
|
||||
const metadata = {
|
||||
type,
|
||||
userId: user._id,
|
||||
};
|
||||
|
||||
let customerId;
|
||||
let subscriptionId;
|
||||
|
||||
if (type === 'edit-card-group') {
|
||||
const groupFields = basicGroupFields.concat(' purchased');
|
||||
const group = await Group.getGroup({
|
||||
user, groupId, populateLeader: false, groupFields,
|
||||
});
|
||||
if (!group) {
|
||||
throw new NotFound(shared.i18n.t('groupNotFound', user.preferences.language));
|
||||
}
|
||||
|
||||
const allowedManagers = [group.leader, group.purchased.plan.owner];
|
||||
|
||||
if (allowedManagers.indexOf(user._id) === -1) {
|
||||
throw new NotAuthorized(shared.i18n.t('onlyGroupLeaderCanManageSubscription', user.preferences.language));
|
||||
}
|
||||
metadata.groupId = groupId;
|
||||
customerId = group.purchased.plan.customerId;
|
||||
subscriptionId = group.purchased.plan.subscriptionId;
|
||||
} else {
|
||||
customerId = user.purchased.plan.customerId;
|
||||
subscriptionId = user.purchased.plan.subscriptionId;
|
||||
}
|
||||
|
||||
if (!customerId) throw new NotAuthorized(shared.i18n.t('missingSubscription', user.preferences.language));
|
||||
|
||||
if (!subscriptionId) {
|
||||
const subscriptions = await stripeApi.subscriptions.list({ customer: customerId });
|
||||
subscriptionId = subscriptions.data[0] && subscriptions.data[0].id;
|
||||
}
|
||||
|
||||
if (!subscriptionId) throw new NotAuthorized(shared.i18n.t('missingSubscription', user.preferences.language));
|
||||
|
||||
const session = await stripeApi.checkout.sessions.create({
|
||||
mode: 'setup',
|
||||
payment_method_types: ['card'],
|
||||
metadata,
|
||||
customer: customerId,
|
||||
setup_intent_data: {
|
||||
metadata: {
|
||||
customer_id: customerId,
|
||||
subscription_id: subscriptionId,
|
||||
},
|
||||
},
|
||||
success_url: `${BASE_URL}/redirect/stripe-success-checkout`,
|
||||
cancel_url: `${BASE_URL}/redirect/stripe-error-checkout`,
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
77
website/server/libs/payments/stripe/index.js
Normal file
77
website/server/libs/payments/stripe/index.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import stripeConstants from './constants';
|
||||
import { handleWebhooks } from './webhooks'; // eslint-disable-line import/no-cycle
|
||||
import { // eslint-disable-line import/no-cycle
|
||||
createCheckoutSession,
|
||||
createEditCardCheckoutSession,
|
||||
} from './checkout';
|
||||
import { // eslint-disable-line import/no-cycle
|
||||
chargeForAdditionalGroupMember,
|
||||
cancelSubscription,
|
||||
} from './subscriptions';
|
||||
import { setStripeApi } from './api';
|
||||
|
||||
const api = {};
|
||||
|
||||
api.constants = { ...stripeConstants };
|
||||
|
||||
api.setStripeApi = setStripeApi;
|
||||
|
||||
/**
|
||||
* Allows for purchasing a user subscription, group subscription or gems with Stripe
|
||||
*
|
||||
* @param options
|
||||
* @param options.user The user object who is purchasing
|
||||
* @param options.gift The gift details if any
|
||||
* @param options.gift The gem block object, if any
|
||||
* @param options.sub The subscription data to purchase
|
||||
* @param options.groupId The id of the group purchasing a subscription
|
||||
* @param options.coupon The coupon code, if any
|
||||
*
|
||||
* @return undefined
|
||||
*/
|
||||
api.createCheckoutSession = createCheckoutSession;
|
||||
|
||||
/**
|
||||
* Create a Stripe checkout session to edit a subscription payment method
|
||||
*
|
||||
* @param options
|
||||
* @param options.user The user object who is making the request
|
||||
* @param options.groupId If editing a group plan, its id
|
||||
*
|
||||
* @return undefined
|
||||
*/
|
||||
api.createEditCardCheckoutSession = createEditCardCheckoutSession;
|
||||
|
||||
/**
|
||||
* Cancels a subscription created by Stripe
|
||||
*
|
||||
* @param options
|
||||
* @param options.user The user object who is purchasing
|
||||
* @param options.groupId The id of the group purchasing a subscription
|
||||
* @param options.cancellationReason A text string to control sending an email
|
||||
*
|
||||
* @return undefined
|
||||
*/
|
||||
api.cancelSubscription = cancelSubscription;
|
||||
|
||||
/**
|
||||
* Update the quantity for a group plan subscription on Stripe
|
||||
*
|
||||
* @param grouo The affected group object
|
||||
*
|
||||
* @return undefined
|
||||
*/
|
||||
api.chargeForAdditionalGroupMember = chargeForAdditionalGroupMember;
|
||||
|
||||
/**
|
||||
* Handle webhooks from stripes
|
||||
*
|
||||
* @param options
|
||||
* @param options.body The raw request body
|
||||
* @param options.groupId The request's headers
|
||||
*
|
||||
* @return undefined
|
||||
*/
|
||||
api.handleWebhooks = handleWebhooks;
|
||||
|
||||
export default api;
|
||||
97
website/server/libs/payments/stripe/oneTimePayments.js
Normal file
97
website/server/libs/payments/stripe/oneTimePayments.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import payments from '../payments'; // eslint-disable-line import/no-cycle
|
||||
import {
|
||||
BadRequest,
|
||||
NotAuthorized,
|
||||
NotFound,
|
||||
} from '../../errors';
|
||||
import stripeConstants from './constants';
|
||||
import shared from '../../../../common';
|
||||
import { getGemsBlock } from '../gems'; // eslint-disable-line import/no-cycle
|
||||
import { checkSubData } from './subscriptions'; // eslint-disable-line import/no-cycle
|
||||
import { model as User } from '../../../models/user'; // eslint-disable-line import/no-cycle
|
||||
|
||||
function getGiftAmount (gift) {
|
||||
if (gift.type === 'subscription') {
|
||||
return `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`;
|
||||
}
|
||||
|
||||
if (gift.gems.amount <= 0) {
|
||||
throw new BadRequest(shared.i18n.t('badAmountOfGemsToPurchase'));
|
||||
}
|
||||
|
||||
return `${(gift.gems.amount / 4) * 100}`;
|
||||
}
|
||||
|
||||
export async function getOneTimePaymentInfo (gemsBlockKey, gift, user) {
|
||||
let receiver = user;
|
||||
|
||||
if (gift) {
|
||||
const member = await User.findById(gift.uuid).exec();
|
||||
if (!member) {
|
||||
throw new NotFound(shared.i18n.t(
|
||||
'userWithIDNotFound', { userId: gift.uuid }, user.preferences.language,
|
||||
));
|
||||
}
|
||||
receiver = member;
|
||||
}
|
||||
|
||||
let amount;
|
||||
let gemsBlock = null;
|
||||
let subscription = null;
|
||||
|
||||
if (gift) {
|
||||
amount = getGiftAmount(gift);
|
||||
|
||||
if (gift.type === 'subscription') {
|
||||
subscription = shared.content.subscriptionBlocks[gift.subscription.key];
|
||||
await checkSubData(subscription, false, null);
|
||||
}
|
||||
} else {
|
||||
gemsBlock = getGemsBlock(gemsBlockKey);
|
||||
amount = gemsBlock.price;
|
||||
}
|
||||
|
||||
if (!gift || gift.type === 'gems') {
|
||||
const receiverCanGetGems = await receiver.canGetGems();
|
||||
if (!receiverCanGetGems) throw new NotAuthorized(shared.i18n.t('groupPolicyCannotGetGems', receiver.preferences.language));
|
||||
}
|
||||
|
||||
return {
|
||||
amount,
|
||||
gemsBlock,
|
||||
subscription,
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyGemPayment (session) {
|
||||
const { metadata, customer: customerId } = session;
|
||||
const { gemsBlock: gemsBlockKey, gift: giftStringified, userId } = metadata;
|
||||
|
||||
const gemsBlock = gemsBlockKey ? getGemsBlock(gemsBlockKey) : undefined;
|
||||
const gift = giftStringified ? JSON.parse(giftStringified) : undefined;
|
||||
|
||||
const user = await User.findById(metadata.userId).exec();
|
||||
if (!user) throw new NotFound(shared.i18n.t('userWithIDNotFound', { userId }));
|
||||
|
||||
let method = 'buyGems';
|
||||
const data = {
|
||||
user,
|
||||
customerId,
|
||||
paymentMethod: stripeConstants.PAYMENT_METHOD,
|
||||
gemsBlock,
|
||||
gift,
|
||||
};
|
||||
|
||||
if (gift) {
|
||||
if (gift.type === 'subscription') method = 'createSubscription';
|
||||
data.paymentMethod = 'Gift';
|
||||
|
||||
const member = await User.findById(gift.uuid).exec();
|
||||
if (!member) {
|
||||
throw new NotFound(shared.i18n.t('userWithIDNotFound', { userId: gift.uuid }));
|
||||
}
|
||||
gift.member = member;
|
||||
}
|
||||
|
||||
await payments[method](data);
|
||||
}
|
||||
147
website/server/libs/payments/stripe/subscriptions.js
Normal file
147
website/server/libs/payments/stripe/subscriptions.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import cc from 'coupon-code';
|
||||
import moment from 'moment';
|
||||
|
||||
import logger from '../../logger';
|
||||
import { model as Coupon } from '../../../models/coupon';
|
||||
import shared from '../../../../common';
|
||||
import payments from '../payments'; // eslint-disable-line import/no-cycle
|
||||
import stripeConstants from './constants';
|
||||
import { model as User } from '../../../models/user'; // eslint-disable-line import/no-cycle
|
||||
import { getStripeApi } from './api';
|
||||
import { // eslint-disable-line import/no-cycle
|
||||
model as Group,
|
||||
basicFields as basicGroupFields,
|
||||
} from '../../../models/group';
|
||||
import {
|
||||
NotAuthorized,
|
||||
BadRequest,
|
||||
NotFound,
|
||||
} from '../../errors';
|
||||
|
||||
export async function checkSubData (sub, isGroup = false, coupon) {
|
||||
if (!sub || !sub.canSubscribe) throw new BadRequest(shared.i18n.t('missingSubscriptionCode'));
|
||||
if (
|
||||
(sub.target === 'group' && !isGroup)
|
||||
|| (sub.target === 'user' && isGroup)
|
||||
) throw new BadRequest(shared.i18n.t('missingSubscriptionCode'));
|
||||
|
||||
if (sub.discount) {
|
||||
if (!coupon) throw new BadRequest(shared.i18n.t('couponCodeRequired'));
|
||||
coupon = await Coupon // eslint-disable-line no-param-reassign
|
||||
.findOne({ _id: cc.validate(coupon), event: sub.key }).exec();
|
||||
if (!coupon) throw new BadRequest(shared.i18n.t('invalidCoupon'));
|
||||
}
|
||||
}
|
||||
|
||||
export async function applySubscription (session) {
|
||||
const { metadata, customer: customerId, subscription: subscriptionId } = session;
|
||||
const { sub: subStringified, userId, groupId } = metadata;
|
||||
|
||||
const sub = subStringified ? JSON.parse(subStringified) : undefined;
|
||||
|
||||
const user = await User.findById(metadata.userId).exec();
|
||||
if (!user) throw new NotFound(shared.i18n.t('userWithIDNotFound', { userId }));
|
||||
|
||||
await payments.createSubscription({
|
||||
user,
|
||||
customerId,
|
||||
paymentMethod: stripeConstants.PAYMENT_METHOD,
|
||||
sub,
|
||||
groupId,
|
||||
subscriptionId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handlePaymentMethodChange (session, stripeInc) {
|
||||
// @TODO: We need to mock this, but curently we don't have correct
|
||||
// Dependency Injection. And the Stripe Api doesn't seem to be a singleton?
|
||||
let stripeApi = getStripeApi();
|
||||
if (stripeInc) stripeApi = stripeInc;
|
||||
|
||||
const { setup_intent: setupIntent } = session;
|
||||
|
||||
const intent = await stripeApi.setupIntents.retrieve(setupIntent);
|
||||
const { payment_method: paymentMethodId } = intent;
|
||||
const subscriptionId = intent.metadata.subscription_id;
|
||||
|
||||
// Update the payment method on the subscription
|
||||
await stripeApi.subscriptions.update(subscriptionId, {
|
||||
default_payment_method: paymentMethodId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function chargeForAdditionalGroupMember (group, stripeInc) {
|
||||
// @TODO: We need to mock this, but curently we don't have correct
|
||||
// Dependency Injection. And the Stripe Api doesn't seem to be a singleton?
|
||||
let stripeApi = getStripeApi();
|
||||
if (stripeInc) stripeApi = stripeInc;
|
||||
|
||||
const plan = shared.content.subscriptionBlocks.group_monthly;
|
||||
|
||||
await stripeApi.subscriptions.update(
|
||||
group.purchased.plan.subscriptionId,
|
||||
{
|
||||
plan: plan.key,
|
||||
quantity: group.memberCount + plan.quantity - 1,
|
||||
},
|
||||
);
|
||||
|
||||
group.purchased.plan.quantity = group.memberCount + plan.quantity - 1;
|
||||
}
|
||||
|
||||
export async function cancelSubscription (options, stripeInc) {
|
||||
const { groupId, user, cancellationReason } = options;
|
||||
let customerId;
|
||||
|
||||
// @TODO: We need to mock this, but curently we don't have correct
|
||||
// Dependency Injection. And the Stripe Api doesn't seem to be a singleton?
|
||||
let stripeApi = getStripeApi();
|
||||
if (stripeInc) stripeApi = stripeInc;
|
||||
|
||||
if (groupId) {
|
||||
const groupFields = basicGroupFields.concat(' purchased');
|
||||
const group = await Group.getGroup({
|
||||
user, groupId, populateLeader: false, groupFields,
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
throw new NotFound(shared.i18n.t('groupNotFound'));
|
||||
}
|
||||
|
||||
const allowedManagers = [group.leader, group.purchased.plan.owner];
|
||||
|
||||
if (allowedManagers.indexOf(user._id) === -1) {
|
||||
throw new NotAuthorized(shared.i18n.t('onlyGroupLeaderCanManageSubscription'));
|
||||
}
|
||||
customerId = group.purchased.plan.customerId;
|
||||
} else {
|
||||
customerId = user.purchased.plan.customerId;
|
||||
}
|
||||
|
||||
if (!customerId) throw new NotAuthorized(shared.i18n.t('missingSubscription'));
|
||||
|
||||
const customer = await stripeApi
|
||||
.customers.retrieve(customerId, { expand: ['subscriptions'] })
|
||||
.catch(err => logger.error(err, 'Error retrieving customer from Stripe (was likely deleted).'));
|
||||
let nextBill = moment().add(30, 'days').unix() * 1000;
|
||||
|
||||
if (customer && (customer.subscription || customer.subscriptions)) {
|
||||
let { subscription } = customer;
|
||||
if (!subscription && customer.subscriptions) {
|
||||
[subscription] = customer.subscriptions.data;
|
||||
}
|
||||
await stripeApi.customers.del(customerId);
|
||||
|
||||
if (subscription && subscription.current_period_end) {
|
||||
nextBill = subscription.current_period_end * 1000; // timestamp in seconds
|
||||
}
|
||||
}
|
||||
|
||||
await payments.cancelSubscription({
|
||||
user,
|
||||
groupId,
|
||||
nextBill,
|
||||
paymentMethod: this.constants.PAYMENT_METHOD,
|
||||
cancellationReason,
|
||||
});
|
||||
}
|
||||
132
website/server/libs/payments/stripe/webhooks.js
Normal file
132
website/server/libs/payments/stripe/webhooks.js
Normal file
@@ -0,0 +1,132 @@
|
||||
import nconf from 'nconf';
|
||||
import moment from 'moment';
|
||||
|
||||
import logger from '../../logger';
|
||||
import { model as User } from '../../../models/user'; // eslint-disable-line import/no-cycle
|
||||
import { getStripeApi } from './api';
|
||||
import {
|
||||
BadRequest,
|
||||
NotFound,
|
||||
} from '../../errors';
|
||||
import payments from '../payments'; // eslint-disable-line import/no-cycle
|
||||
import { // eslint-disable-line import/no-cycle
|
||||
model as Group,
|
||||
basicFields as basicGroupFields,
|
||||
} from '../../../models/group';
|
||||
import shared from '../../../../common';
|
||||
import { applyGemPayment } from './oneTimePayments'; // eslint-disable-line import/no-cycle
|
||||
import { applySubscription, handlePaymentMethodChange } from './subscriptions'; // eslint-disable-line import/no-cycle
|
||||
|
||||
const endpointSecret = nconf.get('STRIPE_WEBHOOKS_ENDPOINT_SECRET');
|
||||
|
||||
export async function handleWebhooks (options, stripeInc) {
|
||||
const { body, headers } = options;
|
||||
|
||||
// @TODO: We need to mock this, but curently we don't have correct
|
||||
// Dependency Injection. And the Stripe Api doesn't seem to be a singleton?
|
||||
let stripeApi = getStripeApi();
|
||||
if (stripeInc) stripeApi = stripeInc;
|
||||
|
||||
let event;
|
||||
|
||||
try {
|
||||
// Verify the event by fetching it from Stripe
|
||||
event = stripeApi.webhooks.constructEvent(body, headers['stripe-signature'], endpointSecret);
|
||||
} catch (err) {
|
||||
logger.error(new Error('Error verifying Stripe webhook'), { err });
|
||||
throw new BadRequest(`Webhook Error: ${err.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'payment_intent.created':
|
||||
case 'payment_intent.succeeded':
|
||||
case 'payment_intent.payment_failed':
|
||||
case 'setup_intent.created':
|
||||
case 'setup_intent.succeeded':
|
||||
case 'charge.succeeded':
|
||||
case 'charge.failed':
|
||||
case 'payment_method.attached':
|
||||
case 'customer.created':
|
||||
case 'customer.updated':
|
||||
case 'customer.deleted':
|
||||
case 'customer.subscription.created':
|
||||
case 'customer.subscription.updated':
|
||||
case 'invoiceitem.created':
|
||||
case 'invoice.created':
|
||||
case 'invoice.updated':
|
||||
case 'invoice.finalized':
|
||||
case 'invoice.paid':
|
||||
case 'invoice.upcoming':
|
||||
case 'invoice.payment_succeeded': {
|
||||
// Events sent even if not active in the Stripe dashboard when a payment is being made
|
||||
// This is to avoid error logs from the webhook handler not being implemented
|
||||
break;
|
||||
}
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object;
|
||||
const { metadata } = session;
|
||||
|
||||
if (metadata.type === 'edit-card-group' || metadata.type === 'edit-card-user') {
|
||||
await handlePaymentMethodChange(session);
|
||||
} else if (metadata.type !== 'subscription') {
|
||||
await applyGemPayment(session);
|
||||
} else {
|
||||
await applySubscription(session);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'customer.subscription.deleted': {
|
||||
// event.request !== null means that the user itself cancelled the subscrioption,
|
||||
// the cancellation on our side has been already handled
|
||||
if (event.request !== null) break;
|
||||
|
||||
const subscription = event.data.object;
|
||||
const customerId = subscription.customer;
|
||||
const isGroupSub = shared.content.subscriptionBlocks[subscription.plan.id].target === 'group';
|
||||
|
||||
let user;
|
||||
let groupId;
|
||||
|
||||
if (isGroupSub) {
|
||||
const groupFields = basicGroupFields.concat(' purchased');
|
||||
const group = await Group.findOne({
|
||||
'purchased.plan.customerId': customerId,
|
||||
'purchased.plan.paymentMethod': this.constants.PAYMENT_METHOD,
|
||||
}).select(groupFields).exec();
|
||||
|
||||
if (!group) throw new NotFound(shared.i18n.t('groupNotFound'));
|
||||
groupId = group._id;
|
||||
|
||||
user = await User.findById(group.leader).exec();
|
||||
} else {
|
||||
user = await User.findOne({
|
||||
'purchased.plan.customerId': customerId,
|
||||
'purchased.plan.paymentMethod': this.constants.PAYMENT_METHOD,
|
||||
}).exec();
|
||||
}
|
||||
|
||||
if (!user) throw new NotFound(shared.i18n.t('userNotFound'));
|
||||
|
||||
await stripeApi.customers.del(customerId);
|
||||
|
||||
await payments.cancelSubscription({
|
||||
user,
|
||||
groupId,
|
||||
paymentMethod: this.constants.PAYMENT_METHOD,
|
||||
// Give three extra days to allow the user to resubscribe without losing benefits
|
||||
nextBill: moment().add({ days: 3 }).toDate(),
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new BadRequest(`Missing handler for Stripe webhook ${event.type}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(new Error('Error handling Stripe webhook'), { event, err });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,16 @@ export default function attachMiddlewares (app, server) {
|
||||
app.use(bodyParser.urlencoded({
|
||||
extended: true, // Uses 'qs' library as old connect middleware
|
||||
}));
|
||||
app.use(bodyParser.json());
|
||||
app.use(function bodyMiddleware (req, res, next) { // eslint-disable-line prefer-arrow-callback
|
||||
if (req.path === '/stripe/webhooks') {
|
||||
// Do not parse the body for `/stripe/webhooks`
|
||||
// See https://stripe.com/docs/webhooks/signatures#verify-official-libraries
|
||||
bodyParser.raw({ type: 'application/json' })(req, res, next);
|
||||
} else {
|
||||
bodyParser.json()(req, res, next);
|
||||
}
|
||||
});
|
||||
|
||||
app.use(methodOverride());
|
||||
|
||||
app.use(cookieSession({
|
||||
|
||||
Reference in New Issue
Block a user