Implement iOS subscriptions (#8493)

* implement iOS subscriptions

* add additional tests to request body

* Improve subscription cancelling

* change string to constant
This commit is contained in:
Phillip Thelen
2017-02-21 19:22:13 +01:00
committed by Matteo Pagliazzi
parent 8550ca4d29
commit 374d528647
12 changed files with 621 additions and 90 deletions

View File

@@ -86,5 +86,6 @@
"FLAGGING_URL": "https://hooks.slack.com/services/id/id/id", "FLAGGING_URL": "https://hooks.slack.com/services/id/id/id",
"FLAGGING_FOOTER_LINK": "https://habitrpg.github.io/flag-o-rama/", "FLAGGING_FOOTER_LINK": "https://habitrpg.github.io/flag-o-rama/",
"SUBSCRIPTIONS_URL": "https://hooks.slack.com/services/id/id/id" "SUBSCRIPTIONS_URL": "https://hooks.slack.com/services/id/id/id"
} },
"ITUNES_SHARED_SECRET": "aaaabbbbccccddddeeeeffff00001111"
} }

View File

@@ -0,0 +1,41 @@
import {generateUser} from '../../../../../helpers/api-integration/v3';
import applePayments from '../../../../../../website/server/libs/applePayments';
describe('payments : apple #cancelSubscribe', () => {
let endpoint = '/iap/ios/subscribe/cancel?noRedirect=true';
let user;
beforeEach(async () => {
user = await generateUser();
});
describe('success', () => {
let cancelStub;
beforeEach(async () => {
cancelStub = sinon.stub(applePayments, 'cancelSubscribe').returnsPromise().resolves({});
});
afterEach(() => {
applePayments.cancelSubscribe.restore();
});
it('cancels the subscription', async () => {
user = await generateUser({
'profile.name': 'sender',
'purchased.plan.paymentMethod': 'Apple',
'purchased.plan.customerId': 'customer-id',
'purchased.plan.planId': 'basic_3mo',
'purchased.plan.lastBillingDate': new Date(),
balance: 2,
});
await user.get(endpoint);
expect(cancelStub).to.be.calledOnce;
expect(cancelStub.args[0][0]._id).to.eql(user._id);
expect(cancelStub.args[0][1]['x-api-key']).to.eql(user.apiToken);
expect(cancelStub.args[0][1]['x-api-user']).to.eql(user._id);
});
});
});

View File

@@ -0,0 +1,40 @@
import {generateUser} from '../../../../../helpers/api-integration/v3';
import applePayments from '../../../../../../website/server/libs/applePayments';
describe('payments : apple #verify', () => {
let endpoint = '/iap/ios/verify';
let user;
beforeEach(async () => {
user = await generateUser();
});
describe('success', () => {
let verifyStub;
beforeEach(async () => {
verifyStub = sinon.stub(applePayments, 'verifyGemPurchase').returnsPromise().resolves({});
});
afterEach(() => {
applePayments.verifyGemPurchase.restore();
});
it('makes a purchase', async () => {
user = await generateUser({
balance: 2,
});
await user.post(endpoint, {
transaction: {
receipt: 'receipt',
}});
expect(verifyStub).to.be.calledOnce;
expect(verifyStub.args[0][0]._id).to.eql(user._id);
expect(verifyStub.args[0][1]).to.eql('receipt');
expect(verifyStub.args[0][2]['x-api-key']).to.eql(user.apiToken);
expect(verifyStub.args[0][2]['x-api-user']).to.eql(user._id);
});
});
});

View File

@@ -0,0 +1,55 @@
import {generateUser, translate as t} from '../../../../../helpers/api-integration/v3';
import applePayments from '../../../../../../website/server/libs/applePayments';
describe('payments : apple #subscribe', () => {
let endpoint = '/iap/ios/subscribe';
let user;
beforeEach(async () => {
user = await generateUser();
});
it('verifies sub key', async () => {
await expect(user.post(endpoint)).to.eventually.be.rejected.and.eql({
code: 400,
error: 'BadRequest',
message: t('missingSubscriptionCode'),
});
});
describe('success', () => {
let subscribeStub;
beforeEach(async () => {
subscribeStub = sinon.stub(applePayments, 'subscribe').returnsPromise().resolves({});
});
afterEach(() => {
applePayments.subscribe.restore();
});
it('makes a purchase', async () => {
user = await generateUser({
'profile.name': 'sender',
'purchased.plan.customerId': 'customer-id',
'purchased.plan.planId': 'basic_3mo',
'purchased.plan.lastBillingDate': new Date(),
balance: 2,
});
let sku = 'com.habitrpg.ios.habitica.subscription.3month';
await user.post(endpoint, {
sku,
receipt: 'receipt',
});
expect(subscribeStub).to.be.calledOnce;
expect(subscribeStub.args[0][0]).to.eql(sku);
expect(subscribeStub.args[0][1]._id).to.eql(user._id);
expect(subscribeStub.args[0][2]).to.eql('receipt');
expect(subscribeStub.args[0][3]['x-api-key']).to.eql(user.apiToken);
expect(subscribeStub.args[0][3]['x-api-user']).to.eql(user._id);
});
});
});

View File

@@ -23,6 +23,7 @@ describe('payments : google #cancelSubscribe', () => {
it('makes a purchase', async () => { it('makes a purchase', async () => {
user = await generateUser({ user = await generateUser({
'profile.name': 'sender', 'profile.name': 'sender',
'purchased.plan.paymentMethod': 'Google',
'purchased.plan.customerId': 'customer-id', 'purchased.plan.customerId': 'customer-id',
'purchased.plan.planId': 'basic_3mo', 'purchased.plan.planId': 'basic_3mo',
'purchased.plan.lastBillingDate': new Date(), 'purchased.plan.lastBillingDate': new Date(),

View File

@@ -0,0 +1,258 @@
/* eslint-disable camelcase */
import iapModule from '../../../../../website/server/libs/inAppPurchases';
import payments from '../../../../../website/server/libs/payments';
import applePayments from '../../../../../website/server/libs/applePayments';
import iap from '../../../../../website/server/libs/inAppPurchases';
import {model as User} from '../../../../../website/server/models/user';
import common from '../../../../../website/common';
import moment from 'moment';
const i18n = common.i18n;
describe('Apple Payments', () => {
let subKey = 'basic_3mo';
describe('verifyGemPurchase', () => {
let sku, user, token, receipt, headers;
let iapSetupStub, iapValidateStub, iapIsValidatedStub, paymentBuyGemsStub, iapGetPurchaseDataStub;
beforeEach(() => {
token = 'testToken';
sku = 'com.habitrpg.ios.habitica.iap.21gems';
user = new User();
receipt = `{"token": "${token}", "productId": "${sku}"}`;
headers = {};
iapSetupStub = sinon.stub(iapModule, 'setup')
.returnsPromise().resolves();
iapValidateStub = sinon.stub(iapModule, 'validate')
.returnsPromise().resolves({});
iapIsValidatedStub = sinon.stub(iapModule, 'isValidated')
.returns(true);
iapGetPurchaseDataStub = sinon.stub(iapModule, 'getPurchaseData')
.returns([{productId: 'com.habitrpg.ios.Habitica.21gems',
transactionId: token,
}]);
paymentBuyGemsStub = sinon.stub(payments, 'buyGems').returnsPromise().resolves({});
});
afterEach(() => {
iapModule.setup.restore();
iapModule.validate.restore();
iapModule.isValidated.restore();
iapModule.getPurchaseData.restore();
payments.buyGems.restore();
});
it('should throw an error if receipt is invalid', async () => {
iapModule.isValidated.restore();
iapIsValidatedStub = sinon.stub(iapModule, 'isValidated')
.returns(false);
await expect(applePayments.verifyGemPurchase(user, receipt, headers))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: applePayments.constants.RESPONSE_INVALID_RECEIPT,
});
});
it('purchases gems', async () => {
await applePayments.verifyGemPurchase(user, receipt, headers);
expect(iapSetupStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledWith(iap.APPLE, receipt);
expect(iapIsValidatedStub).to.be.calledOnce;
expect(iapIsValidatedStub).to.be.calledWith({});
expect(iapGetPurchaseDataStub).to.be.calledOnce;
expect(paymentBuyGemsStub).to.be.calledOnce;
expect(paymentBuyGemsStub).to.be.calledWith({
user,
paymentMethod: applePayments.constants.PAYMENT_METHOD_APPLE,
amount: 5.25,
headers,
});
});
});
describe('subscribe', () => {
let sub, sku, user, token, receipt, headers, nextPaymentProcessing;
let iapSetupStub, iapValidateStub, iapIsValidatedStub, paymentsCreateSubscritionStub, iapGetPurchaseDataStub;
beforeEach(() => {
sub = common.content.subscriptionBlocks[subKey];
sku = 'com.habitrpg.ios.habitica.subscription.3month';
token = 'test-token';
headers = {};
receipt = `{"token": "${token}"}`;
nextPaymentProcessing = moment.utc().add({days: 2});
iapSetupStub = sinon.stub(iapModule, 'setup')
.returnsPromise().resolves();
iapValidateStub = sinon.stub(iapModule, 'validate')
.returnsPromise().resolves({});
iapIsValidatedStub = sinon.stub(iapModule, 'isValidated')
.returns(true);
iapGetPurchaseDataStub = sinon.stub(iapModule, 'getPurchaseData')
.returns([{
expirationDate: moment.utc().subtract({day: 1}).toDate(),
productId: sku,
transactionId: token,
}, {
expirationDate: moment.utc().add({day: 1}).toDate(),
productId: 'wrongsku',
transactionId: token,
}, {
expirationDate: moment.utc().add({day: 1}).toDate(),
productId: sku,
transactionId: token,
}]);
paymentsCreateSubscritionStub = sinon.stub(payments, 'createSubscription').returnsPromise().resolves({});
});
afterEach(() => {
iapModule.setup.restore();
iapModule.validate.restore();
iapModule.isValidated.restore();
iapModule.getPurchaseData.restore();
payments.createSubscription.restore();
});
it('should throw an error if receipt is invalid', async () => {
iapModule.isValidated.restore();
iapIsValidatedStub = sinon.stub(iapModule, 'isValidated')
.returns(false);
await expect(applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: applePayments.constants.RESPONSE_INVALID_RECEIPT,
});
});
it('creates a user subscription', async () => {
await applePayments.subscribe(sku, user, receipt, headers, nextPaymentProcessing);
expect(iapSetupStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledWith(iap.APPLE, receipt);
expect(iapIsValidatedStub).to.be.calledOnce;
expect(iapIsValidatedStub).to.be.calledWith({});
expect(iapGetPurchaseDataStub).to.be.calledOnce;
expect(paymentsCreateSubscritionStub).to.be.calledOnce;
expect(paymentsCreateSubscritionStub).to.be.calledWith({
user,
customerId: token,
paymentMethod: applePayments.constants.PAYMENT_METHOD_APPLE,
sub,
headers,
additionalData: receipt,
nextPaymentProcessing,
});
});
});
describe('cancelSubscribe ', () => {
let user, token, receipt, headers, customerId, expirationDate;
let iapSetupStub, iapValidateStub, iapIsValidatedStub, iapGetPurchaseDataStub, paymentCancelSubscriptionSpy;
beforeEach(async () => {
token = 'test-token';
headers = {};
receipt = `{"token": "${token}"}`;
customerId = 'test-customerId';
expirationDate = moment.utc();
iapSetupStub = sinon.stub(iapModule, 'setup')
.returnsPromise().resolves();
iapValidateStub = sinon.stub(iapModule, 'validate')
.returnsPromise().resolves({
expirationDate,
});
iapGetPurchaseDataStub = sinon.stub(iapModule, 'getPurchaseData')
.returns([{expirationDate: expirationDate.toDate()}]);
iapIsValidatedStub = sinon.stub(iapModule, 'isValidated')
.returns(true);
user = new User();
user.profile.name = 'sender';
user.purchased.plan.paymentMethod = applePayments.constants.PAYMENT_METHOD_APPLE;
user.purchased.plan.customerId = customerId;
user.purchased.plan.planId = subKey;
user.purchased.plan.additionalData = receipt;
paymentCancelSubscriptionSpy = sinon.stub(payments, 'cancelSubscription').returnsPromise().resolves({});
});
afterEach(function () {
iapModule.setup.restore();
iapModule.validate.restore();
iapModule.isValidated.restore();
iapModule.getPurchaseData.restore();
payments.cancelSubscription.restore();
});
it('should throw an error if we are missing a subscription', async () => {
user.purchased.plan.paymentMethod = undefined;
await expect(applePayments.cancelSubscribe(user, headers))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: i18n.t('missingSubscription'),
});
});
it('should throw an error if subscription is still valid', async () => {
iapModule.getPurchaseData.restore();
iapGetPurchaseDataStub = sinon.stub(iapModule, 'getPurchaseData')
.returns([{expirationDate: expirationDate.add({day: 1}).toDate()}]);
await expect(applePayments.cancelSubscribe(user, headers))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: applePayments.constants.RESPONSE_STILL_VALID,
});
});
it('should throw an error if receipt is invalid', async () => {
iapModule.isValidated.restore();
iapIsValidatedStub = sinon.stub(iapModule, 'isValidated')
.returns(false);
await expect(applePayments.cancelSubscribe(user, headers))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: applePayments.constants.RESPONSE_INVALID_RECEIPT,
});
});
it('should cancel a user subscription', async () => {
await applePayments.cancelSubscribe(user, headers);
expect(iapSetupStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledWith(iap.APPLE, receipt);
expect(iapIsValidatedStub).to.be.calledOnce;
expect(iapIsValidatedStub).to.be.calledWith({
expirationDate,
});
expect(iapGetPurchaseDataStub).to.be.calledOnce;
expect(paymentCancelSubscriptionSpy).to.be.calledOnce;
expect(paymentCancelSubscriptionSpy).to.be.calledWith({
user,
paymentMethod: applePayments.constants.PAYMENT_METHOD_APPLE,
nextBill: expirationDate.toDate(),
headers,
});
});
});
});

View File

@@ -190,6 +190,7 @@ describe('Google Payments', () => {
user = new User(); user = new User();
user.profile.name = 'sender'; user.profile.name = 'sender';
user.purchased.plan.customerId = customerId; user.purchased.plan.customerId = customerId;
user.purchased.plan.paymentMethod = googlePayments.constants.PAYMENT_METHOD_GOOGLE;
user.purchased.plan.planId = subKey; user.purchased.plan.planId = subKey;
user.purchased.plan.additionalData = {data: receipt, signature}; user.purchased.plan.additionalData = {data: receipt, signature};
@@ -205,7 +206,7 @@ describe('Google Payments', () => {
}); });
it('should throw an error if we are missing a subscription', async () => { it('should throw an error if we are missing a subscription', async () => {
user.purchased.plan.additionalData = undefined; user.purchased.plan.paymentMethod = undefined;
await expect(googlePayments.cancelSubscribe(user, headers)) await expect(googlePayments.cancelSubscribe(user, headers))
.to.eventually.be.rejected.and.to.eql({ .to.eventually.be.rejected.and.to.eql({

View File

@@ -144,6 +144,7 @@
"missingUnsubscriptionCode": "Missing unsubscription code.", "missingUnsubscriptionCode": "Missing unsubscription code.",
"missingSubscription": "User does not have a plan subscription", "missingSubscription": "User does not have a plan subscription",
"missingSubscriptionCode": "Missing subscription code. Possible values: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo.", "missingSubscriptionCode": "Missing subscription code. Possible values: basic_earned, basic_3mo, basic_6mo, google_6mo, basic_12mo.",
"missingReceipt": "Missing Receipt.",
"cannotDeleteActiveAccount": "You have an active subscription, cancel your plan before deleting your account.", "cannotDeleteActiveAccount": "You have an active subscription, cancel your plan before deleting your account.",
"paymentNotSuccessful": "The payment was not successful", "paymentNotSuccessful": "The payment was not successful",
"planNotActive": "The plan hasn't activated yet (due to a PayPal bug). It will begin <%= nextBillingDate %>, after which you can cancel to retain your full benefits", "planNotActive": "The plan hasn't activated yet (due to a PayPal bug). It will begin <%= nextBillingDate %>, after which you can cancel to retain your full benefits",

View File

@@ -2,14 +2,11 @@ import {
authWithHeaders, authWithHeaders,
authWithUrl, authWithUrl,
} from '../../../middlewares/auth'; } from '../../../middlewares/auth';
import iap from '../../../libs/inAppPurchases';
import payments from '../../../libs/payments';
import { import {
BadRequest, BadRequest,
} from '../../../libs/errors'; } from '../../../libs/errors';
import { model as IapPurchaseReceipt } from '../../../models/iapPurchaseReceipt';
import logger from '../../../libs/logger';
import googlePayments from '../../../libs/googlePayments'; import googlePayments from '../../../libs/googlePayments';
import applePayments from '../../../libs/applePayments';
let api = {}; let api = {};
@@ -79,8 +76,6 @@ api.iapCancelSubscriptionAndroid = {
}, },
}; };
// IMPORTANT: NOT PORTED TO v3 standards (not using res.respond)
/** /**
* @apiIgnore Payments are considered part of the private API * @apiIgnore Payments are considered part of the private API
* @api {post} /iap/ios/verify iOS Verify IAP * @api {post} /iap/ios/verify iOS Verify IAP
@@ -91,89 +86,54 @@ api.iapiOSVerify = {
method: 'POST', method: 'POST',
url: '/iap/ios/verify', url: '/iap/ios/verify',
middlewares: [authWithHeaders()], 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);
res.respond(200, appleRes);
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /iap/android/subscription iOS Subscribe
* @apiName IapiOSSubscribe
* @apiGroup Payments
**/
api.iapSubscriptioniOS = {
method: 'POST',
url: '/iap/ios/subscribe',
middlewares: [authWithUrl],
async handler (req, res) {
if (!req.body.sku) throw new BadRequest(res.t('missingSubscriptionCode'));
if (!req.body.receipt) throw new BadRequest(res.t('missingReceipt'));
await applePayments.subscribe(req.body.sku, res.locals.user, req.body.receipt, req.headers);
res.respond(200);
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {get} /iap/android/subscribe/cancel Apple Payments: subscribe cancel
* @apiName iOSSubscribeCancel
* @apiGroup Payments
**/
api.iapCancelSubscriptioniOS = {
method: 'GET',
url: '/iap/ios/subscribe/cancel',
middlewares: [authWithUrl],
async handler (req, res) { async handler (req, res) {
let user = res.locals.user; let user = res.locals.user;
let iapBody = req.body;
let appleRes; await applePayments.cancelSubscribe(user, req.headers);
try { if (req.query.noRedirect) {
await iap.setup(); res.respond(200);
} else {
appleRes = await iap.validate(iap.APPLE, iapBody.transaction.receipt); res.redirect('/');
let isValidated = iap.isValidated(appleRes);
if (!isValidated) throw new Error('INVALID_RECEIPT');
let purchaseDataList = iap.getPurchaseData(appleRes);
if (purchaseDataList.length === 0) throw new Error('NO_ITEM_PURCHASED');
let correctReceipt = true;
// Purchasing one item at a time (processing of await(s) below is sequential not parallel)
for (let index in purchaseDataList) {
let purchaseData = purchaseDataList[index];
let token = purchaseData.transactionId;
let existingReceipt = await IapPurchaseReceipt.findOne({ // eslint-disable-line no-await-in-loop
_id: token,
}).exec();
if (!existingReceipt) {
await IapPurchaseReceipt.create({ // eslint-disable-line no-await-in-loop
_id: token,
consumed: true,
userId: user._id,
});
} else {
throw new Error('RECEIPT_ALREADY_USED');
}
let amount;
switch (purchaseData.productId) {
case 'com.habitrpg.ios.Habitica.4gems':
amount = 1;
break;
case 'com.habitrpg.ios.Habitica.20gems':
case 'com.habitrpg.ios.Habitica.21gems':
amount = 5.25;
break;
case 'com.habitrpg.ios.Habitica.42gems':
amount = 10.5;
break;
case 'com.habitrpg.ios.Habitica.84gems':
amount = 21;
break;
}
if (!amount) {
correctReceipt = false;
break;
}
await payments.buyGems({ // eslint-disable-line no-await-in-loop
user,
paymentMethod: 'IAP AppleStore',
amount,
headers: req.headers,
});
}
if (!correctReceipt) throw new Error('INVALID_ITEM_PURCHASED');
return res.status(200).json({
ok: true,
data: appleRes,
});
} catch (err) {
logger.error(err, {
userId: user._id,
iapBody,
appleRes,
});
return res.status(500).json({
ok: false,
data: 'An error occurred while processing the purchase.',
});
} }
}, },
}; };

View File

@@ -0,0 +1,172 @@
import shared from '../../common';
import iap from './inAppPurchases';
import payments from './payments';
import {
NotAuthorized,
BadRequest,
} from './errors';
import { model as IapPurchaseReceipt } from '../models/iapPurchaseReceipt';
import {model as User } from '../models/user';
import moment from 'moment';
let api = {};
api.constants = {
PAYMENT_METHOD_APPLE: 'Apple',
RESPONSE_INVALID_RECEIPT: 'INVALID_RECEIPT',
RESPONSE_ALREADY_USED: 'RECEIPT_ALREADY_USED',
RESPONSE_INVALID_ITEM: 'INVALID_ITEM_PURCHASED',
RESPONSE_STILL_VALID: 'SUBSCRIPTION_STILL_VALID',
RESPONSE_NO_ITEM_PURCHASED: 'NO_ITEM_PURCHASED',
};
api.verifyGemPurchase = async function verifyGemPurchase (user, receipt, headers) {
await iap.setup();
let appleRes = await iap.validate(iap.APPLE, receipt);
let 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 correctReceipt = false;
// Purchasing one item at a time (processing of await(s) below is sequential not parallel)
for (let index in purchaseDataList) {
let purchaseData = purchaseDataList[index];
let token = purchaseData.transactionId;
let existingReceipt = await IapPurchaseReceipt.findOne({ // eslint-disable-line no-await-in-loop
_id: token,
}).exec();
if (!existingReceipt) {
await IapPurchaseReceipt.create({ // eslint-disable-line no-await-in-loop
_id: token,
consumed: true,
userId: user._id,
});
let amount;
switch (purchaseData.productId) {
case 'com.habitrpg.ios.Habitica.4gems':
amount = 1;
break;
case 'com.habitrpg.ios.Habitica.20gems':
case 'com.habitrpg.ios.Habitica.21gems':
amount = 5.25;
break;
case 'com.habitrpg.ios.Habitica.42gems':
amount = 10.5;
break;
case 'com.habitrpg.ios.Habitica.84gems':
amount = 21;
break;
}
if (amount) {
correctReceipt = true;
await payments.buyGems({ // eslint-disable-line no-await-in-loop
user,
paymentMethod: api.constants.PAYMENT_METHOD_APPLE,
amount,
headers,
});
}
}
}
if (!correctReceipt) throw new NotAuthorized(api.constants.RESPONSE_INVALID_ITEM);
return appleRes;
};
api.subscribe = async function subscribe (sku, user, receipt, headers, nextPaymentProcessing = undefined) {
if (!sku) throw new BadRequest(shared.i18n.t('missingSubscriptionCode'));
let subCode;
switch (sku) {
case 'subscription1month':
subCode = 'basic_earned';
break;
case 'com.habitrpg.ios.habitica.subscription.3month':
subCode = 'basic_3mo';
break;
case 'com.habitrpg.ios.habitica.subscription.6month':
subCode = 'basic_6mo';
break;
case 'com.habitrpg.ios.habitica.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 appleRes = await iap.validate(iap.APPLE, receipt);
let 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 existingUser = await User.findOne({
'purchased.plan.customerId': transactionId,
}).exec();
if (existingUser) throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
nextPaymentProcessing = nextPaymentProcessing ? nextPaymentProcessing : moment.utc().add({days: 2});
await payments.createSubscription({
user,
customerId: transactionId,
paymentMethod: this.constants.PAYMENT_METHOD_APPLE,
sub,
headers,
nextPaymentProcessing,
additionalData: receipt,
});
} else {
throw new NotAuthorized(api.constants.RESPONSE_INVALID_RECEIPT);
}
};
api.cancelSubscribe = async function cancelSubscribe (user, headers) {
let plan = user.purchased.plan;
if (plan.paymentMethod !== api.constants.PAYMENT_METHOD_APPLE) throw new NotAuthorized(shared.i18n.t('missingSubscription'));
await iap.setup();
let appleRes = await iap.validate(iap.APPLE, plan.additionalData);
let isValidated = iap.isValidated(appleRes);
if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
let purchases = iap.getPurchaseData(appleRes);
if (purchases.length === 0) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
let subscriptionData = purchases[0];
let dateTerminated = new Date(Number(subscriptionData.expirationDate));
if (dateTerminated > new Date()) throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID);
await payments.cancelSubscription({
user,
nextBill: dateTerminated,
paymentMethod: this.constants.PAYMENT_METHOD_APPLE,
headers,
});
};
module.exports = api;

View File

@@ -131,13 +131,13 @@ api.subscribe = async function subscribe (sku, user, receipt, signature, headers
api.cancelSubscribe = async function cancelSubscribe (user, headers) { api.cancelSubscribe = async function cancelSubscribe (user, headers) {
let data = user.purchased.plan.additionalData; let plan = user.purchased.plan;
if (!data) throw new NotAuthorized(shared.i18n.t('missingSubscription')); if (plan.paymentMethod !== api.constants.PAYMENT_METHOD_GOOGLE) throw new NotAuthorized(shared.i18n.t('missingSubscription'));
await iap.setup(); await iap.setup();
let googleRes = await iap.validate(iap.GOOGLE, data); let googleRes = await iap.validate(iap.GOOGLE, plan.additionalData);
let isValidated = iap.isValidated(googleRes); let isValidated = iap.isValidated(googleRes);
if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT); if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);

View File

@@ -14,6 +14,7 @@ iap.config({
googleRefToken: nconf.get('PLAY_API:REFRESH_TOKEN'), googleRefToken: nconf.get('PLAY_API:REFRESH_TOKEN'),
googleClientID: nconf.get('PLAY_API:CLIENT_ID'), googleClientID: nconf.get('PLAY_API:CLIENT_ID'),
googleClientSecret: nconf.get('PLAY_API:CLIENT_SECRET'), googleClientSecret: nconf.get('PLAY_API:CLIENT_SECRET'),
applePassword: nconf.get('ITUNES_SHARED_SECRET'),
}); });
module.exports = { module.exports = {