From f7a03d2eb50e070a1b5a339796771b533417141b Mon Sep 17 00:00:00 2001 From: negue Date: Thu, 27 Oct 2022 08:39:06 +0200 Subject: [PATCH] improve transactions logs + split createSubscription (#14289) * improve transactions logs + split createSubscription --- test/api/unit/libs/payments/payments.test.js | 24 +++++- website/server/controllers/api-v3/hall.js | 15 ++-- website/server/libs/payments/constants.js | 7 ++ website/server/libs/payments/groupPayments.js | 34 ++++---- website/server/libs/payments/payments.js | 9 +- website/server/libs/payments/subscriptions.js | 83 +++++++++++++++---- 6 files changed, 128 insertions(+), 44 deletions(-) create mode 100644 website/server/libs/payments/constants.js diff --git a/test/api/unit/libs/payments/payments.test.js b/test/api/unit/libs/payments/payments.test.js index 48d6325546..65b42b42f3 100644 --- a/test/api/unit/libs/payments/payments.test.js +++ b/test/api/unit/libs/payments/payments.test.js @@ -11,10 +11,13 @@ import { generateGroup, } from '../../../../helpers/api-unit.helper'; import * as worldState from '../../../../../website/server/libs/worldState'; +import { TransactionModel } from '../../../../../website/server/models/transaction'; describe('payments/index', () => { - let user; let group; let data; let - plan; + let user; + let group; + let data; + let plan; beforeEach(async () => { user = new User(); @@ -104,6 +107,23 @@ describe('payments/index', () => { expect(recipient.purchased.plan.extraMonths).to.eql(3); }); + it('add a transaction entry to the recipient', async () => { + recipient.purchased.plan = plan; + + expect(recipient.purchased.plan.extraMonths).to.eql(0); + + await api.createSubscription(data); + + expect(recipient.purchased.plan.extraMonths).to.eql(3); + + const transactions = await TransactionModel + .find({ userId: recipient._id }) + .sort({ createdAt: -1 }) + .exec(); + + expect(transactions).to.have.lengthOf(1); + }); + it('does not set negative extraMonths if plan has past dateTerminated date', async () => { const dateTerminated = moment().subtract(2, 'months').toDate(); recipient.purchased.plan.dateTerminated = dateTerminated; diff --git a/website/server/controllers/api-v3/hall.js b/website/server/controllers/api-v3/hall.js index 456c62a396..73f2e37f6a 100644 --- a/website/server/controllers/api-v3/hall.js +++ b/website/server/controllers/api-v3/hall.js @@ -279,11 +279,16 @@ api.updateHero = { } if (updateData.purchased.plan.consecutive) { if (updateData.purchased.plan.consecutive.trinkets) { - await hero.updateHourglasses( - updateData.purchased.plan.consecutive.trinkets - - hero.purchased.plan.consecutive.trinkets, - 'admin_update_hourglasses', '', 'Updated by Habitica staff', - ); + const changedHourglassTrinkets = updateData.purchased.plan.consecutive.trinkets + - hero.purchased.plan.consecutive.trinkets; + + if (changedHourglassTrinkets !== 0) { + await hero.updateHourglasses( + changedHourglassTrinkets, + 'admin_update_hourglasses', '', 'Updated by Habitica staff', + ); + } + hero.purchased.plan.consecutive.trinkets = updateData.purchased.plan.consecutive.trinkets; } if (updateData.purchased.plan.consecutive.gemCapExtra) { diff --git a/website/server/libs/payments/constants.js b/website/server/libs/payments/constants.js new file mode 100644 index 0000000000..a251a9099c --- /dev/null +++ b/website/server/libs/payments/constants.js @@ -0,0 +1,7 @@ +export const paymentConstants = { + UNLIMITED_CUSTOMER_ID: 'habitrpg', // Users with the customerId have an unlimted free subscription + GROUP_PLAN_CUSTOMER_ID: 'group-plan', + GROUP_PLAN_PAYMENT_METHOD: 'Group Plan', + GOOGLE_PAYMENT_METHOD: 'Google', + IOS_PAYMENT_METHOD: 'Apple', +}; diff --git a/website/server/libs/payments/groupPayments.js b/website/server/libs/payments/groupPayments.js index 3587c7585e..b59566c2fb 100644 --- a/website/server/libs/payments/groupPayments.js +++ b/website/server/libs/payments/groupPayments.js @@ -1,3 +1,5 @@ +// TODO these files need to refactored. + import nconf from 'nconf'; import _ from 'lodash'; import moment from 'moment'; @@ -12,6 +14,8 @@ import { // eslint-disable-line import/no-cycle getUserInfo, sendTxn as txnEmail, } from '../email'; +import { paymentConstants } from './constants'; +import { cancelSubscription, createSubscription } from './subscriptions'; // eslint-disable-line import/no-cycle const TECH_ASSISTANCE_EMAIL = nconf.get('EMAILS_TECH_ASSISTANCE_EMAIL'); const JOINED_GROUP_PLAN = 'joined group plan'; @@ -37,7 +41,8 @@ async function addSubscriptionToGroupUsers (group) { members = await User.find({ 'party._id': group._id }).select('_id purchased items auth profile.name notifications').exec(); } - const promises = members.map(member => this.addSubToGroupUser(member, group)); + // eslint-disable-next-line no-use-before-define + const promises = members.map(member => addSubToGroupUser(member, group)); await Promise.all(promises); } @@ -63,12 +68,12 @@ async function addSubToGroupUser (member, group) { // When changing customerIdsToIgnore or paymentMethodsToIgnore, the code blocks below for // the `group-member-join` email template will probably need to be changed. const customerIdsToIgnore = [ - this.constants.GROUP_PLAN_CUSTOMER_ID, - this.constants.UNLIMITED_CUSTOMER_ID, + paymentConstants.GROUP_PLAN_CUSTOMER_ID, + paymentConstants.UNLIMITED_CUSTOMER_ID, ]; const paymentMethodsToIgnore = [ - this.constants.GOOGLE_PAYMENT_METHOD, - this.constants.IOS_PAYMENT_METHOD, + paymentConstants.GOOGLE_PAYMENT_METHOD, + paymentConstants.IOS_PAYMENT_METHOD, ]; let previousSubscriptionType = EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_NONE; const leader = await User.findById(group.leader).exec(); @@ -104,7 +109,7 @@ async function addSubToGroupUser (member, group) { const memberPlan = member.purchased.plan; if (member.isSubscribed()) { const customerHasCancelledGroupPlan = ( - memberPlan.customerId === this.constants.GROUP_PLAN_CUSTOMER_ID + memberPlan.customerId === paymentConstants.GROUP_PLAN_CUSTOMER_ID && !member.hasNotCancelled() ); const ignorePaymentPlan = paymentMethodsToIgnore.indexOf(memberPlan.paymentMethod) !== -1; @@ -127,13 +132,13 @@ async function addSubToGroupUser (member, group) { 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) { + if (memberPlan.paymentMethod === paymentConstants.GOOGLE_PAYMENT_METHOD) { previousSubscriptionType = EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_GOOGLE; - } else if (memberPlan.paymentMethod === this.constants.IOS_PAYMENT_METHOD) { + } else if (memberPlan.paymentMethod === paymentConstants.IOS_PAYMENT_METHOD) { previousSubscriptionType = EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_IOS; - } else if (memberPlan.customerId === this.constants.UNLIMITED_CUSTOMER_ID) { + } else if (memberPlan.customerId === paymentConstants.UNLIMITED_CUSTOMER_ID) { previousSubscriptionType = EMAIL_TEMPLATE_SUBSCRIPTION_TYPE_LIFETIME_FREE; - } else if (memberPlan.customerId === this.constants.GROUP_PLAN_CUSTOMER_ID) { + } else if (memberPlan.customerId === paymentConstants.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 @@ -183,7 +188,7 @@ async function addSubToGroupUser (member, group) { member.markModified('items.mounts'); data.user = member; - await this.createSubscription(data); + await createSubscription(data); txnEmail(data.user, 'group-member-join', [ { name: 'LEADER', content: leader.profile.name }, @@ -207,13 +212,14 @@ async function cancelGroupUsersSubscription (group) { members = await User.find({ 'party._id': group._id }).select('_id guilds purchased').exec(); } - const promises = members.map(member => this.cancelGroupSubscriptionForUser(member, group)); + // eslint-disable-next-line no-use-before-define + const promises = members.map(member => cancelGroupSubscriptionForUser(member, group)); await Promise.all(promises); } async function cancelGroupSubscriptionForUser (user, group, userWasRemoved = false) { - if (user.purchased.plan.customerId !== this.constants.GROUP_PLAN_CUSTOMER_ID) return; + if (user.purchased.plan.customerId !== paymentConstants.GROUP_PLAN_CUSTOMER_ID) return; const userGroups = user.guilds.toObject(); if (user.party._id) userGroups.push(user.party._id); @@ -241,7 +247,7 @@ async function cancelGroupSubscriptionForUser (user, group, userWasRemoved = fal { name: 'LEADER', content: leader.profile.name }, { name: 'GROUP_NAME', content: group.name }, ]); - await this.cancelSubscription({ user }); + await cancelSubscription({ user }); } } diff --git a/website/server/libs/payments/payments.js b/website/server/libs/payments/payments.js index 5d6b1bb9de..3e93bdb8d4 100644 --- a/website/server/libs/payments/payments.js +++ b/website/server/libs/payments/payments.js @@ -11,16 +11,11 @@ import { // eslint-disable-line import/no-cycle import { // eslint-disable-line import/no-cycle buyGems, } from './gems'; +import { paymentConstants } from './constants'; const api = {}; -api.constants = { - UNLIMITED_CUSTOMER_ID: 'habitrpg', // Users with the customerId have an unlimted free subscription - GROUP_PLAN_CUSTOMER_ID: 'group-plan', - GROUP_PLAN_PAYMENT_METHOD: 'Group Plan', - GOOGLE_PAYMENT_METHOD: 'Google', - IOS_PAYMENT_METHOD: 'Apple', -}; +api.constants = paymentConstants; api.addSubscriptionToGroupUsers = addSubscriptionToGroupUsers; diff --git a/website/server/libs/payments/subscriptions.js b/website/server/libs/payments/subscriptions.js index 80401f21de..e88cf30937 100644 --- a/website/server/libs/payments/subscriptions.js +++ b/website/server/libs/payments/subscriptions.js @@ -1,3 +1,5 @@ +// TODO these files need to refactored. + import _ from 'lodash'; import moment from 'moment'; @@ -20,6 +22,8 @@ import shared from '../../../common'; import { sendNotification as sendPushNotification } from '../pushNotifications'; // eslint-disable-line import/no-cycle import calculateSubscriptionTerminationDate from './calculateSubscriptionTerminationDate'; import { getCurrentEventList } from '../worldState'; // eslint-disable-line import/no-cycle +import { paymentConstants } from './constants'; +import { addSubscriptionToGroupUsers, cancelGroupUsersSubscription } from './groupPayments'; // eslint-disable-line import/no-cycle // @TODO: Abstract to shared/constant const JOINED_GROUP_PLAN = 'joined group plan'; @@ -65,7 +69,7 @@ function _dateDiff (earlyDate, lateDate) { return moment(lateDate).diff(earlyDate, 'months', true); } -async function createSubscription (data) { +async function prepareSubscriptionValues (data) { let recipient = data.gift ? data.gift.member : data.user; const block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key @@ -105,7 +109,7 @@ async function createSubscription (data) { if (group) { analytics.track( - this.groupID, + data.groupID, data.demographics, ); } @@ -130,7 +134,7 @@ async function createSubscription (data) { groupId = group._id; recipient.purchased.plan.quantity = data.sub.quantity; - await this.addSubscriptionToGroupUsers(group); + await addSubscriptionToGroupUsers(group); } const { plan } = recipient.purchased; @@ -139,7 +143,10 @@ async function createSubscription (data) { if (plan.customerId && !plan.dateTerminated) { // User has active plan plan.extraMonths += months; } else { - if (!recipientIsSubscribed || !plan.dateUpdated) plan.dateUpdated = today; + if (!recipientIsSubscribed || !plan.dateUpdated) { + plan.dateUpdated = today; + } + if (moment(plan.dateTerminated).isAfter()) { plan.dateTerminated = moment(plan.dateTerminated).add({ months }).toDate(); } else { @@ -148,9 +155,15 @@ async function createSubscription (data) { } } - if (!plan.customerId) plan.customerId = 'Gift'; // don't override existing customer, but all sub need a customerId + if (!plan.customerId) { + plan.customerId = 'Gift'; + } + + // don't override existing customer, but all sub need a customerId } else { - if (!plan.dateTerminated) plan.dateTerminated = today; + if (!plan.dateTerminated) { + plan.dateTerminated = today; + } Object.assign(plan, { // override plan with new values planId: block.key, @@ -170,22 +183,58 @@ async function createSubscription (data) { }); // allow non-override if a plan was previously used - if (!plan.gemsBought) plan.gemsBought = 0; - if (!plan.dateCreated) plan.dateCreated = today; - if (!plan.mysteryItems) plan.mysteryItems = []; + if (!plan.gemsBought) { + plan.gemsBought = 0; + } + + if (!plan.dateCreated) { + plan.dateCreated = today; + } + + if (!plan.mysteryItems) { + plan.mysteryItems = []; + } if (data.subscriptionId) { plan.subscriptionId = data.subscriptionId; } } + return { + block, + months, + plan, + recipient, + autoRenews, + group, + groupId, + itemPurchased, + purchaseType, + emailType, + }; +} + +async function createSubscription (data) { + const { + block, + months, + plan, + recipient, + autoRenews, + group, + groupId, + itemPurchased, + purchaseType, + emailType, + } = await prepareSubscriptionValues(data); + // Block sub perks const perks = Math.floor(months / 3); if (perks) { plan.consecutive.offset += months; plan.consecutive.gemCapExtra += perks * 5; if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25; - await plan.updateHourglasses(data.user._id, perks, 'subscription_perks'); // one Hourglass every 3 months + await plan.updateHourglasses(recipient._id, perks, 'subscription_perks'); // one Hourglass every 3 months } if (recipient !== group) { @@ -195,7 +244,7 @@ async function createSubscription (data) { } // @TODO: Create a factory pattern for use cases - if (!data.gift && data.customerId !== this.constants.GROUP_PLAN_CUSTOMER_ID) { + if (!data.gift && data.customerId !== paymentConstants.GROUP_PLAN_CUSTOMER_ID) { txnEmail(data.user, emailType); } @@ -284,7 +333,7 @@ async function createSubscription (data) { promo: 'Winter', promoUsername: data.gift.member.auth.local.username, }; - await this.createSubscription(promoData); + await createSubscription(promoData); } if (data.gift.member.preferences.pushNotifications.giftedSubscription !== false) { @@ -365,7 +414,7 @@ async function cancelSubscription (data) { emailType = 'group-cancel-subscription'; emailMergeData.push({ name: 'GROUP_NAME', content: group.name }); - await this.cancelGroupUsersSubscription(group); + await cancelGroupUsersSubscription(group); } else { // cancelling a user subscription plan = data.user.purchased.plan; @@ -375,12 +424,12 @@ async function cancelSubscription (data) { if (data.cancellationReason && data.cancellationReason === JOINED_GROUP_PLAN) sendEmail = false; } - if (plan.customerId === this.constants.GROUP_PLAN_CUSTOMER_ID) { + if (plan.customerId === paymentConstants.GROUP_PLAN_CUSTOMER_ID) { sendEmail = false; // because group-member-cancel email has already been sent } plan.dateTerminated = calculateSubscriptionTerminationDate( - data.nextBill, plan, this.constants.GROUP_PLAN_CUSTOMER_ID, + data.nextBill, plan, paymentConstants.GROUP_PLAN_CUSTOMER_ID, ); // clear extra time. If they subscribe again, it'll be recalculated from p.dateTerminated @@ -392,7 +441,9 @@ async function cancelSubscription (data) { await data.user.save(); } - if (sendEmail) txnEmail(data.user, emailType, emailMergeData); + if (sendEmail) { + txnEmail(data.user, emailType, emailMergeData); + } if (group) { cancelType = 'group-unsubscribe';