Handle subscription cancelation better

This commit is contained in:
Phillip Thelen
2022-11-11 13:54:17 +01:00
committed by Phillip Thelen
parent de48925341
commit 87558a325e
3 changed files with 187 additions and 22 deletions

View File

@@ -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;

View File

@@ -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,

View File

@@ -126,14 +126,20 @@ 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
const terminationDate = moment(Number(purchase.expirationDate)); const terminationDate = moment(Number(purchase.expirationDate));
@@ -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;