mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-15 21:57:22 +01:00
Handle subscription cancelation better
This commit is contained in:
committed by
Phillip Thelen
parent
de48925341
commit
87558a325e
@@ -9,7 +9,7 @@ import * as gems from '../../../../../website/server/libs/payments/gems';
|
|||||||
|
|
||||||
const { i18n } = common;
|
const { i18n } = common;
|
||||||
|
|
||||||
describe('Apple Payments', () => {
|
describe.only('Apple Payments', () => {
|
||||||
const subKey = 'basic_3mo';
|
const subKey = 'basic_3mo';
|
||||||
|
|
||||||
describe('verifyPurchase', () => {
|
describe('verifyPurchase', () => {
|
||||||
@@ -29,8 +29,9 @@ describe('Apple Payments', () => {
|
|||||||
.resolves();
|
.resolves();
|
||||||
iapValidateStub = sinon.stub(iap, 'validate')
|
iapValidateStub = sinon.stub(iap, 'validate')
|
||||||
.resolves({});
|
.resolves({});
|
||||||
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
|
iapIsValidatedStub = sinon.stub(iap, 'isValidated').returns(true);
|
||||||
.returns(true);
|
sinon.stub(iap, 'isExpired').returns(false);
|
||||||
|
sinon.stub(iap, 'isCanceled').returns(false);
|
||||||
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
|
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
|
||||||
.returns([{
|
.returns([{
|
||||||
productId: 'com.habitrpg.ios.Habitica.21gems',
|
productId: 'com.habitrpg.ios.Habitica.21gems',
|
||||||
@@ -44,6 +45,8 @@ describe('Apple Payments', () => {
|
|||||||
iap.setup.restore();
|
iap.setup.restore();
|
||||||
iap.validate.restore();
|
iap.validate.restore();
|
||||||
iap.isValidated.restore();
|
iap.isValidated.restore();
|
||||||
|
iap.isExpired.restore();
|
||||||
|
iap.isCanceled.restore();
|
||||||
iap.getPurchaseData.restore();
|
iap.getPurchaseData.restore();
|
||||||
payments.buySkuItem.restore();
|
payments.buySkuItem.restore();
|
||||||
gems.validateGiftMessage.restore();
|
gems.validateGiftMessage.restore();
|
||||||
@@ -449,6 +452,81 @@ describe('Apple Payments', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('allows second user to subscribe if initial subscription is cancelled', async () => {
|
||||||
|
user.profile.name = 'sender';
|
||||||
|
user.purchased.plan.paymentMethod = applePayments.constants.PAYMENT_METHOD_APPLE;
|
||||||
|
user.purchased.plan.customerId = token;
|
||||||
|
user.purchased.plan.planId = common.content.subscriptionBlocks.basic_3mo.key;
|
||||||
|
user.purchased.plan.additionalData = receipt;
|
||||||
|
user.purchased.plan.dateTerminated = moment.utc().subtract({ day: 1 }).toDate();
|
||||||
|
await user.save()
|
||||||
|
|
||||||
|
iap.getPurchaseData.restore();
|
||||||
|
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
|
||||||
|
.returns([{
|
||||||
|
expirationDate: moment.utc().add({ day: 3 }).toDate(),
|
||||||
|
purchaseDate: moment.utc().toDate(),
|
||||||
|
productId: sku,
|
||||||
|
transactionId: token + "new",
|
||||||
|
originalTransactionId: token,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const secondUser = new User();
|
||||||
|
await secondUser.save();
|
||||||
|
await applePayments.subscribe(secondUser, receipt, headers, nextPaymentProcessing);
|
||||||
|
|
||||||
|
expect(paymentsCreateSubscritionStub).to.be.calledOnce;
|
||||||
|
expect(paymentsCreateSubscritionStub).to.be.calledWith({
|
||||||
|
user: secondUser,
|
||||||
|
customerId: token,
|
||||||
|
paymentMethod: applePayments.constants.PAYMENT_METHOD_APPLE,
|
||||||
|
sub,
|
||||||
|
headers,
|
||||||
|
additionalData: receipt,
|
||||||
|
nextPaymentProcessing,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it('allows second user to subscribe if multiple initial subscription are cancelled', async () => {
|
||||||
|
user.profile.name = 'sender';
|
||||||
|
user.purchased.plan.paymentMethod = applePayments.constants.PAYMENT_METHOD_APPLE;
|
||||||
|
user.purchased.plan.customerId = token;
|
||||||
|
user.purchased.plan.planId = common.content.subscriptionBlocks.basic_3mo.key;
|
||||||
|
user.purchased.plan.additionalData = receipt;
|
||||||
|
user.purchased.plan.dateTerminated = moment.utc().subtract({ day: 1 }).toDate();
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
const secondUser = new User();
|
||||||
|
secondUser.purchased.plan = user.purchased.plan;
|
||||||
|
await secondUser.save()
|
||||||
|
|
||||||
|
iap.getPurchaseData.restore();
|
||||||
|
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
|
||||||
|
.returns([{
|
||||||
|
expirationDate: moment.utc().add({ day: 3 }).toDate(),
|
||||||
|
purchaseDate: moment.utc().toDate(),
|
||||||
|
productId: sku,
|
||||||
|
transactionId: token + "new",
|
||||||
|
originalTransactionId: token,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const thirdUser = new User();
|
||||||
|
await thirdUser.save();
|
||||||
|
await applePayments.subscribe(thirdUser, receipt, headers, nextPaymentProcessing);
|
||||||
|
|
||||||
|
expect(paymentsCreateSubscritionStub).to.be.calledOnce;
|
||||||
|
expect(paymentsCreateSubscritionStub).to.be.calledWith({
|
||||||
|
user: thirdUser,
|
||||||
|
customerId: token,
|
||||||
|
paymentMethod: applePayments.constants.PAYMENT_METHOD_APPLE,
|
||||||
|
sub,
|
||||||
|
headers,
|
||||||
|
additionalData: receipt,
|
||||||
|
nextPaymentProcessing,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('does not apply multiple times', async () => {
|
describe('does not apply multiple times', async () => {
|
||||||
it('errors when a user is using the same subscription', async () => {
|
it('errors when a user is using the same subscription', async () => {
|
||||||
payments.createSubscription.restore();
|
payments.createSubscription.restore();
|
||||||
@@ -513,6 +591,7 @@ describe('Apple Payments', () => {
|
|||||||
await applePayments.subscribe(user, receipt, headers, nextPaymentProcessing);
|
await applePayments.subscribe(user, receipt, headers, nextPaymentProcessing);
|
||||||
|
|
||||||
const secondUser = new User();
|
const secondUser = new User();
|
||||||
|
await secondUser.save();
|
||||||
await expect(applePayments.subscribe(
|
await expect(applePayments.subscribe(
|
||||||
secondUser, receipt, headers, nextPaymentProcessing,
|
secondUser, receipt, headers, nextPaymentProcessing,
|
||||||
))
|
))
|
||||||
@@ -522,6 +601,49 @@ describe('Apple Payments', () => {
|
|||||||
message: applePayments.constants.RESPONSE_ALREADY_USED,
|
message: applePayments.constants.RESPONSE_ALREADY_USED,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it('errors when a multiple users exist using the subscription', async () => {
|
||||||
|
user = new User();
|
||||||
|
await user.save();
|
||||||
|
payments.createSubscription.restore();
|
||||||
|
iap.getPurchaseData.restore();
|
||||||
|
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
|
||||||
|
.returns([{
|
||||||
|
expirationDate: moment.utc().add({ day: 1 }).toDate(),
|
||||||
|
purchaseDate: moment.utc().toDate(),
|
||||||
|
productId: sku,
|
||||||
|
transactionId: token,
|
||||||
|
originalTransactionId: token,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
await applePayments.subscribe(user, receipt, headers, nextPaymentProcessing);
|
||||||
|
const secondUser = new User();
|
||||||
|
secondUser.purchased.plan = user.purchased.plan;
|
||||||
|
secondUser.purchased.plan.dateTerminate = new Date();
|
||||||
|
secondUser.save()
|
||||||
|
|
||||||
|
iap.getPurchaseData.restore();
|
||||||
|
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
|
||||||
|
.returns([{
|
||||||
|
expirationDate: moment.utc().add({ day: 1 }).toDate(),
|
||||||
|
purchaseDate: moment.utc().toDate(),
|
||||||
|
productId: sku,
|
||||||
|
transactionId: token + "new",
|
||||||
|
originalTransactionId: token,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const thirdUser = new User();
|
||||||
|
await thirdUser.save();
|
||||||
|
await expect(applePayments.subscribe(
|
||||||
|
thirdUser, receipt, headers, nextPaymentProcessing,
|
||||||
|
))
|
||||||
|
.to.eventually.be.rejected.and.to.eql({
|
||||||
|
httpCode: 401,
|
||||||
|
name: 'NotAuthorized',
|
||||||
|
message: applePayments.constants.RESPONSE_ALREADY_USED,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -546,9 +668,9 @@ describe('Apple Payments', () => {
|
|||||||
});
|
});
|
||||||
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
|
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
|
||||||
.returns([{ expirationDate: expirationDate.toDate() }]);
|
.returns([{ expirationDate: expirationDate.toDate() }]);
|
||||||
iapIsValidatedStub = sinon.stub(iap, 'isValidated')
|
iapIsValidatedStub = sinon.stub(iap, 'isValidated').returns(true);
|
||||||
.returns(true);
|
sinon.stub(iap, 'isCanceled').returns(false);
|
||||||
|
sinon.stub(iap, 'isExpired').returns(true);
|
||||||
user = new User();
|
user = new User();
|
||||||
user.profile.name = 'sender';
|
user.profile.name = 'sender';
|
||||||
user.purchased.plan.paymentMethod = applePayments.constants.PAYMENT_METHOD_APPLE;
|
user.purchased.plan.paymentMethod = applePayments.constants.PAYMENT_METHOD_APPLE;
|
||||||
@@ -563,6 +685,8 @@ describe('Apple Payments', () => {
|
|||||||
iap.setup.restore();
|
iap.setup.restore();
|
||||||
iap.validate.restore();
|
iap.validate.restore();
|
||||||
iap.isValidated.restore();
|
iap.isValidated.restore();
|
||||||
|
iap.isExpired.restore();
|
||||||
|
iap.isCanceled.restore();
|
||||||
iap.getPurchaseData.restore();
|
iap.getPurchaseData.restore();
|
||||||
payments.cancelSubscription.restore();
|
payments.cancelSubscription.restore();
|
||||||
});
|
});
|
||||||
@@ -582,6 +706,8 @@ describe('Apple Payments', () => {
|
|||||||
iap.getPurchaseData.restore();
|
iap.getPurchaseData.restore();
|
||||||
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
|
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
|
||||||
.returns([{ expirationDate: expirationDate.add({ day: 1 }).toDate() }]);
|
.returns([{ expirationDate: expirationDate.add({ day: 1 }).toDate() }]);
|
||||||
|
iap.isExpired.restore();
|
||||||
|
sinon.stub(iap, 'isExpired').returns(false);
|
||||||
|
|
||||||
await expect(applePayments.cancelSubscribe(user, headers))
|
await expect(applePayments.cancelSubscribe(user, headers))
|
||||||
.to.eventually.be.rejected.and.to.eql({
|
.to.eventually.be.rejected.and.to.eql({
|
||||||
@@ -604,7 +730,38 @@ describe('Apple Payments', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should cancel a user subscription', async () => {
|
it('should cancel a cancelled subscription with termination date in the future', async () => {
|
||||||
|
const futureDate = expirationDate.add({ day: 1 });
|
||||||
|
iap.getPurchaseData.restore();
|
||||||
|
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
|
||||||
|
.returns([{ expirationDate: futureDate }]);
|
||||||
|
iap.isExpired.restore();
|
||||||
|
sinon.stub(iap, 'isExpired').returns(false);
|
||||||
|
|
||||||
|
iap.isCanceled.restore();
|
||||||
|
sinon.stub(iap, 'isCanceled').returns(true);
|
||||||
|
|
||||||
|
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: futureDate,
|
||||||
|
});
|
||||||
|
expect(iapGetPurchaseDataStub).to.be.calledOnce;
|
||||||
|
|
||||||
|
expect(paymentCancelSubscriptionSpy).to.be.calledOnce;
|
||||||
|
expect(paymentCancelSubscriptionSpy).to.be.calledWith({
|
||||||
|
user,
|
||||||
|
paymentMethod: applePayments.constants.PAYMENT_METHOD_APPLE,
|
||||||
|
nextBill: futureDate.toDate(),
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cancel an expired subscription', async () => {
|
||||||
await applePayments.cancelSubscribe(user, headers);
|
await applePayments.cancelSubscribe(user, headers);
|
||||||
|
|
||||||
expect(iapSetupStub).to.be.calledOnce;
|
expect(iapSetupStub).to.be.calledOnce;
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export default {
|
|||||||
setup: util.promisify(iap.setup.bind(iap)),
|
setup: util.promisify(iap.setup.bind(iap)),
|
||||||
validate: util.promisify(iap.validate.bind(iap)),
|
validate: util.promisify(iap.validate.bind(iap)),
|
||||||
isValidated: iap.isValidated,
|
isValidated: iap.isValidated,
|
||||||
|
isCanceled: iap.isCanceled,
|
||||||
|
isExpired: iap.isExpired,
|
||||||
getPurchaseData: iap.getPurchaseData,
|
getPurchaseData: iap.getPurchaseData,
|
||||||
GOOGLE: iap.GOOGLE,
|
GOOGLE: iap.GOOGLE,
|
||||||
APPLE: iap.APPLE,
|
APPLE: iap.APPLE,
|
||||||
|
|||||||
@@ -126,13 +126,19 @@ api.subscribe = async function subscribe (user, receipt, headers, nextPaymentPro
|
|||||||
throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
|
throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const existingUser = await User.findOne({
|
const existingUsers = await User.find({
|
||||||
'purchased.plan.customerId': purchase.originalTransactionId,
|
'purchased.plan.customerId': purchase.originalTransactionId,
|
||||||
}).exec();
|
}).exec();
|
||||||
if (existingUser
|
if (existingUsers.length > 0) {
|
||||||
&& (purchase.originalTransactionId === purchase.transactionId
|
if (purchase.originalTransactionId === purchase.transactionId) {
|
||||||
|| existingUser._id !== user._id)) {
|
throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
|
||||||
throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
|
}
|
||||||
|
for (const index in existingUsers) {
|
||||||
|
const existingUser = existingUsers[index];
|
||||||
|
if (existingUser._id !== user._id && !existingUser.purchased.plan.dateTerminated) {
|
||||||
|
throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nextPaymentProcessing = nextPaymentProcessing || moment.utc().add({ days: 2 }); // eslint-disable-line max-len, no-param-reassign
|
nextPaymentProcessing = nextPaymentProcessing || moment.utc().add({ days: 2 }); // eslint-disable-line max-len, no-param-reassign
|
||||||
@@ -247,8 +253,6 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) {
|
|||||||
|
|
||||||
await iap.setup();
|
await iap.setup();
|
||||||
|
|
||||||
let dateTerminated;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const appleRes = await iap.validate(iap.APPLE, plan.additionalData);
|
const appleRes = await iap.validate(iap.APPLE, plan.additionalData);
|
||||||
|
|
||||||
@@ -258,16 +262,24 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) {
|
|||||||
const purchases = iap.getPurchaseData(appleRes);
|
const purchases = iap.getPurchaseData(appleRes);
|
||||||
if (purchases.length === 0) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
|
if (purchases.length === 0) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
|
||||||
let newestDate;
|
let newestDate;
|
||||||
|
let newestPurchase
|
||||||
|
|
||||||
for (const purchaseData of purchases) {
|
for (const purchaseData of purchases) {
|
||||||
const datePurchased = new Date(Number(purchaseData.purchaseDate));
|
const datePurchased = new Date(Number(purchaseData.purchaseDate));
|
||||||
if (!newestDate || datePurchased > newestDate) {
|
if (!newestDate || datePurchased > newestDate) {
|
||||||
dateTerminated = new Date(Number(purchaseData.expirationDate));
|
|
||||||
newestDate = datePurchased;
|
newestDate = datePurchased;
|
||||||
|
newestPurchase = purchaseData;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dateTerminated > new Date()) throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID);
|
if (!iap.isCanceled(newestPurchase) && !iap.isExpired(newestPurchase)) throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID);
|
||||||
|
|
||||||
|
await payments.cancelSubscription({
|
||||||
|
user,
|
||||||
|
nextBill: new Date(Number(newestPurchase.expirationDate)),
|
||||||
|
paymentMethod: this.constants.PAYMENT_METHOD_APPLE,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// If we have an invalid receipt, cancel anyway
|
// If we have an invalid receipt, cancel anyway
|
||||||
if (
|
if (
|
||||||
@@ -278,12 +290,6 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await payments.cancelSubscription({
|
|
||||||
user,
|
|
||||||
nextBill: dateTerminated,
|
|
||||||
paymentMethod: this.constants.PAYMENT_METHOD_APPLE,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
Reference in New Issue
Block a user