Allow gems and subs to be gifted through in-app-purchases (#10892)

* Allow gems to be gifted through IAPs

* implement non recurring IAP subscriptions

* fix localization issue in error

* fix non renewing subscription handling

* Fix lint error

* fix tests

* move findbyId mock to helper file

* undo package-lock changes

* Fix lint error
This commit is contained in:
Phillip Thelen
2018-12-23 19:20:14 +01:00
committed by Matteo Pagliazzi
parent 88f28188a1
commit cfbfec34aa
12 changed files with 557 additions and 40 deletions
@@ -22,11 +22,14 @@ api.iapAndroidVerify = {
url: '/iap/android/verify',
middlewares: [authWithHeaders()],
async handler (req, res) {
let user = res.locals.user;
let iapBody = req.body;
let googleRes = await googlePayments.verifyGemPurchase(user, iapBody.transaction.receipt, iapBody.transaction.signature, req.headers);
if (!req.body.transaction) throw new BadRequest(res.t('missingReceipt'));
let googleRes = await googlePayments.verifyGemPurchase({
user: res.locals.user,
receipt: req.body.transaction.receipt,
signature: req.body.transaction.signature,
gift: req.body.gift,
headers: req.headers,
});
res.respond(200, googleRes);
},
};
@@ -43,10 +46,34 @@ api.iapSubscriptionAndroid = {
middlewares: [authWithHeaders()],
async handler (req, res) {
if (!req.body.sku) throw new BadRequest(res.t('missingSubscriptionCode'));
let user = res.locals.user;
let iapBody = req.body;
await googlePayments.subscribe(req.body.sku, res.locals.user, req.body.transaction.receipt, req.body.transaction.signature, req.headers);
await googlePayments.subscribe(req.body.sku, user, iapBody.transaction.receipt, iapBody.transaction.signature, req.headers);
res.respond(200);
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /iap/android/norenew-subscribe Android non-renewing subscription IAP
* @apiName iapSubscriptionAndroidNoRenew
* @apiGroup Payments
**/
api.iapSubscriptionAndroidNoRenew = {
method: 'POST',
url: '/iap/android/norenew-subscribe',
middlewares: [authWithHeaders()],
async handler (req, res) {
if (!req.body.sku) throw new BadRequest(res.t('missingSubscriptionCode'));
if (!req.body.transaction) throw new BadRequest(res.t('missingReceipt'));
await googlePayments.noRenewSubscribe({
sku: req.body.sku,
user: res.locals.user,
receipt: req.body.transaction.receipt,
signature: req.body.transaction.signature,
gift: req.body.gift,
headers: req.headers,
});
res.respond(200);
},
@@ -87,9 +114,12 @@ api.iapiOSVerify = {
middlewares: [authWithHeaders()],
async handler (req, res) {
if (!req.body.transaction) throw new BadRequest(res.t('missingReceipt'));
let appleRes = await applePayments.verifyGemPurchase(res.locals.user, req.body.transaction.receipt, req.headers);
let appleRes = await applePayments.verifyGemPurchase({
user: res.locals.user,
receipt: req.body.transaction.receipt,
gift: req.body.gift,
headers: req.headers,
});
res.respond(200, appleRes);
},
};
@@ -137,4 +167,29 @@ api.iapCancelSubscriptioniOS = {
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /iap/ios/norenew-subscribe iOS Verify IAP
* @apiName IapiOSVerify
* @apiGroup Payments
**/
api.iapSubscriptioniOSNoRenew = {
method: 'POST',
url: '/iap/ios/norenew-subscribe',
middlewares: [authWithHeaders()],
async handler (req, res) {
if (!req.body.sku) throw new BadRequest(res.t('missingSubscriptionCode'));
if (!req.body.transaction) throw new BadRequest(res.t('missingReceipt'));
await applePayments.noRenewSubscribe({
sku: req.body.sku,
user: res.locals.user,
receipt: req.body.transaction.receipt,
gift: req.body.gift,
headers: req.headers});
res.respond(200);
},
};
module.exports = api;
+87 -4
View File
@@ -13,6 +13,7 @@ let api = {};
api.constants = {
PAYMENT_METHOD_APPLE: 'Apple',
PAYMENT_METHOD_GIFT: 'Apple (Gift)',
RESPONSE_INVALID_RECEIPT: 'INVALID_RECEIPT',
RESPONSE_ALREADY_USED: 'RECEIPT_ALREADY_USED',
RESPONSE_INVALID_ITEM: 'INVALID_ITEM_PURCHASED',
@@ -20,9 +21,15 @@ api.constants = {
RESPONSE_NO_ITEM_PURCHASED: 'NO_ITEM_PURCHASED',
};
api.verifyGemPurchase = async function verifyGemPurchase (user, receipt, headers) {
const userCanGetGems = await user.canGetGems();
if (!userCanGetGems) throw new NotAuthorized(shared.i18n.t('groupPolicyCannotGetGems', user.preferences.language));
api.verifyGemPurchase = async function verifyGemPurchase (options) {
let {gift, user, receipt, headers} = options;
if (gift) {
gift.member = await User.findById(gift.uuid).exec();
}
const receiver = gift ? gift.member : user;
const receiverCanGetGems = await receiver.canGetGems();
if (!receiverCanGetGems) throw new NotAuthorized(shared.i18n.t('groupPolicyCannotGetGems', user.preferences.language));
await iap.setup();
let appleRes = await iap.validate(iap.APPLE, receipt);
@@ -45,6 +52,7 @@ api.verifyGemPurchase = async function verifyGemPurchase (user, receipt, headers
await IapPurchaseReceipt.create({ // eslint-disable-line no-await-in-loop
_id: token,
consumed: true,
// This should always be the buying user even for a gift.
userId: user._id,
});
@@ -67,7 +75,7 @@ api.verifyGemPurchase = async function verifyGemPurchase (user, receipt, headers
if (amount) {
correctReceipt = true;
await payments.buyGems({ // eslint-disable-line no-await-in-loop
user,
user: receiver,
paymentMethod: api.constants.PAYMENT_METHOD_APPLE,
amount,
headers,
@@ -148,6 +156,81 @@ api.subscribe = async function subscribe (sku, user, receipt, headers, nextPayme
}
};
api.noRenewSubscribe = async function noRenewSubscribe (options) {
let {sku, gift, user, receipt, headers} = options;
if (!sku) throw new BadRequest(shared.i18n.t('missingSubscriptionCode'));
let subCode;
switch (sku) {
case 'com.habitrpg.ios.habitica.norenew_subscription.1month':
subCode = 'basic_earned';
break;
case 'com.habitrpg.ios.habitica.norenew_subscription.3month':
subCode = 'basic_3mo';
break;
case 'com.habitrpg.ios.habitica.norenew_subscription.6month':
subCode = 'basic_6mo';
break;
case 'com.habitrpg.ios.habitica.norenew_subscription.12month':
subCode = 'basic_12mo';
break;
}
const sub = subCode ? shared.content.subscriptionBlocks[subCode] : false;
if (!sub) throw new NotAuthorized(this.constants.RESPONSE_INVALID_ITEM);
await iap.setup();
let appleRes = await iap.validate(iap.APPLE, receipt);
const isValidated = iap.isValidated(appleRes);
if (!isValidated) throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT);
let purchaseDataList = iap.getPurchaseData(appleRes);
if (purchaseDataList.length === 0) throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED);
let transactionId;
for (let index in purchaseDataList) {
let purchaseData = purchaseDataList[index];
let dateTerminated = new Date(Number(purchaseData.expirationDate));
if (purchaseData.productId === sku && dateTerminated > new Date()) {
transactionId = purchaseData.transactionId;
break;
}
}
if (transactionId) {
let existingReceipt = await IapPurchaseReceipt.findOne({ // eslint-disable-line no-await-in-loop
_id: transactionId,
}).exec();
if (existingReceipt) throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
await IapPurchaseReceipt.create({ // eslint-disable-line no-await-in-loop
_id: transactionId,
consumed: true,
// This should always be the buying user even for a gift.
userId: user._id,
});
let data = {
user,
paymentMethod: this.constants.PAYMENT_METHOD_APPLE,
headers,
sub,
autoRenews: false,
};
if (gift) {
gift.member = await User.findById(gift.uuid).exec();
gift.subscription = sub;
data.gift = gift;
data.paymentMethod = this.constants.PAYMENT_METHOD_GIFT;
}
await payments.createSubscription(data);
} else {
throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT);
}
};
api.cancelSubscribe = async function cancelSubscribe (user, headers) {
let plan = user.purchased.plan;
+80 -4
View File
@@ -13,15 +13,22 @@ let api = {};
api.constants = {
PAYMENT_METHOD_GOOGLE: 'Google',
PAYMENT_METHOD_GIFT: 'Google (Gift)',
RESPONSE_INVALID_RECEIPT: 'INVALID_RECEIPT',
RESPONSE_ALREADY_USED: 'RECEIPT_ALREADY_USED',
RESPONSE_INVALID_ITEM: 'INVALID_ITEM_PURCHASED',
RESPONSE_STILL_VALID: 'SUBSCRIPTION_STILL_VALID',
};
api.verifyGemPurchase = async function verifyGemPurchase (user, receipt, signature, headers) {
const userCanGetGems = await user.canGetGems();
if (!userCanGetGems) throw new NotAuthorized(shared.i18n.t('groupPolicyCannotGetGems', user.preferences.language));
api.verifyGemPurchase = async function verifyGemPurchase (options) {
let {gift, user, receipt, signature, headers} = options;
if (gift) {
gift.member = await User.findById(gift.uuid).exec();
}
const receiver = gift ? gift.member : user;
const receiverCanGetGems = await receiver.canGetGems();
if (!receiverCanGetGems) throw new NotAuthorized(shared.i18n.t('groupPolicyCannotGetGems', user.preferences.language));
await iap.setup();
@@ -46,6 +53,7 @@ api.verifyGemPurchase = async function verifyGemPurchase (user, receipt, signatu
await IapPurchaseReceipt.create({
_id: token,
consumed: true,
// This should always be the buying user even for a gift.
userId: user._id,
});
@@ -70,7 +78,7 @@ api.verifyGemPurchase = async function verifyGemPurchase (user, receipt, signatu
if (!amount) throw new NotAuthorized(this.constants.RESPONSE_INVALID_ITEM);
await payments.buyGems({
user,
user: receiver,
paymentMethod: this.constants.PAYMENT_METHOD_GOOGLE,
amount,
headers,
@@ -132,6 +140,74 @@ api.subscribe = async function subscribe (sku, user, receipt, signature, headers
});
};
api.noRenewSubscribe = async function noRenewSubscribe (options) {
let {sku, gift, user, receipt, signature, headers} = options;
if (!sku) throw new BadRequest(shared.i18n.t('missingSubscriptionCode'));
let subCode;
switch (sku) {
case 'com.habitrpg.android.habitica.norenew_subscription.1month':
subCode = 'basic_earned';
break;
case 'com.habitrpg.android.habitica.norenew_subscription.3month':
subCode = 'basic_3mo';
break;
case 'com.habitrpg.android.habitica.norenew_subscription.6month':
subCode = 'basic_6mo';
break;
case 'com.habitrpg.android.habitica.norenew_subscription.12month':
subCode = 'basic_12mo';
break;
}
let sub = subCode ? shared.content.subscriptionBlocks[subCode] : false;
if (!sub) throw new NotAuthorized(this.constants.RESPONSE_INVALID_ITEM);
await iap.setup();
let testObj = {
data: receipt,
signature,
};
let receiptObj = typeof receipt === 'string' ? JSON.parse(receipt) : receipt; // passed as a string
let token = receiptObj.token || receiptObj.purchaseToken;
let existingReceipt = await IapPurchaseReceipt.findOne({ // eslint-disable-line no-await-in-loop
_id: token,
}).exec();
if (existingReceipt) throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
await IapPurchaseReceipt.create({ // eslint-disable-line no-await-in-loop
_id: token,
consumed: true,
// This should always be the buying user even for a gift.
userId: user._id,
});
let googleRes = await iap.validate(iap.GOOGLE, testObj);
let isValidated = iap.isValidated(googleRes);
if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
let data = {
user,
paymentMethod: this.constants.PAYMENT_METHOD_GOOGLE,
headers,
sub,
autoRenews: false,
};
if (gift) {
gift.member = await User.findById(gift.uuid).exec();
gift.subscription = sub;
data.gift = gift;
data.paymentMethod = this.constants.PAYMENT_METHOD_GIFT;
}
await payments.createSubscription(data);
return googleRes;
};
api.cancelSubscribe = async function cancelSubscribe (user, headers) {
let plan = user.purchased.plan;
@@ -51,6 +51,7 @@ function _dateDiff (earlyDate, lateDate) {
async function createSubscription (data) {
let recipient = data.gift ? data.gift.member : data.user;
let block = shared.content.subscriptionBlocks[data.gift ? data.gift.subscription.key : data.sub.key];
let autoRenews = data.autoRenews !== undefined ? data.autoRenews : true;
let months = Number(block.months);
let today = new Date();
let plan;
@@ -85,7 +86,7 @@ async function createSubscription (data) {
plan = recipient.purchased.plan;
if (data.gift) {
if (data.gift || !autoRenews) {
if (plan.customerId && !plan.dateTerminated) { // User has active plan
plan.extraMonths += months;
} else {