Merge branch 'develop' into phillip/chat-skill-merge

This commit is contained in:
SabreCat
2023-07-10 15:12:12 -05:00
648 changed files with 24087 additions and 9026 deletions

View File

@@ -37,7 +37,15 @@ export default function baseModel (schema, options = {}) {
});
schema.pre('update', function preUpdateModel () {
this.update({}, { $set: { updatedAt: new Date() } });
this.set({}, { $set: { updatedAt: new Date() } });
});
schema.pre('updateOne', function preUpdateModel () {
this.set({}, { $set: { updatedAt: new Date() } });
});
schema.pre('updateMany', function preUpdateModel () {
this.set({}, { $set: { updatedAt: new Date() } });
});
}

View File

@@ -63,9 +63,6 @@ const CLEAR_BUFFS = {
};
async function grantEndOfTheMonthPerks (user, now) {
// multi-month subscriptions are for multiples of 3 months
const SUBSCRIPTION_BASIC_BLOCK_LENGTH = 3;
const { plan, elapsedMonths } = getPlanContext(user, now);
if (elapsedMonths > 0) {
@@ -106,32 +103,17 @@ async function grantEndOfTheMonthPerks (user, now) {
planMonthsLength = getPlanMonths(plan);
}
// every 3 months you get one set of perks - this variable records how many sets you need
let perkAmountNeeded = 0;
if (planMonthsLength === 1) {
// User has a single-month recurring subscription and are due for perks
// IF they've been subscribed for a multiple of 3 months.
if (plan.consecutive.count % SUBSCRIPTION_BASIC_BLOCK_LENGTH === 0) { // every 3 months
perkAmountNeeded = 1;
}
plan.consecutive.offset = 0; // allow the same logic to be run next month
} else {
// User has a multi-month recurring subscription
// and it renewed in the previous calendar month.
// e.g., for a 6-month subscription, give two sets of perks
perkAmountNeeded = planMonthsLength / SUBSCRIPTION_BASIC_BLOCK_LENGTH;
// don't need to check for perks again for this many months
// (subtract 1 because we should have run this when the payment was taken last month)
plan.consecutive.offset = planMonthsLength - 1;
}
if (perkAmountNeeded > 0) {
// one Hourglass every 3 months
await plan.updateHourglasses(user._id, perkAmountNeeded, 'subscription_perks'); // eslint-disable-line no-await-in-loop
plan.consecutive.gemCapExtra += 5 * perkAmountNeeded; // 5 extra Gems every 3 months
// cap it at 50 (hard 25 limit + extra 25)
if (plan.consecutive.gemCapExtra > 25) plan.consecutive.gemCapExtra = 25;
}
// eslint-disable-next-line no-await-in-loop
await plan.incrementPerkCounterAndReward(user._id, planMonthsLength);
}
}
}
@@ -297,6 +279,8 @@ export async function cron (options = {}) {
if (user.isSubscribed()) {
await grantEndOfTheMonthPerks(user, now);
} if (!user.isSubscribed() && user.purchased.plan.perkMonthCount > 0) {
user.purchased.plan.perkMonthCount = 0;
}
const { plan } = user.purchased;

View File

@@ -21,6 +21,8 @@ export default {
setup: util.promisify(iap.setup.bind(iap)),
validate: util.promisify(iap.validate.bind(iap)),
isValidated: iap.isValidated,
isCanceled: iap.isCanceled,
isExpired: iap.isExpired,
getPurchaseData: iap.getPurchaseData,
GOOGLE: iap.GOOGLE,
APPLE: iap.APPLE,

View File

@@ -87,10 +87,9 @@ async function inviteUserToParty (userToInvite, group, inviter, res) {
}
if (userToInvite.party._id) {
const userParty = await Group.getGroup({ user: userToInvite, groupId: 'party', fields: 'memberCount' });
const userParty = await Group.getGroup({ user: userToInvite, groupId: 'party', fields: '_id' });
// Allow user to be invited to a new party when they're partying solo
if (userParty && userParty.memberCount !== 1) throw new NotAuthorized(res.t('userAlreadyInAParty', { userId: uuid, username: userToInvite.profile.name }));
if (userParty) throw new NotAuthorized(res.t('userAlreadyInAParty', { userId: uuid, username: userToInvite.profile.name }));
}
const partyInvite = { id: group._id, name: group.name, inviter: inviter._id };
@@ -142,6 +141,22 @@ async function inviteByUUID (uuid, group, inviter, req, res) {
));
}
const analyticsObject = {
hitType: 'event',
category: 'behavior',
uuid: inviter._id,
invitee: uuid,
groupId: group._id,
groupType: group.type,
headers: req.headers,
};
if (group.type === 'party') {
analyticsObject.seekingParty = Boolean(userToInvite.party.seeking);
}
res.analytics.track('group invite', analyticsObject);
return addInvitationToUser(userToInvite, group, inviter, res);
}
@@ -189,6 +204,18 @@ async function inviteByEmail (invite, group, inviter, req, res) {
const userIsUnsubscribed = await EmailUnsubscription.findOne({ email: invite.email }).exec();
const groupLabel = group.type === 'guild' ? '-guild' : '';
if (!userIsUnsubscribed) sendTxnEmail(invite, `invite-friend${groupLabel}`, variables);
const analyticsObject = {
hitType: 'event',
category: 'behavior',
uuid: inviter._id,
invitee: 'email',
groupId: group._id,
groupType: group.type,
headers: req.headers,
};
res.analytics.track('group invite', analyticsObject);
}
return userReturnInfo;
@@ -214,6 +241,23 @@ async function inviteByUserName (username, group, inviter, req, res) {
{ userId: userToInvite._id, username: userToInvite.profile.name },
));
}
const analyticsObject = {
hitType: 'event',
category: 'behavior',
uuid: inviter._id,
invitee: userToInvite._id,
groupId: group._id,
groupType: group.type,
headers: req.headers,
};
if (group.type === 'party') {
analyticsObject.seekingParty = Boolean(userToInvite.party.seeking);
}
res.analytics.track('group invite', analyticsObject);
return addInvitationToUser(userToInvite, group, inviter, res);
}

View File

@@ -46,6 +46,7 @@ api.constants = {
GIFT_TYPE_SUBSCRIPTION: 'subscription',
METHOD_BUY_GEMS: 'buyGems',
METHOD_BUY_SKU_ITEM: 'buySkuItem',
METHOD_CREATE_SUBSCRIPTION: 'createSubscription',
PAYMENT_METHOD: 'Amazon Payments',
PAYMENT_METHOD_GIFT: 'Amazon Payments (Gift)',
@@ -110,7 +111,7 @@ api.authorize = function authorize (inputSet) {
*/
api.checkout = async function checkout (options = {}) {
const {
gift, user, orderReferenceId, headers, gemsBlock: gemsBlockKey,
gift, user, orderReferenceId, headers, gemsBlock: gemsBlockKey, sku,
} = options;
let amount;
let gemsBlock;
@@ -127,6 +128,12 @@ api.checkout = async function checkout (options = {}) {
} else if (gift.type === this.constants.GIFT_TYPE_SUBSCRIPTION) {
amount = common.content.subscriptionBlocks[gift.subscription.key].price;
}
} else if (sku) {
if (sku === 'Pet-Gryphatrice-Jubilant') {
amount = 9.99;
} else {
throw new NotFound('SKU not found.');
}
} else {
gemsBlock = getGemsBlock(gemsBlockKey);
amount = gemsBlock.price / 100;
@@ -171,12 +178,16 @@ api.checkout = async function checkout (options = {}) {
// execute payment
let method = this.constants.METHOD_BUY_GEMS;
if (sku) {
method = this.constants.METHOD_BUY_SKU_ITEM;
}
const data = {
user,
paymentMethod: this.constants.PAYMENT_METHOD,
headers,
gemsBlock,
sku,
};
if (gift) {

View File

@@ -2,7 +2,7 @@ import moment from 'moment';
import shared from '../../../common';
import iap from '../inAppPurchases';
import payments from './payments';
import { getGemsBlock, validateGiftMessage } from './gems';
import { validateGiftMessage } from './gems';
import {
NotAuthorized,
BadRequest,
@@ -22,7 +22,7 @@ api.constants = {
RESPONSE_NO_ITEM_PURCHASED: 'NO_ITEM_PURCHASED',
};
api.verifyGemPurchase = async function verifyGemPurchase (options) {
api.verifyPurchase = async function verifyPurchase (options) {
const {
gift, user, receipt, headers,
} = options;
@@ -44,7 +44,6 @@ api.verifyGemPurchase = async function verifyGemPurchase (options) {
if (purchaseDataList.length === 0) {
throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED);
}
let correctReceipt = false;
// Purchasing one item at a time (processing of await(s) below is sequential not parallel)
for (const purchaseData of purchaseDataList) {
@@ -62,58 +61,45 @@ api.verifyGemPurchase = async function verifyGemPurchase (options) {
userId: user._id,
});
let gemsBlockKey;
switch (purchaseData.productId) { // eslint-disable-line default-case
case 'com.habitrpg.ios.Habitica.4gems':
gemsBlockKey = '4gems';
break;
case 'com.habitrpg.ios.Habitica.20gems':
case 'com.habitrpg.ios.Habitica.21gems':
gemsBlockKey = '21gems';
break;
case 'com.habitrpg.ios.Habitica.42gems':
gemsBlockKey = '42gems';
break;
case 'com.habitrpg.ios.Habitica.84gems':
gemsBlockKey = '84gems';
break;
}
if (!gemsBlockKey) throw new NotAuthorized(api.constants.RESPONSE_INVALID_ITEM);
const gemsBlock = getGemsBlock(gemsBlockKey);
if (gift) {
gift.type = 'gems';
if (!gift.gems) gift.gems = {};
gift.gems.amount = shared.content.gems[gemsBlock.key].gems;
}
if (gemsBlock) {
correctReceipt = true;
await payments.buyGems({ // eslint-disable-line no-await-in-loop
user,
gift,
paymentMethod: api.constants.PAYMENT_METHOD_APPLE,
gemsBlock,
headers,
});
}
await payments.buySkuItem({ // eslint-disable-line no-await-in-loop
user,
gift,
paymentMethod: api.constants.PAYMENT_METHOD_APPLE,
sku: purchaseData.productId,
headers,
});
}
}
if (!correctReceipt) throw new NotAuthorized(api.constants.RESPONSE_INVALID_ITEM);
return appleRes;
};
api.subscribe = async function subscribe (sku, user, receipt, headers, nextPaymentProcessing) {
if (user && user.isSubscribed()) {
throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
api.subscribe = async function subscribe (user, receipt, headers, nextPaymentProcessing) {
await iap.setup();
const appleRes = await iap.validate(iap.APPLE, receipt);
const isValidated = iap.isValidated(appleRes);
if (!isValidated) throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT);
const purchaseDataList = iap.getPurchaseData(appleRes);
if (purchaseDataList.length === 0) {
throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED);
}
if (!sku) throw new BadRequest(shared.i18n.t('missingSubscriptionCode'));
let purchase;
let newestDate;
for (const purchaseData of purchaseDataList) {
const datePurchased = new Date(Number(purchaseData.purchaseDate));
const dateTerminated = new Date(Number(purchaseData.expirationDate));
if ((!newestDate || datePurchased > newestDate) && dateTerminated > new Date()) {
purchase = purchaseData;
newestDate = datePurchased;
}
}
let subCode;
switch (sku) { // eslint-disable-line default-case
switch (purchase.productId) { // eslint-disable-line default-case
case 'subscription1month':
subCode = 'basic_earned';
break;
@@ -128,45 +114,56 @@ api.subscribe = async function subscribe (sku, user, receipt, headers, nextPayme
break;
}
const sub = subCode ? shared.content.subscriptionBlocks[subCode] : false;
if (!sub) throw new NotAuthorized(this.constants.RESPONSE_INVALID_ITEM);
await iap.setup();
const appleRes = await iap.validate(iap.APPLE, receipt);
const isValidated = iap.isValidated(appleRes);
if (!isValidated) throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT);
const purchaseDataList = iap.getPurchaseData(appleRes);
if (purchaseDataList.length === 0) {
throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED);
}
let transactionId;
for (const purchaseData of purchaseDataList) {
const dateTerminated = new Date(Number(purchaseData.expirationDate));
if (purchaseData.productId === sku && dateTerminated > new Date()) {
transactionId = purchaseData.transactionId;
break;
if (purchase.originalTransactionId) {
let existingSub;
if (user && user.isSubscribed()) {
if (user.purchased.plan.customerId !== purchase.originalTransactionId) {
throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
}
existingSub = shared.content.subscriptionBlocks[user.purchased.plan.planId];
if (existingSub === sub) {
throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
}
}
}
if (transactionId) {
const existingUser = await User.findOne({
'purchased.plan.customerId': transactionId,
const existingUsers = await User.find({
$or: [
{ 'purchased.plan.customerId': purchase.originalTransactionId },
{ 'purchased.plan.customerId': purchase.transactionId },
],
}).exec();
if (existingUser) throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
if (existingUsers.length > 0) {
if (purchase.originalTransactionId === purchase.transactionId) {
throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
}
for (const existingUser of existingUsers) {
if (existingUser._id !== user._id && !existingUser.purchased.plan.dateTerminated) {
throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
}
}
}
nextPaymentProcessing = nextPaymentProcessing || moment.utc().add({ days: 2 }); // eslint-disable-line max-len, no-param-reassign
const terminationDate = moment(Number(purchase.expirationDate));
if (nextPaymentProcessing > terminationDate) {
// For test subscriptions that have a significantly shorter expiration period, this is better
nextPaymentProcessing = terminationDate; // eslint-disable-line no-param-reassign
}
await payments.createSubscription({
const data = {
user,
customerId: transactionId,
customerId: purchase.originalTransactionId,
paymentMethod: this.constants.PAYMENT_METHOD_APPLE,
sub,
headers,
nextPaymentProcessing,
additionalData: receipt,
});
};
if (existingSub) {
data.updatedFrom = existingSub;
data.updatedFrom.logic = 'refundAndRepay';
}
await payments.createSubscription(data);
} else {
throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT);
}
@@ -258,8 +255,6 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) {
await iap.setup();
let dateTerminated;
try {
const appleRes = await iap.validate(iap.APPLE, plan.additionalData);
@@ -268,10 +263,27 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) {
const purchases = iap.getPurchaseData(appleRes);
if (purchases.length === 0) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
const subscriptionData = purchases[0];
let newestDate;
let newestPurchase;
dateTerminated = new Date(Number(subscriptionData.expirationDate));
if (dateTerminated > new Date()) throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID);
for (const purchaseData of purchases) {
const datePurchased = new Date(Number(purchaseData.purchaseDate));
if (!newestDate || datePurchased > newestDate) {
newestDate = datePurchased;
newestPurchase = purchaseData;
}
}
if (!iap.isCanceled(newestPurchase) && !iap.isExpired(newestPurchase)) {
throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID);
}
await payments.cancelSubscription({
user,
nextBill: new Date(Number(newestPurchase.expirationDate)),
paymentMethod: this.constants.PAYMENT_METHOD_APPLE,
headers,
});
} catch (err) {
// If we have an invalid receipt, cancel anyway
if (
@@ -281,13 +293,6 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) {
throw err;
}
}
await payments.cancelSubscription({
user,
nextBill: dateTerminated,
paymentMethod: this.constants.PAYMENT_METHOD_APPLE,
headers,
});
};
export default api;

View File

@@ -8,7 +8,7 @@ import {
} from '../errors';
import { model as IapPurchaseReceipt } from '../../models/iapPurchaseReceipt';
import { model as User } from '../../models/user';
import { getGemsBlock, validateGiftMessage } from './gems';
import { validateGiftMessage } from './gems';
const api = {};
@@ -21,7 +21,7 @@ api.constants = {
RESPONSE_STILL_VALID: 'SUBSCRIPTION_STILL_VALID',
};
api.verifyGemPurchase = async function verifyGemPurchase (options) {
api.verifyPurchase = async function verifyPurchase (options) {
const {
gift, user, receipt, signature, headers,
} = options;
@@ -61,39 +61,11 @@ api.verifyGemPurchase = async function verifyGemPurchase (options) {
userId: user._id,
});
let gemsBlockKey;
switch (receiptObj.productId) { // eslint-disable-line default-case
case 'com.habitrpg.android.habitica.iap.4gems':
gemsBlockKey = '4gems';
break;
case 'com.habitrpg.android.habitica.iap.20gems':
case 'com.habitrpg.android.habitica.iap.21gems':
gemsBlockKey = '21gems';
break;
case 'com.habitrpg.android.habitica.iap.42gems':
gemsBlockKey = '42gems';
break;
case 'com.habitrpg.android.habitica.iap.84gems':
gemsBlockKey = '84gems';
break;
}
if (!gemsBlockKey) throw new NotAuthorized(this.constants.RESPONSE_INVALID_ITEM);
const gemsBlock = getGemsBlock(gemsBlockKey);
if (gift) {
gift.type = 'gems';
if (!gift.gems) gift.gems = {};
gift.gems.amount = shared.content.gems[gemsBlock.key].gems;
}
await payments.buyGems({
await payments.buySkuItem({ // eslint-disable-line no-await-in-loop
user,
gift,
paymentMethod: this.constants.PAYMENT_METHOD_GOOGLE,
gemsBlock,
paymentMethod: api.constants.PAYMENT_METHOD_GOOGLE,
sku: googleRes.productId,
headers,
});

View File

@@ -180,6 +180,7 @@ async function addSubToGroupUser (member, group) {
}
// save unused hourglass and mystery items
plan.perkMonthCount = memberPlan.perkMonthCount;
plan.consecutive.trinkets = memberPlan.consecutive.trinkets;
plan.mysteryItems = memberPlan.mysteryItems;

View File

@@ -11,6 +11,9 @@ import { // eslint-disable-line import/no-cycle
import { // eslint-disable-line import/no-cycle
buyGems,
} from './gems';
import { // eslint-disable-line import/no-cycle
buySkuItem,
} from './skuItem';
import { paymentConstants } from './constants';
const api = {};
@@ -31,4 +34,6 @@ api.cancelSubscription = cancelSubscription;
api.buyGems = buyGems;
api.buySkuItem = buySkuItem;
export default api;

View File

@@ -77,7 +77,9 @@ api.paypalBillingAgreementCancel = util
api.ipnVerifyAsync = util.promisify(paypalIpn.verify.bind(paypalIpn));
api.checkout = async function checkout (options = {}) {
const { gift, user, gemsBlock: gemsBlockKey } = options;
const {
gift, gemsBlock: gemsBlockKey, sku, user,
} = options;
let amount;
let gemsBlock;
@@ -99,12 +101,17 @@ api.checkout = async function checkout (options = {}) {
amount = Number(shared.content.subscriptionBlocks[gift.subscription.key].price).toFixed(2);
description = 'mo. Habitica Subscription (Gift)';
}
} else if (sku) {
if (sku === 'Pet-Gryphatrice-Jubilant') {
description = 'Jubilant Gryphatrice';
amount = 9.99;
}
} else {
gemsBlock = getGemsBlock(gemsBlockKey);
amount = gemsBlock.price / 100;
}
if (!gift || gift.type === 'gems') {
if (gemsBlock || (gift && gift.type === 'gems')) {
const receiver = gift ? gift.member : user;
const receiverCanGetGems = await receiver.canGetGems();
if (!receiverCanGetGems) throw new NotAuthorized(shared.i18n.t('groupPolicyCannotGetGems', receiver.preferences.language));
@@ -146,10 +153,10 @@ api.checkout = async function checkout (options = {}) {
api.checkoutSuccess = async function checkoutSuccess (options = {}) {
const {
user, gift, gemsBlock: gemsBlockKey, paymentId, customerId,
user, gift, gemsBlock: gemsBlockKey, paymentId, customerId, sku,
} = options;
let method = 'buyGems';
let method = sku ? 'buySkuItem' : 'buyGems';
const data = {
user,
customerId,
@@ -164,6 +171,8 @@ api.checkoutSuccess = async function checkoutSuccess (options = {}) {
data.paymentMethod = 'PayPal (Gift)';
data.gift = gift;
} else if (sku) {
data.sku = sku;
} else {
data.gemsBlock = getGemsBlock(gemsBlockKey);
}

View File

@@ -0,0 +1,110 @@
import moment from 'moment';
import {
BadRequest,
} from '../errors';
import shared from '../../../common';
import { getAnalyticsServiceByEnvironment } from '../analyticsService';
import { getGemsBlock, buyGems } from './gems'; // eslint-disable-line import/no-cycle
const analytics = getAnalyticsServiceByEnvironment();
const RESPONSE_INVALID_ITEM = 'INVALID_ITEM_PURCHASED';
const EVENTS = {
birthday10: {
start: '2023-01-30T08:00-05:00',
end: '2023-02-08T23:59-05:00',
},
};
function canBuyGryphatrice (user) {
if (!moment().isBetween(EVENTS.birthday10.start, EVENTS.birthday10.end)) return false;
if (user.items.pets['Gryphatrice-Jubilant']) return false;
return true;
}
async function buyGryphatrice (data) {
// Double check it's available
if (!canBuyGryphatrice(data.user)) throw new BadRequest();
const key = 'Gryphatrice-Jubilant';
data.user.items.pets[key] = 5;
data.user.purchased.txnCount += 1;
analytics.trackPurchase({
uuid: data.user._id,
itemPurchased: 'Gryphatrice',
sku: `${data.paymentMethod.toLowerCase()}-checkout`,
purchaseType: 'checkout',
paymentMethod: data.paymentMethod,
quantity: 1,
gift: Boolean(data.gift),
purchaseValue: 10,
headers: data.headers,
firstPurchase: data.user.purchased.txnCount === 1,
});
if (data.user.markModified) data.user.markModified('items.pets');
await data.user.save();
}
export function canBuySkuItem (sku, user) {
switch (sku) {
case 'com.habitrpg.android.habitica.iap.pets.gryphatrice_jubilant':
case 'com.habitrpg.ios.Habitica.pets.Gryphatrice_Jubilant':
case 'Pet-Gryphatrice-Jubilant':
case 'price_0MPZ6iZCD0RifGXlLah2furv':
return canBuyGryphatrice(user);
default:
return true;
}
}
export async function buySkuItem (data) {
let gemsBlockKey;
switch (data.sku) { // eslint-disable-line default-case
case 'com.habitrpg.android.habitica.iap.4gems':
case 'com.habitrpg.ios.Habitica.4gems':
gemsBlockKey = '4gems';
break;
case 'com.habitrpg.android.habitica.iap.20gems':
case 'com.habitrpg.android.habitica.iap.21gems':
case 'com.habitrpg.ios.Habitica.20gems':
case 'com.habitrpg.ios.Habitica.21gems':
gemsBlockKey = '21gems';
break;
case 'com.habitrpg.android.habitica.iap.42gems':
case 'com.habitrpg.ios.Habitica.42gems':
gemsBlockKey = '42gems';
break;
case 'com.habitrpg.android.habitica.iap.84gems':
case 'com.habitrpg.ios.Habitica.84gems':
gemsBlockKey = '84gems';
break;
case 'com.habitrpg.android.habitica.iap.pets.gryphatrice_jubilant':
case 'com.habitrpg.ios.Habitica.pets.Gryphatrice_Jubilant':
case 'Pet-Gryphatrice-Jubilant':
case 'price_0MPZ6iZCD0RifGXlLah2furv':
buyGryphatrice(data);
return;
}
if (gemsBlockKey) {
const gemsBlock = getGemsBlock(gemsBlockKey);
if (data.gift) {
data.gift.type = 'gems';
if (!data.gift.gems) data.gift.gems = {};
data.gift.gems.amount = shared.content.gems[gemsBlock.key].gems;
}
await buyGems({
user: data.user,
gift: data.gift,
paymentMethod: data.paymentMethod,
gemsBlock,
headers: data.headers,
});
return;
}
throw new BadRequest(RESPONSE_INVALID_ITEM);
}

View File

@@ -24,6 +24,7 @@ export async function createCheckoutSession (options, stripeInc) {
sub,
groupId,
coupon,
sku,
} = options;
// @TODO: We need to mock this, but curently we don't have correct
@@ -37,6 +38,8 @@ export async function createCheckoutSession (options, stripeInc) {
validateGiftMessage(gift, user);
} else if (sub) {
type = 'subscription';
} else if (sku) {
type = 'sku';
}
const metadata = {
@@ -71,6 +74,12 @@ export async function createCheckoutSession (options, stripeInc) {
price: sub.key,
quantity,
}];
} else if (type === 'sku') {
metadata.sku = sku;
lineItems = [{
price: sku,
quantity: 1,
}];
} else {
const {
amount,

View File

@@ -22,6 +22,20 @@ function getGiftAmount (gift) {
return `${(gift.gems.amount / 4) * 100}`;
}
export async function applySku (session) {
const { metadata } = session;
const { userId, sku } = metadata;
const user = await User.findById(metadata.userId).exec();
if (!user) throw new NotFound(shared.i18n.t('userWithIDNotFound', { userId }));
if (sku === 'price_0MPZ6iZCD0RifGXlLah2furv') {
await payments.buySkuItem({
sku, user, paymentMethod: stripeConstants.PAYMENT_METHOD,
});
} else {
throw new NotFound('SKU not found.');
}
}
export async function getOneTimePaymentInfo (gemsBlockKey, gift, user) {
let receiver = user;

View File

@@ -14,7 +14,7 @@ import { // eslint-disable-line import/no-cycle
basicFields as basicGroupFields,
} from '../../../models/group';
import shared from '../../../../common';
import { applyGemPayment } from './oneTimePayments'; // eslint-disable-line import/no-cycle
import { applyGemPayment, applySku } from './oneTimePayments'; // eslint-disable-line import/no-cycle
import { applySubscription, handlePaymentMethodChange } from './subscriptions'; // eslint-disable-line import/no-cycle
const endpointSecret = nconf.get('STRIPE_WEBHOOKS_ENDPOINT_SECRET');
@@ -69,10 +69,12 @@ export async function handleWebhooks (options, stripeInc) {
if (metadata.type === 'edit-card-group' || metadata.type === 'edit-card-user') {
await handlePaymentMethodChange(session);
} else if (metadata.type !== 'subscription') {
await applyGemPayment(session);
} else {
} else if (metadata.type === 'subscription') {
await applySubscription(session);
} else if (metadata.type === 'sku') {
await applySku(session);
} else {
await applyGemPayment(session);
}
break;

View File

@@ -74,7 +74,39 @@ async function prepareSubscriptionValues (data) {
? data.gift.subscription.key
: data.sub.key];
const autoRenews = data.autoRenews !== undefined ? data.autoRenews : true;
const months = Number(block.months);
const updatedFrom = data.updatedFrom
? shared.content.subscriptionBlocks[data.updatedFrom.key]
: undefined;
let months;
if (updatedFrom && Number(updatedFrom.months) !== 1) {
if (Number(updatedFrom.months) > Number(block.months)) {
months = 0;
} else if (data.updatedFrom.logic === 'payDifference') {
months = Math.max(0, Number(block.months) - Number(updatedFrom.months));
} else if (data.updatedFrom.logic === 'payFull') {
months = Number(block.months);
} else if (data.updatedFrom.logic === 'refundAndRepay') {
const originalMonths = Number(updatedFrom.months);
let currentCycleBegin = moment(recipient.purchased.plan.dateCurrentTypeCreated);
const today = moment();
while (currentCycleBegin.isBefore()) {
currentCycleBegin = currentCycleBegin.add({ months: originalMonths });
}
// Subtract last iteration again, because we overshot
currentCycleBegin = currentCycleBegin.subtract({ months: originalMonths });
// For simplicity we round every month to 30 days since moment can not add half months
if (currentCycleBegin.add({ days: (originalMonths * 30) / 2.0 }).isBefore(today)) {
// user is in second half of their subscription cycle. Give them full benefits.
months = Number(block.months);
} else {
// user is in first half of their subscription cycle. Give them the difference.
months = Math.max(0, Number(block.months) - Number(updatedFrom.months));
}
}
}
if (months === undefined) {
months = Number(block.months);
}
const today = new Date();
let group;
let groupId;
@@ -82,6 +114,7 @@ async function prepareSubscriptionValues (data) {
let purchaseType = 'subscribe';
let emailType = 'subscription-begins';
let recipientIsSubscribed = recipient.isSubscribed();
const isNewSubscription = !recipientIsSubscribed;
// If we are buying a group subscription
if (data.groupId) {
@@ -122,6 +155,10 @@ async function prepareSubscriptionValues (data) {
const { plan } = recipient.purchased;
if (isNewSubscription) {
plan.perkMonthCount = 0;
}
if (data.gift || !autoRenews) {
if (plan.customerId && !plan.dateTerminated) { // User has active plan
plan.extraMonths += months;
@@ -136,6 +173,7 @@ async function prepareSubscriptionValues (data) {
plan.dateTerminated = moment().add({ months }).toDate();
plan.dateCreated = today;
}
plan.dateCurrentTypeCreated = today;
}
if (!plan.customerId) {
@@ -152,6 +190,7 @@ async function prepareSubscriptionValues (data) {
planId: block.key,
customerId: data.customerId,
dateUpdated: today,
dateCurrentTypeCreated: today,
paymentMethod: data.paymentMethod,
extraMonths: Number(plan.extraMonths) + _dateDiff(today, plan.dateTerminated),
dateTerminated: null,
@@ -194,6 +233,7 @@ async function prepareSubscriptionValues (data) {
itemPurchased,
purchaseType,
emailType,
isNewSubscription,
};
}
@@ -209,15 +249,22 @@ async function createSubscription (data) {
itemPurchased,
purchaseType,
emailType,
isNewSubscription,
} = 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(recipient._id, perks, 'subscription_perks'); // one Hourglass every 3 months
if (months > 1 && (!data.gift || !isNewSubscription)) {
if (!data.gift && !groupId) {
plan.consecutive.offset = block.months;
}
} else if (months === 1) {
plan.consecutive.offset = 0;
}
if (months > 1 || data.gift) {
await plan.incrementPerkCounterAndReward(recipient._id, months);
} else {
// Make sure the perkMonthCount field is initialized.
await plan.incrementPerkCounterAndReward(recipient._id, 0);
}
if (recipient !== group) {

View File

@@ -21,7 +21,7 @@ const apnProvider = APN_ENABLED ? new apn.Provider({
}) : undefined;
function removePushDevice (user, pushDevice) {
return User.update({ _id: user._id }, {
return User.updateOne({ _id: user._id }, {
$pull: { pushDevices: { regId: pushDevice.regId } },
}).exec().catch(err => {
logger.error(err, `Error removing pushDevice ${pushDevice.regId} for user ${user._id}`);

View File

@@ -24,7 +24,7 @@ export function readController (router, controller, overrides = []) {
// If an authentication middleware is used run getUserLanguage after it, otherwise before
// for cron instead use it only if an authentication middleware is present
const authMiddlewareIndex = _.findIndex(middlewares, middleware => {
let authMiddlewareIndex = _.findIndex(middlewares, middleware => {
if (middleware.name.indexOf('authWith') === 0) { // authWith{Headers|Session|Url|...}
return true;
}
@@ -36,6 +36,7 @@ export function readController (router, controller, overrides = []) {
// disable caching for all routes with mandatory or optional authentication
if (authMiddlewareIndex !== -1) {
middlewares.unshift(disableCache);
authMiddlewareIndex += 1;
}
if (action.noLanguage !== true) { // unless getting the language is explictly disabled

View File

@@ -12,7 +12,7 @@ import {
} from '../models/group';
import apiError from './apiError';
const partyMembersFields = 'profile.name stats achievements items.special notifications flags pinnedItems';
const partyMembersFields = 'profile.name stats achievements items.special pinnedItems notifications flags';
// Excluding notifications and flags from the list of public fields to return.
const partyMembersPublicFields = 'profile.name stats achievements items.special';
@@ -75,12 +75,13 @@ async function castSelfSpell (req, user, spell, quantity = 1) {
await user.save();
}
async function castPartySpell (req, party, partyMembers, user, spell, quantity = 1) {
async function getPartyMembers (user, party) {
let partyMembers;
if (!party) {
// Act as solo party
partyMembers = [user]; // eslint-disable-line no-param-reassign
partyMembers = [user];
} else {
partyMembers = await User // eslint-disable-line no-param-reassign
partyMembers = await User
.find({
'party._id': party._id,
_id: { $ne: user._id }, // add separately
@@ -90,22 +91,40 @@ async function castPartySpell (req, party, partyMembers, user, spell, quantity =
partyMembers.unshift(user);
}
for (let i = 0; i < quantity; i += 1) {
spell.cast(user, partyMembers, req);
}
await Promise.all(partyMembers.map(m => m.save()));
return partyMembers;
}
async function castUserSpell (res, req, party, partyMembers, targetId, user, spell, quantity = 1) {
async function castPartySpell (req, party, user, spell, quantity = 1) {
let partyMembers;
if (spell.bulk) {
const data = { };
if (party) {
data.query = { 'party._id': party._id };
} else {
data.query = { _id: user._id };
}
spell.cast(user, data);
await User.updateMany(data.query, data.update);
await user.save();
partyMembers = await getPartyMembers(user, party);
} else {
partyMembers = await getPartyMembers(user, party);
for (let i = 0; i < quantity; i += 1) {
spell.cast(user, partyMembers, req);
}
await Promise.all(partyMembers.map(m => m.save()));
}
return partyMembers;
}
async function castUserSpell (res, req, party, targetId, user, spell, quantity = 1) {
let partyMembers;
if (!party && (!targetId || user._id === targetId)) {
partyMembers = user; // eslint-disable-line no-param-reassign
partyMembers = user;
} else {
if (!targetId) throw new BadRequest(res.t('targetIdUUID'));
if (!party) throw new NotFound(res.t('partyNotFound'));
partyMembers = await User // eslint-disable-line no-param-reassign
partyMembers = await User
.findOne({ _id: targetId, 'party._id': party._id })
.select(partyMembersFields)
.exec();
@@ -196,10 +215,10 @@ async function castSpell (req, res, { isV3 = false }) {
let partyMembers;
if (targetType === 'party') {
partyMembers = await castPartySpell(req, party, partyMembers, user, spell, quantity);
partyMembers = await castPartySpell(req, party, user, spell, quantity);
} else {
partyMembers = await castUserSpell(
res, req, party, partyMembers,
res, req, party,
targetId, user, spell, quantity,
);
}

View File

@@ -114,7 +114,7 @@ async function createTasks (req, res, options = {}) {
};
}
await owner.update(taskOrderUpdateQuery).exec();
await owner.updateOne(taskOrderUpdateQuery).exec();
// tasks with aliases need to be validated asynchronously
await validateTaskAlias(toSave, res);

View File

@@ -51,6 +51,7 @@ const updatablePaths = [
'party.orderAscending',
'party.quest.completed',
'party.quest.RSVPNeeded',
'party.seeking',
'preferences',
'profile',
@@ -97,7 +98,9 @@ function checkPreferencePurchase (user, path, item) {
const itemPath = `${path}.${item}`;
const appearance = _.get(common.content.appearances, itemPath);
if (!appearance) return false;
if (appearance.price === 0) return true;
if (appearance.price === 0 && path !== 'background') {
return true;
}
return _.get(user.purchased, itemPath);
}
@@ -120,6 +123,17 @@ export async function update (req, res, { isV3 = false }) {
let promisesForTagsRemoval = [];
if (req.body['party.seeking'] !== undefined && req.body['party.seeking'] !== null) {
user.invitations.party = {};
user.invitations.parties = [];
res.analytics.track('Starts Looking for Party', {
uuid: user._id,
hitType: 'event',
category: 'behavior',
headers: req.headers,
});
}
if (req.body['profile.name'] !== undefined) {
const newName = req.body['profile.name'];
if (newName === null) throw new BadRequest(res.t('invalidReqParams'));
@@ -168,7 +182,15 @@ export async function update (req, res, { isV3 = false }) {
throw new NotAuthorized(res.t('mustPurchaseToSet', { val, key }));
}
if (key === 'tags') {
if (key === 'party.seeking' && val === null) {
user.party.seeking = undefined;
res.analytics.track('Leaves Looking for Party', {
uuid: user._id,
hitType: 'event',
category: 'behavior',
headers: req.headers,
});
} else if (key === 'tags') {
if (!Array.isArray(val)) throw new BadRequest('Tag list must be an array.');
const removedTagsIds = [];
@@ -198,13 +220,13 @@ export async function update (req, res, { isV3 = false }) {
// Remove from all the tasks
// NOTE each tag to remove requires a query
promisesForTagsRemoval = removedTagsIds.map(tagId => Tasks.Task.update({
promisesForTagsRemoval = removedTagsIds.map(tagId => Tasks.Task.updateMany({
userId: user._id,
}, {
$pull: {
tags: tagId,
},
}, { multi: true }).exec());
}).exec());
} else if (key === 'flags.newStuff' && val === false) {
// flags.newStuff was removed from the user schema and is only returned for compatibility
// reasons but we're keeping the ability to set it in API v3
@@ -254,6 +276,7 @@ export async function reset (req, res, { isV3 = false }) {
uuid: user._id,
hitType: 'event',
category: 'behavior',
headers: req.headers,
});
res.respond(200, ...resetRes);

View File

@@ -61,7 +61,7 @@ function sendWebhook (webhook, body, user) {
};
}
return User.update({
return User.updateOne({
_id: user._id,
'webhooks.id': webhook.id,
}, update).exec();