mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-18 07:07:35 +01:00
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:
committed by
Matteo Pagliazzi
parent
8550ca4d29
commit
374d528647
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(),
|
||||||
|
|||||||
258
test/api/v3/unit/libs/applePayments.test.js
Normal file
258
test/api/v3/unit/libs/applePayments.test.js
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
172
website/server/libs/applePayments.js
Normal file
172
website/server/libs/applePayments.js
Normal 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;
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user