diff --git a/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js deleted file mode 100644 index 757511121a..0000000000 --- a/test/api/v3/unit/libs/amazonPayments.test.js +++ /dev/null @@ -1,739 +0,0 @@ -import moment from 'moment'; -import cc from 'coupon-code'; -import uuid from 'uuid'; - -import { - generateGroup, -} from '../../../../helpers/api-unit.helper.js'; -import { model as User } from '../../../../../website/server/models/user'; -import { model as Group } from '../../../../../website/server/models/group'; -import { model as Coupon } from '../../../../../website/server/models/coupon'; -import amzLib from '../../../../../website/server/libs/amazonPayments'; -import payments from '../../../../../website/server/libs/payments'; -import common from '../../../../../website/common'; - -const i18n = common.i18n; - -describe('Amazon Payments', () => { - let subKey = 'basic_3mo'; - - describe('checkout', () => { - let user, orderReferenceId, headers; - let setOrderReferenceDetailsSpy; - let confirmOrderReferenceSpy; - let authorizeSpy; - let closeOrderReferenceSpy; - - let paymentBuyGemsStub; - let paymentCreateSubscritionStub; - let amount = 5; - - function expectAmazonStubs () { - expect(setOrderReferenceDetailsSpy).to.be.calledOnce; - expect(setOrderReferenceDetailsSpy).to.be.calledWith({ - AmazonOrderReferenceId: orderReferenceId, - OrderReferenceAttributes: { - OrderTotal: { - CurrencyCode: amzLib.constants.CURRENCY_CODE, - Amount: amount, - }, - SellerNote: amzLib.constants.SELLER_NOTE, - SellerOrderAttributes: { - SellerOrderId: common.uuid(), - StoreName: amzLib.constants.STORE_NAME, - }, - }, - }); - - expect(confirmOrderReferenceSpy).to.be.calledOnce; - expect(confirmOrderReferenceSpy).to.be.calledWith({ AmazonOrderReferenceId: orderReferenceId }); - - expect(authorizeSpy).to.be.calledOnce; - expect(authorizeSpy).to.be.calledWith({ - AmazonOrderReferenceId: orderReferenceId, - AuthorizationReferenceId: common.uuid().substring(0, 32), - AuthorizationAmount: { - CurrencyCode: amzLib.constants.CURRENCY_CODE, - Amount: amount, - }, - SellerAuthorizationNote: amzLib.constants.SELLER_NOTE, - TransactionTimeout: 0, - CaptureNow: true, - }); - - expect(closeOrderReferenceSpy).to.be.calledOnce; - expect(closeOrderReferenceSpy).to.be.calledWith({ AmazonOrderReferenceId: orderReferenceId }); - } - - beforeEach(function () { - user = new User(); - headers = {}; - orderReferenceId = 'orderReferenceId'; - - setOrderReferenceDetailsSpy = sinon.stub(amzLib, 'setOrderReferenceDetails'); - setOrderReferenceDetailsSpy.returnsPromise().resolves({}); - - confirmOrderReferenceSpy = sinon.stub(amzLib, 'confirmOrderReference'); - confirmOrderReferenceSpy.returnsPromise().resolves({}); - - authorizeSpy = sinon.stub(amzLib, 'authorize'); - authorizeSpy.returnsPromise().resolves({}); - - closeOrderReferenceSpy = sinon.stub(amzLib, 'closeOrderReference'); - closeOrderReferenceSpy.returnsPromise().resolves({}); - - paymentBuyGemsStub = sinon.stub(payments, 'buyGems'); - paymentBuyGemsStub.returnsPromise().resolves({}); - - paymentCreateSubscritionStub = sinon.stub(payments, 'createSubscription'); - paymentCreateSubscritionStub.returnsPromise().resolves({}); - - sinon.stub(common, 'uuid').returns('uuid-generated'); - }); - - afterEach(function () { - amzLib.setOrderReferenceDetails.restore(); - amzLib.confirmOrderReference.restore(); - amzLib.authorize.restore(); - amzLib.closeOrderReference.restore(); - payments.buyGems.restore(); - payments.createSubscription.restore(); - common.uuid.restore(); - }); - - it('should purchase gems', async () => { - sinon.stub(user, 'canGetGems').returnsPromise().resolves(true); - await amzLib.checkout({user, orderReferenceId, headers}); - - expect(paymentBuyGemsStub).to.be.calledOnce; - expect(paymentBuyGemsStub).to.be.calledWith({ - user, - paymentMethod: amzLib.constants.PAYMENT_METHOD, - headers, - }); - expectAmazonStubs(); - expect(user.canGetGems).to.be.calledOnce; - user.canGetGems.restore(); - }); - - it('should error if gem amount is too low', async () => { - let receivingUser = new User(); - receivingUser.save(); - let gift = { - type: 'gems', - gems: { - amount: 0, - uuid: receivingUser._id, - }, - }; - - await expect(amzLib.checkout({gift, user, orderReferenceId, headers})) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 400, - message: 'Amount must be at least 1.', - name: 'BadRequest', - }); - }); - - it('should error if user cannot get gems gems', async () => { - sinon.stub(user, 'canGetGems').returnsPromise().resolves(false); - await expect(amzLib.checkout({user, orderReferenceId, headers})).to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - message: i18n.t('groupPolicyCannotGetGems'), - name: 'NotAuthorized', - }); - user.canGetGems.restore(); - }); - - it('should gift gems', async () => { - let receivingUser = new User(); - await receivingUser.save(); - let gift = { - type: 'gems', - uuid: receivingUser._id, - gems: { - amount: 16, - }, - }; - amount = 16 / 4; - await amzLib.checkout({gift, user, orderReferenceId, headers}); - - expect(paymentBuyGemsStub).to.be.calledOnce; - expect(paymentBuyGemsStub).to.be.calledWith({ - user, - paymentMethod: amzLib.constants.PAYMENT_METHOD_GIFT, - headers, - gift, - }); - expectAmazonStubs(); - }); - - it('should gift a subscription', async () => { - let receivingUser = new User(); - receivingUser.save(); - let gift = { - type: 'subscription', - subscription: { - key: subKey, - uuid: receivingUser._id, - }, - }; - amount = common.content.subscriptionBlocks[subKey].price; - - await amzLib.checkout({user, orderReferenceId, headers, gift}); - - gift.member = receivingUser; - expect(paymentCreateSubscritionStub).to.be.calledOnce; - expect(paymentCreateSubscritionStub).to.be.calledWith({ - user, - paymentMethod: amzLib.constants.PAYMENT_METHOD_GIFT, - headers, - gift, - }); - expectAmazonStubs(); - }); - }); - - describe('subscribe', () => { - let user, group, amount, billingAgreementId, sub, coupon, groupId, headers; - let amazonSetBillingAgreementDetailsSpy; - let amazonConfirmBillingAgreementSpy; - let amazongAuthorizeOnBillingAgreementSpy; - let createSubSpy; - - 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(); - - amount = common.content.subscriptionBlocks[subKey].price; - billingAgreementId = 'billingAgreementId'; - sub = { - key: subKey, - price: amount, - }; - groupId = group._id; - headers = {}; - - amazonSetBillingAgreementDetailsSpy = sinon.stub(amzLib, 'setBillingAgreementDetails'); - amazonSetBillingAgreementDetailsSpy.returnsPromise().resolves({}); - - amazonConfirmBillingAgreementSpy = sinon.stub(amzLib, 'confirmBillingAgreement'); - amazonConfirmBillingAgreementSpy.returnsPromise().resolves({}); - - amazongAuthorizeOnBillingAgreementSpy = sinon.stub(amzLib, 'authorizeOnBillingAgreement'); - amazongAuthorizeOnBillingAgreementSpy.returnsPromise().resolves({}); - - createSubSpy = sinon.stub(payments, 'createSubscription'); - createSubSpy.returnsPromise().resolves({}); - - sinon.stub(common, 'uuid').returns('uuid-generated'); - }); - - afterEach(function () { - amzLib.setBillingAgreementDetails.restore(); - amzLib.confirmBillingAgreement.restore(); - amzLib.authorizeOnBillingAgreement.restore(); - payments.createSubscription.restore(); - common.uuid.restore(); - }); - - it('should throw an error if we are missing a subscription', async () => { - await expect(amzLib.subscribe({ - billingAgreementId, - coupon, - user, - groupId, - headers, - })) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 400, - name: 'BadRequest', - message: i18n.t('missingSubscriptionCode'), - }); - }); - - it('should throw an error if we are missing a billingAgreementId', async () => { - await expect(amzLib.subscribe({ - sub, - coupon, - user, - groupId, - headers, - })) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 400, - name: 'BadRequest', - message: 'Missing req.body.billingAgreementId', - }); - }); - - it('should throw an error when coupon code is missing', async () => { - sub.discount = 40; - - await expect(amzLib.subscribe({ - billingAgreementId, - sub, - coupon, - user, - groupId, - headers, - })) - .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'; - - let couponModel = new Coupon(); - couponModel.event = 'google_6mo'; - await couponModel.save(); - - sinon.stub(cc, 'validate').returns('invalid'); - - await expect(amzLib.subscribe({ - billingAgreementId, - sub, - coupon, - user, - groupId, - headers, - })) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - name: 'NotAuthorized', - message: i18n.t('invalidCoupon'), - }); - cc.validate.restore(); - }); - - it('subscribes with amazon with a coupon', async () => { - sub.discount = 40; - sub.key = 'google_6mo'; - coupon = 'example-coupon'; - - let couponModel = new Coupon(); - couponModel.event = 'google_6mo'; - let updatedCouponModel = await couponModel.save(); - - sinon.stub(cc, 'validate').returns(updatedCouponModel._id); - - await amzLib.subscribe({ - billingAgreementId, - sub, - coupon, - user, - groupId, - headers, - }); - - expect(createSubSpy).to.be.calledOnce; - expect(createSubSpy).to.be.calledWith({ - user, - customerId: billingAgreementId, - paymentMethod: amzLib.constants.PAYMENT_METHOD, - sub, - headers, - groupId, - }); - - cc.validate.restore(); - }); - - it('subscribes with amazon', async () => { - await amzLib.subscribe({ - billingAgreementId, - sub, - coupon, - user, - groupId, - headers, - }); - - expect(amazonSetBillingAgreementDetailsSpy).to.be.calledOnce; - expect(amazonSetBillingAgreementDetailsSpy).to.be.calledWith({ - AmazonBillingAgreementId: billingAgreementId, - BillingAgreementAttributes: { - SellerNote: amzLib.constants.SELLER_NOTE_SUBSCRIPTION, - SellerBillingAgreementAttributes: { - SellerBillingAgreementId: common.uuid(), - StoreName: amzLib.constants.STORE_NAME, - CustomInformation: amzLib.constants.SELLER_NOTE_SUBSCRIPTION, - }, - }, - }); - - expect(amazonConfirmBillingAgreementSpy).to.be.calledOnce; - expect(amazonConfirmBillingAgreementSpy).to.be.calledWith({ - AmazonBillingAgreementId: billingAgreementId, - }); - - expect(amazongAuthorizeOnBillingAgreementSpy).to.be.calledOnce; - expect(amazongAuthorizeOnBillingAgreementSpy).to.be.calledWith({ - AmazonBillingAgreementId: billingAgreementId, - AuthorizationReferenceId: common.uuid().substring(0, 32), - AuthorizationAmount: { - CurrencyCode: amzLib.constants.CURRENCY_CODE, - Amount: amount, - }, - SellerAuthorizationNote: amzLib.constants.SELLER_NOTE_ATHORIZATION_SUBSCRIPTION, - TransactionTimeout: 0, - CaptureNow: true, - SellerNote: amzLib.constants.SELLER_NOTE_ATHORIZATION_SUBSCRIPTION, - SellerOrderAttributes: { - SellerOrderId: common.uuid(), - StoreName: amzLib.constants.STORE_NAME, - }, - }); - - expect(createSubSpy).to.be.calledOnce; - expect(createSubSpy).to.be.calledWith({ - user, - customerId: billingAgreementId, - paymentMethod: amzLib.constants.PAYMENT_METHOD, - sub, - headers, - groupId, - }); - }); - - it('subscribes with amazon with price to existing users', async () => { - user = new User(); - user.guilds.push(groupId); - await user.save(); - group.memberCount = 2; - await group.save(); - sub.key = 'group_monthly'; - sub.price = 9; - amount = 12; - - await amzLib.subscribe({ - billingAgreementId, - sub, - coupon, - user, - groupId, - headers, - }); - - expect(amazonSetBillingAgreementDetailsSpy).to.be.calledOnce; - expect(amazonSetBillingAgreementDetailsSpy).to.be.calledWith({ - AmazonBillingAgreementId: billingAgreementId, - BillingAgreementAttributes: { - SellerNote: amzLib.constants.SELLER_NOTE_SUBSCRIPTION, - SellerBillingAgreementAttributes: { - SellerBillingAgreementId: common.uuid(), - StoreName: amzLib.constants.STORE_NAME, - CustomInformation: amzLib.constants.SELLER_NOTE_SUBSCRIPTION, - }, - }, - }); - - expect(amazonConfirmBillingAgreementSpy).to.be.calledOnce; - expect(amazonConfirmBillingAgreementSpy).to.be.calledWith({ - AmazonBillingAgreementId: billingAgreementId, - }); - - expect(amazongAuthorizeOnBillingAgreementSpy).to.be.calledOnce; - expect(amazongAuthorizeOnBillingAgreementSpy).to.be.calledWith({ - AmazonBillingAgreementId: billingAgreementId, - AuthorizationReferenceId: common.uuid().substring(0, 32), - AuthorizationAmount: { - CurrencyCode: amzLib.constants.CURRENCY_CODE, - Amount: amount, - }, - SellerAuthorizationNote: amzLib.constants.SELLER_NOTE_ATHORIZATION_SUBSCRIPTION, - TransactionTimeout: 0, - CaptureNow: true, - SellerNote: amzLib.constants.SELLER_NOTE_ATHORIZATION_SUBSCRIPTION, - SellerOrderAttributes: { - SellerOrderId: common.uuid(), - StoreName: amzLib.constants.STORE_NAME, - }, - }); - - expect(createSubSpy).to.be.calledOnce; - expect(createSubSpy).to.be.calledWith({ - user, - customerId: billingAgreementId, - paymentMethod: amzLib.constants.PAYMENT_METHOD, - sub, - headers, - groupId, - }); - }); - }); - - describe('cancelSubscription', () => { - let user, group, headers, billingAgreementId, subscriptionBlock, subscriptionLength; - let getBillingAgreementDetailsSpy; - let paymentCancelSubscriptionSpy; - - function expectAmazonStubs () { - expect(getBillingAgreementDetailsSpy).to.be.calledOnce; - expect(getBillingAgreementDetailsSpy).to.be.calledWith({ - AmazonBillingAgreementId: billingAgreementId, - }); - } - - 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; - group.purchased.plan.lastBillingDate = new Date(); - await group.save(); - - subscriptionBlock = common.content.subscriptionBlocks[subKey]; - subscriptionLength = subscriptionBlock.months * 30; - - headers = {}; - - getBillingAgreementDetailsSpy = sinon.stub(amzLib, 'getBillingAgreementDetails'); - getBillingAgreementDetailsSpy.returnsPromise().resolves({ - BillingAgreementDetails: { - BillingAgreementStatus: {State: 'Closed'}, - }, - }); - - paymentCancelSubscriptionSpy = sinon.stub(payments, 'cancelSubscription'); - paymentCancelSubscriptionSpy.returnsPromise().resolves({}); - }); - - afterEach(function () { - amzLib.getBillingAgreementDetails.restore(); - payments.cancelSubscription.restore(); - }); - - it('should throw an error if we are missing a subscription', async () => { - user.purchased.plan.customerId = undefined; - - await expect(amzLib.cancelSubscription({user})) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - name: 'NotAuthorized', - message: i18n.t('missingSubscription'), - }); - }); - - it('should cancel a user subscription', async () => { - billingAgreementId = user.purchased.plan.customerId; - - await amzLib.cancelSubscription({user, headers}); - - expect(paymentCancelSubscriptionSpy).to.be.calledOnce; - expect(paymentCancelSubscriptionSpy).to.be.calledWith({ - user, - groupId: undefined, - nextBill: moment(user.purchased.plan.lastBillingDate).add({ days: subscriptionLength }), - paymentMethod: amzLib.constants.PAYMENT_METHOD, - headers, - cancellationReason: undefined, - }); - expectAmazonStubs(); - }); - - it('should close a user subscription if amazon not closed', async () => { - amzLib.getBillingAgreementDetails.restore(); - getBillingAgreementDetailsSpy = sinon.stub(amzLib, 'getBillingAgreementDetails') - .returnsPromise() - .resolves({ - BillingAgreementDetails: { - BillingAgreementStatus: {State: 'Open'}, - }, - }); - let closeBillingAgreementSpy = sinon.stub(amzLib, 'closeBillingAgreement').returnsPromise().resolves({}); - billingAgreementId = user.purchased.plan.customerId; - - await amzLib.cancelSubscription({user, headers}); - - expectAmazonStubs(); - expect(closeBillingAgreementSpy).to.be.calledOnce; - expect(closeBillingAgreementSpy).to.be.calledWith({ - AmazonBillingAgreementId: billingAgreementId, - }); - expect(paymentCancelSubscriptionSpy).to.be.calledOnce; - expect(paymentCancelSubscriptionSpy).to.be.calledWith({ - user, - groupId: undefined, - nextBill: moment(user.purchased.plan.lastBillingDate).add({ days: subscriptionLength }), - paymentMethod: amzLib.constants.PAYMENT_METHOD, - headers, - cancellationReason: undefined, - }); - amzLib.closeBillingAgreement.restore(); - }); - - it('should throw an error if group is not found', async () => { - await expect(amzLib.cancelSubscription({user, groupId: 'fake-id'})) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 404, - name: 'NotFound', - message: i18n.t('groupNotFound'), - }); - }); - - it('should throw an error if user is not group leader', async () => { - let nonLeader = new User(); - nonLeader.guilds.push(group._id); - await nonLeader.save(); - - await expect(amzLib.cancelSubscription({user: nonLeader, groupId: group._id})) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - name: 'NotAuthorized', - message: i18n.t('onlyGroupLeaderCanManageSubscription'), - }); - }); - - it('should cancel a group subscription', async () => { - billingAgreementId = group.purchased.plan.customerId; - - await amzLib.cancelSubscription({user, groupId: group._id, headers}); - - expect(paymentCancelSubscriptionSpy).to.be.calledOnce; - expect(paymentCancelSubscriptionSpy).to.be.calledWith({ - user, - groupId: group._id, - nextBill: moment(group.purchased.plan.lastBillingDate).add({ days: subscriptionLength }), - paymentMethod: amzLib.constants.PAYMENT_METHOD, - headers, - cancellationReason: undefined, - }); - expectAmazonStubs(); - }); - - it('should close a group subscription if amazon not closed', async () => { - amzLib.getBillingAgreementDetails.restore(); - getBillingAgreementDetailsSpy = sinon.stub(amzLib, 'getBillingAgreementDetails') - .returnsPromise() - .resolves({ - BillingAgreementDetails: { - BillingAgreementStatus: {State: 'Open'}, - }, - }); - let closeBillingAgreementSpy = sinon.stub(amzLib, 'closeBillingAgreement').returnsPromise().resolves({}); - billingAgreementId = group.purchased.plan.customerId; - - await amzLib.cancelSubscription({user, groupId: group._id, headers}); - - expectAmazonStubs(); - expect(closeBillingAgreementSpy).to.be.calledOnce; - expect(closeBillingAgreementSpy).to.be.calledWith({ - AmazonBillingAgreementId: billingAgreementId, - }); - expect(paymentCancelSubscriptionSpy).to.be.calledOnce; - expect(paymentCancelSubscriptionSpy).to.be.calledWith({ - user, - groupId: group._id, - nextBill: moment(group.purchased.plan.lastBillingDate).add({ days: subscriptionLength }), - paymentMethod: amzLib.constants.PAYMENT_METHOD, - headers, - cancellationReason: undefined, - }); - amzLib.closeBillingAgreement.restore(); - }); - }); - - describe('#upgradeGroupPlan', () => { - let spy, data, user, group, uuidString; - - beforeEach(async function () { - 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: 'public', - leader: user._id, - }); - await group.save(); - - spy = sinon.stub(amzLib, 'authorizeOnBillingAgreement'); - spy.returnsPromise().resolves([]); - - uuidString = 'uuid-v4'; - sinon.stub(uuid, 'v4').returns(uuidString); - - data.groupId = group._id; - data.sub.quantity = 3; - }); - - afterEach(function () { - sinon.restore(amzLib.authorizeOnBillingAgreement); - uuid.v4.restore(); - }); - - it('charges for a new member', async () => { - data.paymentMethod = amzLib.constants.PAYMENT_METHOD; - await payments.createSubscription(data); - - let updatedGroup = await Group.findById(group._id).exec(); - - updatedGroup.memberCount += 1; - await updatedGroup.save(); - - await amzLib.chargeForAdditionalGroupMember(updatedGroup); - - expect(spy.calledOnce).to.be.true; - expect(spy).to.be.calledWith({ - AmazonBillingAgreementId: updatedGroup.purchased.plan.customerId, - AuthorizationReferenceId: uuidString.substring(0, 32), - AuthorizationAmount: { - CurrencyCode: amzLib.constants.CURRENCY_CODE, - Amount: 3, - }, - SellerAuthorizationNote: amzLib.constants.SELLER_NOTE_GROUP_NEW_MEMBER, - TransactionTimeout: 0, - CaptureNow: true, - SellerNote: amzLib.constants.SELLER_NOTE_GROUP_NEW_MEMBER, - SellerOrderAttributes: { - SellerOrderId: uuidString, - StoreName: amzLib.constants.STORE_NAME, - }, - }); - }); - }); -}); diff --git a/test/api/v3/unit/libs/payments/amazon/cancel.test.js b/test/api/v3/unit/libs/payments/amazon/cancel.test.js new file mode 100644 index 0000000000..13e524f58f --- /dev/null +++ b/test/api/v3/unit/libs/payments/amazon/cancel.test.js @@ -0,0 +1,180 @@ +import moment from 'moment'; + +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; +import amzLib from '../../../../../../../website/server/libs/amazonPayments'; +import payments from '../../../../../../../website/server/libs/payments'; +import common from '../../../../../../../website/common'; +import { createNonLeaderGroupMember } from '../paymentHelpers'; + +const i18n = common.i18n; + +describe('Amazon Payments - Cancel Subscription', () => { + const subKey = 'basic_3mo'; + + let user, group, headers, billingAgreementId, subscriptionBlock, subscriptionLength; + let getBillingAgreementDetailsSpy; + let paymentCancelSubscriptionSpy; + + function expectAmazonStubs () { + expect(getBillingAgreementDetailsSpy).to.be.calledOnce; + expect(getBillingAgreementDetailsSpy).to.be.calledWith({ + AmazonBillingAgreementId: billingAgreementId, + }); + } + + function expectAmazonCancelSubscriptionSpy (groupId, lastBillingDate) { + expect(paymentCancelSubscriptionSpy).to.be.calledWith({ + user, + groupId, + nextBill: moment(lastBillingDate).add({ days: subscriptionLength }), + paymentMethod: amzLib.constants.PAYMENT_METHOD, + headers, + cancellationReason: undefined, + }); + } + + function expectAmazonCancelUserSubscriptionSpy () { + expect(paymentCancelSubscriptionSpy).to.be.calledOnce; + expectAmazonCancelSubscriptionSpy(undefined, user.purchased.plan.lastBillingDate); + } + + function expectAmazonCancelGroupSubscriptionSpy (groupId) { + expect(paymentCancelSubscriptionSpy).to.be.calledOnce; + expectAmazonCancelSubscriptionSpy(groupId, group.purchased.plan.lastBillingDate); + } + + function expectBillingAggreementDetailSpy () { + getBillingAgreementDetailsSpy = sinon.stub(amzLib, 'getBillingAgreementDetails') + .returnsPromise() + .resolves({ + BillingAgreementDetails: { + BillingAgreementStatus: {State: 'Open'}, + }, + }); + } + + 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; + group.purchased.plan.lastBillingDate = new Date(); + await group.save(); + + subscriptionBlock = common.content.subscriptionBlocks[subKey]; + subscriptionLength = subscriptionBlock.months * 30; + + headers = {}; + + getBillingAgreementDetailsSpy = sinon.stub(amzLib, 'getBillingAgreementDetails'); + getBillingAgreementDetailsSpy.returnsPromise().resolves({ + BillingAgreementDetails: { + BillingAgreementStatus: {State: 'Closed'}, + }, + }); + + paymentCancelSubscriptionSpy = sinon.stub(payments, 'cancelSubscription'); + paymentCancelSubscriptionSpy.returnsPromise().resolves({}); + }); + + afterEach(function () { + amzLib.getBillingAgreementDetails.restore(); + payments.cancelSubscription.restore(); + }); + + it('should throw an error if we are missing a subscription', async () => { + user.purchased.plan.customerId = undefined; + + await expect(amzLib.cancelSubscription({user})) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('missingSubscription'), + }); + }); + + it('should cancel a user subscription', async () => { + billingAgreementId = user.purchased.plan.customerId; + + await amzLib.cancelSubscription({user, headers}); + + expectAmazonCancelUserSubscriptionSpy(); + expectAmazonStubs(); + }); + + it('should close a user subscription if amazon not closed', async () => { + amzLib.getBillingAgreementDetails.restore(); + expectBillingAggreementDetailSpy(); + let closeBillingAgreementSpy = sinon.stub(amzLib, 'closeBillingAgreement').returnsPromise().resolves({}); + billingAgreementId = user.purchased.plan.customerId; + + await amzLib.cancelSubscription({user, headers}); + + expectAmazonStubs(); + expect(closeBillingAgreementSpy).to.be.calledOnce; + expect(closeBillingAgreementSpy).to.be.calledWith({ + AmazonBillingAgreementId: billingAgreementId, + }); + expectAmazonCancelUserSubscriptionSpy(); + amzLib.closeBillingAgreement.restore(); + }); + + it('should throw an error if group is not found', async () => { + await expect(amzLib.cancelSubscription({user, groupId: 'fake-id'})) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 404, + name: 'NotFound', + message: i18n.t('groupNotFound'), + }); + }); + + it('should throw an error if user is not group leader', async () => { + let nonLeader = await createNonLeaderGroupMember(group); + + await expect(amzLib.cancelSubscription({user: nonLeader, groupId: group._id})) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('onlyGroupLeaderCanManageSubscription'), + }); + }); + + it('should cancel a group subscription', async () => { + billingAgreementId = group.purchased.plan.customerId; + + await amzLib.cancelSubscription({user, groupId: group._id, headers}); + + expectAmazonCancelGroupSubscriptionSpy(group._id); + expectAmazonStubs(); + }); + + it('should close a group subscription if amazon not closed', async () => { + amzLib.getBillingAgreementDetails.restore(); + expectBillingAggreementDetailSpy(); + let closeBillingAgreementSpy = sinon.stub(amzLib, 'closeBillingAgreement').returnsPromise().resolves({}); + billingAgreementId = group.purchased.plan.customerId; + + await amzLib.cancelSubscription({user, groupId: group._id, headers}); + + expectAmazonStubs(); + expect(closeBillingAgreementSpy).to.be.calledOnce; + expect(closeBillingAgreementSpy).to.be.calledWith({ + AmazonBillingAgreementId: billingAgreementId, + }); + expectAmazonCancelGroupSubscriptionSpy(group._id); + amzLib.closeBillingAgreement.restore(); + }); +}); diff --git a/test/api/v3/unit/libs/payments/amazon/checkout.test.js b/test/api/v3/unit/libs/payments/amazon/checkout.test.js new file mode 100644 index 0000000000..9e74cdb145 --- /dev/null +++ b/test/api/v3/unit/libs/payments/amazon/checkout.test.js @@ -0,0 +1,193 @@ +import { model as User } from '../../../../../../../website/server/models/user'; +import amzLib from '../../../../../../../website/server/libs/amazonPayments'; +import payments from '../../../../../../../website/server/libs/payments'; +import common from '../../../../../../../website/common'; + +const i18n = common.i18n; + +describe('Amazon Payments - Checkout', () => { + const subKey = 'basic_3mo'; + let user, orderReferenceId, headers; + let setOrderReferenceDetailsSpy; + let confirmOrderReferenceSpy; + let authorizeSpy; + let closeOrderReferenceSpy; + + let paymentBuyGemsStub; + let paymentCreateSubscritionStub; + let amount = 5; + + function expectOrderReferenceSpy () { + expect(setOrderReferenceDetailsSpy).to.be.calledOnce; + expect(setOrderReferenceDetailsSpy).to.be.calledWith({ + AmazonOrderReferenceId: orderReferenceId, + OrderReferenceAttributes: { + OrderTotal: { + CurrencyCode: amzLib.constants.CURRENCY_CODE, + Amount: amount, + }, + SellerNote: amzLib.constants.SELLER_NOTE, + SellerOrderAttributes: { + SellerOrderId: common.uuid(), + StoreName: amzLib.constants.STORE_NAME, + }, + }, + }); + } + + function expectAuthorizeSpy () { + expect(authorizeSpy).to.be.calledOnce; + expect(authorizeSpy).to.be.calledWith({ + AmazonOrderReferenceId: orderReferenceId, + AuthorizationReferenceId: common.uuid().substring(0, 32), + AuthorizationAmount: { + CurrencyCode: amzLib.constants.CURRENCY_CODE, + Amount: amount, + }, + SellerAuthorizationNote: amzLib.constants.SELLER_NOTE, + TransactionTimeout: 0, + CaptureNow: true, + }); + } + + function expectAmazonStubs () { + expectOrderReferenceSpy(); + + expect(confirmOrderReferenceSpy).to.be.calledOnce; + expect(confirmOrderReferenceSpy).to.be.calledWith({ AmazonOrderReferenceId: orderReferenceId }); + + expectAuthorizeSpy(); + + expect(closeOrderReferenceSpy).to.be.calledOnce; + expect(closeOrderReferenceSpy).to.be.calledWith({ AmazonOrderReferenceId: orderReferenceId }); + } + + beforeEach(function () { + user = new User(); + headers = {}; + orderReferenceId = 'orderReferenceId'; + + setOrderReferenceDetailsSpy = sinon.stub(amzLib, 'setOrderReferenceDetails'); + setOrderReferenceDetailsSpy.returnsPromise().resolves({}); + + confirmOrderReferenceSpy = sinon.stub(amzLib, 'confirmOrderReference'); + confirmOrderReferenceSpy.returnsPromise().resolves({}); + + authorizeSpy = sinon.stub(amzLib, 'authorize'); + authorizeSpy.returnsPromise().resolves({}); + + closeOrderReferenceSpy = sinon.stub(amzLib, 'closeOrderReference'); + closeOrderReferenceSpy.returnsPromise().resolves({}); + + paymentBuyGemsStub = sinon.stub(payments, 'buyGems'); + paymentBuyGemsStub.returnsPromise().resolves({}); + + paymentCreateSubscritionStub = sinon.stub(payments, 'createSubscription'); + paymentCreateSubscritionStub.returnsPromise().resolves({}); + + sinon.stub(common, 'uuid').returns('uuid-generated'); + }); + + afterEach(function () { + amzLib.setOrderReferenceDetails.restore(); + amzLib.confirmOrderReference.restore(); + amzLib.authorize.restore(); + amzLib.closeOrderReference.restore(); + payments.buyGems.restore(); + payments.createSubscription.restore(); + common.uuid.restore(); + }); + + function expectBuyGemsStub (paymentMethod, gift) { + expect(paymentBuyGemsStub).to.be.calledOnce; + + let expectedArgs = { + user, + paymentMethod, + headers, + }; + if (gift) expectedArgs.gift = gift; + expect(paymentBuyGemsStub).to.be.calledWith(expectedArgs); + } + + it('should purchase gems', async () => { + sinon.stub(user, 'canGetGems').returnsPromise().resolves(true); + await amzLib.checkout({user, orderReferenceId, headers}); + + expectBuyGemsStub(amzLib.constants.PAYMENT_METHOD); + expectAmazonStubs(); + expect(user.canGetGems).to.be.calledOnce; + user.canGetGems.restore(); + }); + + it('should error if gem amount is too low', async () => { + let receivingUser = new User(); + receivingUser.save(); + let gift = { + type: 'gems', + gems: { + amount: 0, + uuid: receivingUser._id, + }, + }; + + await expect(amzLib.checkout({gift, user, orderReferenceId, headers})) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 400, + message: 'Amount must be at least 1.', + name: 'BadRequest', + }); + }); + + it('should error if user cannot get gems gems', async () => { + sinon.stub(user, 'canGetGems').returnsPromise().resolves(false); + await expect(amzLib.checkout({user, orderReferenceId, headers})).to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + message: i18n.t('groupPolicyCannotGetGems'), + name: 'NotAuthorized', + }); + user.canGetGems.restore(); + }); + + it('should gift gems', async () => { + let receivingUser = new User(); + await receivingUser.save(); + let gift = { + type: 'gems', + uuid: receivingUser._id, + gems: { + amount: 16, + }, + }; + amount = 16 / 4; + await amzLib.checkout({gift, user, orderReferenceId, headers}); + + expectBuyGemsStub(amzLib.constants.PAYMENT_METHOD_GIFT, gift); + expectAmazonStubs(); + }); + + it('should gift a subscription', async () => { + let receivingUser = new User(); + receivingUser.save(); + let gift = { + type: 'subscription', + subscription: { + key: subKey, + uuid: receivingUser._id, + }, + }; + amount = common.content.subscriptionBlocks[subKey].price; + + await amzLib.checkout({user, orderReferenceId, headers, gift}); + + gift.member = receivingUser; + expect(paymentCreateSubscritionStub).to.be.calledOnce; + expect(paymentCreateSubscritionStub).to.be.calledWith({ + user, + paymentMethod: amzLib.constants.PAYMENT_METHOD_GIFT, + headers, + gift, + }); + expectAmazonStubs(); + }); +}); diff --git a/test/api/v3/unit/libs/payments/amazon/subscribe.test.js b/test/api/v3/unit/libs/payments/amazon/subscribe.test.js new file mode 100644 index 0000000000..8095f90c63 --- /dev/null +++ b/test/api/v3/unit/libs/payments/amazon/subscribe.test.js @@ -0,0 +1,267 @@ +import cc from 'coupon-code'; + +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; +import { model as Coupon } from '../../../../../../../website/server/models/coupon'; +import amzLib from '../../../../../../../website/server/libs/amazonPayments'; +import payments from '../../../../../../../website/server/libs/payments'; +import common from '../../../../../../../website/common'; + +const i18n = common.i18n; + +describe('Amazon Payments - Subscribe', () => { + const subKey = 'basic_3mo'; + let user, group, amount, billingAgreementId, sub, coupon, groupId, headers; + let amazonSetBillingAgreementDetailsSpy; + let amazonConfirmBillingAgreementSpy; + let amazonAuthorizeOnBillingAgreementSpy; + let createSubSpy; + + 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(); + + amount = common.content.subscriptionBlocks[subKey].price; + billingAgreementId = 'billingAgreementId'; + sub = { + key: subKey, + price: amount, + }; + groupId = group._id; + headers = {}; + + amazonSetBillingAgreementDetailsSpy = sinon.stub(amzLib, 'setBillingAgreementDetails'); + amazonSetBillingAgreementDetailsSpy.returnsPromise().resolves({}); + + amazonConfirmBillingAgreementSpy = sinon.stub(amzLib, 'confirmBillingAgreement'); + amazonConfirmBillingAgreementSpy.returnsPromise().resolves({}); + + amazonAuthorizeOnBillingAgreementSpy = sinon.stub(amzLib, 'authorizeOnBillingAgreement'); + amazonAuthorizeOnBillingAgreementSpy.returnsPromise().resolves({}); + + createSubSpy = sinon.stub(payments, 'createSubscription'); + createSubSpy.returnsPromise().resolves({}); + + sinon.stub(common, 'uuid').returns('uuid-generated'); + }); + + afterEach(function () { + amzLib.setBillingAgreementDetails.restore(); + amzLib.confirmBillingAgreement.restore(); + amzLib.authorizeOnBillingAgreement.restore(); + payments.createSubscription.restore(); + common.uuid.restore(); + }); + + function expectAmazonAuthorizeBillingAgreementSpy () { + expect(amazonAuthorizeOnBillingAgreementSpy).to.be.calledOnce; + expect(amazonAuthorizeOnBillingAgreementSpy).to.be.calledWith({ + AmazonBillingAgreementId: billingAgreementId, + AuthorizationReferenceId: common.uuid().substring(0, 32), + AuthorizationAmount: { + CurrencyCode: amzLib.constants.CURRENCY_CODE, + Amount: amount, + }, + SellerAuthorizationNote: amzLib.constants.SELLER_NOTE_ATHORIZATION_SUBSCRIPTION, + TransactionTimeout: 0, + CaptureNow: true, + SellerNote: amzLib.constants.SELLER_NOTE_ATHORIZATION_SUBSCRIPTION, + SellerOrderAttributes: { + SellerOrderId: common.uuid(), + StoreName: amzLib.constants.STORE_NAME, + }, + }); + } + + function expectAmazonSetBillingAgreementDetailsSpy () { + expect(amazonSetBillingAgreementDetailsSpy).to.be.calledOnce; + expect(amazonSetBillingAgreementDetailsSpy).to.be.calledWith({ + AmazonBillingAgreementId: billingAgreementId, + BillingAgreementAttributes: { + SellerNote: amzLib.constants.SELLER_NOTE_SUBSCRIPTION, + SellerBillingAgreementAttributes: { + SellerBillingAgreementId: common.uuid(), + StoreName: amzLib.constants.STORE_NAME, + CustomInformation: amzLib.constants.SELLER_NOTE_SUBSCRIPTION, + }, + }, + }); + } + + function expectCreateSpy () { + expect(createSubSpy).to.be.calledOnce; + expect(createSubSpy).to.be.calledWith({ + user, + customerId: billingAgreementId, + paymentMethod: amzLib.constants.PAYMENT_METHOD, + sub, + headers, + groupId, + }); + } + + it('should throw an error if we are missing a subscription', async () => { + await expect(amzLib.subscribe({ + billingAgreementId, + coupon, + user, + groupId, + headers, + })) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 400, + name: 'BadRequest', + message: i18n.t('missingSubscriptionCode'), + }); + }); + + it('should throw an error if we are missing a billingAgreementId', async () => { + await expect(amzLib.subscribe({ + sub, + coupon, + user, + groupId, + headers, + })) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 400, + name: 'BadRequest', + message: 'Missing req.body.billingAgreementId', + }); + }); + + it('should throw an error when coupon code is missing', async () => { + sub.discount = 40; + + await expect(amzLib.subscribe({ + billingAgreementId, + sub, + coupon, + user, + groupId, + headers, + })) + .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'; + + let couponModel = new Coupon(); + couponModel.event = 'google_6mo'; + await couponModel.save(); + + sinon.stub(cc, 'validate').returns('invalid'); + + await expect(amzLib.subscribe({ + billingAgreementId, + sub, + coupon, + user, + groupId, + headers, + })) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('invalidCoupon'), + }); + cc.validate.restore(); + }); + + it('subscribes with amazon with a coupon', async () => { + sub.discount = 40; + sub.key = 'google_6mo'; + coupon = 'example-coupon'; + + let couponModel = new Coupon(); + couponModel.event = 'google_6mo'; + let updatedCouponModel = await couponModel.save(); + + sinon.stub(cc, 'validate').returns(updatedCouponModel._id); + + await amzLib.subscribe({ + billingAgreementId, + sub, + coupon, + user, + groupId, + headers, + }); + + expectCreateSpy(); + + cc.validate.restore(); + }); + + it('subscribes with amazon', async () => { + await amzLib.subscribe({ + billingAgreementId, + sub, + coupon, + user, + groupId, + headers, + }); + + expectAmazonSetBillingAgreementDetailsSpy(); + + expect(amazonConfirmBillingAgreementSpy).to.be.calledOnce; + expect(amazonConfirmBillingAgreementSpy).to.be.calledWith({ + AmazonBillingAgreementId: billingAgreementId, + }); + + expectAmazonAuthorizeBillingAgreementSpy(); + + expectCreateSpy(); + }); + + it('subscribes with amazon with price to existing users', async () => { + user = new User(); + user.guilds.push(groupId); + await user.save(); + group.memberCount = 2; + await group.save(); + sub.key = 'group_monthly'; + sub.price = 9; + amount = 12; + + await amzLib.subscribe({ + billingAgreementId, + sub, + coupon, + user, + groupId, + headers, + }); + + expectAmazonSetBillingAgreementDetailsSpy(); + expect(amazonConfirmBillingAgreementSpy).to.be.calledOnce; + expect(amazonConfirmBillingAgreementSpy).to.be.calledWith({ + AmazonBillingAgreementId: billingAgreementId, + }); + expectAmazonAuthorizeBillingAgreementSpy(); + expectCreateSpy(); + }); +}); diff --git a/test/api/v3/unit/libs/payments/amazon/upgrade-groupplan.test.js b/test/api/v3/unit/libs/payments/amazon/upgrade-groupplan.test.js new file mode 100644 index 0000000000..a62e7f1b53 --- /dev/null +++ b/test/api/v3/unit/libs/payments/amazon/upgrade-groupplan.test.js @@ -0,0 +1,83 @@ +import uuid from 'uuid'; + +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; +import { model as Group } from '../../../../../../../website/server/models/group'; +import amzLib from '../../../../../../../website/server/libs/amazonPayments'; +import payments from '../../../../../../../website/server/libs/payments'; + +describe('#upgradeGroupPlan', () => { + let spy, data, user, group, uuidString; + + beforeEach(async function () { + 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: 'public', + leader: user._id, + }); + await group.save(); + + spy = sinon.stub(amzLib, 'authorizeOnBillingAgreement'); + spy.returnsPromise().resolves([]); + + uuidString = 'uuid-v4'; + sinon.stub(uuid, 'v4').returns(uuidString); + + data.groupId = group._id; + data.sub.quantity = 3; + }); + + afterEach(function () { + sinon.restore(amzLib.authorizeOnBillingAgreement); + uuid.v4.restore(); + }); + + it('charges for a new member', async () => { + data.paymentMethod = amzLib.constants.PAYMENT_METHOD; + await payments.createSubscription(data); + + let updatedGroup = await Group.findById(group._id).exec(); + + updatedGroup.memberCount += 1; + await updatedGroup.save(); + + await amzLib.chargeForAdditionalGroupMember(updatedGroup); + + expect(spy.calledOnce).to.be.true; + expect(spy).to.be.calledWith({ + AmazonBillingAgreementId: updatedGroup.purchased.plan.customerId, + AuthorizationReferenceId: uuidString.substring(0, 32), + AuthorizationAmount: { + CurrencyCode: amzLib.constants.CURRENCY_CODE, + Amount: 3, + }, + SellerAuthorizationNote: amzLib.constants.SELLER_NOTE_GROUP_NEW_MEMBER, + TransactionTimeout: 0, + CaptureNow: true, + SellerNote: amzLib.constants.SELLER_NOTE_GROUP_NEW_MEMBER, + SellerOrderAttributes: { + SellerOrderId: uuidString, + StoreName: amzLib.constants.STORE_NAME, + }, + }); + }); +}); diff --git a/test/api/v3/unit/libs/payments/paymentHelpers.js b/test/api/v3/unit/libs/payments/paymentHelpers.js new file mode 100644 index 0000000000..99069a8f49 --- /dev/null +++ b/test/api/v3/unit/libs/payments/paymentHelpers.js @@ -0,0 +1,7 @@ +import { model as User } from '../../../../../../website/server/models/user'; + +export async function createNonLeaderGroupMember (group) { + let nonLeader = new User(); + nonLeader.guilds.push(group._id); + return await nonLeader.save(); +} diff --git a/test/api/v3/unit/libs/payments/paypal/checkout-success.test.js b/test/api/v3/unit/libs/payments/paypal/checkout-success.test.js new file mode 100644 index 0000000000..5f63b99050 --- /dev/null +++ b/test/api/v3/unit/libs/payments/paypal/checkout-success.test.js @@ -0,0 +1,87 @@ +/* eslint-disable camelcase */ +import payments from '../../../../../../../website/server/libs/payments'; +import paypalPayments from '../../../../../../../website/server/libs/paypalPayments'; +import { model as User } from '../../../../../../../website/server/models/user'; + +describe('checkout success', () => { + const subKey = 'basic_3mo'; + let user, gift, customerId, paymentId; + let paypalPaymentExecuteStub, paymentBuyGemsStub, paymentsCreateSubscritionStub; + + beforeEach(() => { + user = new User(); + customerId = 'customerId-test'; + paymentId = 'paymentId-test'; + + paypalPaymentExecuteStub = sinon.stub(paypalPayments, 'paypalPaymentExecute').returnsPromise().resolves({}); + paymentBuyGemsStub = sinon.stub(payments, 'buyGems').returnsPromise().resolves({}); + paymentsCreateSubscritionStub = sinon.stub(payments, 'createSubscription').returnsPromise().resolves({}); + }); + + afterEach(() => { + paypalPayments.paypalPaymentExecute.restore(); + payments.buyGems.restore(); + payments.createSubscription.restore(); + }); + + it('purchases gems', async () => { + await paypalPayments.checkoutSuccess({user, gift, paymentId, customerId}); + + expect(paypalPaymentExecuteStub).to.be.calledOnce; + expect(paypalPaymentExecuteStub).to.be.calledWith(paymentId, { payer_id: customerId }); + expect(paymentBuyGemsStub).to.be.calledOnce; + expect(paymentBuyGemsStub).to.be.calledWith({ + user, + customerId, + paymentMethod: 'Paypal', + }); + }); + + it('gifts gems', async () => { + let receivingUser = new User(); + await receivingUser.save(); + gift = { + type: 'gems', + gems: { + amount: 16, + uuid: receivingUser._id, + }, + }; + + await paypalPayments.checkoutSuccess({user, gift, paymentId, customerId}); + + expect(paypalPaymentExecuteStub).to.be.calledOnce; + expect(paypalPaymentExecuteStub).to.be.calledWith(paymentId, { payer_id: customerId }); + expect(paymentBuyGemsStub).to.be.calledOnce; + expect(paymentBuyGemsStub).to.be.calledWith({ + user, + customerId, + paymentMethod: 'PayPal (Gift)', + gift, + }); + }); + + it('gifts subscription', async () => { + let receivingUser = new User(); + await receivingUser.save(); + gift = { + type: 'subscription', + subscription: { + key: subKey, + uuid: receivingUser._id, + }, + }; + + await paypalPayments.checkoutSuccess({user, gift, paymentId, customerId}); + + expect(paypalPaymentExecuteStub).to.be.calledOnce; + expect(paypalPaymentExecuteStub).to.be.calledWith(paymentId, { payer_id: customerId }); + expect(paymentsCreateSubscritionStub).to.be.calledOnce; + expect(paymentsCreateSubscritionStub).to.be.calledWith({ + user, + customerId, + paymentMethod: 'PayPal (Gift)', + gift, + }); + }); +}); diff --git a/test/api/v3/unit/libs/payments/paypal/checkout.test.js b/test/api/v3/unit/libs/payments/paypal/checkout.test.js new file mode 100644 index 0000000000..9ed40c1ad8 --- /dev/null +++ b/test/api/v3/unit/libs/payments/paypal/checkout.test.js @@ -0,0 +1,127 @@ +/* eslint-disable camelcase */ +import nconf from 'nconf'; + +import paypalPayments from '../../../../../../../website/server/libs/paypalPayments'; +import { model as User } from '../../../../../../../website/server/models/user'; +import common from '../../../../../../../website/common'; + +const BASE_URL = nconf.get('BASE_URL'); +const i18n = common.i18n; + +describe('checkout', () => { + const subKey = 'basic_3mo'; + let paypalPaymentCreateStub; + let approvalHerf; + + function getPaypalCreateOptions (description, amount) { + return { + intent: 'sale', + payer: { payment_method: 'Paypal' }, + redirect_urls: { + return_url: `${BASE_URL}/paypal/checkout/success`, + cancel_url: `${BASE_URL}`, + }, + transactions: [{ + item_list: { + items: [{ + name: description, + price: amount, + currency: 'USD', + quantity: 1, + }], + }, + amount: { + currency: 'USD', + total: amount, + }, + description, + }], + }; + } + + beforeEach(() => { + approvalHerf = 'approval_href'; + paypalPaymentCreateStub = sinon.stub(paypalPayments, 'paypalPaymentCreate') + .returnsPromise().resolves({ + links: [{ rel: 'approval_url', href: approvalHerf }], + }); + }); + + afterEach(() => { + paypalPayments.paypalPaymentCreate.restore(); + }); + + it('creates a link for gem purchases', async () => { + let link = await paypalPayments.checkout({user: new User()}); + + expect(paypalPaymentCreateStub).to.be.calledOnce; + expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('Habitica Gems', 5.00)); + expect(link).to.eql(approvalHerf); + }); + + it('should error if gem amount is too low', async () => { + let receivingUser = new User(); + receivingUser.save(); + let gift = { + type: 'gems', + gems: { + amount: 0, + uuid: receivingUser._id, + }, + }; + + await expect(paypalPayments.checkout({gift})) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 400, + message: 'Amount must be at least 1.', + name: 'BadRequest', + }); + }); + + it('should error if the user cannot get gems', async () => { + let user = new User(); + sinon.stub(user, 'canGetGems').returnsPromise().resolves(false); + + await expect(paypalPayments.checkout({user})).to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + message: i18n.t('groupPolicyCannotGetGems'), + name: 'NotAuthorized', + }); + }); + + it('creates a link for gifting gems', async () => { + let receivingUser = new User(); + await receivingUser.save(); + let gift = { + type: 'gems', + uuid: receivingUser._id, + gems: { + amount: 16, + }, + }; + + let link = await paypalPayments.checkout({gift}); + + 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 () => { + let receivingUser = new User(); + receivingUser.save(); + let gift = { + type: 'subscription', + subscription: { + key: subKey, + uuid: receivingUser._id, + }, + }; + + let link = await paypalPayments.checkout({gift}); + + expect(paypalPaymentCreateStub).to.be.calledOnce; + expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('mo. Habitica Subscription (Gift)', '15.00')); + expect(link).to.eql(approvalHerf); + }); +}); diff --git a/test/api/v3/unit/libs/payments/paypal/ipn.test.js b/test/api/v3/unit/libs/payments/paypal/ipn.test.js new file mode 100644 index 0000000000..7094b67cb9 --- /dev/null +++ b/test/api/v3/unit/libs/payments/paypal/ipn.test.js @@ -0,0 +1,66 @@ +/* eslint-disable camelcase */ +import payments from '../../../../../../../website/server/libs/payments'; +import paypalPayments from '../../../../../../../website/server/libs/paypalPayments'; +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; + +describe('ipn', () => { + const subKey = 'basic_3mo'; + let user, group, txn_type, userPaymentId, groupPaymentId; + let ipnVerifyAsyncStub, paymentCancelSubscriptionSpy; + + beforeEach(async () => { + txn_type = 'recurring_payment_profile_cancel'; + userPaymentId = 'userPaymentId-test'; + groupPaymentId = 'groupPaymentId-test'; + + user = new User(); + user.profile.name = 'sender'; + user.purchased.plan.customerId = userPaymentId; + user.purchased.plan.planId = subKey; + user.purchased.plan.lastBillingDate = new Date(); + await user.save(); + + group = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + group.purchased.plan.customerId = groupPaymentId; + group.purchased.plan.planId = subKey; + group.purchased.plan.lastBillingDate = new Date(); + await group.save(); + + ipnVerifyAsyncStub = sinon.stub(paypalPayments, 'ipnVerifyAsync').returnsPromise().resolves({}); + paymentCancelSubscriptionSpy = sinon.stub(payments, 'cancelSubscription').returnsPromise().resolves({}); + }); + + afterEach(function () { + paypalPayments.ipnVerifyAsync.restore(); + payments.cancelSubscription.restore(); + }); + + it('should cancel a user subscription', async () => { + await paypalPayments.ipn({txn_type, recurring_payment_id: userPaymentId}); + + expect(ipnVerifyAsyncStub).to.be.calledOnce; + expect(ipnVerifyAsyncStub).to.be.calledWith({txn_type, recurring_payment_id: userPaymentId}); + + expect(paymentCancelSubscriptionSpy).to.be.calledOnce; + expect(paymentCancelSubscriptionSpy.args[0][0].user._id).to.eql(user._id); + expect(paymentCancelSubscriptionSpy.args[0][0].paymentMethod).to.eql('Paypal'); + }); + + it('should cancel a group subscription', async () => { + await paypalPayments.ipn({txn_type, recurring_payment_id: groupPaymentId}); + + expect(ipnVerifyAsyncStub).to.be.calledOnce; + expect(ipnVerifyAsyncStub).to.be.calledWith({txn_type, recurring_payment_id: groupPaymentId}); + + expect(paymentCancelSubscriptionSpy).to.be.calledOnce; + expect(paymentCancelSubscriptionSpy).to.be.calledWith({ groupId: group._id, paymentMethod: 'Paypal' }); + }); +}); diff --git a/test/api/v3/unit/libs/payments/paypal/subscribe-cancel.test.js b/test/api/v3/unit/libs/payments/paypal/subscribe-cancel.test.js new file mode 100644 index 0000000000..ef5b399fed --- /dev/null +++ b/test/api/v3/unit/libs/payments/paypal/subscribe-cancel.test.js @@ -0,0 +1,124 @@ +/* eslint-disable camelcase */ +import payments from '../../../../../../../website/server/libs/payments'; +import paypalPayments from '../../../../../../../website/server/libs/paypalPayments'; +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; +import common from '../../../../../../../website/common'; +import { createNonLeaderGroupMember } from '../paymentHelpers'; + +const i18n = common.i18n; + +describe('subscribeCancel', () => { + const subKey = 'basic_3mo'; + let user, group, groupId, customerId, groupCustomerId, nextBillingDate; + let paymentCancelSubscriptionSpy, paypalBillingAgreementCancelStub, paypalBillingAgreementGetStub; + + beforeEach(async () => { + customerId = 'customer-id'; + groupCustomerId = 'groupCustomerId-test'; + + user = new User(); + user.profile.name = 'sender'; + user.purchased.plan.customerId = customerId; + 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 = groupCustomerId; + group.purchased.plan.planId = subKey; + group.purchased.plan.lastBillingDate = new Date(); + await group.save(); + + nextBillingDate = new Date(); + + paypalBillingAgreementCancelStub = sinon.stub(paypalPayments, 'paypalBillingAgreementCancel').returnsPromise().resolves({}); + paypalBillingAgreementGetStub = sinon.stub(paypalPayments, 'paypalBillingAgreementGet') + .returnsPromise().resolves({ + agreement_details: { + next_billing_date: nextBillingDate, + cycles_completed: 1, + }, + }); + paymentCancelSubscriptionSpy = sinon.stub(payments, 'cancelSubscription').returnsPromise().resolves({}); + }); + + afterEach(function () { + paypalPayments.paypalBillingAgreementGet.restore(); + paypalPayments.paypalBillingAgreementCancel.restore(); + payments.cancelSubscription.restore(); + }); + + it('should throw an error if we are missing a subscription', async () => { + user.purchased.plan.customerId = undefined; + + await expect(paypalPayments.subscribeCancel({user})) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('missingSubscription'), + }); + }); + + it('should throw an error if group is not found', async () => { + await expect(paypalPayments.subscribeCancel({user, groupId: 'fake-id'})) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 404, + name: 'NotFound', + message: i18n.t('groupNotFound'), + }); + }); + + it('should throw an error if user is not group leader', async () => { + let nonLeader = await createNonLeaderGroupMember(group); + + await expect(paypalPayments.subscribeCancel({user: nonLeader, groupId: group._id})) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('onlyGroupLeaderCanManageSubscription'), + }); + }); + + it('should cancel a user subscription', async () => { + await paypalPayments.subscribeCancel({user}); + + expect(paypalBillingAgreementGetStub).to.be.calledOnce; + expect(paypalBillingAgreementGetStub).to.be.calledWith(customerId); + expect(paypalBillingAgreementCancelStub).to.be.calledOnce; + expect(paypalBillingAgreementCancelStub).to.be.calledWith(customerId, { note: i18n.t('cancelingSubscription') }); + + expect(paymentCancelSubscriptionSpy).to.be.calledOnce; + expect(paymentCancelSubscriptionSpy).to.be.calledWith({ + user, + groupId, + paymentMethod: 'Paypal', + nextBill: nextBillingDate, + cancellationReason: undefined, + }); + }); + + it('should cancel a group subscription', async () => { + await paypalPayments.subscribeCancel({user, groupId: group._id}); + + expect(paypalBillingAgreementGetStub).to.be.calledOnce; + expect(paypalBillingAgreementGetStub).to.be.calledWith(groupCustomerId); + expect(paypalBillingAgreementCancelStub).to.be.calledOnce; + expect(paypalBillingAgreementCancelStub).to.be.calledWith(groupCustomerId, { note: i18n.t('cancelingSubscription') }); + + expect(paymentCancelSubscriptionSpy).to.be.calledOnce; + expect(paymentCancelSubscriptionSpy).to.be.calledWith({ + user, + groupId: group._id, + paymentMethod: 'Paypal', + nextBill: nextBillingDate, + cancellationReason: undefined, + }); + }); +}); diff --git a/test/api/v3/unit/libs/payments/paypal/subscribe-success.test.js b/test/api/v3/unit/libs/payments/paypal/subscribe-success.test.js new file mode 100644 index 0000000000..5caaf34fc8 --- /dev/null +++ b/test/api/v3/unit/libs/payments/paypal/subscribe-success.test.js @@ -0,0 +1,77 @@ +/* eslint-disable camelcase */ +import payments from '../../../../../../../website/server/libs/payments'; +import paypalPayments from '../../../../../../../website/server/libs/paypalPayments'; +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; +import common from '../../../../../../../website/common'; + +describe('subscribeSuccess', () => { + const subKey = 'basic_3mo'; + let user, group, block, groupId, token, headers, customerId; + let paypalBillingAgreementExecuteStub, paymentsCreateSubscritionStub; + + beforeEach(async () => { + user = new User(); + + group = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: user._id, + }); + + token = 'test-token'; + headers = {}; + block = common.content.subscriptionBlocks[subKey]; + customerId = 'test-customerId'; + + paypalBillingAgreementExecuteStub = sinon.stub(paypalPayments, 'paypalBillingAgreementExecute') + .returnsPromise({}).resolves({ + id: customerId, + }); + paymentsCreateSubscritionStub = sinon.stub(payments, 'createSubscription').returnsPromise().resolves({}); + }); + + afterEach(() => { + paypalPayments.paypalBillingAgreementExecute.restore(); + payments.createSubscription.restore(); + }); + + it('creates a user subscription', async () => { + await paypalPayments.subscribeSuccess({user, block, groupId, token, headers}); + + expect(paypalBillingAgreementExecuteStub).to.be.calledOnce; + expect(paypalBillingAgreementExecuteStub).to.be.calledWith(token, {}); + + expect(paymentsCreateSubscritionStub).to.be.calledOnce; + expect(paymentsCreateSubscritionStub).to.be.calledWith({ + user, + groupId, + customerId, + paymentMethod: 'Paypal', + sub: block, + headers, + }); + }); + + it('create a group subscription', async () => { + groupId = group._id; + + await paypalPayments.subscribeSuccess({user, block, groupId, token, headers}); + + expect(paypalBillingAgreementExecuteStub).to.be.calledOnce; + expect(paypalBillingAgreementExecuteStub).to.be.calledWith(token, {}); + + expect(paymentsCreateSubscritionStub).to.be.calledOnce; + expect(paymentsCreateSubscritionStub).to.be.calledWith({ + user, + groupId, + customerId, + paymentMethod: 'Paypal', + sub: block, + headers, + }); + }); +}); diff --git a/test/api/v3/unit/libs/payments/paypal/subscribe.test.js b/test/api/v3/unit/libs/payments/paypal/subscribe.test.js new file mode 100644 index 0000000000..f0450ccfc1 --- /dev/null +++ b/test/api/v3/unit/libs/payments/paypal/subscribe.test.js @@ -0,0 +1,112 @@ +/* eslint-disable camelcase */ +import moment from 'moment'; +import cc from 'coupon-code'; + +import paypalPayments from '../../../../../../../website/server/libs/paypalPayments'; +import { model as Coupon } from '../../../../../../../website/server/models/coupon'; +import common from '../../../../../../../website/common'; + +const i18n = common.i18n; + +describe('subscribe', () => { + const subKey = 'basic_3mo'; + let coupon, sub, approvalHerf; + let paypalBillingAgreementCreateStub; + + beforeEach(() => { + approvalHerf = 'approvalHerf-test'; + sub = Object.assign({}, common.content.subscriptionBlocks[subKey]); + + paypalBillingAgreementCreateStub = sinon.stub(paypalPayments, 'paypalBillingAgreementCreate') + .returnsPromise().resolves({ + links: [{ rel: 'approval_url', href: approvalHerf }], + }); + }); + + afterEach(() => { + paypalPayments.paypalBillingAgreementCreate.restore(); + }); + + it('should throw an error when coupon code is missing', async () => { + sub.discount = 40; + + await expect(paypalPayments.subscribe({sub, 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'; + + let couponModel = new Coupon(); + couponModel.event = 'google_6mo'; + await couponModel.save(); + + sinon.stub(cc, 'validate').returns('invalid'); + + await expect(paypalPayments.subscribe({sub, coupon})) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + name: 'NotAuthorized', + message: i18n.t('invalidCoupon'), + }); + cc.validate.restore(); + }); + + it('subscribes with amazon with a coupon', async () => { + sub.discount = 40; + sub.key = 'google_6mo'; + coupon = 'example-coupon'; + + let couponModel = new Coupon(); + couponModel.event = 'google_6mo'; + let updatedCouponModel = await couponModel.save(); + + sinon.stub(cc, 'validate').returns(updatedCouponModel._id); + + let link = await paypalPayments.subscribe({sub, coupon}); + + expect(link).to.eql(approvalHerf); + expect(paypalBillingAgreementCreateStub).to.be.calledOnce; + let billingPlanTitle = `Habitica Subscription ($${sub.price} every ${sub.months} months, recurring)`; + expect(paypalBillingAgreementCreateStub).to.be.calledWith({ + name: billingPlanTitle, + description: billingPlanTitle, + start_date: moment().add({ minutes: 5 }).format(), + plan: { + id: sub.paypalKey, + }, + payer: { + payment_method: 'Paypal', + }, + }); + + cc.validate.restore(); + }); + + it('creates a link for a subscription', async () => { + delete sub.discount; + + let link = await paypalPayments.subscribe({sub, coupon}); + + expect(link).to.eql(approvalHerf); + expect(paypalBillingAgreementCreateStub).to.be.calledOnce; + let billingPlanTitle = `Habitica Subscription ($${sub.price} every ${sub.months} months, recurring)`; + expect(paypalBillingAgreementCreateStub).to.be.calledWith({ + name: billingPlanTitle, + description: billingPlanTitle, + start_date: moment().add({ minutes: 5 }).format(), + plan: { + id: sub.paypalKey, + }, + payer: { + payment_method: 'Paypal', + }, + }); + }); +}); diff --git a/test/api/v3/unit/libs/payments/stripe/cancel-subscription.test.js b/test/api/v3/unit/libs/payments/stripe/cancel-subscription.test.js new file mode 100644 index 0000000000..2e09014a39 --- /dev/null +++ b/test/api/v3/unit/libs/payments/stripe/cancel-subscription.test.js @@ -0,0 +1,143 @@ +import stripeModule from 'stripe'; + +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; +import stripePayments from '../../../../../../../website/server/libs/stripePayments'; +import payments from '../../../../../../../website/server/libs/payments'; +import common from '../../../../../../../website/common'; + +const i18n = common.i18n; + +describe('cancel subscription', () => { + const subKey = 'basic_3mo'; + const stripe = stripeModule('test'); + let user, groupId, 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 () => { + let 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, paymentsCancelSubStub, stripeRetrieveStub, subscriptionId, currentPeriodEndTimeStamp; + + beforeEach(() => { + subscriptionId = 'subId'; + stripeDeleteCustomerStub = sinon.stub(stripe.customers, 'del').returnsPromise().resolves({}); + paymentsCancelSubStub = sinon.stub(payments, 'cancelSubscription').returnsPromise().resolves({}); + + currentPeriodEndTimeStamp = (new Date()).getTime(); + stripeRetrieveStub = sinon.stub(stripe.customers, 'retrieve') + .returnsPromise().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/v3/unit/libs/payments/stripe/checkout-subscription.test.js b/test/api/v3/unit/libs/payments/stripe/checkout-subscription.test.js new file mode 100644 index 0000000000..0c2683b8c2 --- /dev/null +++ b/test/api/v3/unit/libs/payments/stripe/checkout-subscription.test.js @@ -0,0 +1,307 @@ +import stripeModule from 'stripe'; +import cc from 'coupon-code'; + +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; +import { model as Coupon } from '../../../../../../../website/server/models/coupon'; +import stripePayments from '../../../../../../../website/server/libs/stripePayments'; +import payments from '../../../../../../../website/server/libs/payments'; +import common from '../../../../../../../website/common'; + +const i18n = common.i18n; + +describe('checkout with subscription', () => { + const subKey = 'basic_3mo'; + const stripe = stripeModule('test'); + let user, group, data, gift, sub, groupId, email, headers, coupon, customerIdResponse, subscriptionId, 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.returnsPromise().resolves; + + stripeCreateCustomerSpy = sinon.stub(stripe.customers, 'create'); + let stripCustomerResponse = { + id: customerIdResponse, + subscriptions: { + data: [{id: subscriptionId}], + }, + }; + stripeCreateCustomerSpy.returnsPromise().resolves(stripCustomerResponse); + + stripePaymentsCreateSubSpy = sinon.stub(payments, 'createSubscription'); + stripePaymentsCreateSubSpy.returnsPromise().resolves({}); + + data.groupId = group._id; + data.sub.quantity = 3; + }); + + afterEach(function () { + sinon.restore(stripe.subscriptions.update); + 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'; + + let 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 amazon with a coupon', async () => { + sub.discount = 40; + sub.key = 'google_6mo'; + coupon = 'example-coupon'; + + let couponModel = new Coupon(); + couponModel.event = 'google_6mo'; + let 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'; + 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 = {}; + 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/v3/unit/libs/payments/stripe/checkout.test.js b/test/api/v3/unit/libs/payments/stripe/checkout.test.js new file mode 100644 index 0000000000..f784bca300 --- /dev/null +++ b/test/api/v3/unit/libs/payments/stripe/checkout.test.js @@ -0,0 +1,193 @@ +import stripeModule from 'stripe'; + +import { model as User } from '../../../../../../../website/server/models/user'; +import stripePayments from '../../../../../../../website/server/libs/stripePayments'; +import payments from '../../../../../../../website/server/libs/payments'; +import common from '../../../../../../../website/common'; + +const i18n = common.i18n; + +describe('checkout', () => { + const subKey = 'basic_3mo'; + const stripe = stripeModule('test'); + let stripeChargeStub, paymentBuyGemsStub, paymentCreateSubscritionStub; + let user, gift, groupId, email, headers, coupon, customerIdResponse, token; + + 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'; + let stripCustomerResponse = { + id: customerIdResponse, + }; + stripeChargeStub = sinon.stub(stripe.charges, 'create').returnsPromise().resolves(stripCustomerResponse); + paymentBuyGemsStub = sinon.stub(payments, 'buyGems').returnsPromise().resolves({}); + paymentCreateSubscritionStub = sinon.stub(payments, 'createSubscription').returnsPromise().resolves({}); + }); + + afterEach(() => { + stripe.charges.create.restore(); + payments.buyGems.restore(); + payments.createSubscription.restore(); + }); + + it('should error if gem amount is too low', async () => { + let receivingUser = new User(); + receivingUser.save(); + gift = { + type: 'gems', + gems: { + amount: 0, + uuid: receivingUser._id, + }, + }; + + await expect(stripePayments.checkout({ + token, + user, + gift, + groupId, + email, + headers, + coupon, + }, stripe)) + .to.eventually.be.rejected.and.to.eql({ + httpCode: 400, + message: 'Amount must be at least 1.', + name: 'BadRequest', + }); + }); + + + it('should error if user cannot get gems', async () => { + gift = undefined; + sinon.stub(user, 'canGetGems').returnsPromise().resolves(false); + + await expect(stripePayments.checkout({ + token, + user, + gift, + groupId, + email, + headers, + coupon, + }, stripe)).to.eventually.be.rejected.and.to.eql({ + httpCode: 401, + message: i18n.t('groupPolicyCannotGetGems'), + name: 'NotAuthorized', + }); + }); + + it('should purchase gems', async () => { + gift = undefined; + sinon.stub(user, 'canGetGems').returnsPromise().resolves(true); + + await stripePayments.checkout({ + token, + user, + gift, + groupId, + email, + headers, + coupon, + }, stripe); + + expect(stripeChargeStub).to.be.calledOnce; + expect(stripeChargeStub).to.be.calledWith({ + amount: 500, + currency: 'usd', + card: token, + }); + + expect(paymentBuyGemsStub).to.be.calledOnce; + expect(paymentBuyGemsStub).to.be.calledWith({ + user, + customerId: customerIdResponse, + paymentMethod: 'Stripe', + gift, + }); + expect(user.canGetGems).to.be.calledOnce; + user.canGetGems.restore(); + }); + + it('should gift gems', async () => { + let 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, + }); + }); + + it('should gift a subscription', async () => { + let receivingUser = new User(); + receivingUser.save(); + gift = { + type: 'subscription', + subscription: { + key: subKey, + uuid: receivingUser._id, + }, + }; + + await stripePayments.checkout({ + token, + user, + gift, + groupId, + email, + headers, + coupon, + }, stripe); + + gift.member = receivingUser; + expect(stripeChargeStub).to.be.calledOnce; + expect(stripeChargeStub).to.be.calledWith({ + amount: '1500', + currency: 'usd', + card: token, + }); + + expect(paymentCreateSubscritionStub).to.be.calledOnce; + expect(paymentCreateSubscritionStub).to.be.calledWith({ + user, + customerId: customerIdResponse, + paymentMethod: 'Gift', + gift, + }); + }); +}); diff --git a/test/api/v3/unit/libs/payments/stripe/edit-subscription.test.js b/test/api/v3/unit/libs/payments/stripe/edit-subscription.test.js new file mode 100644 index 0000000000..9c58c4bae5 --- /dev/null +++ b/test/api/v3/unit/libs/payments/stripe/edit-subscription.test.js @@ -0,0 +1,147 @@ +import stripeModule from 'stripe'; + +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; +import stripePayments from '../../../../../../../website/server/libs/stripePayments'; +import common from '../../../../../../../website/common'; + +const i18n = common.i18n; + +describe('edit subscription', () => { + const subKey = 'basic_3mo'; + const stripe = stripeModule('test'); + let user, groupId, group, 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 () => { + let 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, stripeUpdateSubscriptionStub, subscriptionId; + + beforeEach(() => { + subscriptionId = 'subId'; + stripeListSubscriptionStub = sinon.stub(stripe.customers, 'listSubscriptions') + .returnsPromise().resolves({ + data: [{id: subscriptionId}], + }); + + stripeUpdateSubscriptionStub = sinon.stub(stripe.customers, 'updateSubscription').returnsPromise().resolves({}); + }); + + afterEach(() => { + stripe.customers.listSubscriptions.restore(); + stripe.customers.updateSubscription.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(user.purchased.plan.customerId); + expect(stripeUpdateSubscriptionStub).to.be.calledOnce; + expect(stripeUpdateSubscriptionStub).to.be.calledWith( + user.purchased.plan.customerId, + 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(group.purchased.plan.customerId); + expect(stripeUpdateSubscriptionStub).to.be.calledOnce; + expect(stripeUpdateSubscriptionStub).to.be.calledWith( + group.purchased.plan.customerId, + subscriptionId, + { card: token } + ); + }); + }); +}); diff --git a/test/api/v3/unit/libs/payments/stripe/handle-webhook.test.js b/test/api/v3/unit/libs/payments/stripe/handle-webhook.test.js new file mode 100644 index 0000000000..c91cb7e919 --- /dev/null +++ b/test/api/v3/unit/libs/payments/stripe/handle-webhook.test.js @@ -0,0 +1,257 @@ +import stripeModule from 'stripe'; + +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; +import stripePayments from '../../../../../../../website/server/libs/stripePayments'; +import payments from '../../../../../../../website/server/libs/payments'; +import common from '../../../../../../../website/common'; +import logger from '../../../../../../../website/server/libs/logger'; +import { v4 as uuid } from 'uuid'; +import moment from 'moment'; + +const i18n = common.i18n; + +describe('Stripe - Webhooks', () => { + const stripe = stripeModule('test'); + + describe('all events', () => { + const eventType = 'account.updated'; + const event = {id: 123}; + const eventRetrieved = {type: eventType}; + + beforeEach(() => { + sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves(eventRetrieved); + sinon.stub(logger, 'error'); + }); + + afterEach(() => { + stripe.events.retrieve.restore(); + logger.error.restore(); + }); + + 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.called.once; + expect(logger.error).to.have.been.calledWith(error, {event: eventRetrieved}); + }); + + it('retrieves and validates the event from Stripe', async () => { + await stripePayments.handleWebhooks({requestBody: event}, stripe); + expect(stripe.events.retrieve).to.have.been.called.once; + expect(stripe.events.retrieve).to.have.been.calledWith(event.id); + }); + }); + + describe('customer.subscription.deleted', () => { + const eventType = 'customer.subscription.deleted'; + + beforeEach(() => { + sinon.stub(stripe.customers, 'del').returnsPromise().resolves({}); + sinon.stub(payments, 'cancelSubscription').returnsPromise().resolves({}); + }); + + afterEach(() => { + stripe.customers.del.restore(); + payments.cancelSubscription.restore(); + }); + + it('does not do anything if event.request is null (subscription cancelled manually)', async () => { + sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({ + id: 123, + type: eventType, + request: 123, + }); + + await stripePayments.handleWebhooks({requestBody: {}}, stripe); + + expect(stripe.events.retrieve).to.have.been.called.once; + 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').returnsPromise().resolves({ + id: 123, + type: eventType, + data: { + object: { + plan: { + id: 'basic_earned', + }, + customer: customerId, + }, + }, + request: null, + }); + + await expect(stripePayments.handleWebhooks({requestBody: {}}, stripe)).to.eventually.be.rejectedWith({ + message: i18n.t('userNotFound'), + httpCode: 404, + name: 'NotFound', + }); + + 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 () => { + const customerId = '456'; + + let subscriber = new User(); + subscriber.purchased.plan.customerId = customerId; + subscriber.purchased.plan.paymentMethod = 'Stripe'; + await subscriber.save(); + + sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({ + id: 123, + type: eventType, + data: { + object: { + plan: { + id: 'basic_earned', + }, + customer: customerId, + }, + }, + request: null, + }); + + await stripePayments.handleWebhooks({requestBody: {}}, stripe); + + expect(stripe.customers.del).to.have.been.calledOnce; + expect(stripe.customers.del).to.have.been.calledWith(customerId); + expect(payments.cancelSubscription).to.have.been.calledOnce; + + let cancelSubscriptionOpts = payments.cancelSubscription.lastCall.args[0]; + expect(cancelSubscriptionOpts.user._id).to.equal(subscriber._id); + 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').returnsPromise().resolves({ + id: 123, + type: eventType, + data: { + object: { + plan: { + id: 'group_monthly', + }, + customer: customerId, + }, + }, + request: null, + }); + + await expect(stripePayments.handleWebhooks({requestBody: {}}, stripe)).to.eventually.be.rejectedWith({ + message: i18n.t('groupNotFound'), + httpCode: 404, + name: 'NotFound', + }); + + 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 () => { + const customerId = 456; + + let subscriber = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: uuid(), + }); + subscriber.purchased.plan.customerId = customerId; + subscriber.purchased.plan.paymentMethod = 'Stripe'; + await subscriber.save(); + + sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({ + id: 123, + type: eventType, + data: { + object: { + plan: { + id: 'group_monthly', + }, + customer: customerId, + }, + }, + request: null, + }); + + await expect(stripePayments.handleWebhooks({requestBody: {}}, stripe)).to.eventually.be.rejectedWith({ + message: i18n.t('userNotFound'), + httpCode: 404, + name: 'NotFound', + }); + + 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 () => { + const customerId = '456'; + + let leader = new User(); + await leader.save(); + + let subscriber = generateGroup({ + name: 'test group', + type: 'guild', + privacy: 'public', + leader: leader._id, + }); + subscriber.purchased.plan.customerId = customerId; + subscriber.purchased.plan.paymentMethod = 'Stripe'; + await subscriber.save(); + + sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({ + id: 123, + type: eventType, + data: { + object: { + plan: { + id: 'group_monthly', + }, + customer: customerId, + }, + }, + request: null, + }); + + await stripePayments.handleWebhooks({requestBody: {}}, stripe); + + expect(stripe.customers.del).to.have.been.calledOnce; + expect(stripe.customers.del).to.have.been.calledWith(customerId); + expect(payments.cancelSubscription).to.have.been.calledOnce; + + let cancelSubscriptionOpts = payments.cancelSubscription.lastCall.args[0]; + expect(cancelSubscriptionOpts.user._id).to.equal(leader._id); + 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(); + }); + }); + }); +}); diff --git a/test/api/v3/unit/libs/payments/stripe/upgrade-group-plan.test.js b/test/api/v3/unit/libs/payments/stripe/upgrade-group-plan.test.js new file mode 100644 index 0000000000..f0126631a9 --- /dev/null +++ b/test/api/v3/unit/libs/payments/stripe/upgrade-group-plan.test.js @@ -0,0 +1,66 @@ +import stripeModule from 'stripe'; + +import { + generateGroup, +} from '../../../../../../helpers/api-unit.helper.js'; +import { model as User } from '../../../../../../../website/server/models/user'; +import { model as Group } from '../../../../../../../website/server/models/group'; +import stripePayments from '../../../../../../../website/server/libs/stripePayments'; +import payments from '../../../../../../../website/server/libs/payments'; + +describe('Stripe - Upgrade Group Plan', () => { + const stripe = stripeModule('test'); + let spy, data, user, group; + + beforeEach(async function () { + 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: 'public', + leader: user._id, + }); + await group.save(); + + spy = sinon.stub(stripe.subscriptions, 'update'); + spy.returnsPromise().resolves([]); + data.groupId = group._id; + data.sub.quantity = 3; + stripePayments.setStripeApi(stripe); + }); + + afterEach(function () { + sinon.restore(stripe.subscriptions.update); + }); + + it('updates a group plan quantity', async () => { + data.paymentMethod = 'Stripe'; + await payments.createSubscription(data); + + let 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/v3/unit/libs/paypalPayments.test.js b/test/api/v3/unit/libs/paypalPayments.test.js deleted file mode 100644 index ae67cf3ede..0000000000 --- a/test/api/v3/unit/libs/paypalPayments.test.js +++ /dev/null @@ -1,561 +0,0 @@ -/* eslint-disable camelcase */ -import nconf from 'nconf'; -import moment from 'moment'; -import cc from 'coupon-code'; - -import payments from '../../../../../website/server/libs/payments'; -import paypalPayments from '../../../../../website/server/libs/paypalPayments'; -import { - generateGroup, -} from '../../../../helpers/api-unit.helper.js'; -import { model as User } from '../../../../../website/server/models/user'; -import { model as Coupon } from '../../../../../website/server/models/coupon'; -import common from '../../../../../website/common'; - -const BASE_URL = nconf.get('BASE_URL'); -const i18n = common.i18n; - -describe('Paypal Payments', () => { - let subKey = 'basic_3mo'; - - describe('checkout', () => { - let paypalPaymentCreateStub; - let approvalHerf; - - function getPaypalCreateOptions (description, amount) { - return { - intent: 'sale', - payer: { payment_method: 'Paypal' }, - redirect_urls: { - return_url: `${BASE_URL}/paypal/checkout/success`, - cancel_url: `${BASE_URL}`, - }, - transactions: [{ - item_list: { - items: [{ - name: description, - price: amount, - currency: 'USD', - quantity: 1, - }], - }, - amount: { - currency: 'USD', - total: amount, - }, - description, - }], - }; - } - - beforeEach(() => { - approvalHerf = 'approval_href'; - paypalPaymentCreateStub = sinon.stub(paypalPayments, 'paypalPaymentCreate') - .returnsPromise().resolves({ - links: [{ rel: 'approval_url', href: approvalHerf }], - }); - }); - - afterEach(() => { - paypalPayments.paypalPaymentCreate.restore(); - }); - - it('creates a link for gem purchases', async () => { - let link = await paypalPayments.checkout({user: new User()}); - - expect(paypalPaymentCreateStub).to.be.calledOnce; - expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('Habitica Gems', 5.00)); - expect(link).to.eql(approvalHerf); - }); - - it('should error if gem amount is too low', async () => { - let receivingUser = new User(); - receivingUser.save(); - let gift = { - type: 'gems', - gems: { - amount: 0, - uuid: receivingUser._id, - }, - }; - - await expect(paypalPayments.checkout({gift})) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 400, - message: 'Amount must be at least 1.', - name: 'BadRequest', - }); - }); - - it('should error if the user cannot get gems', async () => { - let user = new User(); - sinon.stub(user, 'canGetGems').returnsPromise().resolves(false); - - await expect(paypalPayments.checkout({user})).to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - message: i18n.t('groupPolicyCannotGetGems'), - name: 'NotAuthorized', - }); - }); - - it('creates a link for gifting gems', async () => { - let receivingUser = new User(); - await receivingUser.save(); - let gift = { - type: 'gems', - uuid: receivingUser._id, - gems: { - amount: 16, - }, - }; - - let link = await paypalPayments.checkout({gift}); - - 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 () => { - let receivingUser = new User(); - receivingUser.save(); - let gift = { - type: 'subscription', - subscription: { - key: subKey, - uuid: receivingUser._id, - }, - }; - - let link = await paypalPayments.checkout({gift}); - - expect(paypalPaymentCreateStub).to.be.calledOnce; - expect(paypalPaymentCreateStub).to.be.calledWith(getPaypalCreateOptions('mo. Habitica Subscription (Gift)', '15.00')); - expect(link).to.eql(approvalHerf); - }); - }); - - describe('checkout success', () => { - let user, gift, customerId, paymentId; - let paypalPaymentExecuteStub, paymentBuyGemsStub, paymentsCreateSubscritionStub; - - beforeEach(() => { - user = new User(); - customerId = 'customerId-test'; - paymentId = 'paymentId-test'; - - paypalPaymentExecuteStub = sinon.stub(paypalPayments, 'paypalPaymentExecute').returnsPromise().resolves({}); - paymentBuyGemsStub = sinon.stub(payments, 'buyGems').returnsPromise().resolves({}); - paymentsCreateSubscritionStub = sinon.stub(payments, 'createSubscription').returnsPromise().resolves({}); - }); - - afterEach(() => { - paypalPayments.paypalPaymentExecute.restore(); - payments.buyGems.restore(); - payments.createSubscription.restore(); - }); - - it('purchases gems', async () => { - await paypalPayments.checkoutSuccess({user, gift, paymentId, customerId}); - - expect(paypalPaymentExecuteStub).to.be.calledOnce; - expect(paypalPaymentExecuteStub).to.be.calledWith(paymentId, { payer_id: customerId }); - expect(paymentBuyGemsStub).to.be.calledOnce; - expect(paymentBuyGemsStub).to.be.calledWith({ - user, - customerId, - paymentMethod: 'Paypal', - }); - }); - - it('gifts gems', async () => { - let receivingUser = new User(); - await receivingUser.save(); - gift = { - type: 'gems', - gems: { - amount: 16, - uuid: receivingUser._id, - }, - }; - - await paypalPayments.checkoutSuccess({user, gift, paymentId, customerId}); - - expect(paypalPaymentExecuteStub).to.be.calledOnce; - expect(paypalPaymentExecuteStub).to.be.calledWith(paymentId, { payer_id: customerId }); - expect(paymentBuyGemsStub).to.be.calledOnce; - expect(paymentBuyGemsStub).to.be.calledWith({ - user, - customerId, - paymentMethod: 'PayPal (Gift)', - gift, - }); - }); - - it('gifts subscription', async () => { - let receivingUser = new User(); - await receivingUser.save(); - gift = { - type: 'subscription', - subscription: { - key: subKey, - uuid: receivingUser._id, - }, - }; - - await paypalPayments.checkoutSuccess({user, gift, paymentId, customerId}); - - expect(paypalPaymentExecuteStub).to.be.calledOnce; - expect(paypalPaymentExecuteStub).to.be.calledWith(paymentId, { payer_id: customerId }); - expect(paymentsCreateSubscritionStub).to.be.calledOnce; - expect(paymentsCreateSubscritionStub).to.be.calledWith({ - user, - customerId, - paymentMethod: 'PayPal (Gift)', - gift, - }); - }); - }); - - describe('subscribe', () => { - let coupon, sub, approvalHerf; - let paypalBillingAgreementCreateStub; - - beforeEach(() => { - approvalHerf = 'approvalHerf-test'; - sub = common.content.subscriptionBlocks[subKey]; - - paypalBillingAgreementCreateStub = sinon.stub(paypalPayments, 'paypalBillingAgreementCreate') - .returnsPromise().resolves({ - links: [{ rel: 'approval_url', href: approvalHerf }], - }); - }); - - afterEach(() => { - paypalPayments.paypalBillingAgreementCreate.restore(); - }); - - it('should throw an error when coupon code is missing', async () => { - sub.discount = 40; - - await expect(paypalPayments.subscribe({sub, 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'; - - let couponModel = new Coupon(); - couponModel.event = 'google_6mo'; - await couponModel.save(); - - sinon.stub(cc, 'validate').returns('invalid'); - - await expect(paypalPayments.subscribe({sub, coupon})) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - name: 'NotAuthorized', - message: i18n.t('invalidCoupon'), - }); - cc.validate.restore(); - }); - - it('subscribes with amazon with a coupon', async () => { - sub.discount = 40; - sub.key = 'google_6mo'; - coupon = 'example-coupon'; - - let couponModel = new Coupon(); - couponModel.event = 'google_6mo'; - let updatedCouponModel = await couponModel.save(); - - sinon.stub(cc, 'validate').returns(updatedCouponModel._id); - - let link = await paypalPayments.subscribe({sub, coupon}); - - expect(link).to.eql(approvalHerf); - expect(paypalBillingAgreementCreateStub).to.be.calledOnce; - let billingPlanTitle = `Habitica Subscription ($${sub.price} every ${sub.months} months, recurring)`; - expect(paypalBillingAgreementCreateStub).to.be.calledWith({ - name: billingPlanTitle, - description: billingPlanTitle, - start_date: moment().add({ minutes: 5 }).format(), - plan: { - id: sub.paypalKey, - }, - payer: { - payment_method: 'Paypal', - }, - }); - - cc.validate.restore(); - }); - - it('creates a link for a subscription', async () => { - delete sub.discount; - - let link = await paypalPayments.subscribe({sub, coupon}); - - expect(link).to.eql(approvalHerf); - expect(paypalBillingAgreementCreateStub).to.be.calledOnce; - let billingPlanTitle = `Habitica Subscription ($${sub.price} every ${sub.months} months, recurring)`; - expect(paypalBillingAgreementCreateStub).to.be.calledWith({ - name: billingPlanTitle, - description: billingPlanTitle, - start_date: moment().add({ minutes: 5 }).format(), - plan: { - id: sub.paypalKey, - }, - payer: { - payment_method: 'Paypal', - }, - }); - }); - }); - - describe('subscribeSuccess', () => { - let user, group, block, groupId, token, headers, customerId; - let paypalBillingAgreementExecuteStub, paymentsCreateSubscritionStub; - - beforeEach(async () => { - user = new User(); - - group = generateGroup({ - name: 'test group', - type: 'guild', - privacy: 'public', - leader: user._id, - }); - - token = 'test-token'; - headers = {}; - block = common.content.subscriptionBlocks[subKey]; - customerId = 'test-customerId'; - - paypalBillingAgreementExecuteStub = sinon.stub(paypalPayments, 'paypalBillingAgreementExecute') - .returnsPromise({}).resolves({ - id: customerId, - }); - paymentsCreateSubscritionStub = sinon.stub(payments, 'createSubscription').returnsPromise().resolves({}); - }); - - afterEach(() => { - paypalPayments.paypalBillingAgreementExecute.restore(); - payments.createSubscription.restore(); - }); - - it('creates a user subscription', async () => { - await paypalPayments.subscribeSuccess({user, block, groupId, token, headers}); - - expect(paypalBillingAgreementExecuteStub).to.be.calledOnce; - expect(paypalBillingAgreementExecuteStub).to.be.calledWith(token, {}); - - expect(paymentsCreateSubscritionStub).to.be.calledOnce; - expect(paymentsCreateSubscritionStub).to.be.calledWith({ - user, - groupId, - customerId, - paymentMethod: 'Paypal', - sub: block, - headers, - }); - }); - - it('create a group subscription', async () => { - groupId = group._id; - - await paypalPayments.subscribeSuccess({user, block, groupId, token, headers}); - - expect(paypalBillingAgreementExecuteStub).to.be.calledOnce; - expect(paypalBillingAgreementExecuteStub).to.be.calledWith(token, {}); - - expect(paymentsCreateSubscritionStub).to.be.calledOnce; - expect(paymentsCreateSubscritionStub).to.be.calledWith({ - user, - groupId, - customerId, - paymentMethod: 'Paypal', - sub: block, - headers, - }); - }); - }); - - describe('subscribeCancel', () => { - let user, group, groupId, customerId, groupCustomerId, nextBillingDate; - let paymentCancelSubscriptionSpy, paypalBillingAgreementCancelStub, paypalBillingAgreementGetStub; - - beforeEach(async () => { - customerId = 'customer-id'; - groupCustomerId = 'groupCustomerId-test'; - - user = new User(); - user.profile.name = 'sender'; - user.purchased.plan.customerId = customerId; - 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 = groupCustomerId; - group.purchased.plan.planId = subKey; - group.purchased.plan.lastBillingDate = new Date(); - await group.save(); - - nextBillingDate = new Date(); - - paypalBillingAgreementCancelStub = sinon.stub(paypalPayments, 'paypalBillingAgreementCancel').returnsPromise().resolves({}); - paypalBillingAgreementGetStub = sinon.stub(paypalPayments, 'paypalBillingAgreementGet') - .returnsPromise().resolves({ - agreement_details: { - next_billing_date: nextBillingDate, - cycles_completed: 1, - }, - }); - paymentCancelSubscriptionSpy = sinon.stub(payments, 'cancelSubscription').returnsPromise().resolves({}); - }); - - afterEach(function () { - paypalPayments.paypalBillingAgreementGet.restore(); - paypalPayments.paypalBillingAgreementCancel.restore(); - payments.cancelSubscription.restore(); - }); - - it('should throw an error if we are missing a subscription', async () => { - user.purchased.plan.customerId = undefined; - - await expect(paypalPayments.subscribeCancel({user})) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - name: 'NotAuthorized', - message: i18n.t('missingSubscription'), - }); - }); - - it('should throw an error if group is not found', async () => { - await expect(paypalPayments.subscribeCancel({user, groupId: 'fake-id'})) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 404, - name: 'NotFound', - message: i18n.t('groupNotFound'), - }); - }); - - it('should throw an error if user is not group leader', async () => { - let nonLeader = new User(); - nonLeader.guilds.push(group._id); - await nonLeader.save(); - - await expect(paypalPayments.subscribeCancel({user: nonLeader, groupId: group._id})) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - name: 'NotAuthorized', - message: i18n.t('onlyGroupLeaderCanManageSubscription'), - }); - }); - - it('should cancel a user subscription', async () => { - await paypalPayments.subscribeCancel({user}); - - expect(paypalBillingAgreementGetStub).to.be.calledOnce; - expect(paypalBillingAgreementGetStub).to.be.calledWith(customerId); - expect(paypalBillingAgreementCancelStub).to.be.calledOnce; - expect(paypalBillingAgreementCancelStub).to.be.calledWith(customerId, { note: i18n.t('cancelingSubscription') }); - - expect(paymentCancelSubscriptionSpy).to.be.calledOnce; - expect(paymentCancelSubscriptionSpy).to.be.calledWith({ - user, - groupId, - paymentMethod: 'Paypal', - nextBill: nextBillingDate, - cancellationReason: undefined, - }); - }); - - it('should cancel a group subscription', async () => { - await paypalPayments.subscribeCancel({user, groupId: group._id}); - - expect(paypalBillingAgreementGetStub).to.be.calledOnce; - expect(paypalBillingAgreementGetStub).to.be.calledWith(groupCustomerId); - expect(paypalBillingAgreementCancelStub).to.be.calledOnce; - expect(paypalBillingAgreementCancelStub).to.be.calledWith(groupCustomerId, { note: i18n.t('cancelingSubscription') }); - - expect(paymentCancelSubscriptionSpy).to.be.calledOnce; - expect(paymentCancelSubscriptionSpy).to.be.calledWith({ - user, - groupId: group._id, - paymentMethod: 'Paypal', - nextBill: nextBillingDate, - cancellationReason: undefined, - }); - }); - }); - - describe('ipn', () => { - let user, group, txn_type, userPaymentId, groupPaymentId; - let ipnVerifyAsyncStub, paymentCancelSubscriptionSpy; - - beforeEach(async () => { - txn_type = 'recurring_payment_profile_cancel'; - userPaymentId = 'userPaymentId-test'; - groupPaymentId = 'groupPaymentId-test'; - - user = new User(); - user.profile.name = 'sender'; - user.purchased.plan.customerId = userPaymentId; - user.purchased.plan.planId = subKey; - user.purchased.plan.lastBillingDate = new Date(); - await user.save(); - - group = generateGroup({ - name: 'test group', - type: 'guild', - privacy: 'public', - leader: user._id, - }); - group.purchased.plan.customerId = groupPaymentId; - group.purchased.plan.planId = subKey; - group.purchased.plan.lastBillingDate = new Date(); - await group.save(); - - ipnVerifyAsyncStub = sinon.stub(paypalPayments, 'ipnVerifyAsync').returnsPromise().resolves({}); - paymentCancelSubscriptionSpy = sinon.stub(payments, 'cancelSubscription').returnsPromise().resolves({}); - }); - - afterEach(function () { - paypalPayments.ipnVerifyAsync.restore(); - payments.cancelSubscription.restore(); - }); - - it('should cancel a user subscription', async () => { - await paypalPayments.ipn({txn_type, recurring_payment_id: userPaymentId}); - - expect(ipnVerifyAsyncStub).to.be.calledOnce; - expect(ipnVerifyAsyncStub).to.be.calledWith({txn_type, recurring_payment_id: userPaymentId}); - - expect(paymentCancelSubscriptionSpy).to.be.calledOnce; - expect(paymentCancelSubscriptionSpy.args[0][0].user._id).to.eql(user._id); - expect(paymentCancelSubscriptionSpy.args[0][0].paymentMethod).to.eql('Paypal'); - }); - - it('should cancel a group subscription', async () => { - await paypalPayments.ipn({txn_type, recurring_payment_id: groupPaymentId}); - - expect(ipnVerifyAsyncStub).to.be.calledOnce; - expect(ipnVerifyAsyncStub).to.be.calledWith({txn_type, recurring_payment_id: groupPaymentId}); - - expect(paymentCancelSubscriptionSpy).to.be.calledOnce; - expect(paymentCancelSubscriptionSpy).to.be.calledWith({ groupId: group._id, paymentMethod: 'Paypal' }); - }); - }); -}); diff --git a/test/api/v3/unit/libs/stripePayments.test.js b/test/api/v3/unit/libs/stripePayments.test.js deleted file mode 100644 index 75aaffafcb..0000000000 --- a/test/api/v3/unit/libs/stripePayments.test.js +++ /dev/null @@ -1,1059 +0,0 @@ -import stripeModule from 'stripe'; -import cc from 'coupon-code'; - -import { - generateGroup, -} from '../../../../helpers/api-unit.helper.js'; -import { model as User } from '../../../../../website/server/models/user'; -import { model as Group } from '../../../../../website/server/models/group'; -import { model as Coupon } from '../../../../../website/server/models/coupon'; -import stripePayments from '../../../../../website/server/libs/stripePayments'; -import payments from '../../../../../website/server/libs/payments'; -import common from '../../../../../website/common'; -import logger from '../../../../../website/server/libs/logger'; -import { v4 as uuid } from 'uuid'; -import moment from 'moment'; - -const i18n = common.i18n; - -describe('Stripe Payments', () => { - let subKey = 'basic_3mo'; - let stripe = stripeModule('test'); - - describe('checkout', () => { - let stripeChargeStub, paymentBuyGemsStub, paymentCreateSubscritionStub; - let user, gift, groupId, email, headers, coupon, customerIdResponse, token; - - 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'; - let stripCustomerResponse = { - id: customerIdResponse, - }; - stripeChargeStub = sinon.stub(stripe.charges, 'create').returnsPromise().resolves(stripCustomerResponse); - paymentBuyGemsStub = sinon.stub(payments, 'buyGems').returnsPromise().resolves({}); - paymentCreateSubscritionStub = sinon.stub(payments, 'createSubscription').returnsPromise().resolves({}); - }); - - afterEach(() => { - stripe.charges.create.restore(); - payments.buyGems.restore(); - payments.createSubscription.restore(); - }); - - it('should error if gem amount is too low', async () => { - let receivingUser = new User(); - receivingUser.save(); - gift = { - type: 'gems', - gems: { - amount: 0, - uuid: receivingUser._id, - }, - }; - - await expect(stripePayments.checkout({ - token, - user, - gift, - groupId, - email, - headers, - coupon, - }, stripe)) - .to.eventually.be.rejected.and.to.eql({ - httpCode: 400, - message: 'Amount must be at least 1.', - name: 'BadRequest', - }); - }); - - - it('should error if user cannot get gems', async () => { - gift = undefined; - sinon.stub(user, 'canGetGems').returnsPromise().resolves(false); - - await expect(stripePayments.checkout({ - token, - user, - gift, - groupId, - email, - headers, - coupon, - }, stripe)).to.eventually.be.rejected.and.to.eql({ - httpCode: 401, - message: i18n.t('groupPolicyCannotGetGems'), - name: 'NotAuthorized', - }); - }); - - it('should purchase gems', async () => { - gift = undefined; - sinon.stub(user, 'canGetGems').returnsPromise().resolves(true); - - await stripePayments.checkout({ - token, - user, - gift, - groupId, - email, - headers, - coupon, - }, stripe); - - expect(stripeChargeStub).to.be.calledOnce; - expect(stripeChargeStub).to.be.calledWith({ - amount: 500, - currency: 'usd', - card: token, - }); - - expect(paymentBuyGemsStub).to.be.calledOnce; - expect(paymentBuyGemsStub).to.be.calledWith({ - user, - customerId: customerIdResponse, - paymentMethod: 'Stripe', - gift, - }); - expect(user.canGetGems).to.be.calledOnce; - user.canGetGems.restore(); - }); - - it('should gift gems', async () => { - let 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, - }); - }); - - it('should gift a subscription', async () => { - let receivingUser = new User(); - receivingUser.save(); - gift = { - type: 'subscription', - subscription: { - key: subKey, - uuid: receivingUser._id, - }, - }; - - await stripePayments.checkout({ - token, - user, - gift, - groupId, - email, - headers, - coupon, - }, stripe); - - gift.member = receivingUser; - expect(stripeChargeStub).to.be.calledOnce; - expect(stripeChargeStub).to.be.calledWith({ - amount: '1500', - currency: 'usd', - card: token, - }); - - expect(paymentCreateSubscritionStub).to.be.calledOnce; - expect(paymentCreateSubscritionStub).to.be.calledWith({ - user, - customerId: customerIdResponse, - paymentMethod: 'Gift', - gift, - }); - }); - }); - - describe('checkout with subscription', () => { - let user, group, data, gift, sub, groupId, email, headers, coupon, customerIdResponse, subscriptionId, 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.returnsPromise().resolves; - - stripeCreateCustomerSpy = sinon.stub(stripe.customers, 'create'); - let stripCustomerResponse = { - id: customerIdResponse, - subscriptions: { - data: [{id: subscriptionId}], - }, - }; - stripeCreateCustomerSpy.returnsPromise().resolves(stripCustomerResponse); - - stripePaymentsCreateSubSpy = sinon.stub(payments, 'createSubscription'); - stripePaymentsCreateSubSpy.returnsPromise().resolves({}); - - data.groupId = group._id; - data.sub.quantity = 3; - }); - - afterEach(function () { - sinon.restore(stripe.subscriptions.update); - 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'; - - let 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 amazon with a coupon', async () => { - sub.discount = 40; - sub.key = 'google_6mo'; - coupon = 'example-coupon'; - - let couponModel = new Coupon(); - couponModel.event = 'google_6mo'; - let 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'; - 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 = {}; - 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, - }); - }); - }); - - describe('edit subscription', () => { - let user, groupId, group, 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 () => { - let 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, stripeUpdateSubscriptionStub, subscriptionId; - - beforeEach(() => { - subscriptionId = 'subId'; - stripeListSubscriptionStub = sinon.stub(stripe.customers, 'listSubscriptions') - .returnsPromise().resolves({ - data: [{id: subscriptionId}], - }); - - stripeUpdateSubscriptionStub = sinon.stub(stripe.customers, 'updateSubscription').returnsPromise().resolves({}); - }); - - afterEach(() => { - stripe.customers.listSubscriptions.restore(); - stripe.customers.updateSubscription.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(user.purchased.plan.customerId); - expect(stripeUpdateSubscriptionStub).to.be.calledOnce; - expect(stripeUpdateSubscriptionStub).to.be.calledWith( - user.purchased.plan.customerId, - 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(group.purchased.plan.customerId); - expect(stripeUpdateSubscriptionStub).to.be.calledOnce; - expect(stripeUpdateSubscriptionStub).to.be.calledWith( - group.purchased.plan.customerId, - subscriptionId, - { card: token } - ); - }); - }); - }); - - describe('cancel subscription', () => { - let user, groupId, 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 () => { - let 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, paymentsCancelSubStub, stripeRetrieveStub, subscriptionId, currentPeriodEndTimeStamp; - - beforeEach(() => { - subscriptionId = 'subId'; - stripeDeleteCustomerStub = sinon.stub(stripe.customers, 'del').returnsPromise().resolves({}); - paymentsCancelSubStub = sinon.stub(payments, 'cancelSubscription').returnsPromise().resolves({}); - - currentPeriodEndTimeStamp = (new Date()).getTime(); - stripeRetrieveStub = sinon.stub(stripe.customers, 'retrieve') - .returnsPromise().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, - }); - }); - }); - }); - - describe('#upgradeGroupPlan', () => { - let spy, data, user, group; - - beforeEach(async function () { - 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: 'public', - leader: user._id, - }); - await group.save(); - - spy = sinon.stub(stripe.subscriptions, 'update'); - spy.returnsPromise().resolves([]); - data.groupId = group._id; - data.sub.quantity = 3; - stripePayments.setStripeApi(stripe); - }); - - afterEach(function () { - sinon.restore(stripe.subscriptions.update); - }); - - it('updates a group plan quantity', async () => { - data.paymentMethod = 'Stripe'; - await payments.createSubscription(data); - - let 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); - }); - }); - - describe('handleWebhooks', () => { - describe('all events', () => { - const eventType = 'account.updated'; - const event = {id: 123}; - const eventRetrieved = {type: eventType}; - - beforeEach(() => { - sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves(eventRetrieved); - sinon.stub(logger, 'error'); - }); - - afterEach(() => { - stripe.events.retrieve.restore(); - logger.error.restore(); - }); - - 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.called.once; - expect(logger.error).to.have.been.calledWith(error, {event: eventRetrieved}); - }); - - it('retrieves and validates the event from Stripe', async () => { - await stripePayments.handleWebhooks({requestBody: event}, stripe); - expect(stripe.events.retrieve).to.have.been.called.once; - expect(stripe.events.retrieve).to.have.been.calledWith(event.id); - }); - }); - - describe('customer.subscription.deleted', () => { - const eventType = 'customer.subscription.deleted'; - - beforeEach(() => { - sinon.stub(stripe.customers, 'del').returnsPromise().resolves({}); - sinon.stub(payments, 'cancelSubscription').returnsPromise().resolves({}); - }); - - afterEach(() => { - stripe.customers.del.restore(); - payments.cancelSubscription.restore(); - }); - - it('does not do anything if event.request is null (subscription cancelled manually)', async () => { - sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({ - id: 123, - type: eventType, - request: 123, - }); - - await stripePayments.handleWebhooks({requestBody: {}}, stripe); - - expect(stripe.events.retrieve).to.have.been.called.once; - 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').returnsPromise().resolves({ - id: 123, - type: eventType, - data: { - object: { - plan: { - id: 'basic_earned', - }, - customer: customerId, - }, - }, - request: null, - }); - - await expect(stripePayments.handleWebhooks({requestBody: {}}, stripe)).to.eventually.be.rejectedWith({ - message: i18n.t('userNotFound'), - httpCode: 404, - name: 'NotFound', - }); - - 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 () => { - const customerId = '456'; - - let subscriber = new User(); - subscriber.purchased.plan.customerId = customerId; - subscriber.purchased.plan.paymentMethod = 'Stripe'; - await subscriber.save(); - - sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({ - id: 123, - type: eventType, - data: { - object: { - plan: { - id: 'basic_earned', - }, - customer: customerId, - }, - }, - request: null, - }); - - await stripePayments.handleWebhooks({requestBody: {}}, stripe); - - expect(stripe.customers.del).to.have.been.calledOnce; - expect(stripe.customers.del).to.have.been.calledWith(customerId); - expect(payments.cancelSubscription).to.have.been.calledOnce; - - let cancelSubscriptionOpts = payments.cancelSubscription.lastCall.args[0]; - expect(cancelSubscriptionOpts.user._id).to.equal(subscriber._id); - 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').returnsPromise().resolves({ - id: 123, - type: eventType, - data: { - object: { - plan: { - id: 'group_monthly', - }, - customer: customerId, - }, - }, - request: null, - }); - - await expect(stripePayments.handleWebhooks({requestBody: {}}, stripe)).to.eventually.be.rejectedWith({ - message: i18n.t('groupNotFound'), - httpCode: 404, - name: 'NotFound', - }); - - 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 () => { - const customerId = 456; - - let subscriber = generateGroup({ - name: 'test group', - type: 'guild', - privacy: 'public', - leader: uuid(), - }); - subscriber.purchased.plan.customerId = customerId; - subscriber.purchased.plan.paymentMethod = 'Stripe'; - await subscriber.save(); - - sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({ - id: 123, - type: eventType, - data: { - object: { - plan: { - id: 'group_monthly', - }, - customer: customerId, - }, - }, - request: null, - }); - - await expect(stripePayments.handleWebhooks({requestBody: {}}, stripe)).to.eventually.be.rejectedWith({ - message: i18n.t('userNotFound'), - httpCode: 404, - name: 'NotFound', - }); - - 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 () => { - const customerId = '456'; - - let leader = new User(); - await leader.save(); - - let subscriber = generateGroup({ - name: 'test group', - type: 'guild', - privacy: 'public', - leader: leader._id, - }); - subscriber.purchased.plan.customerId = customerId; - subscriber.purchased.plan.paymentMethod = 'Stripe'; - await subscriber.save(); - - sinon.stub(stripe.events, 'retrieve').returnsPromise().resolves({ - id: 123, - type: eventType, - data: { - object: { - plan: { - id: 'group_monthly', - }, - customer: customerId, - }, - }, - request: null, - }); - - await stripePayments.handleWebhooks({requestBody: {}}, stripe); - - expect(stripe.customers.del).to.have.been.calledOnce; - expect(stripe.customers.del).to.have.been.calledWith(customerId); - expect(payments.cancelSubscription).to.have.been.calledOnce; - - let cancelSubscriptionOpts = payments.cancelSubscription.lastCall.args[0]; - expect(cancelSubscriptionOpts.user._id).to.equal(leader._id); - 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(); - }); - }); - }); - }); -});