diff --git a/test/api/v3/unit/libs/amazonPayments.test.js b/test/api/v3/unit/libs/amazonPayments.test.js index a22abb90a1..c40c0e1f35 100644 --- a/test/api/v3/unit/libs/amazonPayments.test.js +++ b/test/api/v3/unit/libs/amazonPayments.test.js @@ -525,6 +525,7 @@ describe('Amazon Payments', () => { nextBill: moment(user.purchased.plan.lastBillingDate).add({ days: subscriptionLength }), paymentMethod: amzLib.constants.PAYMENT_METHOD, headers, + cancellationReason: undefined, }); expectAmazonStubs(); }); @@ -555,6 +556,7 @@ describe('Amazon Payments', () => { nextBill: moment(user.purchased.plan.lastBillingDate).add({ days: subscriptionLength }), paymentMethod: amzLib.constants.PAYMENT_METHOD, headers, + cancellationReason: undefined, }); amzLib.closeBillingAgreement.restore(); }); @@ -593,6 +595,7 @@ describe('Amazon Payments', () => { nextBill: moment(group.purchased.plan.lastBillingDate).add({ days: subscriptionLength }), paymentMethod: amzLib.constants.PAYMENT_METHOD, headers, + cancellationReason: undefined, }); expectAmazonStubs(); }); @@ -623,6 +626,7 @@ describe('Amazon Payments', () => { nextBill: moment(group.purchased.plan.lastBillingDate).add({ days: subscriptionLength }), paymentMethod: amzLib.constants.PAYMENT_METHOD, headers, + cancellationReason: undefined, }); amzLib.closeBillingAgreement.restore(); }); diff --git a/test/api/v3/unit/libs/payments.test.js b/test/api/v3/unit/libs/payments.test.js index ccec83b42a..fbe82377ac 100644 --- a/test/api/v3/unit/libs/payments.test.js +++ b/test/api/v3/unit/libs/payments.test.js @@ -16,6 +16,7 @@ describe('payments/index', () => { beforeEach(async () => { user = new User(); user.profile.name = 'sender'; + await user.save(); group = generateGroup({ name: 'test group', diff --git a/test/api/v3/unit/libs/payments/group-plans/group-payments-cancel.test.js b/test/api/v3/unit/libs/payments/group-plans/group-payments-cancel.test.js index 5a7dac424f..438ce5dc60 100644 --- a/test/api/v3/unit/libs/payments/group-plans/group-payments-cancel.test.js +++ b/test/api/v3/unit/libs/payments/group-plans/group-payments-cancel.test.js @@ -138,7 +138,7 @@ describe('Canceling a subscription for group', () => { ]); }); - it('prevents non group leader from manging subscription', async () => { + it('prevents non group leader from managing subscription', async () => { let groupMember = new User(); data.user = groupMember; data.groupId = group._id; diff --git a/test/api/v3/unit/libs/payments/group-plans/group-payments-create.test.js b/test/api/v3/unit/libs/payments/group-plans/group-payments-create.test.js index d39487d12a..de79e2b525 100644 --- a/test/api/v3/unit/libs/payments/group-plans/group-payments-create.test.js +++ b/test/api/v3/unit/libs/payments/group-plans/group-payments-create.test.js @@ -1,5 +1,6 @@ import moment from 'moment'; import stripeModule from 'stripe'; +import nconf from 'nconf'; import * as sender from '../../../../../../../website/server/libs/email'; import * as api from '../../../../../../../website/server/libs/payments'; @@ -12,17 +13,24 @@ import { generateGroup, } from '../../../../../../helpers/api-unit.helper.js'; -describe('Purchasing a subscription for group', () => { +describe('Purchasing a group plan for group', () => { + const EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_GOOGLE = 'Google_subscription'; + const EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_IOS = 'iOS_subscription'; + const EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_NORMAL = 'normal_subscription'; + const EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_NONE = 'no_subscription'; + let plan, group, user, data; let stripe = stripeModule('test'); + let groupLeaderName = 'sender'; + let groupName = 'test group'; beforeEach(async () => { user = new User(); - user.profile.name = 'sender'; + user.profile.name = groupLeaderName; await user.save(); group = generateGroup({ - name: 'test group', + name: groupName, type: 'guild', privacy: 'public', leader: user._id, @@ -81,7 +89,7 @@ describe('Purchasing a subscription for group', () => { sender.sendTxn.restore(); }); - it('creates a subscription', async () => { + it('creates a group plan', async () => { expect(group.purchased.plan.planId).to.not.exist; data.groupId = group._id; @@ -157,7 +165,7 @@ describe('Purchasing a subscription for group', () => { expect(updatedLeader.items.mounts['Jackalope-RoyalPurple']).to.be.true; }); - it('sends an email to members of group', async () => { + it('sends an email to member of group who was not a subscriber', async () => { let recipient = new User(); recipient.profile.name = 'recipient'; recipient.guilds.push(group._id); @@ -169,11 +177,181 @@ describe('Purchasing a subscription for group', () => { expect(sender.sendTxn).to.be.calledTwice; expect(sender.sendTxn.firstCall.args[0]._id).to.equal(recipient._id); - expect(sender.sendTxn.firstCall.args[1]).to.equal('group-member-joining'); + expect(sender.sendTxn.firstCall.args[1]).to.equal('group-member-join'); expect(sender.sendTxn.firstCall.args[2]).to.eql([ {name: 'LEADER', content: user.profile.name}, {name: 'GROUP_NAME', content: group.name}, + {name: 'PREVIOUS_SUBSCRIPTION_TYPE', content: EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_NONE}, ]); + // confirm that the other email sent is appropriate: + expect(sender.sendTxn.secondCall.args[0]._id).to.equal(group.leader); + expect(sender.sendTxn.secondCall.args[1]).to.equal('group-subscription-begins'); + }); + + it('sends one email to subscribed member of group, stating subscription is cancelled (Stripe)', async () => { + let recipient = new User(); + recipient.profile.name = 'recipient'; + plan.key = 'basic_earned'; + plan.paymentMethod = stripePayments.constants.PAYMENT_METHOD; + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + await recipient.save(); + + data.groupId = group._id; + + await api.createSubscription(data); + + expect(sender.sendTxn).to.be.calledTwice; + expect(sender.sendTxn.firstCall.args[0]._id).to.equal(recipient._id); + expect(sender.sendTxn.firstCall.args[1]).to.equal('group-member-join'); + expect(sender.sendTxn.firstCall.args[2]).to.eql([ + {name: 'LEADER', content: user.profile.name}, + {name: 'GROUP_NAME', content: group.name}, + {name: 'PREVIOUS_SUBSCRIPTION_TYPE', content: EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_NORMAL}, + ]); + // confirm that the other email sent is not a cancel-subscription email: + expect(sender.sendTxn.secondCall.args[0]._id).to.equal(group.leader); + expect(sender.sendTxn.secondCall.args[1]).to.equal('group-subscription-begins'); + }); + + it('sends one email to subscribed member of group, stating subscription is cancelled (Amazon)', async () => { + sinon.stub(amzLib, 'getBillingAgreementDetails') + .returnsPromise() + .resolves({ + BillingAgreementDetails: { + BillingAgreementStatus: {State: 'Closed'}, + }, + }); + + let recipient = new User(); + recipient.profile.name = 'recipient'; + plan.planId = 'basic_earned'; + plan.paymentMethod = amzLib.constants.PAYMENT_METHOD; + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + await recipient.save(); + + data.groupId = group._id; + + await api.createSubscription(data); + + expect(sender.sendTxn).to.be.calledTwice; + expect(sender.sendTxn.firstCall.args[0]._id).to.equal(recipient._id); + expect(sender.sendTxn.firstCall.args[1]).to.equal('group-member-join'); + expect(sender.sendTxn.firstCall.args[2]).to.eql([ + {name: 'LEADER', content: user.profile.name}, + {name: 'GROUP_NAME', content: group.name}, + {name: 'PREVIOUS_SUBSCRIPTION_TYPE', content: EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_NORMAL}, + ]); + // confirm that the other email sent is not a cancel-subscription email: + expect(sender.sendTxn.secondCall.args[0]._id).to.equal(group.leader); + expect(sender.sendTxn.secondCall.args[1]).to.equal('group-subscription-begins'); + + amzLib.getBillingAgreementDetails.restore(); + }); + + it('sends one email to subscribed member of group, stating subscription is cancelled (PayPal)', async () => { + sinon.stub(paypalPayments, 'paypalBillingAgreementCancel').returnsPromise().resolves({}); + sinon.stub(paypalPayments, 'paypalBillingAgreementGet') + .returnsPromise().resolves({ + agreement_details: { // eslint-disable-line camelcase + next_billing_date: moment().add(3, 'months').toDate(), // eslint-disable-line camelcase + cycles_completed: 1, // eslint-disable-line camelcase + }, + }); + + let recipient = new User(); + recipient.profile.name = 'recipient'; + plan.planId = 'basic_earned'; + plan.paymentMethod = paypalPayments.constants.PAYMENT_METHOD; + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + await recipient.save(); + + data.groupId = group._id; + + await api.createSubscription(data); + + expect(sender.sendTxn).to.be.calledTwice; + expect(sender.sendTxn.firstCall.args[0]._id).to.equal(recipient._id); + expect(sender.sendTxn.firstCall.args[1]).to.equal('group-member-join'); + expect(sender.sendTxn.firstCall.args[2]).to.eql([ + {name: 'LEADER', content: user.profile.name}, + {name: 'GROUP_NAME', content: group.name}, + {name: 'PREVIOUS_SUBSCRIPTION_TYPE', content: EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_NORMAL}, + ]); + // confirm that the other email sent is not a cancel-subscription email: + expect(sender.sendTxn.secondCall.args[0]._id).to.equal(group.leader); + expect(sender.sendTxn.secondCall.args[1]).to.equal('group-subscription-begins'); + + paypalPayments.paypalBillingAgreementGet.restore(); + paypalPayments.paypalBillingAgreementCancel.restore(); + }); + + it('sends appropriate emails when subscribed member of group must manually cancel recurring Android subscription', async () => { + const TECH_ASSISTANCE_EMAIL = nconf.get('TECH_ASSISTANCE_EMAIL'); + plan.customerId = 'random'; + plan.paymentMethod = api.constants.GOOGLE_PAYMENT_METHOD; + + let recipient = new User(); + recipient.profile.name = 'recipient'; + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + await recipient.save(); + + user.guilds.push(group._id); + await user.save(); + data.groupId = group._id; + + await api.createSubscription(data); + + expect(sender.sendTxn).to.be.calledFourTimes; + expect(sender.sendTxn.args[0][0]._id).to.equal(TECH_ASSISTANCE_EMAIL); + expect(sender.sendTxn.args[0][1]).to.equal('admin-user-subscription-details'); + expect(sender.sendTxn.args[1][0]._id).to.equal(recipient._id); + expect(sender.sendTxn.args[1][1]).to.equal('group-member-join'); + expect(sender.sendTxn.args[1][2]).to.eql([ + {name: 'LEADER', content: groupLeaderName}, + {name: 'GROUP_NAME', content: groupName}, + {name: 'PREVIOUS_SUBSCRIPTION_TYPE', content: EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_GOOGLE}, + ]); + expect(sender.sendTxn.args[2][0]._id).to.equal(group.leader); + expect(sender.sendTxn.args[2][1]).to.equal('group-member-join'); + expect(sender.sendTxn.args[3][0]._id).to.equal(group.leader); + expect(sender.sendTxn.args[3][1]).to.equal('group-subscription-begins'); + }); + + it('sends appropriate emails when subscribed member of group must manually cancel recurring iOS subscription', async () => { + const TECH_ASSISTANCE_EMAIL = nconf.get('TECH_ASSISTANCE_EMAIL'); + plan.customerId = 'random'; + plan.paymentMethod = api.constants.IOS_PAYMENT_METHOD; + + let recipient = new User(); + recipient.profile.name = 'recipient'; + recipient.purchased.plan = plan; + recipient.guilds.push(group._id); + await recipient.save(); + + user.guilds.push(group._id); + await user.save(); + data.groupId = group._id; + + await api.createSubscription(data); + + expect(sender.sendTxn).to.be.calledFourTimes; + expect(sender.sendTxn.args[0][0]._id).to.equal(TECH_ASSISTANCE_EMAIL); + expect(sender.sendTxn.args[0][1]).to.equal('admin-user-subscription-details'); + expect(sender.sendTxn.args[1][0]._id).to.equal(recipient._id); + expect(sender.sendTxn.args[1][1]).to.equal('group-member-join'); + expect(sender.sendTxn.args[1][2]).to.eql([ + {name: 'LEADER', content: groupLeaderName}, + {name: 'GROUP_NAME', content: groupName}, + {name: 'PREVIOUS_SUBSCRIPTION_TYPE', content: EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_IOS}, + ]); + expect(sender.sendTxn.args[2][0]._id).to.equal(group.leader); + expect(sender.sendTxn.args[2][1]).to.equal('group-member-join'); + expect(sender.sendTxn.args[3][0]._id).to.equal(group.leader); + expect(sender.sendTxn.args[3][1]).to.equal('group-subscription-begins'); }); it('adds months to members with existing gift subscription', async () => { @@ -333,7 +511,7 @@ describe('Purchasing a subscription for group', () => { }); it('adds months to members with existing recurring subscription (Android)'); - it('adds months to members with existing recurring subscription (iOs)'); + it('adds months to members with existing recurring subscription (iOS)'); it('adds months to members who already cancelled but not yet terminated recurring subscription', async () => { let recipient = new User(); @@ -603,7 +781,7 @@ describe('Purchasing a subscription for group', () => { expect(updatedUser.purchased.plan.dateCreated).to.exist; }); - it('does not modify a user with a Google subscription', async () => { + it('does not modify a user with an Android subscription', async () => { plan.customerId = 'random'; plan.paymentMethod = api.constants.GOOGLE_PAYMENT_METHOD; diff --git a/test/api/v3/unit/libs/paypalPayments.test.js b/test/api/v3/unit/libs/paypalPayments.test.js index ddc05a92fb..d8a3e096c0 100644 --- a/test/api/v3/unit/libs/paypalPayments.test.js +++ b/test/api/v3/unit/libs/paypalPayments.test.js @@ -447,6 +447,7 @@ describe('Paypal Payments', () => { groupId, paymentMethod: 'Paypal', nextBill: nextBillingDate, + cancellationReason: undefined, }); }); @@ -464,6 +465,7 @@ describe('Paypal Payments', () => { groupId: group._id, paymentMethod: 'Paypal', nextBill: nextBillingDate, + cancellationReason: undefined, }); }); }); diff --git a/test/api/v3/unit/libs/stripePayments.test.js b/test/api/v3/unit/libs/stripePayments.test.js index 8834bf1a92..00338f9e4c 100644 --- a/test/api/v3/unit/libs/stripePayments.test.js +++ b/test/api/v3/unit/libs/stripePayments.test.js @@ -683,6 +683,7 @@ describe('Stripe Payments', () => { groupId: undefined, nextBill: currentPeriodEndTimeStamp * 1000, // timestamp in seconds paymentMethod: 'Stripe', + cancellationReason: undefined, }); }); @@ -702,6 +703,7 @@ describe('Stripe Payments', () => { groupId, nextBill: currentPeriodEndTimeStamp * 1000, // timestamp in seconds paymentMethod: 'Stripe', + cancellationReason: undefined, }); }); }); diff --git a/website/server/libs/amazonPayments.js b/website/server/libs/amazonPayments.js index 8c5c88eca7..4351209b30 100644 --- a/website/server/libs/amazonPayments.js +++ b/website/server/libs/amazonPayments.js @@ -161,11 +161,12 @@ api.checkout = async function checkout (options = {}) { * @param options.user The user object who is canceling * @param options.groupId The id of the group that is canceling * @param options.headers The request headers + * @param options.cancellationReason A text string to control sending an email * * @return undefined */ api.cancelSubscription = async function cancelSubscription (options = {}) { - let {user, groupId, headers} = options; + let {user, groupId, headers, cancellationReason} = options; let billingAgreementId; let planId; @@ -218,6 +219,7 @@ api.cancelSubscription = async function cancelSubscription (options = {}) { nextBill: moment(lastBillingDate).add({ days: subscriptionLength }), paymentMethod: this.constants.PAYMENT_METHOD, headers, + cancellationReason, }); }; diff --git a/website/server/libs/payments.js b/website/server/libs/payments.js index 3c15a560c3..c87d8f225f 100644 --- a/website/server/libs/payments.js +++ b/website/server/libs/payments.js @@ -20,6 +20,7 @@ import { import slack from './slack'; const TECH_ASSISTANCE_EMAIL = nconf.get('EMAILS:TECH_ASSISTANCE_EMAIL'); +const JOINED_GROUP_PLAN = 'joined group plan'; let api = {}; @@ -81,8 +82,22 @@ api.addSubscriptionToGroupUsers = async function addSubscriptionToGroupUsers (gr * @return undefined */ api.addSubToGroupUser = async function addSubToGroupUser (member, group) { + // These EMAIL_TEMPLATE constants are used to pass strings into templates that are + // stored externally and so their values must not be changed. + const EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_GOOGLE = 'Google_subscription'; + const EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_IOS = 'iOS_subscription'; + const EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_GROUP_PLAN = 'group_plan_free_subscription'; + const EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_LIFETIME_FREE = 'lifetime_free_subscription'; + const EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_NORMAL = 'normal_subscription'; + const EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_UNKNOWN = 'unknown_type_of_subscription'; + const EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_NONE = 'no_subscription'; + + // When changing customerIdsToIgnore or paymentMethodsToIgnore, the code blocks below for + // the `group-member-join` email template will probably need to be changed. let customerIdsToIgnore = [this.constants.GROUP_PLAN_CUSTOMER_ID, this.constants.UNLIMITED_CUSTOMER_ID]; let paymentMethodsToIgnore = [this.constants.GOOGLE_PAYMENT_METHOD, this.constants.IOS_PAYMENT_METHOD]; + let previousSubscriptionType = EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_NONE; + let leader = await User.findById(group.leader).exec(); let data = { user: {}, @@ -125,13 +140,39 @@ api.addSubToGroupUser = async function addSubToGroupUser (member, group) { {name: 'EMAIL', content: getUserInfo(member, ['email']).email}, {name: 'PAYMENT_METHOD', content: memberPlan.paymentMethod}, {name: 'PURCHASED_PLAN', content: JSON.stringify(memberPlan)}, - {name: 'ACTION_NEEDED', content: 'User has joined group plan. Tell them to cancel subscription then give them free sub.'}, + {name: 'ACTION_NEEDED', content: 'User has joined group plan and has been told to cancel their subscription then email us. Ensure they do that then give them free sub.'}, + // TODO User won't get email instructions if they've opted out of all emails. See if we can make this email an exception and if not, report here whether they've opted out. ]); } - if ((ignorePaymentPlan || ignoreCustomerId) && !customerHasCancelledGroupPlan) return; + if ((ignorePaymentPlan || ignoreCustomerId) && !customerHasCancelledGroupPlan) { + // member has been added to group plan but their subscription will not be changed + // automatically so they need a special message in the email + if (memberPlan.paymentMethod === this.constants.GOOGLE_PAYMENT_METHOD) { + previousSubscriptionType = EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_GOOGLE; + } else if (memberPlan.paymentMethod === this.constants.IOS_PAYMENT_METHOD) { + previousSubscriptionType = EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_IOS; + } else if (memberPlan.customerId === this.constants.UNLIMITED_CUSTOMER_ID) { + previousSubscriptionType = EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_LIFETIME_FREE; + } else if (memberPlan.customerId === this.constants.GROUP_PLAN_CUSTOMER_ID) { + previousSubscriptionType = EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_GROUP_PLAN; + } else { + // this triggers a generic message in the email template in case we forget + // to update this code for new special cases + previousSubscriptionType = EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_UNKNOWN; + } + txnEmail(member, 'group-member-join', [ + {name: 'LEADER', content: leader.profile.name}, + {name: 'GROUP_NAME', content: group.name}, + {name: 'PREVIOUS_SUBSCRIPTION_TYPE', content: previousSubscriptionType}, + ]); + return; + } - if (member.hasNotCancelled()) await member.cancelSubscription(); + if (member.hasNotCancelled()) { + await member.cancelSubscription({cancellationReason: JOINED_GROUP_PLAN}); + previousSubscriptionType = EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_NORMAL; + } let today = new Date(); plan = member.purchased.plan.toObject(); @@ -164,10 +205,10 @@ api.addSubToGroupUser = async function addSubToGroupUser (member, group) { data.user = member; await this.createSubscription(data); - let leader = await User.findById(group.leader).exec(); - txnEmail(data.user, 'group-member-joining', [ + txnEmail(data.user, 'group-member-join', [ {name: 'LEADER', content: leader.profile.name}, {name: 'GROUP_NAME', content: group.name}, + {name: 'PREVIOUS_SUBSCRIPTION_TYPE', content: previousSubscriptionType}, ]); }; @@ -409,17 +450,18 @@ api.createSubscription = async function createSubscription (data) { }); }; -// Sets their subscription to be cancelled later +// Cancels a subscription or group plan, setting termination to happen later api.cancelSubscription = async function cancelSubscription (data) { let plan; let group; let cancelType = 'unsubscribe'; let groupId; - let emailType = 'cancel-subscription'; + let emailType; let emailMergeData = []; + let sendEmail = true; - // If we are buying a group subscription if (data.groupId) { + // cancelling a group plan let groupFields = basicGroupFields.concat(' purchased'); group = await Group.getGroup({user: data.user, groupId: data.groupId, populateLeader: false, groupFields}); @@ -438,15 +480,20 @@ api.cancelSubscription = async function cancelSubscription (data) { await this.cancelGroupUsersSubscription(group); } else { + // cancelling a user subscription plan = data.user.purchased.plan; + emailType = 'cancel-subscription'; + // When cancelling because the user joined a group plan, no cancel-subscription email is sent + // because the group-member-join email says the subscription is cancelled. + if (data.cancellationReason && data.cancellationReason === JOINED_GROUP_PLAN) sendEmail = false; } - let customerId = plan.customerId; let now = moment(); let defaultRemainingDays = 30; if (plan.customerId === this.constants.GROUP_PLAN_CUSTOMER_ID) { defaultRemainingDays = 2; + sendEmail = false; // because group-member-cancel email has already been sent } let remaining = data.nextBill ? moment(data.nextBill).diff(new Date(), 'days', true) : defaultRemainingDays; @@ -469,7 +516,7 @@ api.cancelSubscription = async function cancelSubscription (data) { await data.user.save(); } - if (customerId !== this.constants.GROUP_PLAN_CUSTOMER_ID) txnEmail(data.user, emailType, emailMergeData); + if (sendEmail) txnEmail(data.user, emailType, emailMergeData); if (group) { cancelType = 'group-unsubscribe'; diff --git a/website/server/libs/paypalPayments.js b/website/server/libs/paypalPayments.js index 6b18e60a19..2161436964 100644 --- a/website/server/libs/paypalPayments.js +++ b/website/server/libs/paypalPayments.js @@ -176,8 +176,18 @@ api.subscribeSuccess = async function subscribeSuccess (options = {}) { }); }; +/** + * Cancel a PayPal Subscription + * + * @param options + * @param options.user The user object who is canceling + * @param options.groupId The id of the group that is canceling + * @param options.cancellationReason A text string to control sending an email + * + * @return undefined + */ api.subscribeCancel = async function subscribeCancel (options = {}) { - let {groupId, user} = options; + let {groupId, user, cancellationReason} = options; let customerId; if (groupId) { @@ -212,6 +222,7 @@ api.subscribeCancel = async function subscribeCancel (options = {}) { groupId, paymentMethod: this.constants.PAYMENT_METHOD, nextBill: nextBillingDate, + cancellationReason, }); }; diff --git a/website/server/libs/stripePayments.js b/website/server/libs/stripePayments.js index 83a812b952..3175d839d8 100644 --- a/website/server/libs/stripePayments.js +++ b/website/server/libs/stripePayments.js @@ -198,11 +198,12 @@ api.editSubscription = async function editSubscription (options, stripeInc) { * @param options * @param options.user The user object who is purchasing * @param options.groupId The id of the group purchasing a subscription + * @param options.cancellationReason A text string to control sending an email * * @return undefined */ api.cancelSubscription = async function cancelSubscription (options, stripeInc) { - let {groupId, user} = options; + let {groupId, user, cancellationReason} = options; let customerId; // @TODO: We need to mock this, but curently we don't have correct Dependency Injection. And the Stripe Api doesn't seem to be a singleton? @@ -252,6 +253,7 @@ api.cancelSubscription = async function cancelSubscription (options, stripeInc) groupId, nextBill, paymentMethod: this.constants.PAYMENT_METHOD, + cancellationReason, }); }; diff --git a/website/server/models/user/methods.js b/website/server/models/user/methods.js index 8959171d1d..b76801633e 100644 --- a/website/server/models/user/methods.js +++ b/website/server/models/user/methods.js @@ -155,21 +155,34 @@ schema.methods.addComputedStatsToJSONObj = function addComputedStatsToUserJSONOb return statsObject; }; +/** + * Cancels a subscription. + * + * @param options + * @param options.user The user object who is purchasing + * @param options.groupId The id of the group purchasing a subscription + * @param options.headers The request headers (only for Amazon subscriptions) + * @param options.cancellationReason A text string to control sending an email + * + * @return a Promise from api.cancelSubscription() + */ // @TODO: There is currently a three way relation between the user, payment methods and the payment helper // This creates some odd Dependency Injection issues. To counter that, we use the user as the third layer // To negotiate between the payment providers and the payment helper (which probably has too many responsiblities) // In summary, currently is is best practice to use this method to cancel a user subscription, rather than calling the // payment helper. -schema.methods.cancelSubscription = async function cancelSubscription () { +schema.methods.cancelSubscription = async function cancelSubscription (options = {}) { let plan = this.purchased.plan; + options.user = this; if (plan.paymentMethod === amazonPayments.constants.PAYMENT_METHOD) { - return await amazonPayments.cancelSubscription({user: this}); + return await amazonPayments.cancelSubscription(options); } else if (plan.paymentMethod === stripePayments.constants.PAYMENT_METHOD) { - return await stripePayments.cancelSubscription({user: this}); + return await stripePayments.cancelSubscription(options); } else if (plan.paymentMethod === paypalPayments.constants.PAYMENT_METHOD) { - return await paypalPayments.subscribeCancel({user: this}); + return await paypalPayments.subscribeCancel(options); } + // Android and iOS subscriptions cannot be cancelled by Habitica. - return await payments.cancelSubscription({user: this}); + return await payments.cancelSubscription(options); };