From 6d34319455c4c01e06eea06d32006d304213d810 Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Mon, 14 Dec 2020 15:59:17 +0100 Subject: [PATCH] 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 --- config.json.example | 1 + package-lock.json | 7 +- package.json | 2 +- .../libs/payments/amazon/checkout.test.js | 5 + test/api/unit/libs/payments/apple.test.js | 9 +- test/api/unit/libs/payments/gems.test.js | 54 +- test/api/unit/libs/payments/google.test.js | 10 +- .../group-plans/group-payments-create.test.js | 4 +- .../libs/payments/paypal/checkout.test.js | 14 +- .../stripe/cancel-subscription.test.js | 149 ---- .../stripe/checkout-subscription.test.js | 321 --------- .../libs/payments/stripe/checkout.test.js | 673 ++++++++++++------ .../payments/stripe/edit-subscription.test.js | 151 ---- .../payments/stripe/oneTimePayments.test.js | 316 ++++++++ .../payments/stripe/subscriptions.test.js | 442 ++++++++++++ .../stripe/upgrade-group-plan.test.js | 70 -- ...andle-webhook.test.js => webhooks.test.js} | 170 +++-- ...T-payments_stripe_checkout-session.test.js | 45 ++ .../POST-payments_stripe_checkout.test.js | 79 -- ...OST-payments_stripe_subscribe_edit.test.js | 76 +- .../POST-payments_stripe_webhooks.test.js | 30 + .../src/components/group-plans/billing.vue | 2 +- .../group-plans/createGroupModalPages.vue | 2 +- .../src/components/groups/groupPlan.vue | 4 +- .../src/components/payments/buyGemsModal.vue | 2 +- .../src/components/payments/sendGemsModal.vue | 6 +- .../src/components/settings/subscription.vue | 2 +- .../settings/subscriptionOptions.vue | 5 +- website/client/src/libs/payments.js | 2 +- website/client/src/mixins/payments.js | 205 +++--- website/client/src/router/handleRedirect.js | 101 ++- website/client/src/router/index.js | 5 + website/common/locales/en/groups.json | 1 + website/common/locales/en/npc.json | 3 + website/common/locales/en/subscriber.json | 2 - website/common/script/constants.js | 2 + website/common/script/index.js | 2 + website/server/controllers/api-v3/groups.js | 55 +- .../controllers/top-level/payments/stripe.js | 63 +- website/server/libs/cron.js | 1 + website/server/libs/payments/amazon.js | 3 +- website/server/libs/payments/apple.js | 4 +- website/server/libs/payments/gems.js | 11 + website/server/libs/payments/google.js | 4 +- website/server/libs/payments/paypal.js | 4 +- website/server/libs/payments/stripe.js | 243 ------- website/server/libs/payments/stripe/api.js | 4 +- .../server/libs/payments/stripe/checkout.js | 288 ++++---- website/server/libs/payments/stripe/index.js | 77 ++ .../libs/payments/stripe/oneTimePayments.js | 97 +++ .../libs/payments/stripe/subscriptions.js | 147 ++++ .../server/libs/payments/stripe/webhooks.js | 132 ++++ website/server/middlewares/index.js | 11 +- 53 files changed, 2457 insertions(+), 1661 deletions(-) delete mode 100644 test/api/unit/libs/payments/stripe/cancel-subscription.test.js delete mode 100644 test/api/unit/libs/payments/stripe/checkout-subscription.test.js delete mode 100644 test/api/unit/libs/payments/stripe/edit-subscription.test.js create mode 100644 test/api/unit/libs/payments/stripe/oneTimePayments.test.js create mode 100644 test/api/unit/libs/payments/stripe/subscriptions.test.js delete mode 100644 test/api/unit/libs/payments/stripe/upgrade-group-plan.test.js rename test/api/unit/libs/payments/stripe/{handle-webhook.test.js => webhooks.test.js} (54%) create mode 100644 test/api/v3/integration/payments/stripe/POST-payments_stripe_checkout-session.test.js delete mode 100644 test/api/v3/integration/payments/stripe/POST-payments_stripe_checkout.test.js create mode 100644 test/api/v3/integration/payments/stripe/POST-payments_stripe_webhooks.test.js delete mode 100644 website/server/libs/payments/stripe.js create mode 100644 website/server/libs/payments/stripe/index.js create mode 100644 website/server/libs/payments/stripe/oneTimePayments.js create mode 100644 website/server/libs/payments/stripe/subscriptions.js create mode 100644 website/server/libs/payments/stripe/webhooks.js diff --git a/config.json.example b/config.json.example index 8d8ff52992..04c1c535b4 100644 --- a/config.json.example +++ b/config.json.example @@ -71,6 +71,7 @@ "SLACK_URL": "https://hooks.slack.com/services/some-url", "STRIPE_API_KEY": "aaaabbbbccccddddeeeeffff00001111", "STRIPE_PUB_KEY": "22223333444455556666777788889999", + "STRIPE_WEBHOOKS_ENDPOINT_SECRET": "111111", "TRANSIFEX_SLACK_CHANNEL": "transifex", "WEB_CONCURRENCY": 1, "SKIP_SSL_CHECK_KEY": "key", diff --git a/package-lock.json b/package-lock.json index 75f582fbde..45fb1d9528 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12709,10 +12709,11 @@ } }, "stripe": { - "version": "7.15.0", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-7.15.0.tgz", - "integrity": "sha512-TmouNGv1rIU7cgw7iFKjdQueJSwYKdPRPBuO7eNjrRliZUnsf2bpJqYe+n6ByarUJr38KmhLheVUxDyRawByPQ==", + "version": "8.121.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-8.121.0.tgz", + "integrity": "sha512-Uswmut57hVdyPrb+EJUTWbrLcTIEL4LS5T6UQZPO5AJNYT0PGHajgY1esQwmV7yVBL+Kgt3y/16zIAY/gAwifg==", "requires": { + "@types/node": ">=8.1.0", "qs": "^6.6.0" }, "dependencies": { diff --git a/package.json b/package.json index 65b04d9824..1dce8c2a4d 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "remove-markdown": "^0.3.0", "rimraf": "^3.0.2", "short-uuid": "^4.1.0", - "stripe": "^7.15.0", + "stripe": "^8.121.0", "superagent": "^6.1.0", "universal-analytics": "^0.4.23", "useragent": "^2.1.9", diff --git a/test/api/unit/libs/payments/amazon/checkout.test.js b/test/api/unit/libs/payments/amazon/checkout.test.js index 1a29543e79..02402b14a9 100644 --- a/test/api/unit/libs/payments/amazon/checkout.test.js +++ b/test/api/unit/libs/payments/amazon/checkout.test.js @@ -3,6 +3,7 @@ import amzLib from '../../../../../../website/server/libs/payments/amazon'; import payments from '../../../../../../website/server/libs/payments/payments'; import common from '../../../../../../website/common'; import apiError from '../../../../../../website/server/libs/apiError'; +import * as gems from '../../../../../../website/server/libs/payments/gems'; const { i18n } = common; @@ -88,6 +89,7 @@ describe('Amazon Payments - Checkout', () => { paymentCreateSubscritionStub.resolves({}); sinon.stub(common, 'uuid').returns('uuid-generated'); + sandbox.stub(gems, 'validateGiftMessage'); }); afterEach(() => { @@ -111,7 +113,10 @@ describe('Amazon Payments - Checkout', () => { if (gift) { expectedArgs.gift = gift; expectedArgs.gemsBlock = undefined; + expect(gems.validateGiftMessage).to.be.calledOnce; + expect(gems.validateGiftMessage).to.be.calledWith(gift, user); } else { + expect(gems.validateGiftMessage).to.not.be.called; expectedArgs.gemsBlock = gemsBlock; } expect(paymentBuyGemsStub).to.be.calledWith(expectedArgs); diff --git a/test/api/unit/libs/payments/apple.test.js b/test/api/unit/libs/payments/apple.test.js index 437e83001d..08cb34607e 100644 --- a/test/api/unit/libs/payments/apple.test.js +++ b/test/api/unit/libs/payments/apple.test.js @@ -5,6 +5,7 @@ import applePayments from '../../../../../website/server/libs/payments/apple'; import iap from '../../../../../website/server/libs/inAppPurchases'; import { model as User } from '../../../../../website/server/models/user'; import common from '../../../../../website/common'; +import * as gems from '../../../../../website/server/libs/payments/gems'; const { i18n } = common; @@ -15,7 +16,7 @@ describe('Apple Payments', () => { let sku; let user; let token; let receipt; let headers; let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let paymentBuyGemsStub; let - iapGetPurchaseDataStub; + iapGetPurchaseDataStub; let validateGiftMessageStub; beforeEach(() => { token = 'testToken'; @@ -36,6 +37,7 @@ describe('Apple Payments', () => { transactionId: token, }]); paymentBuyGemsStub = sinon.stub(payments, 'buyGems').resolves({}); + validateGiftMessageStub = sinon.stub(gems, 'validateGiftMessage'); }); afterEach(() => { @@ -44,6 +46,7 @@ describe('Apple Payments', () => { iap.isValidated.restore(); iap.getPurchaseData.restore(); payments.buyGems.restore(); + gems.validateGiftMessage.restore(); }); it('should throw an error if receipt is invalid', async () => { @@ -143,6 +146,7 @@ describe('Apple Payments', () => { expect(iapIsValidatedStub).to.be.calledOnce; expect(iapIsValidatedStub).to.be.calledWith({}); expect(iapGetPurchaseDataStub).to.be.calledOnce; + expect(validateGiftMessageStub).to.not.be.called; expect(paymentBuyGemsStub).to.be.calledOnce; expect(paymentBuyGemsStub).to.be.calledWith({ @@ -180,6 +184,9 @@ describe('Apple Payments', () => { expect(iapIsValidatedStub).to.be.calledWith({}); expect(iapGetPurchaseDataStub).to.be.calledOnce; + expect(validateGiftMessageStub).to.be.calledOnce; + expect(validateGiftMessageStub).to.be.calledWith(gift, user); + expect(paymentBuyGemsStub).to.be.calledOnce; expect(paymentBuyGemsStub).to.be.calledWith({ user, diff --git a/test/api/unit/libs/payments/gems.test.js b/test/api/unit/libs/payments/gems.test.js index 29d632d5cc..635a26a1d7 100644 --- a/test/api/unit/libs/payments/gems.test.js +++ b/test/api/unit/libs/payments/gems.test.js @@ -1,5 +1,11 @@ import common from '../../../../../website/common'; -import { getGemsBlock } from '../../../../../website/server/libs/payments/gems'; +import { + getGemsBlock, + validateGiftMessage, +} from '../../../../../website/server/libs/payments/gems'; +import { model as User } from '../../../../../website/server/models/user'; + +const { i18n } = common; describe('payments/gems', () => { describe('#getGemsBlock', () => { @@ -11,4 +17,50 @@ describe('payments/gems', () => { expect(getGemsBlock('21gems')).to.equal(common.content.gems['21gems']); }); }); + + describe('#validateGiftMessage', () => { + let user; + let gift; + + beforeEach(() => { + user = new User(); + + gift = { + message: (` // exactly 201 chars +A gift message that is over the 200 chars limit. +A gift message that is over the 200 chars limit. +A gift message that is over the 200 chars limit. +A gift message that is over the 200 chars limit. 1 + `).trim().substring(0, 201), + }; + + expect(gift.message.length).to.equal(201); + }); + + it('throws if the gift message is too long', () => { + let expectedErr; + + try { + validateGiftMessage(gift, user); + } catch (err) { + expectedErr = err; + } + + expect(expectedErr).to.exist; + expect(expectedErr).to.eql({ + httpCode: 400, + name: 'BadRequest', + message: i18n.t('giftMessageTooLong', { maxGiftMessageLength: 200 }), + }); + }); + + it('does not throw if the gift message is not too long', () => { + gift.message = gift.message.substring(0, 200); + expect(() => validateGiftMessage(gift, user)).to.not.throw; + }); + + it('does not throw if it is not a gift', () => { + expect(() => validateGiftMessage(null, user)).to.not.throw; + }); + }); }); diff --git a/test/api/unit/libs/payments/google.test.js b/test/api/unit/libs/payments/google.test.js index 8396ba29a9..266abf04b3 100644 --- a/test/api/unit/libs/payments/google.test.js +++ b/test/api/unit/libs/payments/google.test.js @@ -5,6 +5,7 @@ import googlePayments from '../../../../../website/server/libs/payments/google'; import iap from '../../../../../website/server/libs/inAppPurchases'; import { model as User } from '../../../../../website/server/models/user'; import common from '../../../../../website/common'; +import * as gems from '../../../../../website/server/libs/payments/gems'; const { i18n } = common; @@ -15,7 +16,7 @@ describe('Google Payments', () => { let sku; let user; let token; let receipt; let signature; let headers; const gemsBlock = common.content.gems['21gems']; let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let - paymentBuyGemsStub; + paymentBuyGemsStub; let validateGiftMessageStub; beforeEach(() => { sku = 'com.habitrpg.android.habitica.iap.21gems'; @@ -31,6 +32,7 @@ describe('Google Payments', () => { iapIsValidatedStub = sinon.stub(iap, 'isValidated') .returns(true); paymentBuyGemsStub = sinon.stub(payments, 'buyGems').resolves({}); + validateGiftMessageStub = sinon.stub(gems, 'validateGiftMessage'); }); afterEach(() => { @@ -38,6 +40,7 @@ describe('Google Payments', () => { iap.validate.restore(); iap.isValidated.restore(); payments.buyGems.restore(); + gems.validateGiftMessage.restore(); }); it('should throw an error if receipt is invalid', async () => { @@ -89,6 +92,8 @@ describe('Google Payments', () => { user, receipt, signature, headers, }); + expect(validateGiftMessageStub).to.not.be.called; + expect(iapSetupStub).to.be.calledOnce; expect(iapValidateStub).to.be.calledOnce; expect(iapValidateStub).to.be.calledWith(iap.GOOGLE, { @@ -119,6 +124,9 @@ describe('Google Payments', () => { user, gift, receipt, signature, headers, }); + expect(validateGiftMessageStub).to.be.calledOnce; + expect(validateGiftMessageStub).to.be.calledWith(gift, user); + expect(iapSetupStub).to.be.calledOnce; expect(iapValidateStub).to.be.calledOnce; expect(iapValidateStub).to.be.calledWith(iap.GOOGLE, { diff --git a/test/api/unit/libs/payments/group-plans/group-payments-create.test.js b/test/api/unit/libs/payments/group-plans/group-payments-create.test.js index 383d7dfe6f..692c5a658b 100644 --- a/test/api/unit/libs/payments/group-plans/group-payments-create.test.js +++ b/test/api/unit/libs/payments/group-plans/group-payments-create.test.js @@ -22,7 +22,9 @@ describe('Purchasing a group plan for group', () => { let plan; let group; let user; let data; - const stripe = stripeModule('test'); + const stripe = stripeModule('test', { + apiVersion: '2020-08-27', + }); const groupLeaderName = 'sender'; const groupName = 'test group'; diff --git a/test/api/unit/libs/payments/paypal/checkout.test.js b/test/api/unit/libs/payments/paypal/checkout.test.js index da8e57fe66..6350f278a6 100644 --- a/test/api/unit/libs/payments/paypal/checkout.test.js +++ b/test/api/unit/libs/payments/paypal/checkout.test.js @@ -5,6 +5,7 @@ import paypalPayments from '../../../../../../website/server/libs/payments/paypa import { model as User } from '../../../../../../website/server/models/user'; import common from '../../../../../../website/common'; import apiError from '../../../../../../website/server/libs/apiError'; +import * as gems from '../../../../../../website/server/libs/payments/gems'; const BASE_URL = nconf.get('BASE_URL'); const { i18n } = common; @@ -48,6 +49,7 @@ describe('paypal - checkout', () => { .resolves({ links: [{ rel: 'approval_url', href: approvalHerf }], }); + sandbox.stub(gems, 'validateGiftMessage'); }); afterEach(() => { @@ -57,6 +59,7 @@ describe('paypal - checkout', () => { it('creates a link for gem purchases', async () => { const link = await paypalPayments.checkout({ user: new User(), gemsBlock: gemsBlockKey }); + expect(gems.validateGiftMessage).to.not.be.called; expect(paypalPaymentCreateStub).to.be.calledOnce; expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('Habitica Gems', 4.99)); expect(link).to.eql(approvalHerf); @@ -105,6 +108,7 @@ describe('paypal - checkout', () => { }); it('creates a link for gifting gems', async () => { + const user = new User(); const receivingUser = new User(); await receivingUser.save(); const gift = { @@ -115,14 +119,17 @@ describe('paypal - checkout', () => { }, }; - const link = await paypalPayments.checkout({ gift }); + const link = await paypalPayments.checkout({ user, gift }); + expect(gems.validateGiftMessage).to.be.calledOnce; + expect(gems.validateGiftMessage).to.be.calledWith(gift, user); expect(paypalPaymentCreateStub).to.be.calledOnce; expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('Habitica Gems (Gift)', '4.00')); expect(link).to.eql(approvalHerf); }); it('creates a link for gifting a subscription', async () => { + const user = new User(); const receivingUser = new User(); receivingUser.save(); const gift = { @@ -133,7 +140,10 @@ describe('paypal - checkout', () => { }, }; - const link = await paypalPayments.checkout({ gift }); + const link = await paypalPayments.checkout({ user, gift }); + + expect(gems.validateGiftMessage).to.be.calledOnce; + expect(gems.validateGiftMessage).to.be.calledWith(gift, user); expect(paypalPaymentCreateStub).to.be.calledOnce; expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('mo. Habitica Subscription (Gift)', '15.00')); diff --git a/test/api/unit/libs/payments/stripe/cancel-subscription.test.js b/test/api/unit/libs/payments/stripe/cancel-subscription.test.js deleted file mode 100644 index c1dc1c5f86..0000000000 --- a/test/api/unit/libs/payments/stripe/cancel-subscription.test.js +++ /dev/null @@ -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, - }); - }); - }); -}); diff --git a/test/api/unit/libs/payments/stripe/checkout-subscription.test.js b/test/api/unit/libs/payments/stripe/checkout-subscription.test.js deleted file mode 100644 index 887b1c2e8e..0000000000 --- a/test/api/unit/libs/payments/stripe/checkout-subscription.test.js +++ /dev/null @@ -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, - }); - }); -}); diff --git a/test/api/unit/libs/payments/stripe/checkout.test.js b/test/api/unit/libs/payments/stripe/checkout.test.js index 4e9f9d7585..fe132f4c5e 100644 --- a/test/api/unit/libs/payments/stripe/checkout.test.js +++ b/test/api/unit/libs/payments/stripe/checkout.test.js @@ -1,235 +1,484 @@ import stripeModule from 'stripe'; - -import { model as User } from '../../../../../../website/server/models/user'; -import stripePayments from '../../../../../../website/server/libs/payments/stripe'; -import payments from '../../../../../../website/server/libs/payments/payments'; +import nconf from 'nconf'; import common from '../../../../../../website/common'; -import apiError from '../../../../../../website/server/libs/apiError'; +import * as subscriptions from '../../../../../../website/server/libs/payments/stripe/subscriptions'; +import * as oneTimePayments from '../../../../../../website/server/libs/payments/stripe/oneTimePayments'; +import { + createCheckoutSession, + createEditCardCheckoutSession, +} from '../../../../../../website/server/libs/payments/stripe/checkout'; +import { + generateGroup, +} from '../../../../../helpers/api-unit.helper'; +import { model as User } from '../../../../../../website/server/models/user'; +import { model as Group } from '../../../../../../website/server/models/group'; +import * as gems from '../../../../../../website/server/libs/payments/gems'; const { i18n } = common; -describe('stripe - checkout', () => { - const subKey = 'basic_3mo'; - const stripe = stripeModule('test'); - let stripeChargeStub; let paymentBuyGemsStub; let - paymentCreateSubscritionStub; - let user; let gift; let groupId; let email; let headers; let coupon; let customerIdResponse; let - token; const gemsBlockKey = '21gems'; const gemsBlock = common.content.gems[gemsBlockKey]; - - beforeEach(() => { - user = new User(); - user.profile.name = 'sender'; - user.purchased.plan.customerId = 'customer-id'; - user.purchased.plan.planId = subKey; - user.purchased.plan.lastBillingDate = new Date(); - - token = 'test-token'; - - customerIdResponse = 'example-customerIdResponse'; - const stripCustomerResponse = { - id: customerIdResponse, - }; - stripeChargeStub = sinon.stub(stripe.charges, 'create').resolves(stripCustomerResponse); - paymentBuyGemsStub = sinon.stub(payments, 'buyGems').resolves({}); - paymentCreateSubscritionStub = sinon.stub(payments, 'createSubscription').resolves({}); +describe('Stripe - Checkout', () => { + const stripe = stripeModule('test', { + apiVersion: '2020-08-27', }); + const BASE_URL = nconf.get('BASE_URL'); + const redirectUrls = { + success_url: `${BASE_URL}/redirect/stripe-success-checkout`, + cancel_url: `${BASE_URL}/redirect/stripe-error-checkout`, + }; - afterEach(() => { - stripe.charges.create.restore(); - payments.buyGems.restore(); - payments.createSubscription.restore(); - }); + describe('createCheckoutSession', () => { + let user; + const sessionId = 'session-id'; - it('should error if there is no token', async () => { - await expect(stripePayments.checkout({ - user, - gift, - groupId, - email, - headers, - coupon, - }, stripe)) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 400, - message: 'Missing req.body.id', - name: 'BadRequest', + beforeEach(() => { + user = new User(); + sandbox.stub(stripe.checkout.sessions, 'create').returns(sessionId); + sandbox.stub(gems, 'validateGiftMessage'); + }); + + it('gems', async () => { + const amount = 999; + const gemsBlockKey = '21gems'; + sandbox.stub(oneTimePayments, 'getOneTimePaymentInfo').returns({ + amount, + gemsBlock: common.content.gems[gemsBlockKey], }); - }); - it('should error if gem amount is too low', async () => { - const receivingUser = new User(); - receivingUser.save(); - gift = { - type: 'gems', - gems: { - amount: 0, - uuid: receivingUser._id, - }, - }; + const res = await createCheckoutSession({ user, gemsBlock: gemsBlockKey }, stripe); + expect(res).to.equal(sessionId); - await expect(stripePayments.checkout({ - token, - user, - gift, - groupId, - email, - headers, - coupon, - }, stripe)) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 400, - message: 'Amount must be at least 1.', - name: 'BadRequest', + const metadata = { + type: 'gems', + userId: user._id, + gift: undefined, + sub: undefined, + gemsBlock: gemsBlockKey, + }; + + expect(gems.validateGiftMessage).to.not.be.called; + expect(oneTimePayments.getOneTimePaymentInfo).to.be.calledOnce; + expect(oneTimePayments.getOneTimePaymentInfo).to.be.calledWith(gemsBlockKey, undefined, user); + expect(stripe.checkout.sessions.create).to.be.calledOnce; + expect(stripe.checkout.sessions.create).to.be.calledWith({ + payment_method_types: ['card'], + metadata, + line_items: [{ + price_data: { + product_data: { + name: common.i18n.t('nGems', { nGems: 21 }), + }, + unit_amount: amount, + currency: 'usd', + }, + quantity: 1, + }], + mode: 'payment', + ...redirectUrls, }); - }); - - it('should error if user cannot get gems', async () => { - gift = undefined; - sinon.stub(user, 'canGetGems').resolves(false); - - await expect(stripePayments.checkout({ - token, - user, - gemsBlock: gemsBlockKey, - gift, - groupId, - email, - headers, - coupon, - }, stripe)).to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - message: i18n.t('groupPolicyCannotGetGems'), - name: 'NotAuthorized', - }); - }); - - it('should error if the gems block is invalid', async () => { - gift = undefined; - - await expect(stripePayments.checkout({ - token, - user, - gemsBlock: 'invalid', - gift, - groupId, - email, - headers, - coupon, - }, stripe)).to.eventually.be.rejected.and.to.eql({ - httpCode: 400, - message: apiError('invalidGemsBlock'), - name: 'BadRequest', - }); - }); - - it('should purchase gems', async () => { - gift = undefined; - sinon.stub(user, 'canGetGems').resolves(true); - - await stripePayments.checkout({ - token, - user, - gemsBlock: gemsBlockKey, - gift, - groupId, - email, - headers, - coupon, - }, stripe); - - expect(stripeChargeStub).to.be.calledOnce; - expect(stripeChargeStub).to.be.calledWith({ - amount: 499, - currency: 'usd', - card: token, }); - expect(paymentBuyGemsStub).to.be.calledOnce; - expect(paymentBuyGemsStub).to.be.calledWith({ - user, - customerId: customerIdResponse, - paymentMethod: 'Stripe', - gift, - gemsBlock, - }); - expect(user.canGetGems).to.be.calledOnce; - user.canGetGems.restore(); - }); + it('gems gift', async () => { + const receivingUser = new User(); + await receivingUser.save(); - it('should gift gems', async () => { - const receivingUser = new User(); - await receivingUser.save(); - gift = { - type: 'gems', - uuid: receivingUser._id, - gems: { - amount: 16, - }, - }; - - await stripePayments.checkout({ - token, - user, - gift, - groupId, - email, - headers, - coupon, - }, stripe); - - expect(stripeChargeStub).to.be.calledOnce; - expect(stripeChargeStub).to.be.calledWith({ - amount: '400', - currency: 'usd', - card: token, - }); - - expect(paymentBuyGemsStub).to.be.calledOnce; - expect(paymentBuyGemsStub).to.be.calledWith({ - user, - customerId: customerIdResponse, - paymentMethod: 'Gift', - gift, - gemsBlock: undefined, - }); - }); - - it('should gift a subscription', async () => { - const receivingUser = new User(); - receivingUser.save(); - gift = { - type: 'subscription', - subscription: { - key: subKey, + const gift = { + type: 'gems', uuid: receivingUser._id, - }, - }; + gems: { + amount: 4, + }, + }; + const amount = 100; + sandbox.stub(oneTimePayments, 'getOneTimePaymentInfo').returns({ + amount, + gemsBlock: null, + }); - await stripePayments.checkout({ - token, - user, - gift, - groupId, - email, - headers, - coupon, - }, stripe); + const res = await createCheckoutSession({ user, gift }, stripe); + expect(res).to.equal(sessionId); - gift.member = receivingUser; - expect(stripeChargeStub).to.be.calledOnce; - expect(stripeChargeStub).to.be.calledWith({ - amount: '1500', - currency: 'usd', - card: token, + const metadata = { + type: 'gift-gems', + userId: user._id, + gift: JSON.stringify(gift), + sub: undefined, + gemsBlock: undefined, + }; + + expect(gems.validateGiftMessage).to.be.calledOnce; + expect(gems.validateGiftMessage).to.be.calledWith(gift, user); + + expect(oneTimePayments.getOneTimePaymentInfo).to.be.calledOnce; + expect(oneTimePayments.getOneTimePaymentInfo).to.be.calledWith(undefined, gift, user); + expect(stripe.checkout.sessions.create).to.be.calledOnce; + expect(stripe.checkout.sessions.create).to.be.calledWith({ + payment_method_types: ['card'], + metadata, + line_items: [{ + price_data: { + product_data: { + name: common.i18n.t('nGemsGift', { nGems: 4 }), + }, + unit_amount: amount, + currency: 'usd', + }, + quantity: 1, + }], + mode: 'payment', + ...redirectUrls, + }); }); - expect(paymentCreateSubscritionStub).to.be.calledOnce; - expect(paymentCreateSubscritionStub).to.be.calledWith({ - user, - customerId: customerIdResponse, - paymentMethod: 'Gift', - gift, - gemsBlock: undefined, + it('subscription gift', async () => { + const receivingUser = new User(); + await receivingUser.save(); + const subKey = 'basic_3mo'; + + const gift = { + type: 'subscription', + uuid: receivingUser._id, + subscription: { + key: subKey, + }, + }; + const amount = 1500; + sandbox.stub(oneTimePayments, 'getOneTimePaymentInfo').returns({ + amount, + gemsBlock: null, + subscription: common.content.subscriptionBlocks[subKey], + }); + + const res = await createCheckoutSession({ user, gift }, stripe); + expect(res).to.equal(sessionId); + + const metadata = { + type: 'gift-sub', + userId: user._id, + gift: JSON.stringify(gift), + sub: undefined, + gemsBlock: undefined, + }; + + expect(oneTimePayments.getOneTimePaymentInfo).to.be.calledOnce; + expect(oneTimePayments.getOneTimePaymentInfo).to.be.calledWith(undefined, gift, user); + expect(stripe.checkout.sessions.create).to.be.calledOnce; + expect(stripe.checkout.sessions.create).to.be.calledWith({ + payment_method_types: ['card'], + metadata, + line_items: [{ + price_data: { + product_data: { + name: common.i18n.t('nMonthsSubscriptionGift', { nMonths: 3 }), + }, + unit_amount: amount, + currency: 'usd', + }, + quantity: 1, + }], + mode: 'payment', + ...redirectUrls, + }); + }); + + it('subscription', async () => { + const subKey = 'basic_3mo'; + const coupon = null; + sandbox.stub(subscriptions, 'checkSubData').returns(undefined); + const sub = common.content.subscriptionBlocks[subKey]; + + const res = await createCheckoutSession({ user, sub, coupon }, stripe); + expect(res).to.equal(sessionId); + + const metadata = { + type: 'subscription', + userId: user._id, + gift: undefined, + sub: JSON.stringify(sub), + }; + + expect(subscriptions.checkSubData).to.be.calledOnce; + expect(subscriptions.checkSubData).to.be.calledWith(sub, false, coupon); + expect(stripe.checkout.sessions.create).to.be.calledOnce; + expect(stripe.checkout.sessions.create).to.be.calledWith({ + payment_method_types: ['card'], + metadata, + line_items: [{ + price: sub.key, + quantity: 1, + // @TODO proper copy + }], + mode: 'subscription', + ...redirectUrls, + }); + }); + + it('throws if group does not exists', async () => { + const groupId = 'invalid'; + sandbox.stub(Group.prototype, 'getMemberCount').resolves(4); + + const subKey = 'group_monthly'; + const coupon = null; + const sub = common.content.subscriptionBlocks[subKey]; + + await expect(createCheckoutSession({ + user, sub, coupon, groupId, + }, stripe)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 404, + name: 'NotFound', + message: i18n.t('groupNotFound'), + }); + }); + + it('group plan', async () => { + const group = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + const groupId = group._id; + await group.save(); + sandbox.stub(Group.prototype, 'getMemberCount').resolves(4); + + // Add user to group + user.guilds.push(groupId); + await user.save(); + + const subKey = 'group_monthly'; + const coupon = null; + sandbox.stub(subscriptions, 'checkSubData').returns(undefined); + const sub = common.content.subscriptionBlocks[subKey]; + + const res = await createCheckoutSession({ + user, sub, coupon, groupId, + }, stripe); + expect(res).to.equal(sessionId); + + const metadata = { + type: 'subscription', + userId: user._id, + gift: undefined, + sub: JSON.stringify(sub), + groupId, + }; + + expect(Group.prototype.getMemberCount).to.be.calledOnce; + expect(subscriptions.checkSubData).to.be.calledOnce; + expect(subscriptions.checkSubData).to.be.calledWith(sub, true, coupon); + expect(stripe.checkout.sessions.create).to.be.calledOnce; + expect(stripe.checkout.sessions.create).to.be.calledWith({ + payment_method_types: ['card'], + metadata, + line_items: [{ + price: sub.key, + quantity: 6, + // @TODO proper copy + }], + mode: 'subscription', + ...redirectUrls, + }); + }); + + // no gift, sub or gem payment + it('throws if type is invalid', async () => { + await expect(createCheckoutSession({ user }, stripe)) + .to.eventually.be.rejected; + }); + }); + + describe('createEditCardCheckoutSession', () => { + let user; + const sessionId = 'session-id'; + const customerId = 'customerId'; + const subscriptionId = 'subscription-id'; + let subscriptionsListStub; + + beforeEach(() => { + user = new User(); + sandbox.stub(stripe.checkout.sessions, 'create').returns(sessionId); + subscriptionsListStub = sandbox.stub(stripe.subscriptions, 'list'); + subscriptionsListStub.resolves({ data: [{ id: subscriptionId }] }); + }); + + it('throws if no valid data is supplied', async () => { + await expect(createEditCardCheckoutSession({}, stripe)) + .to.eventually.be.rejected; + }); + + it('throws if customer does not exists', async () => { + await expect(createEditCardCheckoutSession({ user }, stripe)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('missingSubscription'), + }); + }); + + it('throws if subscription does not exists', async () => { + user.purchased.plan.customerId = customerId; + subscriptionsListStub.resolves({ data: [] }); + + await expect(createEditCardCheckoutSession({ user }, stripe)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('missingSubscription'), + }); + }); + it('change card for user subscription', async () => { + user.purchased.plan.customerId = customerId; + + const metadata = { + userId: user._id, + type: 'edit-card-user', + }; + + const res = await createEditCardCheckoutSession({ user }, stripe); + expect(res).to.equal(sessionId); + expect(subscriptionsListStub).to.be.calledOnce; + expect(subscriptionsListStub).to.be.calledWith({ customer: customerId }); + + expect(stripe.checkout.sessions.create).to.be.calledOnce; + expect(stripe.checkout.sessions.create).to.be.calledWith({ + mode: 'setup', + payment_method_types: ['card'], + metadata, + customer: customerId, + setup_intent_data: { + metadata: { + customer_id: customerId, + subscription_id: subscriptionId, + }, + }, + ...redirectUrls, + }); + }); + + it('throws if group does not exists', async () => { + const groupId = 'invalid'; + + await expect(createEditCardCheckoutSession({ user, groupId }, stripe)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 404, + name: 'NotFound', + message: i18n.t('groupNotFound'), + }); + }); + + describe('with group', () => { + let group; let groupId; + beforeEach(async () => { + group = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + groupId = group._id; + await group.save(); + }); + + it('throws if user is not allowed to change group plan', async () => { + const anotherUser = new User(); + anotherUser.guilds.push(groupId); + await anotherUser.save(); + + await expect(createEditCardCheckoutSession({ user: anotherUser, groupId }, stripe)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('onlyGroupLeaderCanManageSubscription'), + }); + }); + + it('throws if customer does not exists (group)', async () => { + await expect(createEditCardCheckoutSession({ user, groupId }, stripe)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('missingSubscription'), + }); + }); + + it('throws if subscription does not exists (group)', async () => { + group.purchased.plan.customerId = customerId; + subscriptionsListStub.resolves({ data: [] }); + + await expect(createEditCardCheckoutSession({ user, groupId }, stripe)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('missingSubscription'), + }); + }); + + it('change card for group plans - leader', async () => { + group.purchased.plan.customerId = customerId; + await group.save(); + + const metadata = { + userId: user._id, + type: 'edit-card-group', + groupId, + }; + + const res = await createEditCardCheckoutSession({ user, groupId }, stripe); + expect(res).to.equal(sessionId); + expect(subscriptionsListStub).to.be.calledOnce; + expect(subscriptionsListStub).to.be.calledWith({ customer: customerId }); + + expect(stripe.checkout.sessions.create).to.be.calledOnce; + expect(stripe.checkout.sessions.create).to.be.calledWith({ + mode: 'setup', + payment_method_types: ['card'], + metadata, + customer: customerId, + setup_intent_data: { + metadata: { + customer_id: customerId, + subscription_id: subscriptionId, + }, + }, + ...redirectUrls, + }); + }); + + it('change card for group plans - plan owner', async () => { + const anotherUser = new User(); + anotherUser.guilds.push(groupId); + await anotherUser.save(); + + group.purchased.plan.customerId = customerId; + group.purchased.plan.owner = anotherUser._id; + await group.save(); + + const metadata = { + userId: anotherUser._id, + type: 'edit-card-group', + groupId, + }; + + const res = await createEditCardCheckoutSession({ user: anotherUser, groupId }, stripe); + expect(res).to.equal(sessionId); + expect(subscriptionsListStub).to.be.calledOnce; + expect(subscriptionsListStub).to.be.calledWith({ customer: customerId }); + + expect(stripe.checkout.sessions.create).to.be.calledOnce; + expect(stripe.checkout.sessions.create).to.be.calledWith({ + mode: 'setup', + payment_method_types: ['card'], + metadata, + customer: customerId, + setup_intent_data: { + metadata: { + customer_id: customerId, + subscription_id: subscriptionId, + }, + }, + ...redirectUrls, + }); + }); }); }); }); diff --git a/test/api/unit/libs/payments/stripe/edit-subscription.test.js b/test/api/unit/libs/payments/stripe/edit-subscription.test.js deleted file mode 100644 index d0dabed6a5..0000000000 --- a/test/api/unit/libs/payments/stripe/edit-subscription.test.js +++ /dev/null @@ -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 }, - ); - }); - }); -}); diff --git a/test/api/unit/libs/payments/stripe/oneTimePayments.test.js b/test/api/unit/libs/payments/stripe/oneTimePayments.test.js new file mode 100644 index 0000000000..6036d74ea3 --- /dev/null +++ b/test/api/unit/libs/payments/stripe/oneTimePayments.test.js @@ -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, + }); + }); + }); + }); +}); diff --git a/test/api/unit/libs/payments/stripe/subscriptions.test.js b/test/api/unit/libs/payments/stripe/subscriptions.test.js new file mode 100644 index 0000000000..96beeee523 --- /dev/null +++ b/test/api/unit/libs/payments/stripe/subscriptions.test.js @@ -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, + }); + }); + }); + }); +}); diff --git a/test/api/unit/libs/payments/stripe/upgrade-group-plan.test.js b/test/api/unit/libs/payments/stripe/upgrade-group-plan.test.js deleted file mode 100644 index 045d119a0e..0000000000 --- a/test/api/unit/libs/payments/stripe/upgrade-group-plan.test.js +++ /dev/null @@ -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); - }); -}); diff --git a/test/api/unit/libs/payments/stripe/handle-webhook.test.js b/test/api/unit/libs/payments/stripe/webhooks.test.js similarity index 54% rename from test/api/unit/libs/payments/stripe/handle-webhook.test.js rename to test/api/unit/libs/payments/stripe/webhooks.test.js index 9be8121773..de8d515273 100644 --- a/test/api/unit/libs/payments/stripe/handle-webhook.test.js +++ b/test/api/unit/libs/payments/stripe/webhooks.test.js @@ -1,5 +1,5 @@ import stripeModule from 'stripe'; - +import nconf from 'nconf'; import { v4 as uuid } from 'uuid'; import moment from 'moment'; import { @@ -10,76 +10,104 @@ import stripePayments from '../../../../../../website/server/libs/payments/strip import payments from '../../../../../../website/server/libs/payments/payments'; import common from '../../../../../../website/common'; import logger from '../../../../../../website/server/libs/logger'; +import * as oneTimePayments from '../../../../../../website/server/libs/payments/stripe/oneTimePayments'; +import * as subscriptions from '../../../../../../website/server/libs/payments/stripe/subscriptions'; const { i18n } = common; describe('Stripe - Webhooks', () => { - const stripe = stripeModule('test'); + const stripe = stripeModule('test', { + apiVersion: '2020-08-27', + }); + const endpointSecret = nconf.get('STRIPE_WEBHOOKS_ENDPOINT_SECRET'); + const headers = {}; + const body = {}; describe('all events', () => { - const eventType = 'account.updated'; - const event = { id: 123 }; - const eventRetrieved = { type: eventType }; + let event; + let constructEventStub; beforeEach(() => { - sinon.stub(stripe.events, 'retrieve').resolves(eventRetrieved); - sinon.stub(logger, 'error'); + event = { type: 'payment_intent.created' }; + constructEventStub = sandbox.stub(stripe.webhooks, 'constructEvent'); + constructEventStub.returns(event); + sandbox.stub(logger, 'error'); }); - afterEach(() => { - stripe.events.retrieve.restore(); - logger.error.restore(); + it('throws if the event can\'t be validated', async () => { + const err = new Error('fail'); + constructEventStub.throws(err); + await expect(stripePayments.handleWebhooks({ body: event, headers }, stripe)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 400, + name: 'BadRequest', + message: `Webhook Error: ${err.message}`, + }); + + expect(logger.error).to.have.been.calledOnce; + const calledWith = logger.error.getCall(0).args; + expect(calledWith[0].message).to.equal('Error verifying Stripe webhook'); + expect(calledWith[1]).to.eql({ err }); }); it('logs an error if an unsupported webhook event is passed', async () => { - const error = new Error(`Missing handler for Stripe webhook ${eventType}`); - await stripePayments.handleWebhooks({ requestBody: event }, stripe); - expect(logger.error).to.have.been.calledOnce; + event.type = 'account.updated'; + await expect(stripePayments.handleWebhooks({ body, headers }, stripe)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 400, + name: 'BadRequest', + message: `Missing handler for Stripe webhook ${event.type}`, + }); + expect(logger.error).to.have.been.calledOnce; const calledWith = logger.error.getCall(0).args; - expect(calledWith[0].message).to.equal(error.message); - expect(calledWith[1].event).to.equal(eventRetrieved); + expect(calledWith[0].message).to.equal('Error handling Stripe webhook'); + expect(calledWith[1].event).to.eql(event); + expect(calledWith[1].err.message).to.eql(`Missing handler for Stripe webhook ${event.type}`); }); it('retrieves and validates the event from Stripe', async () => { - await stripePayments.handleWebhooks({ requestBody: event }, stripe); - expect(stripe.events.retrieve).to.have.been.calledOnce; - expect(stripe.events.retrieve).to.have.been.calledWith(event.id); + await stripePayments.handleWebhooks({ body, headers }, stripe); + expect(stripe.webhooks.constructEvent).to.have.been.calledOnce; + expect(stripe.webhooks.constructEvent) + .to.have.been.calledWith(body, undefined, endpointSecret); }); }); describe('customer.subscription.deleted', () => { const eventType = 'customer.subscription.deleted'; + let event; + let constructEventStub; beforeEach(() => { - sinon.stub(stripe.customers, 'del').resolves({}); - sinon.stub(payments, 'cancelSubscription').resolves({}); + event = { type: eventType }; + constructEventStub = sandbox.stub(stripe.webhooks, 'constructEvent'); + constructEventStub.returns(event); }); - afterEach(() => { - stripe.customers.del.restore(); - payments.cancelSubscription.restore(); + beforeEach(() => { + sandbox.stub(stripe.customers, 'del').resolves({}); + sandbox.stub(payments, 'cancelSubscription').resolves({}); }); it('does not do anything if event.request is null (subscription cancelled manually)', async () => { - sinon.stub(stripe.events, 'retrieve').resolves({ + constructEventStub.returns({ id: 123, type: eventType, request: 123, }); - await stripePayments.handleWebhooks({ requestBody: {} }, stripe); + await stripePayments.handleWebhooks({ body, headers }, stripe); - expect(stripe.events.retrieve).to.have.been.calledOnce; + expect(stripe.webhooks.constructEvent).to.have.been.calledOnce; expect(stripe.customers.del).to.not.have.been.called; expect(payments.cancelSubscription).to.not.have.been.called; - stripe.events.retrieve.restore(); }); describe('user subscription', () => { it('throws an error if the user is not found', async () => { const customerId = 456; - sinon.stub(stripe.events, 'retrieve').resolves({ + constructEventStub.returns({ id: 123, type: eventType, data: { @@ -93,7 +121,7 @@ describe('Stripe - Webhooks', () => { request: null, }); - await expect(stripePayments.handleWebhooks({ requestBody: {} }, stripe)) + await expect(stripePayments.handleWebhooks({ body, headers }, stripe)) .to.eventually.be.rejectedWith({ message: i18n.t('userNotFound'), httpCode: 404, @@ -102,8 +130,6 @@ describe('Stripe - Webhooks', () => { expect(stripe.customers.del).to.not.have.been.called; expect(payments.cancelSubscription).to.not.have.been.called; - - stripe.events.retrieve.restore(); }); it('deletes the customer on Stripe and calls payments.cancelSubscription', async () => { @@ -114,7 +140,7 @@ describe('Stripe - Webhooks', () => { subscriber.purchased.plan.paymentMethod = 'Stripe'; await subscriber.save(); - sinon.stub(stripe.events, 'retrieve').resolves({ + constructEventStub.returns({ id: 123, type: eventType, data: { @@ -128,7 +154,7 @@ describe('Stripe - Webhooks', () => { request: null, }); - await stripePayments.handleWebhooks({ requestBody: {} }, stripe); + await stripePayments.handleWebhooks({ body, headers }, stripe); expect(stripe.customers.del).to.have.been.calledOnce; expect(stripe.customers.del).to.have.been.calledWith(customerId); @@ -139,15 +165,13 @@ describe('Stripe - Webhooks', () => { expect(cancelSubscriptionOpts.paymentMethod).to.equal('Stripe'); expect(Math.round(moment(cancelSubscriptionOpts.nextBill).diff(new Date(), 'days', true))).to.equal(3); expect(cancelSubscriptionOpts.groupId).to.be.undefined; - - stripe.events.retrieve.restore(); }); }); describe('group plan subscription', () => { it('throws an error if the group is not found', async () => { const customerId = 456; - sinon.stub(stripe.events, 'retrieve').resolves({ + constructEventStub.returns({ id: 123, type: eventType, data: { @@ -161,7 +185,7 @@ describe('Stripe - Webhooks', () => { request: null, }); - await expect(stripePayments.handleWebhooks({ requestBody: {} }, stripe)) + await expect(stripePayments.handleWebhooks({ body, headers }, stripe)) .to.eventually.be.rejectedWith({ message: i18n.t('groupNotFound'), httpCode: 404, @@ -170,8 +194,6 @@ describe('Stripe - Webhooks', () => { expect(stripe.customers.del).to.not.have.been.called; expect(payments.cancelSubscription).to.not.have.been.called; - - stripe.events.retrieve.restore(); }); it('throws an error if the group leader is not found', async () => { @@ -187,7 +209,7 @@ describe('Stripe - Webhooks', () => { subscriber.purchased.plan.paymentMethod = 'Stripe'; await subscriber.save(); - sinon.stub(stripe.events, 'retrieve').resolves({ + constructEventStub.returns({ id: 123, type: eventType, data: { @@ -201,7 +223,7 @@ describe('Stripe - Webhooks', () => { request: null, }); - await expect(stripePayments.handleWebhooks({ requestBody: {} }, stripe)) + await expect(stripePayments.handleWebhooks({ body, headers }, stripe)) .to.eventually.be.rejectedWith({ message: i18n.t('userNotFound'), httpCode: 404, @@ -210,8 +232,6 @@ describe('Stripe - Webhooks', () => { expect(stripe.customers.del).to.not.have.been.called; expect(payments.cancelSubscription).to.not.have.been.called; - - stripe.events.retrieve.restore(); }); it('deletes the customer on Stripe and calls payments.cancelSubscription', async () => { @@ -230,7 +250,7 @@ describe('Stripe - Webhooks', () => { subscriber.purchased.plan.paymentMethod = 'Stripe'; await subscriber.save(); - sinon.stub(stripe.events, 'retrieve').resolves({ + constructEventStub.returns({ id: 123, type: eventType, data: { @@ -244,7 +264,7 @@ describe('Stripe - Webhooks', () => { request: null, }); - await stripePayments.handleWebhooks({ requestBody: {} }, stripe); + await stripePayments.handleWebhooks({ body, headers }, stripe); expect(stripe.customers.del).to.have.been.calledOnce; expect(stripe.customers.del).to.have.been.calledWith(customerId); @@ -255,9 +275,65 @@ describe('Stripe - Webhooks', () => { expect(cancelSubscriptionOpts.paymentMethod).to.equal('Stripe'); expect(Math.round(moment(cancelSubscriptionOpts.nextBill).diff(new Date(), 'days', true))).to.equal(3); expect(cancelSubscriptionOpts.groupId).to.equal(subscriber._id); - - stripe.events.retrieve.restore(); }); }); }); + + describe('checkout.session.completed', () => { + const eventType = 'checkout.session.completed'; + let event; + let constructEventStub; + const session = {}; + + beforeEach(() => { + session.metadata = {}; + event = { type: eventType, data: { object: session } }; + constructEventStub = sandbox.stub(stripe.webhooks, 'constructEvent'); + constructEventStub.returns(event); + + sandbox.stub(oneTimePayments, 'applyGemPayment').resolves({}); + sandbox.stub(subscriptions, 'applySubscription').resolves({}); + sandbox.stub(subscriptions, 'handlePaymentMethodChange').resolves({}); + }); + + it('handles changing an user sub', async () => { + session.metadata.type = 'edit-card-user'; + + await stripePayments.handleWebhooks({ body, headers }, stripe); + + expect(stripe.webhooks.constructEvent).to.have.been.calledOnce; + expect(subscriptions.handlePaymentMethodChange).to.have.been.calledOnce; + expect(subscriptions.handlePaymentMethodChange).to.have.been.calledWith(session); + }); + + it('handles changing a group sub', async () => { + session.metadata.type = 'edit-card-group'; + + await stripePayments.handleWebhooks({ body, headers }, stripe); + + expect(stripe.webhooks.constructEvent).to.have.been.calledOnce; + expect(subscriptions.handlePaymentMethodChange).to.have.been.calledOnce; + expect(subscriptions.handlePaymentMethodChange).to.have.been.calledWith(session); + }); + + it('applies a subscription', async () => { + session.metadata.type = 'subscription'; + + await stripePayments.handleWebhooks({ body, headers }, stripe); + + expect(stripe.webhooks.constructEvent).to.have.been.calledOnce; + expect(subscriptions.applySubscription).to.have.been.calledOnce; + expect(subscriptions.applySubscription).to.have.been.calledWith(session); + }); + + it('handles a one time payment', async () => { + session.metadata.type = 'something else'; + + await stripePayments.handleWebhooks({ body, headers }, stripe); + + expect(stripe.webhooks.constructEvent).to.have.been.calledOnce; + expect(oneTimePayments.applyGemPayment).to.have.been.calledOnce; + expect(oneTimePayments.applyGemPayment).to.have.been.calledWith(session); + }); + }); }); diff --git a/test/api/v3/integration/payments/stripe/POST-payments_stripe_checkout-session.test.js b/test/api/v3/integration/payments/stripe/POST-payments_stripe_checkout-session.test.js new file mode 100644 index 0000000000..c249e96b7c --- /dev/null +++ b/test/api/v3/integration/payments/stripe/POST-payments_stripe_checkout-session.test.js @@ -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); + }); +}); diff --git a/test/api/v3/integration/payments/stripe/POST-payments_stripe_checkout.test.js b/test/api/v3/integration/payments/stripe/POST-payments_stripe_checkout.test.js deleted file mode 100644 index c04399a4b3..0000000000 --- a/test/api/v3/integration/payments/stripe/POST-payments_stripe_checkout.test.js +++ /dev/null @@ -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); - }); - }); -}); diff --git a/test/api/v3/integration/payments/stripe/POST-payments_stripe_subscribe_edit.test.js b/test/api/v3/integration/payments/stripe/POST-payments_stripe_subscribe_edit.test.js index 640182f168..f3a91ee2fe 100644 --- a/test/api/v3/integration/payments/stripe/POST-payments_stripe_subscribe_edit.test.js +++ b/test/api/v3/integration/payments/stripe/POST-payments_stripe_subscribe_edit.test.js @@ -1,79 +1,31 @@ import { generateUser, - generateGroup, - translate as t, } from '../../../../../helpers/api-integration/v3'; import stripePayments from '../../../../../../website/server/libs/payments/stripe'; describe('payments - stripe - #subscribeEdit', () => { const endpoint = '/stripe/subscribe/edit'; - let user; let - group; + let user; const groupId = 'groupId'; + let stripeEditSubscriptionStub; + const sessionId = 'sessionId'; beforeEach(async () => { user = await generateUser(); + stripeEditSubscriptionStub = sinon + .stub(stripePayments, 'createEditCardCheckoutSession') + .resolves({ id: sessionId }); }); - it('verifies credentials', async () => { - await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({ - code: 401, - error: 'NotAuthorized', - message: t('missingSubscription'), - }); + afterEach(() => { + stripePayments.createEditCardCheckoutSession.restore(); }); - describe('success', () => { - let stripeEditSubscriptionStub; + it('works', async () => { + const res = await user.post(endpoint, { groupId }); + expect(res.sessionId).to.equal(sessionId); - beforeEach(async () => { - stripeEditSubscriptionStub = sinon.stub(stripePayments, 'editSubscription').resolves({}); - }); - - afterEach(() => { - stripePayments.editSubscription.restore(); - }); - - it('cancels a user subscription', async () => { - user = await generateUser({ - 'profile.name': 'sender', - 'purchased.plan.customerId': 'customer-id', - 'purchased.plan.planId': 'basic_3mo', - 'purchased.plan.lastBillingDate': new Date(), - balance: 2, - }); - - await user.post(endpoint); - - expect(stripeEditSubscriptionStub).to.be.calledOnce; - expect(stripeEditSubscriptionStub.args[0][0].user._id).to.eql(user._id); - expect(stripeEditSubscriptionStub.args[0][0].groupId).to.eql(undefined); - }); - - it('cancels a group subscription', async () => { - user = await generateUser({ - 'profile.name': 'sender', - 'purchased.plan.customerId': 'customer-id', - 'purchased.plan.planId': 'basic_3mo', - 'purchased.plan.lastBillingDate': new Date(), - balance: 2, - }); - - group = await generateGroup(user, { - name: 'test group', - type: 'guild', - privacy: 'public', - 'purchased.plan.customerId': 'customer-id', - 'purchased.plan.planId': 'basic_3mo', - 'purchased.plan.lastBillingDate': new Date(), - }); - - await user.post(endpoint, { - groupId: group._id, - }); - - expect(stripeEditSubscriptionStub).to.be.calledOnce; - expect(stripeEditSubscriptionStub.args[0][0].user._id).to.eql(user._id); - expect(stripeEditSubscriptionStub.args[0][0].groupId).to.eql(group._id); - }); + expect(stripeEditSubscriptionStub).to.be.calledOnce; + expect(stripeEditSubscriptionStub.args[0][0].user._id).to.eql(user._id); + expect(stripeEditSubscriptionStub.args[0][0].groupId).to.eql(groupId); }); }); diff --git a/test/api/v3/integration/payments/stripe/POST-payments_stripe_webhooks.test.js b/test/api/v3/integration/payments/stripe/POST-payments_stripe_webhooks.test.js new file mode 100644 index 0000000000..5ce33f566b --- /dev/null +++ b/test/api/v3/integration/payments/stripe/POST-payments_stripe_webhooks.test.js @@ -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; + }); +}); diff --git a/website/client/src/components/group-plans/billing.vue b/website/client/src/components/group-plans/billing.vue index 99f50c7a6a..c3669a90e4 100644 --- a/website/client/src/components/group-plans/billing.vue +++ b/website/client/src/components/group-plans/billing.vue @@ -53,7 +53,7 @@ v-if="!group.purchased.plan.dateTerminated && group.purchased.plan.paymentMethod === 'Stripe'" class="btn btn-primary" - @click="showStripeEdit({groupId: group.id})" + @click="redirectToStripeEdit({groupId: group.id})" > {{ $t('subUpdateCard') }} diff --git a/website/client/src/components/group-plans/createGroupModalPages.vue b/website/client/src/components/group-plans/createGroupModalPages.vue index b029eab7b1..e8cd2698fd 100644 --- a/website/client/src/components/group-plans/createGroupModalPages.vue +++ b/website/client/src/components/group-plans/createGroupModalPages.vue @@ -202,7 +202,7 @@ export default { this.paymentMethod = paymentMethod; if (this.paymentMethod === this.PAYMENTS.STRIPE) { - this.showStripe(paymentData); + this.redirectToStripe(paymentData); } else if (this.paymentMethod === this.PAYMENTS.AMAZON) { paymentData.type = 'subscription'; return paymentData; diff --git a/website/client/src/components/groups/groupPlan.vue b/website/client/src/components/groups/groupPlan.vue index e90a61514f..9d932760c5 100644 --- a/website/client/src/components/groups/groupPlan.vue +++ b/website/client/src/components/groups/groupPlan.vue @@ -155,7 +155,7 @@ @@ -524,7 +524,7 @@ export default { } if (this.paymentMethod === this.PAYMENTS.STRIPE) { - this.showStripe(paymentData); + this.redirectToStripe(paymentData); } return null; diff --git a/website/client/src/components/payments/buyGemsModal.vue b/website/client/src/components/payments/buyGemsModal.vue index 83b79bebf4..abdee6df28 100644 --- a/website/client/src/components/payments/buyGemsModal.vue +++ b/website/client/src/components/payments/buyGemsModal.vue @@ -135,7 +135,7 @@ + {{ gift.message.length || 0 }} / {{ MAX_GIFT_MESSAGE_LENGTH }}