mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-14 21:27:23 +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",
|
"SLACK_URL": "https://hooks.slack.com/services/some-url",
|
||||||
"STRIPE_API_KEY": "aaaabbbbccccddddeeeeffff00001111",
|
"STRIPE_API_KEY": "aaaabbbbccccddddeeeeffff00001111",
|
||||||
"STRIPE_PUB_KEY": "22223333444455556666777788889999",
|
"STRIPE_PUB_KEY": "22223333444455556666777788889999",
|
||||||
|
"STRIPE_WEBHOOKS_ENDPOINT_SECRET": "111111",
|
||||||
"TRANSIFEX_SLACK_CHANNEL": "transifex",
|
"TRANSIFEX_SLACK_CHANNEL": "transifex",
|
||||||
"WEB_CONCURRENCY": 1,
|
"WEB_CONCURRENCY": 1,
|
||||||
"SKIP_SSL_CHECK_KEY": "key",
|
"SKIP_SSL_CHECK_KEY": "key",
|
||||||
|
|||||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -12709,10 +12709,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"stripe": {
|
"stripe": {
|
||||||
"version": "7.15.0",
|
"version": "8.121.0",
|
||||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-7.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/stripe/-/stripe-8.121.0.tgz",
|
||||||
"integrity": "sha512-TmouNGv1rIU7cgw7iFKjdQueJSwYKdPRPBuO7eNjrRliZUnsf2bpJqYe+n6ByarUJr38KmhLheVUxDyRawByPQ==",
|
"integrity": "sha512-Uswmut57hVdyPrb+EJUTWbrLcTIEL4LS5T6UQZPO5AJNYT0PGHajgY1esQwmV7yVBL+Kgt3y/16zIAY/gAwifg==",
|
||||||
"requires": {
|
"requires": {
|
||||||
|
"@types/node": ">=8.1.0",
|
||||||
"qs": "^6.6.0"
|
"qs": "^6.6.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
"remove-markdown": "^0.3.0",
|
"remove-markdown": "^0.3.0",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"short-uuid": "^4.1.0",
|
"short-uuid": "^4.1.0",
|
||||||
"stripe": "^7.15.0",
|
"stripe": "^8.121.0",
|
||||||
"superagent": "^6.1.0",
|
"superagent": "^6.1.0",
|
||||||
"universal-analytics": "^0.4.23",
|
"universal-analytics": "^0.4.23",
|
||||||
"useragent": "^2.1.9",
|
"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 payments from '../../../../../../website/server/libs/payments/payments';
|
||||||
import common from '../../../../../../website/common';
|
import common from '../../../../../../website/common';
|
||||||
import apiError from '../../../../../../website/server/libs/apiError';
|
import apiError from '../../../../../../website/server/libs/apiError';
|
||||||
|
import * as gems from '../../../../../../website/server/libs/payments/gems';
|
||||||
|
|
||||||
const { i18n } = common;
|
const { i18n } = common;
|
||||||
|
|
||||||
@@ -88,6 +89,7 @@ describe('Amazon Payments - Checkout', () => {
|
|||||||
paymentCreateSubscritionStub.resolves({});
|
paymentCreateSubscritionStub.resolves({});
|
||||||
|
|
||||||
sinon.stub(common, 'uuid').returns('uuid-generated');
|
sinon.stub(common, 'uuid').returns('uuid-generated');
|
||||||
|
sandbox.stub(gems, 'validateGiftMessage');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -111,7 +113,10 @@ describe('Amazon Payments - Checkout', () => {
|
|||||||
if (gift) {
|
if (gift) {
|
||||||
expectedArgs.gift = gift;
|
expectedArgs.gift = gift;
|
||||||
expectedArgs.gemsBlock = undefined;
|
expectedArgs.gemsBlock = undefined;
|
||||||
|
expect(gems.validateGiftMessage).to.be.calledOnce;
|
||||||
|
expect(gems.validateGiftMessage).to.be.calledWith(gift, user);
|
||||||
} else {
|
} else {
|
||||||
|
expect(gems.validateGiftMessage).to.not.be.called;
|
||||||
expectedArgs.gemsBlock = gemsBlock;
|
expectedArgs.gemsBlock = gemsBlock;
|
||||||
}
|
}
|
||||||
expect(paymentBuyGemsStub).to.be.calledWith(expectedArgs);
|
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 iap from '../../../../../website/server/libs/inAppPurchases';
|
||||||
import { model as User } from '../../../../../website/server/models/user';
|
import { model as User } from '../../../../../website/server/models/user';
|
||||||
import common from '../../../../../website/common';
|
import common from '../../../../../website/common';
|
||||||
|
import * as gems from '../../../../../website/server/libs/payments/gems';
|
||||||
|
|
||||||
const { i18n } = common;
|
const { i18n } = common;
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ describe('Apple Payments', () => {
|
|||||||
let sku; let user; let token; let receipt; let
|
let sku; let user; let token; let receipt; let
|
||||||
headers;
|
headers;
|
||||||
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let paymentBuyGemsStub; let
|
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let paymentBuyGemsStub; let
|
||||||
iapGetPurchaseDataStub;
|
iapGetPurchaseDataStub; let validateGiftMessageStub;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
token = 'testToken';
|
token = 'testToken';
|
||||||
@@ -36,6 +37,7 @@ describe('Apple Payments', () => {
|
|||||||
transactionId: token,
|
transactionId: token,
|
||||||
}]);
|
}]);
|
||||||
paymentBuyGemsStub = sinon.stub(payments, 'buyGems').resolves({});
|
paymentBuyGemsStub = sinon.stub(payments, 'buyGems').resolves({});
|
||||||
|
validateGiftMessageStub = sinon.stub(gems, 'validateGiftMessage');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -44,6 +46,7 @@ describe('Apple Payments', () => {
|
|||||||
iap.isValidated.restore();
|
iap.isValidated.restore();
|
||||||
iap.getPurchaseData.restore();
|
iap.getPurchaseData.restore();
|
||||||
payments.buyGems.restore();
|
payments.buyGems.restore();
|
||||||
|
gems.validateGiftMessage.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if receipt is invalid', async () => {
|
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.calledOnce;
|
||||||
expect(iapIsValidatedStub).to.be.calledWith({});
|
expect(iapIsValidatedStub).to.be.calledWith({});
|
||||||
expect(iapGetPurchaseDataStub).to.be.calledOnce;
|
expect(iapGetPurchaseDataStub).to.be.calledOnce;
|
||||||
|
expect(validateGiftMessageStub).to.not.be.called;
|
||||||
|
|
||||||
expect(paymentBuyGemsStub).to.be.calledOnce;
|
expect(paymentBuyGemsStub).to.be.calledOnce;
|
||||||
expect(paymentBuyGemsStub).to.be.calledWith({
|
expect(paymentBuyGemsStub).to.be.calledWith({
|
||||||
@@ -180,6 +184,9 @@ describe('Apple Payments', () => {
|
|||||||
expect(iapIsValidatedStub).to.be.calledWith({});
|
expect(iapIsValidatedStub).to.be.calledWith({});
|
||||||
expect(iapGetPurchaseDataStub).to.be.calledOnce;
|
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.calledOnce;
|
||||||
expect(paymentBuyGemsStub).to.be.calledWith({
|
expect(paymentBuyGemsStub).to.be.calledWith({
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import common from '../../../../../website/common';
|
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('payments/gems', () => {
|
||||||
describe('#getGemsBlock', () => {
|
describe('#getGemsBlock', () => {
|
||||||
@@ -11,4 +17,50 @@ describe('payments/gems', () => {
|
|||||||
expect(getGemsBlock('21gems')).to.equal(common.content.gems['21gems']);
|
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 iap from '../../../../../website/server/libs/inAppPurchases';
|
||||||
import { model as User } from '../../../../../website/server/models/user';
|
import { model as User } from '../../../../../website/server/models/user';
|
||||||
import common from '../../../../../website/common';
|
import common from '../../../../../website/common';
|
||||||
|
import * as gems from '../../../../../website/server/libs/payments/gems';
|
||||||
|
|
||||||
const { i18n } = common;
|
const { i18n } = common;
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ describe('Google Payments', () => {
|
|||||||
let sku; let user; let token; let receipt; let signature; let
|
let sku; let user; let token; let receipt; let signature; let
|
||||||
headers; const gemsBlock = common.content.gems['21gems'];
|
headers; const gemsBlock = common.content.gems['21gems'];
|
||||||
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let
|
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let
|
||||||
paymentBuyGemsStub;
|
paymentBuyGemsStub; let validateGiftMessageStub;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sku = 'com.habitrpg.android.habitica.iap.21gems';
|
sku = 'com.habitrpg.android.habitica.iap.21gems';
|
||||||
@@ -31,6 +32,7 @@ describe('Google Payments', () => {
|
|||||||
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
|
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
|
||||||
.returns(true);
|
.returns(true);
|
||||||
paymentBuyGemsStub = sinon.stub(payments, 'buyGems').resolves({});
|
paymentBuyGemsStub = sinon.stub(payments, 'buyGems').resolves({});
|
||||||
|
validateGiftMessageStub = sinon.stub(gems, 'validateGiftMessage');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -38,6 +40,7 @@ describe('Google Payments', () => {
|
|||||||
iap.validate.restore();
|
iap.validate.restore();
|
||||||
iap.isValidated.restore();
|
iap.isValidated.restore();
|
||||||
payments.buyGems.restore();
|
payments.buyGems.restore();
|
||||||
|
gems.validateGiftMessage.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if receipt is invalid', async () => {
|
it('should throw an error if receipt is invalid', async () => {
|
||||||
@@ -89,6 +92,8 @@ describe('Google Payments', () => {
|
|||||||
user, receipt, signature, headers,
|
user, receipt, signature, headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(validateGiftMessageStub).to.not.be.called;
|
||||||
|
|
||||||
expect(iapSetupStub).to.be.calledOnce;
|
expect(iapSetupStub).to.be.calledOnce;
|
||||||
expect(iapValidateStub).to.be.calledOnce;
|
expect(iapValidateStub).to.be.calledOnce;
|
||||||
expect(iapValidateStub).to.be.calledWith(iap.GOOGLE, {
|
expect(iapValidateStub).to.be.calledWith(iap.GOOGLE, {
|
||||||
@@ -119,6 +124,9 @@ describe('Google Payments', () => {
|
|||||||
user, gift, receipt, signature, headers,
|
user, gift, receipt, signature, headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(validateGiftMessageStub).to.be.calledOnce;
|
||||||
|
expect(validateGiftMessageStub).to.be.calledWith(gift, user);
|
||||||
|
|
||||||
expect(iapSetupStub).to.be.calledOnce;
|
expect(iapSetupStub).to.be.calledOnce;
|
||||||
expect(iapValidateStub).to.be.calledOnce;
|
expect(iapValidateStub).to.be.calledOnce;
|
||||||
expect(iapValidateStub).to.be.calledWith(iap.GOOGLE, {
|
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
|
let plan; let group; let user; let
|
||||||
data;
|
data;
|
||||||
const stripe = stripeModule('test');
|
const stripe = stripeModule('test', {
|
||||||
|
apiVersion: '2020-08-27',
|
||||||
|
});
|
||||||
const groupLeaderName = 'sender';
|
const groupLeaderName = 'sender';
|
||||||
const groupName = 'test group';
|
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 { model as User } from '../../../../../../website/server/models/user';
|
||||||
import common from '../../../../../../website/common';
|
import common from '../../../../../../website/common';
|
||||||
import apiError from '../../../../../../website/server/libs/apiError';
|
import apiError from '../../../../../../website/server/libs/apiError';
|
||||||
|
import * as gems from '../../../../../../website/server/libs/payments/gems';
|
||||||
|
|
||||||
const BASE_URL = nconf.get('BASE_URL');
|
const BASE_URL = nconf.get('BASE_URL');
|
||||||
const { i18n } = common;
|
const { i18n } = common;
|
||||||
@@ -48,6 +49,7 @@ describe('paypal - checkout', () => {
|
|||||||
.resolves({
|
.resolves({
|
||||||
links: [{ rel: 'approval_url', href: approvalHerf }],
|
links: [{ rel: 'approval_url', href: approvalHerf }],
|
||||||
});
|
});
|
||||||
|
sandbox.stub(gems, 'validateGiftMessage');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -57,6 +59,7 @@ describe('paypal - checkout', () => {
|
|||||||
it('creates a link for gem purchases', async () => {
|
it('creates a link for gem purchases', async () => {
|
||||||
const link = await paypalPayments.checkout({ user: new User(), gemsBlock: gemsBlockKey });
|
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.calledOnce;
|
||||||
expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('Habitica Gems', 4.99));
|
expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('Habitica Gems', 4.99));
|
||||||
expect(link).to.eql(approvalHerf);
|
expect(link).to.eql(approvalHerf);
|
||||||
@@ -105,6 +108,7 @@ describe('paypal - checkout', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('creates a link for gifting gems', async () => {
|
it('creates a link for gifting gems', async () => {
|
||||||
|
const user = new User();
|
||||||
const receivingUser = new User();
|
const receivingUser = new User();
|
||||||
await receivingUser.save();
|
await receivingUser.save();
|
||||||
const gift = {
|
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.calledOnce;
|
||||||
expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('Habitica Gems (Gift)', '4.00'));
|
expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('Habitica Gems (Gift)', '4.00'));
|
||||||
expect(link).to.eql(approvalHerf);
|
expect(link).to.eql(approvalHerf);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a link for gifting a subscription', async () => {
|
it('creates a link for gifting a subscription', async () => {
|
||||||
|
const user = new User();
|
||||||
const receivingUser = new User();
|
const receivingUser = new User();
|
||||||
receivingUser.save();
|
receivingUser.save();
|
||||||
const gift = {
|
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.calledOnce;
|
||||||
expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('mo. Habitica Subscription (Gift)', '15.00'));
|
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 stripeModule from 'stripe';
|
||||||
|
import nconf from 'nconf';
|
||||||
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';
|
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;
|
const { i18n } = common;
|
||||||
|
|
||||||
describe('stripe - checkout', () => {
|
describe('Stripe - Checkout', () => {
|
||||||
const subKey = 'basic_3mo';
|
const stripe = stripeModule('test', {
|
||||||
const stripe = stripeModule('test');
|
apiVersion: '2020-08-27',
|
||||||
let stripeChargeStub; let paymentBuyGemsStub; let
|
});
|
||||||
paymentCreateSubscritionStub;
|
const BASE_URL = nconf.get('BASE_URL');
|
||||||
let user; let gift; let groupId; let email; let headers; let coupon; let customerIdResponse; let
|
const redirectUrls = {
|
||||||
token; const gemsBlockKey = '21gems'; const gemsBlock = common.content.gems[gemsBlockKey];
|
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(() => {
|
beforeEach(() => {
|
||||||
user = new User();
|
user = new User();
|
||||||
user.profile.name = 'sender';
|
sandbox.stub(stripe.checkout.sessions, 'create').returns(sessionId);
|
||||||
user.purchased.plan.customerId = 'customer-id';
|
sandbox.stub(gems, 'validateGiftMessage');
|
||||||
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({});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
it('gems', async () => {
|
||||||
stripe.charges.create.restore();
|
const amount = 999;
|
||||||
payments.buyGems.restore();
|
const gemsBlockKey = '21gems';
|
||||||
payments.createSubscription.restore();
|
sandbox.stub(oneTimePayments, 'getOneTimePaymentInfo').returns({
|
||||||
|
amount,
|
||||||
|
gemsBlock: common.content.gems[gemsBlockKey],
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should error if there is no token', async () => {
|
const res = await createCheckoutSession({ user, gemsBlock: gemsBlockKey }, stripe);
|
||||||
await expect(stripePayments.checkout({
|
expect(res).to.equal(sessionId);
|
||||||
user,
|
|
||||||
gift,
|
|
||||||
groupId,
|
|
||||||
email,
|
|
||||||
headers,
|
|
||||||
coupon,
|
|
||||||
}, stripe))
|
|
||||||
.to.eventually.be.rejected.and.to.eql({
|
|
||||||
httpCode: 400,
|
|
||||||
message: 'Missing req.body.id',
|
|
||||||
name: 'BadRequest',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should error if gem amount is too low', async () => {
|
const metadata = {
|
||||||
const receivingUser = new User();
|
|
||||||
receivingUser.save();
|
|
||||||
gift = {
|
|
||||||
type: 'gems',
|
type: 'gems',
|
||||||
gems: {
|
userId: user._id,
|
||||||
amount: 0,
|
gift: undefined,
|
||||||
uuid: receivingUser._id,
|
sub: undefined,
|
||||||
},
|
gemsBlock: gemsBlockKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
await expect(stripePayments.checkout({
|
expect(gems.validateGiftMessage).to.not.be.called;
|
||||||
token,
|
expect(oneTimePayments.getOneTimePaymentInfo).to.be.calledOnce;
|
||||||
user,
|
expect(oneTimePayments.getOneTimePaymentInfo).to.be.calledWith(gemsBlockKey, undefined, user);
|
||||||
gift,
|
expect(stripe.checkout.sessions.create).to.be.calledOnce;
|
||||||
groupId,
|
expect(stripe.checkout.sessions.create).to.be.calledWith({
|
||||||
email,
|
payment_method_types: ['card'],
|
||||||
headers,
|
metadata,
|
||||||
coupon,
|
line_items: [{
|
||||||
}, stripe))
|
price_data: {
|
||||||
.to.eventually.be.rejected.and.to.eql({
|
product_data: {
|
||||||
httpCode: 400,
|
name: common.i18n.t('nGems', { nGems: 21 }),
|
||||||
message: 'Amount must be at least 1.',
|
},
|
||||||
name: 'BadRequest',
|
unit_amount: amount,
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should error if user cannot get gems', async () => {
|
|
||||||
gift = undefined;
|
|
||||||
sinon.stub(user, 'canGetGems').resolves(false);
|
|
||||||
|
|
||||||
await expect(stripePayments.checkout({
|
|
||||||
token,
|
|
||||||
user,
|
|
||||||
gemsBlock: gemsBlockKey,
|
|
||||||
gift,
|
|
||||||
groupId,
|
|
||||||
email,
|
|
||||||
headers,
|
|
||||||
coupon,
|
|
||||||
}, stripe)).to.eventually.be.rejected.and.to.eql({
|
|
||||||
httpCode: 401,
|
|
||||||
message: i18n.t('groupPolicyCannotGetGems'),
|
|
||||||
name: 'NotAuthorized',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should error if the gems block is invalid', async () => {
|
|
||||||
gift = undefined;
|
|
||||||
|
|
||||||
await expect(stripePayments.checkout({
|
|
||||||
token,
|
|
||||||
user,
|
|
||||||
gemsBlock: 'invalid',
|
|
||||||
gift,
|
|
||||||
groupId,
|
|
||||||
email,
|
|
||||||
headers,
|
|
||||||
coupon,
|
|
||||||
}, stripe)).to.eventually.be.rejected.and.to.eql({
|
|
||||||
httpCode: 400,
|
|
||||||
message: apiError('invalidGemsBlock'),
|
|
||||||
name: 'BadRequest',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should purchase gems', async () => {
|
|
||||||
gift = undefined;
|
|
||||||
sinon.stub(user, 'canGetGems').resolves(true);
|
|
||||||
|
|
||||||
await stripePayments.checkout({
|
|
||||||
token,
|
|
||||||
user,
|
|
||||||
gemsBlock: gemsBlockKey,
|
|
||||||
gift,
|
|
||||||
groupId,
|
|
||||||
email,
|
|
||||||
headers,
|
|
||||||
coupon,
|
|
||||||
}, stripe);
|
|
||||||
|
|
||||||
expect(stripeChargeStub).to.be.calledOnce;
|
|
||||||
expect(stripeChargeStub).to.be.calledWith({
|
|
||||||
amount: 499,
|
|
||||||
currency: 'usd',
|
currency: 'usd',
|
||||||
card: token,
|
},
|
||||||
|
quantity: 1,
|
||||||
|
}],
|
||||||
|
mode: 'payment',
|
||||||
|
...redirectUrls,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(paymentBuyGemsStub).to.be.calledOnce;
|
it('gems gift', async () => {
|
||||||
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 () => {
|
|
||||||
const receivingUser = new User();
|
const receivingUser = new User();
|
||||||
await receivingUser.save();
|
await receivingUser.save();
|
||||||
gift = {
|
|
||||||
|
const gift = {
|
||||||
type: 'gems',
|
type: 'gems',
|
||||||
uuid: receivingUser._id,
|
uuid: receivingUser._id,
|
||||||
gems: {
|
gems: {
|
||||||
amount: 16,
|
amount: 4,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const amount = 100;
|
||||||
await stripePayments.checkout({
|
sandbox.stub(oneTimePayments, 'getOneTimePaymentInfo').returns({
|
||||||
token,
|
amount,
|
||||||
user,
|
gemsBlock: null,
|
||||||
gift,
|
|
||||||
groupId,
|
|
||||||
email,
|
|
||||||
headers,
|
|
||||||
coupon,
|
|
||||||
}, stripe);
|
|
||||||
|
|
||||||
expect(stripeChargeStub).to.be.calledOnce;
|
|
||||||
expect(stripeChargeStub).to.be.calledWith({
|
|
||||||
amount: '400',
|
|
||||||
currency: 'usd',
|
|
||||||
card: token,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(paymentBuyGemsStub).to.be.calledOnce;
|
const res = await createCheckoutSession({ user, gift }, stripe);
|
||||||
expect(paymentBuyGemsStub).to.be.calledWith({
|
expect(res).to.equal(sessionId);
|
||||||
user,
|
|
||||||
customerId: customerIdResponse,
|
const metadata = {
|
||||||
paymentMethod: 'Gift',
|
type: 'gift-gems',
|
||||||
gift,
|
userId: user._id,
|
||||||
|
gift: JSON.stringify(gift),
|
||||||
|
sub: undefined,
|
||||||
gemsBlock: 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();
|
const receivingUser = new User();
|
||||||
receivingUser.save();
|
await receivingUser.save();
|
||||||
gift = {
|
const subKey = 'basic_3mo';
|
||||||
|
|
||||||
|
const gift = {
|
||||||
type: 'subscription',
|
type: 'subscription',
|
||||||
|
uuid: receivingUser._id,
|
||||||
subscription: {
|
subscription: {
|
||||||
key: subKey,
|
key: subKey,
|
||||||
uuid: receivingUser._id,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const amount = 1500;
|
||||||
await stripePayments.checkout({
|
sandbox.stub(oneTimePayments, 'getOneTimePaymentInfo').returns({
|
||||||
token,
|
amount,
|
||||||
user,
|
gemsBlock: null,
|
||||||
gift,
|
subscription: common.content.subscriptionBlocks[subKey],
|
||||||
groupId,
|
|
||||||
email,
|
|
||||||
headers,
|
|
||||||
coupon,
|
|
||||||
}, stripe);
|
|
||||||
|
|
||||||
gift.member = receivingUser;
|
|
||||||
expect(stripeChargeStub).to.be.calledOnce;
|
|
||||||
expect(stripeChargeStub).to.be.calledWith({
|
|
||||||
amount: '1500',
|
|
||||||
currency: 'usd',
|
|
||||||
card: token,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(paymentCreateSubscritionStub).to.be.calledOnce;
|
const res = await createCheckoutSession({ user, gift }, stripe);
|
||||||
expect(paymentCreateSubscritionStub).to.be.calledWith({
|
expect(res).to.equal(sessionId);
|
||||||
user,
|
|
||||||
customerId: customerIdResponse,
|
const metadata = {
|
||||||
paymentMethod: 'Gift',
|
type: 'gift-sub',
|
||||||
gift,
|
userId: user._id,
|
||||||
|
gift: JSON.stringify(gift),
|
||||||
|
sub: undefined,
|
||||||
gemsBlock: 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 stripeModule from 'stripe';
|
||||||
|
import nconf from 'nconf';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import {
|
import {
|
||||||
@@ -10,76 +10,104 @@ import stripePayments from '../../../../../../website/server/libs/payments/strip
|
|||||||
import payments from '../../../../../../website/server/libs/payments/payments';
|
import payments from '../../../../../../website/server/libs/payments/payments';
|
||||||
import common from '../../../../../../website/common';
|
import common from '../../../../../../website/common';
|
||||||
import logger from '../../../../../../website/server/libs/logger';
|
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;
|
const { i18n } = common;
|
||||||
|
|
||||||
describe('Stripe - Webhooks', () => {
|
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', () => {
|
describe('all events', () => {
|
||||||
const eventType = 'account.updated';
|
let event;
|
||||||
const event = { id: 123 };
|
let constructEventStub;
|
||||||
const eventRetrieved = { type: eventType };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sinon.stub(stripe.events, 'retrieve').resolves(eventRetrieved);
|
event = { type: 'payment_intent.created' };
|
||||||
sinon.stub(logger, 'error');
|
constructEventStub = sandbox.stub(stripe.webhooks, 'constructEvent');
|
||||||
|
constructEventStub.returns(event);
|
||||||
|
sandbox.stub(logger, 'error');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
it('throws if the event can\'t be validated', async () => {
|
||||||
stripe.events.retrieve.restore();
|
const err = new Error('fail');
|
||||||
logger.error.restore();
|
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 () => {
|
it('logs an error if an unsupported webhook event is passed', async () => {
|
||||||
const error = new Error(`Missing handler for Stripe webhook ${eventType}`);
|
event.type = 'account.updated';
|
||||||
await stripePayments.handleWebhooks({ requestBody: event }, stripe);
|
await expect(stripePayments.handleWebhooks({ body, headers }, stripe))
|
||||||
expect(logger.error).to.have.been.calledOnce;
|
.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;
|
const calledWith = logger.error.getCall(0).args;
|
||||||
expect(calledWith[0].message).to.equal(error.message);
|
expect(calledWith[0].message).to.equal('Error handling Stripe webhook');
|
||||||
expect(calledWith[1].event).to.equal(eventRetrieved);
|
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 () => {
|
it('retrieves and validates the event from Stripe', async () => {
|
||||||
await stripePayments.handleWebhooks({ requestBody: event }, 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.events.retrieve).to.have.been.calledWith(event.id);
|
expect(stripe.webhooks.constructEvent)
|
||||||
|
.to.have.been.calledWith(body, undefined, endpointSecret);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('customer.subscription.deleted', () => {
|
describe('customer.subscription.deleted', () => {
|
||||||
const eventType = 'customer.subscription.deleted';
|
const eventType = 'customer.subscription.deleted';
|
||||||
|
let event;
|
||||||
|
let constructEventStub;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sinon.stub(stripe.customers, 'del').resolves({});
|
event = { type: eventType };
|
||||||
sinon.stub(payments, 'cancelSubscription').resolves({});
|
constructEventStub = sandbox.stub(stripe.webhooks, 'constructEvent');
|
||||||
|
constructEventStub.returns(event);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
beforeEach(() => {
|
||||||
stripe.customers.del.restore();
|
sandbox.stub(stripe.customers, 'del').resolves({});
|
||||||
payments.cancelSubscription.restore();
|
sandbox.stub(payments, 'cancelSubscription').resolves({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not do anything if event.request is null (subscription cancelled manually)', async () => {
|
it('does not do anything if event.request is null (subscription cancelled manually)', async () => {
|
||||||
sinon.stub(stripe.events, 'retrieve').resolves({
|
constructEventStub.returns({
|
||||||
id: 123,
|
id: 123,
|
||||||
type: eventType,
|
type: eventType,
|
||||||
request: 123,
|
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(stripe.customers.del).to.not.have.been.called;
|
||||||
expect(payments.cancelSubscription).to.not.have.been.called;
|
expect(payments.cancelSubscription).to.not.have.been.called;
|
||||||
stripe.events.retrieve.restore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('user subscription', () => {
|
describe('user subscription', () => {
|
||||||
it('throws an error if the user is not found', async () => {
|
it('throws an error if the user is not found', async () => {
|
||||||
const customerId = 456;
|
const customerId = 456;
|
||||||
sinon.stub(stripe.events, 'retrieve').resolves({
|
constructEventStub.returns({
|
||||||
id: 123,
|
id: 123,
|
||||||
type: eventType,
|
type: eventType,
|
||||||
data: {
|
data: {
|
||||||
@@ -93,7 +121,7 @@ describe('Stripe - Webhooks', () => {
|
|||||||
request: null,
|
request: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(stripePayments.handleWebhooks({ requestBody: {} }, stripe))
|
await expect(stripePayments.handleWebhooks({ body, headers }, stripe))
|
||||||
.to.eventually.be.rejectedWith({
|
.to.eventually.be.rejectedWith({
|
||||||
message: i18n.t('userNotFound'),
|
message: i18n.t('userNotFound'),
|
||||||
httpCode: 404,
|
httpCode: 404,
|
||||||
@@ -102,8 +130,6 @@ describe('Stripe - Webhooks', () => {
|
|||||||
|
|
||||||
expect(stripe.customers.del).to.not.have.been.called;
|
expect(stripe.customers.del).to.not.have.been.called;
|
||||||
expect(payments.cancelSubscription).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 () => {
|
it('deletes the customer on Stripe and calls payments.cancelSubscription', async () => {
|
||||||
@@ -114,7 +140,7 @@ describe('Stripe - Webhooks', () => {
|
|||||||
subscriber.purchased.plan.paymentMethod = 'Stripe';
|
subscriber.purchased.plan.paymentMethod = 'Stripe';
|
||||||
await subscriber.save();
|
await subscriber.save();
|
||||||
|
|
||||||
sinon.stub(stripe.events, 'retrieve').resolves({
|
constructEventStub.returns({
|
||||||
id: 123,
|
id: 123,
|
||||||
type: eventType,
|
type: eventType,
|
||||||
data: {
|
data: {
|
||||||
@@ -128,7 +154,7 @@ describe('Stripe - Webhooks', () => {
|
|||||||
request: null,
|
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.calledOnce;
|
||||||
expect(stripe.customers.del).to.have.been.calledWith(customerId);
|
expect(stripe.customers.del).to.have.been.calledWith(customerId);
|
||||||
@@ -139,15 +165,13 @@ describe('Stripe - Webhooks', () => {
|
|||||||
expect(cancelSubscriptionOpts.paymentMethod).to.equal('Stripe');
|
expect(cancelSubscriptionOpts.paymentMethod).to.equal('Stripe');
|
||||||
expect(Math.round(moment(cancelSubscriptionOpts.nextBill).diff(new Date(), 'days', true))).to.equal(3);
|
expect(Math.round(moment(cancelSubscriptionOpts.nextBill).diff(new Date(), 'days', true))).to.equal(3);
|
||||||
expect(cancelSubscriptionOpts.groupId).to.be.undefined;
|
expect(cancelSubscriptionOpts.groupId).to.be.undefined;
|
||||||
|
|
||||||
stripe.events.retrieve.restore();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('group plan subscription', () => {
|
describe('group plan subscription', () => {
|
||||||
it('throws an error if the group is not found', async () => {
|
it('throws an error if the group is not found', async () => {
|
||||||
const customerId = 456;
|
const customerId = 456;
|
||||||
sinon.stub(stripe.events, 'retrieve').resolves({
|
constructEventStub.returns({
|
||||||
id: 123,
|
id: 123,
|
||||||
type: eventType,
|
type: eventType,
|
||||||
data: {
|
data: {
|
||||||
@@ -161,7 +185,7 @@ describe('Stripe - Webhooks', () => {
|
|||||||
request: null,
|
request: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(stripePayments.handleWebhooks({ requestBody: {} }, stripe))
|
await expect(stripePayments.handleWebhooks({ body, headers }, stripe))
|
||||||
.to.eventually.be.rejectedWith({
|
.to.eventually.be.rejectedWith({
|
||||||
message: i18n.t('groupNotFound'),
|
message: i18n.t('groupNotFound'),
|
||||||
httpCode: 404,
|
httpCode: 404,
|
||||||
@@ -170,8 +194,6 @@ describe('Stripe - Webhooks', () => {
|
|||||||
|
|
||||||
expect(stripe.customers.del).to.not.have.been.called;
|
expect(stripe.customers.del).to.not.have.been.called;
|
||||||
expect(payments.cancelSubscription).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 () => {
|
it('throws an error if the group leader is not found', async () => {
|
||||||
@@ -187,7 +209,7 @@ describe('Stripe - Webhooks', () => {
|
|||||||
subscriber.purchased.plan.paymentMethod = 'Stripe';
|
subscriber.purchased.plan.paymentMethod = 'Stripe';
|
||||||
await subscriber.save();
|
await subscriber.save();
|
||||||
|
|
||||||
sinon.stub(stripe.events, 'retrieve').resolves({
|
constructEventStub.returns({
|
||||||
id: 123,
|
id: 123,
|
||||||
type: eventType,
|
type: eventType,
|
||||||
data: {
|
data: {
|
||||||
@@ -201,7 +223,7 @@ describe('Stripe - Webhooks', () => {
|
|||||||
request: null,
|
request: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(stripePayments.handleWebhooks({ requestBody: {} }, stripe))
|
await expect(stripePayments.handleWebhooks({ body, headers }, stripe))
|
||||||
.to.eventually.be.rejectedWith({
|
.to.eventually.be.rejectedWith({
|
||||||
message: i18n.t('userNotFound'),
|
message: i18n.t('userNotFound'),
|
||||||
httpCode: 404,
|
httpCode: 404,
|
||||||
@@ -210,8 +232,6 @@ describe('Stripe - Webhooks', () => {
|
|||||||
|
|
||||||
expect(stripe.customers.del).to.not.have.been.called;
|
expect(stripe.customers.del).to.not.have.been.called;
|
||||||
expect(payments.cancelSubscription).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 () => {
|
it('deletes the customer on Stripe and calls payments.cancelSubscription', async () => {
|
||||||
@@ -230,7 +250,7 @@ describe('Stripe - Webhooks', () => {
|
|||||||
subscriber.purchased.plan.paymentMethod = 'Stripe';
|
subscriber.purchased.plan.paymentMethod = 'Stripe';
|
||||||
await subscriber.save();
|
await subscriber.save();
|
||||||
|
|
||||||
sinon.stub(stripe.events, 'retrieve').resolves({
|
constructEventStub.returns({
|
||||||
id: 123,
|
id: 123,
|
||||||
type: eventType,
|
type: eventType,
|
||||||
data: {
|
data: {
|
||||||
@@ -244,7 +264,7 @@ describe('Stripe - Webhooks', () => {
|
|||||||
request: null,
|
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.calledOnce;
|
||||||
expect(stripe.customers.del).to.have.been.calledWith(customerId);
|
expect(stripe.customers.del).to.have.been.calledWith(customerId);
|
||||||
@@ -255,9 +275,65 @@ describe('Stripe - Webhooks', () => {
|
|||||||
expect(cancelSubscriptionOpts.paymentMethod).to.equal('Stripe');
|
expect(cancelSubscriptionOpts.paymentMethod).to.equal('Stripe');
|
||||||
expect(Math.round(moment(cancelSubscriptionOpts.nextBill).diff(new Date(), 'days', true))).to.equal(3);
|
expect(Math.round(moment(cancelSubscriptionOpts.nextBill).diff(new Date(), 'days', true))).to.equal(3);
|
||||||
expect(cancelSubscriptionOpts.groupId).to.equal(subscriber._id);
|
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 {
|
import {
|
||||||
generateUser,
|
generateUser,
|
||||||
generateGroup,
|
|
||||||
translate as t,
|
|
||||||
} from '../../../../../helpers/api-integration/v3';
|
} from '../../../../../helpers/api-integration/v3';
|
||||||
import stripePayments from '../../../../../../website/server/libs/payments/stripe';
|
import stripePayments from '../../../../../../website/server/libs/payments/stripe';
|
||||||
|
|
||||||
describe('payments - stripe - #subscribeEdit', () => {
|
describe('payments - stripe - #subscribeEdit', () => {
|
||||||
const endpoint = '/stripe/subscribe/edit';
|
const endpoint = '/stripe/subscribe/edit';
|
||||||
let user; let
|
let user; const groupId = 'groupId';
|
||||||
group;
|
let stripeEditSubscriptionStub;
|
||||||
|
const sessionId = 'sessionId';
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
user = await generateUser();
|
user = await generateUser();
|
||||||
});
|
stripeEditSubscriptionStub = sinon
|
||||||
|
.stub(stripePayments, 'createEditCardCheckoutSession')
|
||||||
it('verifies credentials', async () => {
|
.resolves({ id: sessionId });
|
||||||
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({});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
stripePayments.editSubscription.restore();
|
stripePayments.createEditCardCheckoutSession.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cancels a user subscription', async () => {
|
it('works', async () => {
|
||||||
user = await generateUser({
|
const res = await user.post(endpoint, { groupId });
|
||||||
'profile.name': 'sender',
|
expect(res.sessionId).to.equal(sessionId);
|
||||||
'purchased.plan.customerId': 'customer-id',
|
|
||||||
'purchased.plan.planId': 'basic_3mo',
|
|
||||||
'purchased.plan.lastBillingDate': new Date(),
|
|
||||||
balance: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
await user.post(endpoint);
|
|
||||||
|
|
||||||
expect(stripeEditSubscriptionStub).to.be.calledOnce;
|
expect(stripeEditSubscriptionStub).to.be.calledOnce;
|
||||||
expect(stripeEditSubscriptionStub.args[0][0].user._id).to.eql(user._id);
|
expect(stripeEditSubscriptionStub.args[0][0].user._id).to.eql(user._id);
|
||||||
expect(stripeEditSubscriptionStub.args[0][0].groupId).to.eql(undefined);
|
expect(stripeEditSubscriptionStub.args[0][0].groupId).to.eql(groupId);
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
v-if="!group.purchased.plan.dateTerminated
|
||||||
&& group.purchased.plan.paymentMethod === 'Stripe'"
|
&& group.purchased.plan.paymentMethod === 'Stripe'"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@click="showStripeEdit({groupId: group.id})"
|
@click="redirectToStripeEdit({groupId: group.id})"
|
||||||
>
|
>
|
||||||
{{ $t('subUpdateCard') }}
|
{{ $t('subUpdateCard') }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ export default {
|
|||||||
|
|
||||||
this.paymentMethod = paymentMethod;
|
this.paymentMethod = paymentMethod;
|
||||||
if (this.paymentMethod === this.PAYMENTS.STRIPE) {
|
if (this.paymentMethod === this.PAYMENTS.STRIPE) {
|
||||||
this.showStripe(paymentData);
|
this.redirectToStripe(paymentData);
|
||||||
} else if (this.paymentMethod === this.PAYMENTS.AMAZON) {
|
} else if (this.paymentMethod === this.PAYMENTS.AMAZON) {
|
||||||
paymentData.type = 'subscription';
|
paymentData.type = 'subscription';
|
||||||
return paymentData;
|
return paymentData;
|
||||||
|
|||||||
@@ -155,7 +155,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<b-modal
|
<b-modal
|
||||||
id="group-plan-modal"
|
id="group-plan-modal"
|
||||||
title="Select Payment"
|
:title="activePage === PAGES.CREATE_GROUP ? 'Create your Group' : 'Select Payment'"
|
||||||
size="md"
|
size="md"
|
||||||
hide-footer="hide-footer"
|
hide-footer="hide-footer"
|
||||||
>
|
>
|
||||||
@@ -524,7 +524,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.paymentMethod === this.PAYMENTS.STRIPE) {
|
if (this.paymentMethod === this.PAYMENTS.STRIPE) {
|
||||||
this.showStripe(paymentData);
|
this.redirectToStripe(paymentData);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -135,7 +135,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<payments-buttons
|
<payments-buttons
|
||||||
:disabled="!selectedGemsBlock"
|
:disabled="!selectedGemsBlock"
|
||||||
:stripe-fn="() => showStripe({ gemsBlock: selectedGemsBlock })"
|
:stripe-fn="() => redirectToStripe({ gemsBlock: selectedGemsBlock })"
|
||||||
:paypal-fn="() => openPaypal({
|
:paypal-fn="() => openPaypal({
|
||||||
url: paypalCheckoutLink, type: 'gems', gemsBlock: selectedGemsBlock
|
url: paypalCheckoutLink, type: 'gems', gemsBlock: selectedGemsBlock
|
||||||
})"
|
})"
|
||||||
|
|||||||
@@ -118,7 +118,9 @@
|
|||||||
class="form-control"
|
class="form-control"
|
||||||
rows="3"
|
rows="3"
|
||||||
:placeholder="$t('sendGiftMessagePlaceholder')"
|
:placeholder="$t('sendGiftMessagePlaceholder')"
|
||||||
|
:maxlength="MAX_GIFT_MESSAGE_LENGTH"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<span>{{ gift.message.length || 0 }} / {{ MAX_GIFT_MESSAGE_LENGTH }}</span>
|
||||||
<!--include ../formatting-help-->
|
<!--include ../formatting-help-->
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -133,7 +135,7 @@
|
|||||||
<payments-buttons
|
<payments-buttons
|
||||||
v-else
|
v-else
|
||||||
:disabled="!gift.subscription.key && gift.gems.amount < 1"
|
: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({
|
:paypal-fn="() => openPaypalGift({
|
||||||
gift: gift, giftedTo: userReceivingGems._id, receiverName,
|
gift: gift, giftedTo: userReceivingGems._id, receiverName,
|
||||||
})"
|
})"
|
||||||
@@ -181,6 +183,7 @@ import planGemLimits from '@/../../common/script/libs/planGemLimits';
|
|||||||
import paymentsMixin from '@/mixins/payments';
|
import paymentsMixin from '@/mixins/payments';
|
||||||
import notificationsMixin from '@/mixins/notifications';
|
import notificationsMixin from '@/mixins/notifications';
|
||||||
import paymentsButtons from '@/components/payments/buttons/list';
|
import paymentsButtons from '@/components/payments/buttons/list';
|
||||||
|
import { MAX_GIFT_MESSAGE_LENGTH } from '@/../../common/script/constants';
|
||||||
|
|
||||||
// @TODO: EMAILS.TECH_ASSISTANCE_EMAIL, load from config
|
// @TODO: EMAILS.TECH_ASSISTANCE_EMAIL, load from config
|
||||||
const TECH_ASSISTANCE_EMAIL = 'admin@habitica.com';
|
const TECH_ASSISTANCE_EMAIL = 'admin@habitica.com';
|
||||||
@@ -208,6 +211,7 @@ export default {
|
|||||||
},
|
},
|
||||||
sendingInProgress: false,
|
sendingInProgress: false,
|
||||||
userReceivingGems: null,
|
userReceivingGems: null,
|
||||||
|
MAX_GIFT_MESSAGE_LENGTH: MAX_GIFT_MESSAGE_LENGTH.toString(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|||||||
@@ -132,7 +132,7 @@
|
|||||||
<button
|
<button
|
||||||
class="btn btn-primary btn-update-card
|
class="btn btn-primary btn-update-card
|
||||||
d-flex justify-content-center align-items-center"
|
d-flex justify-content-center align-items-center"
|
||||||
@click="showStripeEdit()"
|
@click="redirectToStripeEdit()"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-once
|
v-once
|
||||||
|
|||||||
@@ -27,7 +27,10 @@
|
|||||||
</b-form-group>
|
</b-form-group>
|
||||||
<payments-buttons
|
<payments-buttons
|
||||||
:disabled="!subscription.key"
|
: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'})"
|
:paypal-fn="() => openPaypal({url: paypalPurchaseLink, type: 'subscription'})"
|
||||||
:amazon-data="{
|
:amazon-data="{
|
||||||
type: 'subscription',
|
type: 'subscription',
|
||||||
|
|||||||
@@ -25,6 +25,6 @@ export function setup () { // eslint-disable-line import/prefer-default-export
|
|||||||
const stripeScript = document.createElement('script');
|
const stripeScript = document.createElement('script');
|
||||||
[firstScript] = document.getElementsByTagName('script');
|
[firstScript] = document.getElementsByTagName('script');
|
||||||
stripeScript.async = true;
|
stripeScript.async = true;
|
||||||
stripeScript.src = '//checkout.stripe.com/v2/checkout.js';
|
stripeScript.src = 'https://js.stripe.com/v3/';
|
||||||
firstScript.parentNode.insertBefore(stripeScript, firstScript);
|
firstScript.parentNode.insertBefore(stripeScript, firstScript);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import subscriptionBlocks from '@/../../common/script/content/subscriptionBlocks
|
|||||||
import { mapState } from '@/libs/store';
|
import { mapState } from '@/libs/store';
|
||||||
import encodeParams from '@/libs/encodeParams';
|
import encodeParams from '@/libs/encodeParams';
|
||||||
import notificationsMixin from '@/mixins/notifications';
|
import notificationsMixin from '@/mixins/notifications';
|
||||||
import * as Analytics from '@/libs/analytics';
|
|
||||||
import { CONSTANTS, setLocalSetting } from '@/libs/userlocalManager';
|
import { CONSTANTS, setLocalSetting } from '@/libs/userlocalManager';
|
||||||
|
|
||||||
const { STRIPE_PUB_KEY } = process.env;
|
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 {
|
export default {
|
||||||
mixins: [notificationsMixin],
|
mixins: [notificationsMixin],
|
||||||
@@ -100,7 +100,10 @@ export default {
|
|||||||
// Listen for changes to local storage, indicating that the payment completed
|
// Listen for changes to local storage, indicating that the payment completed
|
||||||
window.addEventListener('storage', localStorageChangeHandled);
|
window.addEventListener('storage', localStorageChangeHandled);
|
||||||
},
|
},
|
||||||
showStripe (data) {
|
async redirectToStripe (data) {
|
||||||
|
if (!stripeInstance) {
|
||||||
|
stripeInstance = window.Stripe(STRIPE_PUB_KEY);
|
||||||
|
}
|
||||||
if (!this.checkGemAmount(data)) return;
|
if (!this.checkGemAmount(data)) return;
|
||||||
|
|
||||||
let sub = false;
|
let sub = false;
|
||||||
@@ -113,12 +116,6 @@ export default {
|
|||||||
|
|
||||||
sub = sub && subscriptionBlocks[sub];
|
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;
|
let paymentType;
|
||||||
if (sub === false && !data.gift) paymentType = 'gems';
|
if (sub === false && !data.gift) paymentType = 'gems';
|
||||||
if (sub !== false && !data.gift) paymentType = 'subscription';
|
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 === 'gems') paymentType = 'gift-gems';
|
||||||
if (data.gift && data.gift.type === 'subscription') paymentType = 'gift-subscription';
|
if (data.gift && data.gift.type === 'subscription') paymentType = 'gift-subscription';
|
||||||
|
|
||||||
const label = (sub && paymentType !== 'gift-subscription')
|
let url = '/stripe/checkout-session';
|
||||||
? this.$t('subscribe')
|
const postData = {};
|
||||||
: 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
|
|
||||||
|
|
||||||
if (data.groupToCreate) {
|
if (data.groupToCreate) {
|
||||||
url = '/api/v4/groups/create-plan?a=a';
|
url = '/api/v4/groups/create-plan';
|
||||||
res.groupToCreate = data.groupToCreate;
|
postData.groupToCreate = data.groupToCreate;
|
||||||
res.paymentType = 'Stripe';
|
postData.paymentType = 'Stripe';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.gemsBlock) url += `&gemsBlock=${data.gemsBlock.key}`;
|
if (data.gemsBlock) postData.gemsBlock = data.gemsBlock.key;
|
||||||
if (data.gift) url += `&gift=${this.encodeGift(data.uuid, data.gift)}`;
|
if (data.gift) {
|
||||||
if (data.subscription) url += `&sub=${sub.key}`;
|
data.gift.uuid = data.uuid;
|
||||||
if (data.coupon) url += `&coupon=${data.coupon}`;
|
postData.gift = data.gift;
|
||||||
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.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 = {
|
const appState = {
|
||||||
paymentMethod: 'stripe',
|
paymentMethod: 'stripe',
|
||||||
paymentCompleted: true,
|
paymentCompleted: false,
|
||||||
paymentType,
|
paymentType,
|
||||||
};
|
};
|
||||||
if (paymentType === 'subscription') {
|
if (paymentType === 'subscription') {
|
||||||
@@ -172,12 +153,17 @@ export default {
|
|||||||
} else if (paymentType === 'groupPlan') {
|
} else if (paymentType === 'groupPlan') {
|
||||||
appState.subscriptionKey = sub.key;
|
appState.subscriptionKey = sub.key;
|
||||||
|
|
||||||
|
// Handle new user signup
|
||||||
|
if (!this.$store.state.isUserLoggedIn) {
|
||||||
|
appState.newSignup = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (data.groupToCreate) {
|
if (data.groupToCreate) {
|
||||||
appState.newGroup = true;
|
appState.newGroup = true;
|
||||||
appState.group = pick(data.groupToCreate, ['_id', 'memberCount', 'name']);
|
appState.group = pick(response.data.data.group, ['_id', 'memberCount', 'name', 'type']);
|
||||||
} else {
|
} else {
|
||||||
appState.newGroup = false;
|
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) {
|
} else if (paymentType.indexOf('gift-') === 0) {
|
||||||
appState.gift = data.gift;
|
appState.gift = data.gift;
|
||||||
@@ -186,64 +172,61 @@ export default {
|
|||||||
appState.gemsBlock = data.gemsBlock;
|
appState.gemsBlock = data.gemsBlock;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
setLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE, JSON.stringify(appState));
|
setLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE, JSON.stringify(appState));
|
||||||
|
|
||||||
const newGroup = response.data.data;
|
try {
|
||||||
if (newGroup && newGroup._id) {
|
const checkoutSessionResult = await stripeInstance.redirectToCheckout({
|
||||||
// @TODO this does not do anything as we reload just below
|
sessionId: response.data.data.sessionId,
|
||||||
// @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',
|
|
||||||
});
|
});
|
||||||
|
if (checkoutSessionResult.error) {
|
||||||
window.location.assign(`${habiticaUrl}/group-plans/${newGroup._id}/task-information?showGroupOverview=true`);
|
console.error(checkoutSessionResult.error); // eslint-disable-line
|
||||||
return;
|
alert(`Error while redirecting to Stripe: ${checkoutSessionResult.error.message}`);
|
||||||
|
throw checkoutSessionResult.error;
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
this.user.guilds.push(newGroup._id);
|
console.error('Error while redirecting to Stripe', err); // eslint-disable-line
|
||||||
window.location.assign(`${habiticaUrl}/group-plans/${newGroup._id}/task-information`);
|
alert(`Error while redirecting to Stripe: ${err.message}`);
|
||||||
return;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.groupId) {
|
|
||||||
window.location.assign(`${habiticaUrl}/group-plans/${data.groupId}/task-information`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.reload(true);
|
|
||||||
},
|
},
|
||||||
});
|
async redirectToStripeEdit (config) {
|
||||||
},
|
if (!stripeInstance) {
|
||||||
showStripeEdit (config) {
|
stripeInstance = window.Stripe(STRIPE_PUB_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
let groupId;
|
let groupId;
|
||||||
if (config && config.groupId) {
|
if (config && config.groupId) {
|
||||||
groupId = config.groupId;
|
groupId = config.groupId;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.StripeCheckout.open({
|
const appState = {
|
||||||
key: STRIPE_PUB_KEY,
|
paymentMethod: 'stripe',
|
||||||
address: false,
|
isStripeEdit: true,
|
||||||
name: this.$t('subUpdateTitle'),
|
paymentCompleted: false,
|
||||||
description: this.$t('subUpdateDescription'),
|
paymentType: groupId ? 'groupPlan' : 'subscription',
|
||||||
panelLabel: this.$t('subUpdateCard'),
|
groupId,
|
||||||
token: async data => {
|
};
|
||||||
data.groupId = groupId;
|
|
||||||
const url = '/stripe/subscribe/edit';
|
|
||||||
const response = await axios.post(url, data);
|
|
||||||
|
|
||||||
// Success
|
const response = await axios.post('/stripe/subscribe/edit', {
|
||||||
window.location.reload(true);
|
groupId,
|
||||||
// error
|
|
||||||
window.alert(response.message); // eslint-disable-line no-alert
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) {
|
checkGemAmount (data) {
|
||||||
const isGem = data && data.gift && data.gift.type === 'gems';
|
const isGem = data && data.gift && data.gift.type === 'gems';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { CONSTANTS, getLocalSetting, setLocalSetting } from '@/libs/userlocalManager';
|
import { CONSTANTS, getLocalSetting, setLocalSetting } from '@/libs/userlocalManager';
|
||||||
|
import * as Analytics from '@/libs/analytics';
|
||||||
|
|
||||||
export default function (to, from, next) {
|
export default function (to, from, next) {
|
||||||
const { redirect } = to.params;
|
const { redirect } = to.params;
|
||||||
@@ -13,9 +14,105 @@ export default function (to, from, next) {
|
|||||||
setLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE, JSON.stringify(newAppState));
|
setLocalSetting(CONSTANTS.savedAppStateValues.SAVED_APP_STATE, JSON.stringify(newAppState));
|
||||||
}
|
}
|
||||||
window.close();
|
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:
|
default:
|
||||||
next({ name: 'notFound' });
|
return next({ name: 'notFound' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -425,6 +425,11 @@ router.beforeEach((to, from, next) => {
|
|||||||
return null;
|
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) {
|
if ((to.name === 'stats' || to.name === 'achievements' || to.name === 'profile') && from.name !== null) {
|
||||||
router.app.$emit('habitica:show-profile', {
|
router.app.$emit('habitica:show-profile', {
|
||||||
startingPage: to.name,
|
startingPage: to.name,
|
||||||
|
|||||||
@@ -135,6 +135,7 @@
|
|||||||
"sendGiftMessagePlaceholder": "Personal message (optional)",
|
"sendGiftMessagePlaceholder": "Personal message (optional)",
|
||||||
"sendGiftSubscription": "<%= months %> Month(s): $<%= price %> USD",
|
"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 %>.",
|
"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",
|
"battleWithFriends": "Battle Monsters With Friends",
|
||||||
"startAParty": "Start a Party",
|
"startAParty": "Start a Party",
|
||||||
"partyUpName": "Party Up",
|
"partyUpName": "Party Up",
|
||||||
|
|||||||
@@ -43,6 +43,9 @@
|
|||||||
"namedHatchingPotion": "<%= type %> Hatching Potion",
|
"namedHatchingPotion": "<%= type %> Hatching Potion",
|
||||||
"buyGems": "Buy Gems",
|
"buyGems": "Buy Gems",
|
||||||
"purchaseGems": "Purchase Gems",
|
"purchaseGems": "Purchase Gems",
|
||||||
|
"nGems": "<%= nGems %> Gems",
|
||||||
|
"nGemsGift": "<%= nGems %> Gems (Gift)",
|
||||||
|
"nMonthsSubscriptionGift": "<%= nMonths %> Month(s) Subscription (Gift)",
|
||||||
"items": "Items",
|
"items": "Items",
|
||||||
"AZ": "A-Z",
|
"AZ": "A-Z",
|
||||||
"sort": "Sort",
|
"sort": "Sort",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
"giftSubscriptionText4": "Thanks for supporting Habitica!",
|
"giftSubscriptionText4": "Thanks for supporting Habitica!",
|
||||||
"organization": "Organization",
|
"organization": "Organization",
|
||||||
"groupPlans": "Group Plans",
|
"groupPlans": "Group Plans",
|
||||||
"subscribe": "Subscribe",
|
|
||||||
"nowSubscribed": "You are now subscribed to Habitica!",
|
"nowSubscribed": "You are now subscribed to Habitica!",
|
||||||
"cancelSub": "Cancel Subscription",
|
"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.",
|
"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",
|
"mysterySetwondercon": "Wondercon",
|
||||||
"subUpdateCard": "Update Credit Card",
|
"subUpdateCard": "Update Credit Card",
|
||||||
"subUpdateTitle": "Update",
|
"subUpdateTitle": "Update",
|
||||||
"subUpdateDescription": "Update the card to be charged.",
|
|
||||||
"notEnoughHourglasses": "You don't have enough Mystic Hourglasses.",
|
"notEnoughHourglasses": "You don't have enough Mystic Hourglasses.",
|
||||||
"backgroundAlreadyOwned": "Background already owned.",
|
"backgroundAlreadyOwned": "Background already owned.",
|
||||||
"petsAlreadyOwned": "Pet 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 MIN_SHORTNAME_SIZE_FOR_CHALLENGES = 3;
|
||||||
export const MAX_MESSAGE_LENGTH = 3000;
|
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_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
|
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
|
// a shadow-muted user's post starts with this many flags
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
SUPPORTED_SOCIAL_NETWORKS,
|
SUPPORTED_SOCIAL_NETWORKS,
|
||||||
TAVERN_ID,
|
TAVERN_ID,
|
||||||
MAX_MESSAGE_LENGTH,
|
MAX_MESSAGE_LENGTH,
|
||||||
|
MAX_GIFT_MESSAGE_LENGTH,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import content from './content/index';
|
import content from './content/index';
|
||||||
import * as count from './count';
|
import * as count from './count';
|
||||||
@@ -111,6 +112,7 @@ api.constants = {
|
|||||||
CHAT_FLAG_FROM_SHADOW_MUTE,
|
CHAT_FLAG_FROM_SHADOW_MUTE,
|
||||||
MINIMUM_PASSWORD_LENGTH,
|
MINIMUM_PASSWORD_LENGTH,
|
||||||
MAX_MESSAGE_LENGTH,
|
MAX_MESSAGE_LENGTH,
|
||||||
|
MAX_GIFT_MESSAGE_LENGTH,
|
||||||
};
|
};
|
||||||
// TODO Move these under api.constants
|
// TODO Move these under api.constants
|
||||||
api.maxLevel = MAX_LEVEL;
|
api.maxLevel = MAX_LEVEL;
|
||||||
|
|||||||
@@ -198,8 +198,7 @@ api.createGroupPlan = {
|
|||||||
const results = await Promise.all([user.save(), group.save()]);
|
const results = await Promise.all([user.save(), group.save()]);
|
||||||
const savedGroup = results[1];
|
const savedGroup = results[1];
|
||||||
|
|
||||||
// Analytics
|
res.analytics.track('join group', {
|
||||||
const analyticsObject = {
|
|
||||||
uuid: user._id,
|
uuid: user._id,
|
||||||
hitType: 'event',
|
hitType: 'event',
|
||||||
category: 'behavior',
|
category: 'behavior',
|
||||||
@@ -207,27 +206,31 @@ api.createGroupPlan = {
|
|||||||
groupType: savedGroup.type,
|
groupType: savedGroup.type,
|
||||||
privacy: savedGroup.privacy,
|
privacy: savedGroup.privacy,
|
||||||
headers: req.headers,
|
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') {
|
if (req.body.paymentType === 'Stripe') {
|
||||||
const token = req.body.id;
|
const {
|
||||||
const gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
|
gift, sub: subKey, gemsBlock, coupon,
|
||||||
const sub = req.query.sub ? common.content.subscriptionBlocks[req.query.sub] : false;
|
} = req.body;
|
||||||
const groupId = savedGroup._id;
|
|
||||||
const { email } = req.body;
|
|
||||||
const { headers } = req;
|
|
||||||
const { coupon } = req.query;
|
|
||||||
|
|
||||||
await stripePayments.checkout({
|
const sub = subKey ? common.content.subscriptionBlocks[subKey] : false;
|
||||||
token,
|
const groupId = savedGroup._id;
|
||||||
user,
|
|
||||||
gift,
|
const session = await stripePayments.createCheckoutSession({
|
||||||
sub,
|
user, gemsBlock, gift, sub, groupId, coupon, headers: req.headers,
|
||||||
groupId,
|
});
|
||||||
email,
|
|
||||||
headers,
|
res.respond(200, {
|
||||||
coupon,
|
sessionId: session.id,
|
||||||
|
group: groupResponse,
|
||||||
});
|
});
|
||||||
} else if (req.body.paymentType === 'Amazon') {
|
} else if (req.body.paymentType === 'Amazon') {
|
||||||
const { billingAgreementId } = req.body;
|
const { billingAgreementId } = req.body;
|
||||||
@@ -246,19 +249,9 @@ api.createGroupPlan = {
|
|||||||
groupId,
|
groupId,
|
||||||
headers,
|
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
|
* @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
|
* @apiName StripeCheckout
|
||||||
* @apiGroup Payments
|
* @apiGroup Payments
|
||||||
*
|
*
|
||||||
* @apiParam {String} id Body parameter - The token
|
* @apiParam (Body) {String} [gemsBlock] If purchasing a gem block, its key
|
||||||
* @apiParam {String} email Body parameter - the customer email
|
* @apiParam (Body) {Object} [gift] The gift object
|
||||||
* @apiParam {String} gift Query parameter - stringified json object, gift
|
* @apiParam (Body) {String} [sub] If purchasing a subscription, its key
|
||||||
* @apiParam {String} sub Query parameter - subscription, possible values are:
|
* @apiParam (Body) {UUID} [groupId] If purchasing a group plan, the group id
|
||||||
* basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo
|
* @apiParam (Body) {String} [coupon] Subscription Coupon
|
||||||
* @apiParam {String} coupon Query parameter - coupon for the matching subscription,
|
|
||||||
* required only for certain subscriptions
|
|
||||||
*
|
*
|
||||||
* @apiSuccess {Object} data Empty object
|
* @apiSuccess {String} data.sessionId The created checkout session id
|
||||||
* */
|
* */
|
||||||
api.checkout = {
|
api.createCheckoutSession = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/stripe/checkout',
|
url: '/stripe/checkout-session',
|
||||||
middlewares: [authWithHeaders()],
|
middlewares: [authWithHeaders()],
|
||||||
async handler (req, res) {
|
async handler (req, res) {
|
||||||
// @TODO: These quer params need to be changed to body
|
|
||||||
const token = req.body.id;
|
|
||||||
const { user } = res.locals;
|
const { user } = res.locals;
|
||||||
const gift = req.query.gift ? JSON.parse(req.query.gift) : undefined;
|
const {
|
||||||
const sub = req.query.sub ? shared.content.subscriptionBlocks[req.query.sub] : false;
|
gift, sub: subKey, gemsBlock, coupon, groupId,
|
||||||
const { groupId, coupon, gemsBlock } = req.query;
|
} = req.body;
|
||||||
|
|
||||||
await stripePayments.checkout({
|
const sub = subKey ? shared.content.subscriptionBlocks[subKey] : false;
|
||||||
token, user, gemsBlock, gift, sub, groupId, coupon, headers: req.headers,
|
|
||||||
|
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
|
* @apiName StripeSubscribeEdit
|
||||||
* @apiGroup Payments
|
* @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 = {
|
api.subscribeEdit = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/stripe/subscribe/edit',
|
url: '/stripe/subscribe/edit',
|
||||||
middlewares: [authWithHeaders()],
|
middlewares: [authWithHeaders()],
|
||||||
async handler (req, res) {
|
async handler (req, res) {
|
||||||
const token = req.body.id;
|
|
||||||
const { groupId } = req.body;
|
const { groupId } = req.body;
|
||||||
const { user } = res.locals;
|
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
|
* @api {get} /stripe/subscribe/cancel Cancel Stripe subscription
|
||||||
* @apiName StripeSubscribeCancel
|
* @apiName StripeSubscribeCancel
|
||||||
* @apiGroup Payments
|
* @apiGroup Payments
|
||||||
|
*
|
||||||
|
* @apiParam (Body) {UUID} [groupId] If editing a group plan, the group id
|
||||||
|
*
|
||||||
* */
|
* */
|
||||||
api.subscribeCancel = {
|
api.subscribeCancel = {
|
||||||
method: 'GET',
|
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 = {
|
api.handleWebhooks = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/stripe/webhooks',
|
url: '/stripe/webhooks',
|
||||||
async handler (req, res) {
|
async handler (req, res) {
|
||||||
await stripePayments.handleWebhooks({ requestBody: req.body });
|
await stripePayments.handleWebhooks({ body: req.body, headers: req.headers });
|
||||||
|
|
||||||
return res.respond(200, {});
|
return res.respond(200, {});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ function removeTerminatedSubscription (user) {
|
|||||||
_.merge(plan, {
|
_.merge(plan, {
|
||||||
planId: null,
|
planId: null,
|
||||||
customerId: null,
|
customerId: null,
|
||||||
|
subscriptionId: null,
|
||||||
paymentMethod: null,
|
paymentMethod: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { // eslint-disable-line import/no-cycle
|
|||||||
basicFields as basicGroupFields,
|
basicFields as basicGroupFields,
|
||||||
} from '../../models/group';
|
} from '../../models/group';
|
||||||
import { model as Coupon } from '../../models/coupon';
|
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
|
// TODO better handling of errors
|
||||||
|
|
||||||
@@ -117,6 +117,7 @@ api.checkout = async function checkout (options = {}) {
|
|||||||
|
|
||||||
if (gift) {
|
if (gift) {
|
||||||
gift.member = await User.findById(gift.uuid).exec();
|
gift.member = await User.findById(gift.uuid).exec();
|
||||||
|
validateGiftMessage(gift, user);
|
||||||
|
|
||||||
if (gift.type === this.constants.GIFT_TYPE_GEMS) {
|
if (gift.type === this.constants.GIFT_TYPE_GEMS) {
|
||||||
if (gift.gems.amount <= 0) {
|
if (gift.gems.amount <= 0) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import moment from 'moment';
|
|||||||
import shared from '../../../common';
|
import shared from '../../../common';
|
||||||
import iap from '../inAppPurchases';
|
import iap from '../inAppPurchases';
|
||||||
import payments from './payments';
|
import payments from './payments';
|
||||||
import { getGemsBlock } from './gems';
|
import { getGemsBlock, validateGiftMessage } from './gems';
|
||||||
import {
|
import {
|
||||||
NotAuthorized,
|
NotAuthorized,
|
||||||
BadRequest,
|
BadRequest,
|
||||||
@@ -28,6 +28,7 @@ api.verifyGemPurchase = async function verifyGemPurchase (options) {
|
|||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
if (gift) {
|
if (gift) {
|
||||||
|
validateGiftMessage(gift, user);
|
||||||
gift.member = await User.findById(gift.uuid).exec();
|
gift.member = await User.findById(gift.uuid).exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +233,7 @@ api.noRenewSubscribe = async function noRenewSubscribe (options) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (gift) {
|
if (gift) {
|
||||||
|
validateGiftMessage(gift, user);
|
||||||
gift.member = await User.findById(gift.uuid).exec();
|
gift.member = await User.findById(gift.uuid).exec();
|
||||||
gift.subscription = sub;
|
gift.subscription = sub;
|
||||||
data.gift = gift;
|
data.gift = gift;
|
||||||
|
|||||||
@@ -62,6 +62,17 @@ async function buyGemGift (data) {
|
|||||||
await data.gift.member.save();
|
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) {
|
export function getGemsBlock (gemsBlock) {
|
||||||
const block = shared.content.gems[gemsBlock];
|
const block = shared.content.gems[gemsBlock];
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from '../errors';
|
} from '../errors';
|
||||||
import { model as IapPurchaseReceipt } from '../../models/iapPurchaseReceipt';
|
import { model as IapPurchaseReceipt } from '../../models/iapPurchaseReceipt';
|
||||||
import { model as User } from '../../models/user';
|
import { model as User } from '../../models/user';
|
||||||
import { getGemsBlock } from './gems';
|
import { getGemsBlock, validateGiftMessage } from './gems';
|
||||||
|
|
||||||
const api = {};
|
const api = {};
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ api.verifyGemPurchase = async function verifyGemPurchase (options) {
|
|||||||
|
|
||||||
if (gift) {
|
if (gift) {
|
||||||
gift.member = await User.findById(gift.uuid).exec();
|
gift.member = await User.findById(gift.uuid).exec();
|
||||||
|
validateGiftMessage(gift, user);
|
||||||
}
|
}
|
||||||
const receiver = gift ? gift.member : user;
|
const receiver = gift ? gift.member : user;
|
||||||
const receiverCanGetGems = await receiver.canGetGems();
|
const receiverCanGetGems = await receiver.canGetGems();
|
||||||
@@ -214,6 +215,7 @@ api.noRenewSubscribe = async function noRenewSubscribe (options) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (gift) {
|
if (gift) {
|
||||||
|
validateGiftMessage(gift, user);
|
||||||
gift.member = await User.findById(gift.uuid).exec();
|
gift.member = await User.findById(gift.uuid).exec();
|
||||||
gift.subscription = sub;
|
gift.subscription = sub;
|
||||||
data.gift = gift;
|
data.gift = gift;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import paypal from 'paypal-rest-sdk';
|
|||||||
import cc from 'coupon-code';
|
import cc from 'coupon-code';
|
||||||
import shared from '../../../common';
|
import shared from '../../../common';
|
||||||
import payments from './payments'; // eslint-disable-line import/no-cycle
|
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 Coupon } from '../../models/coupon';
|
||||||
import { model as User } from '../../models/user'; // 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
|
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();
|
const member = await User.findById(gift.uuid).exec();
|
||||||
gift.member = member;
|
gift.member = member;
|
||||||
|
|
||||||
|
validateGiftMessage(gift, user);
|
||||||
|
|
||||||
if (gift.type === 'gems') {
|
if (gift.type === 'gems') {
|
||||||
if (gift.gems.amount <= 0) {
|
if (gift.gems.amount <= 0) {
|
||||||
throw new BadRequest(i18n.t('badAmountOfGemsToPurchase'));
|
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 stripeModule from 'stripe';
|
||||||
import nconf from 'nconf';
|
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) {
|
function setStripeApi (stripeInc) {
|
||||||
stripe = stripeInc;
|
stripe = stripeInc;
|
||||||
|
|||||||
@@ -1,161 +1,187 @@
|
|||||||
import cc from 'coupon-code';
|
import nconf from 'nconf';
|
||||||
|
|
||||||
import { getStripeApi } from './api';
|
import { getStripeApi } from './api';
|
||||||
import { model as User } from '../../../models/user'; // eslint-disable-line import/no-cycle
|
import {
|
||||||
import { model as Coupon } from '../../../models/coupon';
|
NotAuthorized,
|
||||||
|
NotFound,
|
||||||
|
} from '../../errors';
|
||||||
import { // eslint-disable-line import/no-cycle
|
import { // eslint-disable-line import/no-cycle
|
||||||
model as Group,
|
model as Group,
|
||||||
basicFields as basicGroupFields,
|
basicFields as basicGroupFields,
|
||||||
} from '../../../models/group';
|
} from '../../../models/group';
|
||||||
import shared from '../../../../common';
|
import shared from '../../../../common';
|
||||||
import {
|
import { getOneTimePaymentInfo } from './oneTimePayments'; // eslint-disable-line import/no-cycle
|
||||||
BadRequest,
|
import { checkSubData } from './subscriptions'; // eslint-disable-line import/no-cycle
|
||||||
NotAuthorized,
|
import { validateGiftMessage } from '../gems'; // eslint-disable-line import/no-cycle
|
||||||
} 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';
|
|
||||||
|
|
||||||
function getGiftAmount (gift) {
|
const BASE_URL = nconf.get('BASE_URL');
|
||||||
if (gift.type === 'subscription') {
|
|
||||||
return `${shared.content.subscriptionBlocks[gift.subscription.key].price * 100}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gift.gems.amount <= 0) {
|
export async function createCheckoutSession (options, stripeInc) {
|
||||||
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) {
|
|
||||||
const {
|
const {
|
||||||
token,
|
|
||||||
user,
|
user,
|
||||||
gift,
|
gift,
|
||||||
gemsBlock,
|
gemsBlock: gemsBlockKey,
|
||||||
sub,
|
sub,
|
||||||
groupId,
|
groupId,
|
||||||
email,
|
|
||||||
headers,
|
|
||||||
coupon,
|
coupon,
|
||||||
} = options;
|
} = options;
|
||||||
let response;
|
|
||||||
let subscriptionId;
|
|
||||||
|
|
||||||
// @TODO: We need to mock this, but curently we don't have correct
|
// @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?
|
// Dependency Injection. And the Stripe Api doesn't seem to be a singleton?
|
||||||
let stripeApi = getStripeApi();
|
let stripeApi = getStripeApi();
|
||||||
if (stripeInc) stripeApi = stripeInc;
|
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) {
|
if (gift) {
|
||||||
const member = await User.findById(gift.uuid).exec();
|
if (gift.type === 'subscription') {
|
||||||
gift.member = member;
|
productName = shared.i18n.t('nMonthsSubscriptionGift', { nMonths: subscription.months }, user.preferences.language);
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
} else {
|
} 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) {
|
lineItems = [{
|
||||||
await payments.createSubscription({
|
price_data: {
|
||||||
user,
|
product_data: {
|
||||||
customerId: response.id,
|
name: productName,
|
||||||
paymentMethod: this.constants.PAYMENT_METHOD,
|
},
|
||||||
sub,
|
unit_amount: amount,
|
||||||
headers,
|
currency: 'usd',
|
||||||
groupId,
|
},
|
||||||
subscriptionId,
|
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({
|
app.use(bodyParser.urlencoded({
|
||||||
extended: true, // Uses 'qs' library as old connect middleware
|
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(methodOverride());
|
||||||
|
|
||||||
app.use(cookieSession({
|
app.use(cookieSession({
|
||||||
|
|||||||
Reference in New Issue
Block a user