Support subscription payment through Google Play Store (#8437)

* Support subscription payment through Google Play Store

* minor fixes to iap subscriptions

* Support subscription payment through Google Play Store

* minor fixes to iap subscriptions

* revert change to test

* add unit tests for google payments

* add integration tests for google payments

* change config formatting for play api

* fix typo in file name

* fix typo in example config

* Improve google payment tests

* fix linter errors
This commit is contained in:
Phillip Thelen
2017-02-02 01:39:37 +01:00
committed by Sabe Jones
parent a002bc5e20
commit 4d0295a60d
10 changed files with 630 additions and 54 deletions

View File

@@ -8,6 +8,12 @@
"FACEBOOK_SECRET":"aaaabbbbccccddddeeeeffff00001111",
"GOOGLE_CLIENT_ID":"123456789012345",
"GOOGLE_CLIENT_SECRET":"aaaabbbbccccddddeeeeffff00001111",
"PLAY_API": {
"CLIENT_ID": "aaaabbbbccccddddeeeeffff00001111",
"CLIENT_SECRET": "aaaabbbbccccddddeeeeffff00001111",
"ACCESS_TOKEN":"aaaabbbbccccddddeeeeffff00001111",
"REFRESH_TOKEN":"aaaabbbbccccddddeeeeffff00001111"
},
"NODE_DB_URI":"mongodb://localhost/habitrpg",
"TEST_DB_URI":"mongodb://localhost/habitrpg_test",
"NODE_ENV":"development",

View File

@@ -0,0 +1,40 @@
import {generateUser} from '../../../../../helpers/api-integration/v3';
import googlePayments from '../../../../../../website/server/libs/googlePayments';
describe('payments : google #cancelSubscribe', () => {
let endpoint = '/iap/android/subscribe/cancel';
let user;
beforeEach(async () => {
user = await generateUser();
});
describe('success', () => {
let cancelStub;
beforeEach(async () => {
cancelStub = sinon.stub(googlePayments, 'cancelSubscribe').returnsPromise().resolves({});
});
afterEach(() => {
googlePayments.cancelSubscribe.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,
});
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,56 @@
import {generateUser, translate as t} from '../../../../../helpers/api-integration/v3';
import googlePayments from '../../../../../../website/server/libs/googlePayments';
describe('payments : google #subscribe', () => {
let endpoint = '/iap/android/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(googlePayments, 'subscribe').returnsPromise().resolves({});
});
afterEach(() => {
googlePayments.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.android.habitica.subscription.3month';
await user.post(endpoint, {
sku,
transaction: {receipt: 'receipt', signature: 'signature'},
});
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]).to.eql('signature');
expect(subscribeStub.args[0][4]['x-api-key']).to.eql(user.apiToken);
expect(subscribeStub.args[0][4]['x-api-user']).to.eql(user._id);
});
});
});

View File

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

View File

@@ -0,0 +1,268 @@
/* eslint-disable camelcase */
import iapModule from '../../../../../website/server/libs/inAppPurchases';
import payments from '../../../../../website/server/libs/payments';
import googlePayments from '../../../../../website/server/libs/googlePayments';
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('Google Payments', () => {
let subKey = 'basic_3mo';
describe('verifyGemPurchase', () => {
let sku, user, token, receipt, signature, headers;
let iapSetupStub, iapValidateStub, iapIsValidatedStub, paymentBuyGemsStub;
beforeEach(() => {
sku = 'com.habitrpg.android.habitica.iap.21gems';
user = new User();
receipt = `{"token": "${token}", "productId": "${sku}"}`;
signature = '';
headers = {};
iapSetupStub = sinon.stub(iapModule, 'setup')
.returnsPromise().resolves();
iapValidateStub = sinon.stub(iapModule, 'validate')
.returnsPromise().resolves({});
iapIsValidatedStub = sinon.stub(iapModule, 'isValidated')
.returns(true);
paymentBuyGemsStub = sinon.stub(payments, 'buyGems').returnsPromise().resolves({});
});
afterEach(() => {
iapModule.setup.restore();
iapModule.validate.restore();
iapModule.isValidated.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(googlePayments.verifyGemPurchase(user, receipt, signature, headers))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: googlePayments.constants.RESPONSE_INVALID_RECEIPT,
});
});
it('should throw an error if productId is invalid', async () => {
receipt = `{"token": "${token}", "productId": "invalid"}`;
await expect(googlePayments.verifyGemPurchase(user, receipt, signature, headers))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: googlePayments.constants.RESPONSE_INVALID_ITEM,
});
});
it('purchases gems', async () => {
await googlePayments.verifyGemPurchase(user, receipt, signature, headers);
expect(iapSetupStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledWith(iap.GOOGLE, {
data: receipt,
signature,
});
expect(iapIsValidatedStub).to.be.calledOnce;
expect(iapIsValidatedStub).to.be.calledWith({});
expect(paymentBuyGemsStub).to.be.calledOnce;
expect(paymentBuyGemsStub).to.be.calledWith({
user,
paymentMethod: googlePayments.constants.PAYMENT_METHOD_GOOGLE,
amount: 5.25,
headers,
});
});
});
describe('subscribe', () => {
let sub, sku, user, token, receipt, signature, headers, nextPaymentProcessing;
let iapSetupStub, iapValidateStub, iapIsValidatedStub, paymentsCreateSubscritionStub;
beforeEach(() => {
sub = common.content.subscriptionBlocks[subKey];
sku = 'com.habitrpg.android.habitica.subscription.3month';
token = 'test-token';
headers = {};
receipt = `{"token": "${token}"}`;
signature = '';
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);
paymentsCreateSubscritionStub = sinon.stub(payments, 'createSubscription').returnsPromise().resolves({});
});
afterEach(() => {
iapModule.setup.restore();
iapModule.validate.restore();
iapModule.isValidated.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(googlePayments.subscribe(sku, user, receipt, signature, headers, nextPaymentProcessing))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: googlePayments.constants.RESPONSE_INVALID_RECEIPT,
});
});
it('should throw an error if sku is invalid', async () => {
sku = 'invalid';
await expect(googlePayments.subscribe(sku, user, receipt, signature, headers, nextPaymentProcessing))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: googlePayments.constants.RESPONSE_INVALID_ITEM,
});
});
it('creates a user subscription', async () => {
await googlePayments.subscribe(sku, user, receipt, signature, headers, nextPaymentProcessing);
expect(iapSetupStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledWith(iap.GOOGLE, {
data: receipt,
signature,
});
expect(iapIsValidatedStub).to.be.calledOnce;
expect(iapIsValidatedStub).to.be.calledWith({});
expect(paymentsCreateSubscritionStub).to.be.calledOnce;
expect(paymentsCreateSubscritionStub).to.be.calledWith({
user,
customerId: token,
paymentMethod: googlePayments.constants.PAYMENT_METHOD_GOOGLE,
sub,
headers,
additionalData: {data: receipt, signature},
nextPaymentProcessing,
});
});
});
describe('cancelSubscribe ', () => {
let user, token, receipt, signature, headers, customerId, expirationDate;
let iapSetupStub, iapValidateStub, iapIsValidatedStub, iapGetPurchaseDataStub, paymentCancelSubscriptionSpy;
beforeEach(async () => {
token = 'test-token';
headers = {};
receipt = `{"token": "${token}"}`;
signature = '';
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.customerId = customerId;
user.purchased.plan.planId = subKey;
user.purchased.plan.additionalData = {data: receipt, signature};
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.additionalData = undefined;
await expect(googlePayments.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(googlePayments.cancelSubscribe(user, headers))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: googlePayments.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(googlePayments.cancelSubscribe(user, headers))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: googlePayments.constants.RESPONSE_INVALID_RECEIPT,
});
});
it('should cancel a user subscription', async () => {
await googlePayments.cancelSubscribe(user, headers);
expect(iapSetupStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledOnce;
expect(iapValidateStub).to.be.calledWith(iap.GOOGLE, {
data: receipt,
signature,
});
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: googlePayments.constants.PAYMENT_METHOD_GOOGLE,
nextBill: expirationDate.toDate(),
headers,
});
});
});
});

View File

@@ -5,10 +5,11 @@ import {
import iap from '../../../libs/inAppPurchases';
import payments from '../../../libs/payments';
import {
NotAuthorized,
BadRequest,
} from '../../../libs/errors';
import { model as IapPurchaseReceipt } from '../../../models/iapPurchaseReceipt';
import logger from '../../../libs/logger';
import googlePayments from '../../../libs/googlePayments';
let api = {};
@@ -28,63 +29,56 @@ api.iapAndroidVerify = {
let user = res.locals.user;
let iapBody = req.body;
await iap.setup();
let testObj = {
data: iapBody.transaction.receipt,
signature: iapBody.transaction.signature,
};
let googleRes = await iap.validate(iap.GOOGLE, testObj);
let isValidated = iap.isValidated(googleRes);
if (!isValidated) throw new NotAuthorized('INVALID_RECEIPT');
let receiptObj = JSON.parse(testObj.data); // passed as a string
let token = receiptObj.token || receiptObj.purchaseToken;
let existingReceipt = await IapPurchaseReceipt.findOne({
_id: token,
}).exec();
if (existingReceipt) throw new NotAuthorized('RECEIPT_ALREADY_USED');
await IapPurchaseReceipt.create({
_id: token,
consumed: true,
userId: user._id,
});
let amount;
switch (receiptObj.productId) {
case 'com.habitrpg.android.habitica.iap.4gems':
amount = 1;
break;
case 'com.habitrpg.android.habitica.iap.20.gems':
case 'com.habitrpg.android.habitica.iap.21gems':
amount = 5.25;
break;
case 'com.habitrpg.android.habitica.iap.42gems':
amount = 10.5;
break;
case 'com.habitrpg.android.habitica.iap.84gems':
amount = 21;
break;
}
if (!amount) throw new Error('INVALID_ITEM_PURCHASED');
await payments.buyGems({
user,
paymentMethod: 'IAP GooglePlay',
amount,
headers: req.headers,
});
let googleRes = await googlePayments.verifyGemPurchase(user, iapBody.transaction.receipt, iapBody.transaction.signature, req.headers);
res.respond(200, googleRes);
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {post} /iap/android/subscription Android Subscribe
* @apiName IapAndroidSubscribe
* @apiGroup Payments
**/
api.iapSubscriptionAndroid = {
method: 'POST',
url: '/iap/android/subscribe',
middlewares: [authWithUrl],
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, user, iapBody.transaction.receipt, iapBody.transaction.signature, req.headers);
res.respond(200);
},
};
/**
* @apiIgnore Payments are considered part of the private API
* @api {get} /iap/android/subscribe/cancel Google Payments: subscribe cancel
* @apiName AndroidSubscribeCancel
* @apiGroup Payments
**/
api.iapCancelSubscriptionAndroid = {
method: 'GET',
url: '/iap/android/subscribe/cancel',
middlewares: [authWithUrl],
async handler (req, res) {
let user = res.locals.user;
await googlePayments.cancelSubscribe(user, req.headers);
if (req.query.noRedirect) {
res.respond(200);
} else {
res.redirect('/');
}
},
};
// IMPORTANT: NOT PORTED TO v3 standards (not using res.respond)
/**

View File

@@ -0,0 +1,161 @@
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_GOOGLE: 'Google',
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) {
await iap.setup();
let testObj = {
data: receipt,
signature,
};
let googleRes = await iap.validate(iap.GOOGLE, testObj);
let isValidated = iap.isValidated(googleRes);
if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
let receiptObj = JSON.parse(testObj.data); // passed as a string
let token = receiptObj.token || receiptObj.purchaseToken;
let existingReceipt = await IapPurchaseReceipt.findOne({
_id: token,
}).exec();
if (existingReceipt) throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
await IapPurchaseReceipt.create({
_id: token,
consumed: true,
userId: user._id,
});
let amount;
switch (receiptObj.productId) {
case 'com.habitrpg.android.habitica.iap.4gems':
amount = 1;
break;
case 'com.habitrpg.android.habitica.iap.20.gems':
case 'com.habitrpg.android.habitica.iap.21gems':
amount = 5.25;
break;
case 'com.habitrpg.android.habitica.iap.42gems':
amount = 10.5;
break;
case 'com.habitrpg.android.habitica.iap.84gems':
amount = 21;
break;
}
if (!amount) throw new NotAuthorized(this.constants.RESPONSE_INVALID_ITEM);
await payments.buyGems({
user,
paymentMethod: this.constants.PAYMENT_METHOD_GOOGLE,
amount,
headers,
});
return googleRes;
};
api.subscribe = async function subscribe (sku, user, receipt, signature, headers, nextPaymentProcessing = undefined) {
if (!sku) throw new BadRequest(shared.i18n.t('missingSubscriptionCode'));
let subCode;
switch (sku) {
case 'com.habitrpg.android.habitica.subscription.1month':
subCode = 'basic_earned';
break;
case 'com.habitrpg.android.habitica.subscription.3month':
subCode = 'basic_3mo';
break;
case 'com.habitrpg.android.habitica.subscription.6month':
subCode = 'basic_6mo';
break;
case 'com.habitrpg.android.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 testObj = {
data: receipt,
signature,
};
let receiptObj = JSON.parse(receipt); // passed as a string
let token = receiptObj.token || receiptObj.purchaseToken;
let existingUser = await User.findOne({
'payments.plan.customerId': token,
}).exec();
if (existingUser) throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
let googleRes = await iap.validate(iap.GOOGLE, testObj);
let isValidated = iap.isValidated(googleRes);
if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
nextPaymentProcessing = nextPaymentProcessing ? nextPaymentProcessing : moment.utc().add({days: 2});
await payments.createSubscription({
user,
customerId: token,
paymentMethod: this.constants.PAYMENT_METHOD_GOOGLE,
sub,
headers,
nextPaymentProcessing,
additionalData: testObj,
});
};
api.cancelSubscribe = async function cancelSubscribe (user, headers) {
let data = user.purchased.plan.additionalData;
if (!data) throw new NotAuthorized(shared.i18n.t('missingSubscription'));
await iap.setup();
let googleRes = await iap.validate(iap.GOOGLE, data);
let isValidated = iap.isValidated(googleRes);
if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
let purchases = iap.getPurchaseData(googleRes);
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_GOOGLE,
headers,
});
};
module.exports = api;

View File

@@ -10,6 +10,10 @@ import Bluebird from 'bluebird';
iap.config({
// This is the path to the directory containing iap-sanbox/iap-live files
googlePublicKeyPath: nconf.get('IAP_GOOGLE_KEYDIR'),
googleAccToken: nconf.get('PLAY_API:ACCESS_TOKEN'),
googleRefToken: nconf.get('PLAY_API:REFRESH_TOKEN'),
googleClientID: nconf.get('PLAY_API:CLIENT_ID'),
googleClientSecret: nconf.get('PLAY_API:CLIENT_SECRET'),
});
module.exports = {

View File

@@ -106,6 +106,9 @@ api.createSubscription = async function createSubscription (data) {
// Specify a lastBillingDate just for Amazon Payments
// Resetted every time the subscription restarts
lastBillingDate: data.paymentMethod === 'Amazon Payments' ? today : undefined,
nextPaymentProcessing: data.nextPaymentProcessing,
nextBillingDate: data.nextBillingDate,
additionalData: data.additionalData,
owner: data.user._id,
}).defaults({ // allow non-override if a plan was previously used
gemsBought: 0,

View File

@@ -7,7 +7,7 @@ export let schema = new mongoose.Schema({
subscriptionId: String,
owner: {type: String, ref: 'User', validate: [validator.isUUID, 'Invalid uuid.']},
quantity: {type: Number, default: 1},
paymentMethod: String, // enum: ['Paypal','Stripe', 'Gift', 'Amazon Payments', '']}
paymentMethod: String, // enum: ['Paypal', 'Stripe', 'Gift', 'Amazon Payments', 'Google', '']}
customerId: String, // Billing Agreement Id in case of Amazon Payments
dateCreated: Date,
dateTerminated: Date,
@@ -16,6 +16,9 @@ export let schema = new mongoose.Schema({
gemsBought: {type: Number, default: 0},
mysteryItems: {type: Array, default: () => []},
lastBillingDate: Date, // Used only for Amazon Payments to keep track of billing date
additionalData: mongoose.Schema.Types.Mixed, // Example for Google: {'receipt': 'serialized receipt json', 'signature': 'signature string'}
nextPaymentProcessing: Date, // indicates when the queue server should process this subscription again.
nextBillingDate: Date, // Next time google will bill this user.
consecutive: {
count: {type: Number, default: 0},
offset: {type: Number, default: 0}, // when gifted subs, offset++ for each month. offset-- each new-month (cron). count doesn't ++ until offset==0
@@ -29,6 +32,7 @@ export let schema = new mongoose.Schema({
});
schema.plugin(baseModel, {
private: ['additionalData'],
noSet: ['_id'],
timestamps: false,
_id: false,