add detailed information about sub payment for google and apple

This commit is contained in:
Phillip Thelen
2025-08-11 15:45:52 +02:00
parent 9b52198631
commit 71c2e19330
8 changed files with 387 additions and 118 deletions

View File

@@ -6,17 +6,40 @@ import iap from '../../../../../website/server/libs/inAppPurchases';
import { model as User } from '../../../../../website/server/models/user'; import { model as User } from '../../../../../website/server/models/user';
import common from '../../../../../website/common'; import common from '../../../../../website/common';
import * as gems from '../../../../../website/server/libs/payments/gems'; import * as gems from '../../../../../website/server/libs/payments/gems';
import { after } from 'lodash';
const { i18n } = common; const { i18n } = common;
describe('Apple Payments', () => { describe.only('Apple Payments', () => {
const subKey = 'basic_3mo'; const subKey = 'basic_3mo';
let iapSetupStub;
let iapValidateStub;
let iapIsValidatedStub;
let iapIsCanceledStub;
let iapIsExpiredStub;
let paymentBuySkuStub;
let iapGetPurchaseDataStub;
let validateGiftMessageStub;
let paymentsCreateSubscritionStub;
beforeEach(() => {
iapSetupStub = sinon.stub(iap, 'setup').resolves();
iapValidateStub = sinon.stub(iap, 'validate').resolves({});
});
afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.isExpired.restore();
iap.isCanceled.restore();
iap.getPurchaseData.restore();
});
describe('verifyPurchase', () => { describe('verifyPurchase', () => {
let sku; let user; let token; let receipt; let let sku; let user; let token; let receipt; let
headers; headers;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let paymentBuySkuStub; let
iapGetPurchaseDataStub; let validateGiftMessageStub;
beforeEach(() => { beforeEach(() => {
token = 'testToken'; token = 'testToken';
@@ -25,13 +48,9 @@ describe('Apple Payments', () => {
receipt = `{"token": "${token}", "productId": "${sku}"}`; receipt = `{"token": "${token}", "productId": "${sku}"}`;
headers = {}; headers = {};
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({});
iapIsValidatedStub = sinon.stub(iap, 'isValidated').returns(true); iapIsValidatedStub = sinon.stub(iap, 'isValidated').returns(true);
sinon.stub(iap, 'isExpired').returns(false); iapIsCanceledStub = sinon.stub(iap, 'isCanceled').returns(false);
sinon.stub(iap, 'isCanceled').returns(false); iapIsExpiredStub = sinon.stub(iap, 'isExpired').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',
@@ -42,12 +61,6 @@ describe('Apple Payments', () => {
}); });
afterEach(() => { afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.isExpired.restore();
iap.isCanceled.restore();
iap.getPurchaseData.restore();
payments.buySkuItem.restore(); payments.buySkuItem.restore();
gems.validateGiftMessage.restore(); gems.validateGiftMessage.restore();
}); });
@@ -209,9 +222,6 @@ describe('Apple Payments', () => {
describe('subscribe', () => { describe('subscribe', () => {
let sub; let sku; let user; let token; let receipt; let headers; let let sub; let sku; let user; let token; let receipt; let headers; let
nextPaymentProcessing; nextPaymentProcessing;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub;
let paymentsCreateSubscritionStub; let
iapGetPurchaseDataStub;
beforeEach(() => { beforeEach(() => {
sub = common.content.subscriptionBlocks[subKey]; sub = common.content.subscriptionBlocks[subKey];
@@ -223,12 +233,10 @@ describe('Apple Payments', () => {
nextPaymentProcessing = moment.utc().add({ days: 2 }); nextPaymentProcessing = moment.utc().add({ days: 2 });
user = new User(); user = new User();
iapSetupStub = sinon.stub(iap, 'setup')
.resolves();
iapValidateStub = sinon.stub(iap, 'validate')
.resolves({});
iapIsValidatedStub = sinon.stub(iap, 'isValidated') iapIsValidatedStub = sinon.stub(iap, 'isValidated')
.returns(true); .returns(true);
iapIsCanceledStub = sinon.stub(iap, 'isCanceled').returns(false);
iapIsExpiredStub = sinon.stub(iap, 'isExpired').returns(false);
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData') iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{ .returns([{
expirationDate: moment.utc().subtract({ day: 1 }).toDate(), expirationDate: moment.utc().subtract({ day: 1 }).toDate(),
@@ -250,10 +258,6 @@ describe('Apple Payments', () => {
}); });
afterEach(() => { afterEach(() => {
iap.setup.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.getPurchaseData.restore();
if (payments.createSubscription.restore) payments.createSubscription.restore(); if (payments.createSubscription.restore) payments.createSubscription.restore();
}); });
@@ -270,6 +274,29 @@ describe('Apple Payments', () => {
}); });
}); });
it('should throw an error if no active subscription is found', async () => {
iap.isCanceled.restore();
iapIsCanceledStub = sinon.stub(iap, 'isCanceled')
.returns(true);
iap.getPurchaseData.restore();
iapGetPurchaseDataStub = sinon.stub(iap, 'getPurchaseData')
.returns([{
expirationDate: moment.utc().add({ day: -2 }).toDate(),
purchaseDate: new Date(),
productId: 'subscription1month',
transactionId: token,
originalTransactionId: token,
}]);
await expect(applePayments.subscribe(user, receipt, headers, nextPaymentProcessing))
.to.eventually.be.rejected.and.to.eql({
httpCode: 401,
name: 'NotAuthorized',
message: applePayments.constants.RESPONSE_NO_ITEM_PURCHASED,
});
});
const subOptions = [ const subOptions = [
{ {
sku: 'subscription1month', sku: 'subscription1month',
@@ -574,8 +601,7 @@ describe('Apple Payments', () => {
describe('cancelSubscribe ', () => { describe('cancelSubscribe ', () => {
let user; let token; let receipt; let headers; let customerId; let let user; let token; let receipt; let headers; let customerId; let
expirationDate; expirationDate;
let iapSetupStub; let iapValidateStub; let iapIsValidatedStub; let iapGetPurchaseDataStub; let let paymentCancelSubscriptionSpy;
paymentCancelSubscriptionSpy;
beforeEach(async () => { beforeEach(async () => {
token = 'test-token'; token = 'test-token';
@@ -584,8 +610,7 @@ describe('Apple Payments', () => {
customerId = 'test-customerId'; customerId = 'test-customerId';
expirationDate = moment.utc(); expirationDate = moment.utc();
iapSetupStub = sinon.stub(iap, 'setup') iapValidateStub.restore();
.resolves();
iapValidateStub = sinon.stub(iap, 'validate') iapValidateStub = sinon.stub(iap, 'validate')
.resolves({ .resolves({
expirationDate, expirationDate,
@@ -593,8 +618,8 @@ 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').returns(true); iapIsValidatedStub = sinon.stub(iap, 'isValidated').returns(true);
sinon.stub(iap, 'isCanceled').returns(false); iapIsCanceledStub = sinon.stub(iap, 'isCanceled').returns(false);
sinon.stub(iap, 'isExpired').returns(true); iapIsExpiredStub = 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;
@@ -606,13 +631,7 @@ describe('Apple Payments', () => {
}); });
afterEach(() => { afterEach(() => {
iap.setup.restore(); paymentCancelSubscriptionSpy.restore();
iap.validate.restore();
iap.isValidated.restore();
iap.isExpired.restore();
iap.isCanceled.restore();
iap.getPurchaseData.restore();
payments.cancelSubscription.restore();
}); });
it('should throw an error if we are missing a subscription', async () => { it('should throw an error if we are missing a subscription', async () => {
@@ -695,6 +714,8 @@ describe('Apple Payments', () => {
expect(iapIsValidatedStub).to.be.calledWith({ expect(iapIsValidatedStub).to.be.calledWith({
expirationDate, expirationDate,
}); });
expect(iapIsCanceledStub).to.be.calledOnce;
expect(iapIsExpiredStub).to.be.calledOnce;
expect(iapGetPurchaseDataStub).to.be.calledOnce; expect(iapGetPurchaseDataStub).to.be.calledOnce;
expect(paymentCancelSubscriptionSpy).to.be.calledOnce; expect(paymentCancelSubscriptionSpy).to.be.calledOnce;

View File

@@ -98,7 +98,8 @@
/> />
<button <button
class="btn btn-danger mt-3" class="btn btn-danger mt-3"
@click="confirmDeleteHero"> @click="confirmDeleteHero"
>
Begin Member deletion Begin Member deletion
</button> </button>
<b-modal <b-modal
@@ -107,7 +108,8 @@
ok-title="Delete" ok-title="Delete"
ok-variant="danger" ok-variant="danger"
cancel-title="Cancel" cancel-title="Cancel"
@ok="deleteHero"> @ok="deleteHero"
>
<b-modal-body> <b-modal-body>
<p> <p>
Are you sure you want to delete this member? Are you sure you want to delete this member?
@@ -116,29 +118,37 @@
Please note: This action cannot be undone! Please note: This action cannot be undone!
</p> </p>
<div class="ml-4"> <div class="ml-4">
<div class="form-check"> <div class="form-check">
<input <input
class="form-check-input" id="deleteAccountCheck"
type="checkbox" v-model="deleteHabiticaAccount"
v-model="deleteHabiticaAccount" class="form-check-input"
id="deleteAccountCheck"> type="checkbox"
<label class="form-check-label" for="deleteAccountCheck"> >
Delete Habitica account <label
</label> class="form-check-label"
for="deleteAccountCheck"
>
Delete Habitica account
</label>
</div>
<div class="form-check">
<input
id="deleteAmplitudeCheck"
v-model="deleteAmplitudeData"
class="form-check-input"
type="checkbox"
>
<label
class="form-check-label"
for="deleteAmplitudeCheck"
>
Delete Amplitude data
</label>
</div>
</div> </div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
v-model="deleteAmplitudeData"
id="deleteAmplitudeCheck">
<label class="form-check-label" for="deleteAmplitudeCheck">
Delete Amplitude data
</label>
</div>
</div>
</b-modal-body> </b-modal-body>
</b-modal> </b-modal>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -419,6 +419,57 @@
> >
</div> </div>
</div> </div>
<div class="form-group row">
<h2>Payment Details</h2>
</div>
<div class="form-group row">
<div class="offset-sm-3 col-sm-9 mb-3">
<button
type="button"
class="btn btn-secondary btn-sm"
@click="getSubscriptionPaymentDetails"
>
Get Subscription Payment Details
</button>
</div>
</div>
<div
v-if="paymentDetails">
<div
v-for="(value, key) in paymentDetails"
class="form-group row">
<label class="col-sm-3 col-form-label">
{{ getHumandReadablePaymentDetails(key).label }}:
<span
:id="`${key}_tooltip`"
class="info-icon"
v-b-tooltip.hover.right="getHumandReadablePaymentDetails(key).help"
>?</span>
</label>
<strong class="col-sm-9 col-form-label">
<span v-if="value === true">Yes</span>
<span v-else-if="value === false">No</span>
<span v-else-if="isDate(value)"
v-b-tooltip.hover="value">
{{ formatDate(value) }}
</span>
<span v-else-if="value === null">---</span>
<span v-else>{{ value }}</span>
</strong>
</div>
<div class="form-group row">
<div class="offset-sm-3 col-sm-9">
<a
v-if="hero.purchased.plan.paymentMethod === 'Google'"
class="btn btn-primary btn-sm"
target="_blank"
:href="playOrdersUrl">
Play Console
</a>
</div>
</div>
</div>
</div> </div>
<div <div
v-if="expand" v-if="expand"
@@ -474,17 +525,36 @@
<style lang="scss" scoped> <style lang="scss" scoped>
@import '@/assets/scss/colors.scss'; @import '@/assets/scss/colors.scss';
.input-group-append { .form-group {
width: auto; margin-bottom: 0.4rem;
}
.input-group-text {
border-bottom-right-radius: 2px; .input-group-append {
border-top-right-radius: 2px; width: auto;
font-weight: 600;
font-size: 0.8rem; .input-group-text {
color: $gray-200; border-bottom-right-radius: 2px;
border-top-right-radius: 2px;
font-weight: 600;
font-size: 0.8rem;
color: $gray-200;
}
}
.info-icon {
font-size: 0.8rem;
color: $purple-400;
cursor: pointer;
margin-left: 0.2rem;
background-color: $gray-500;
padding: 0.1rem 0.3rem;
border-radius: 0.2rem;
}
.info-icon:hover {
background-color: $purple-400;
color: white;
} }
}
</style> </style>
<script> <script>
@@ -494,6 +564,48 @@ import { getPlanContext } from '@/../../common/script/cron';
import subscriptionBlocks from '@/../../common/script/content/subscriptionBlocks'; import subscriptionBlocks from '@/../../common/script/content/subscriptionBlocks';
import saveHero from '../mixins/saveHero'; import saveHero from '../mixins/saveHero';
import LoadingSpinner from '@/components/ui/loadingSpinner'; import LoadingSpinner from '@/components/ui/loadingSpinner';
import { get } from 'lodash';
const PLAY_CONSOLE_ORDERS_BASE_URL = import.meta.env.PLAY_CONSOLE_ORDERS_BASE_URL;
const humandReadablePaymentDetails = {
customerId: {
label: 'Customer ID',
help: 'The unique identifier for the customer in the payment system.',
},
purchaseDate: {
label: 'Purchase Date',
help: 'The date when the subscription was purchased or renewed.',
},
originalPurchaseDate: {
label: 'Original Purchase Date',
help: 'The date when the subscription was first purchased.',
},
productId: {
label: 'Product ID',
help: 'The identifier for the product associated with the subscription.',
},
transactionId: {
label: 'Transaction ID',
help: 'The unique identifier for the last transaction in the payment system.',
},
isCanceled: {
label: 'Is Canceled',
help: 'Indicates whether the subscription has been canceled by the user or the system.',
},
isExpired: {
label: 'Is Expired',
help: 'Indicates whether the subscription has expired. A cancelled subscription may still be active until the end of the billing cycle.',
},
expirationDate: {
label: 'Termination Date',
help: 'The date when the subscription will expire or has expired.',
},
nextPaymentDate: {
label: 'Next Payment Date',
help: 'The date when the next payment is due. If the subscription is canceled or expired, this may be null.',
},
}
export default { export default {
components: { components: {
@@ -520,6 +632,7 @@ export default {
isConvertingToGroupPlan: false, isConvertingToGroupPlan: false,
groupPlanID: '', groupPlanID: '',
subscriptionBlocks, subscriptionBlocks,
paymentDetails: null,
}; };
}, },
computed: { computed: {
@@ -553,6 +666,9 @@ export default {
} }
return terminationDate; return terminationDate;
}, },
playOrdersUrl () {
return `${PLAY_CONSOLE_ORDERS_BASE_URL}${this.paymentDetails?.transactionId || ''}`;
}
}, },
methods: { methods: {
dateFormat (date) { dateFormat (date) {
@@ -583,6 +699,20 @@ export default {
this.isConvertingToGroupPlan = true; this.isConvertingToGroupPlan = true;
this.hero.purchased.plan.owner = ''; this.hero.purchased.plan.owner = '';
}, },
getSubscriptionPaymentDetails () {
this.$store.dispatch('adminPanel:getSubscriptionPaymentDetails', { userIdentifier: this.hero._id })
.then((details) => {
if (details) {
this.paymentDetails = details
} else {
alert('No payment details found.');
}
})
.catch((error) => {
console.error('Error fetching subscription payment details:', error);
alert(`Failed to fetch payment details: ${error.message || 'Unknown error'}`);
});
},
saveClicked (e) { saveClicked (e) {
e.preventDefault(); e.preventDefault();
if (this.isConvertingToGroupPlan) { if (this.isConvertingToGroupPlan) {
@@ -601,6 +731,15 @@ export default {
this.$emit('changeUserIdentifier', id); this.$emit('changeUserIdentifier', id);
} }
}, },
getHumandReadablePaymentDetails (key) {
return humandReadablePaymentDetails[key] || { label: key, help: '' };
},
isDate (date) {
return moment(date).isValid();
},
formatDate (date) {
return date ? moment(date).format('MM/DD/YYYY') : '---';
}
}, },
}; };
</script> </script>

View File

@@ -11,3 +11,9 @@ export async function getUserHistory (store, payload) {
const response = await axios.get(url); const response = await axios.get(url);
return response.data.data; return response.data.data;
} }
export async function getSubscriptionPaymentDetails (store, payload) {
const url = `/api/v4/admin/user/${payload.userIdentifier}/subscription-payment-details`;
const response = await axios.get(url);
return response.data.data;
}

View File

@@ -36,7 +36,7 @@ const envVars = [
'TIME_TRAVEL_ENABLED', 'TIME_TRAVEL_ENABLED',
'DEBUG_ENABLED', 'DEBUG_ENABLED',
'CONTENT_SWITCHOVER_TIME_OFFSET', 'CONTENT_SWITCHOVER_TIME_OFFSET',
// TODO necessary? if yes how not to mess up with vue cli? 'NODE_ENV' 'PLAY_CONSOLE_ORDERS_BASE_URL',
]; ];
const envObject = {}; const envObject = {};

View File

@@ -9,6 +9,8 @@ import { model as Blocker } from '../../models/blocker';
import { import {
NotFound, NotFound,
} from '../../libs/errors'; } from '../../libs/errors';
import apple from '../../libs/payments/apple';
import google from '../../libs/payments/google';
const api = {}; const api = {};
@@ -188,4 +190,46 @@ api.deleteBlocker = {
}, },
}; };
api.validateSubscriptionPaymentDetails = {
method: 'GET',
url: '/admin/user/:userId/subscription-payment-details',
middlewares: [authWithHeaders(), ensurePermission('userSupport')],
async handler (req, res) {
req.checkParams('userId', res.t('heroIdRequired')).notEmpty().isUUID();
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
const { userId } = req.params;
const user = await User.findById(userId)
.select('purchased')
.lean()
.exec();
if (!user) throw new NotFound(res.t('userWithIDNotFound', { userId }));
if (!user.purchased || !user.purchased.plan || !user.purchased.plan.paymentMethod || !user.purchased.plan.paymentMethod === '') {
throw new NotFound(res.t('subscriptionNotFoundForUser', { userId }));
}
let paymentDetails;
if (user.purchased.plan.paymentMethod === 'Apple') {
paymentDetails = await apple.getSubscriptionPaymentDetails(userId, user.purchased.plan);
} else if (user.purchased.plan.paymentMethod === 'Google') {
paymentDetails = await google.getSubscriptionPaymentDetails(userId, user.purchased.plan);
} else if (user.purchased.plan.paymentMethod === 'Paypal') {
throw new NotFound(res.t('paypalSubscriptionNotValidated'));
} else if (user.purchased.plan.paymentMethod === 'Stripe') {
throw new NotFound(res.t('stripeSubscriptionNotValidated'));
} else if (user.purchased.plan.paymentMethod === 'Amazon Payments') {
throw new NotFound(res.t('amazonSubscriptionNotValidated'));
} else if (user.purchased.plan.paymentMethod === 'Gift') {
throw new NotFound(res.t('giftSubscriptionNotValidated'));
} else {
throw new NotFound(res.t('unknownSubscriptionPaymentMethod', { method: user.purchased.paymentMethod }));
}
res.respond(200, paymentDetails);
},
};
export default api; export default api;

View File

@@ -74,7 +74,7 @@ api.verifyPurchase = async function verifyPurchase (options) {
return appleRes; return appleRes;
}; };
api.subscribe = async function subscribe (user, receipt, headers, nextPaymentProcessing) { async function findSubscriptionPurchase (receipt, onlyActive = true) {
await iap.setup(); await iap.setup();
const appleRes = await iap.validate(iap.APPLE, receipt); const appleRes = await iap.validate(iap.APPLE, receipt);
@@ -85,18 +85,51 @@ api.subscribe = async function subscribe (user, receipt, headers, nextPaymentPro
if (purchaseDataList.length === 0) { if (purchaseDataList.length === 0) {
throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED); throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED);
} }
let purchase; let purchase;
let newestDate; let newestDate;
for (const purchaseData of purchaseDataList) { for (const purchaseData of purchaseDataList) {
const datePurchased = new Date(Number(purchaseData.purchaseDate)); const datePurchased = new Date(Number(purchaseData.purchaseDateMs));
const dateTerminated = new Date(Number(purchaseData.expirationDate)); const dateTerminated = new Date(Number(purchaseData.expirationDate || 0));
if ((!newestDate || datePurchased > newestDate) && dateTerminated > new Date()) { if ((!newestDate || datePurchased > newestDate)) {
purchase = purchaseData; if (!onlyActive || dateTerminated > new Date()) {
newestDate = datePurchased; purchase = purchaseData;
newestDate = datePurchased;
}
} }
} }
if (!purchase) {
throw new NotAuthorized(api.constants.RESPONSE_NO_ITEM_PURCHASED);
}
return {
purchase,
isCanceled: iap.isCanceled(purchase),
isExpired: iap.isExpired(purchase),
expirationDate: new Date(Number(purchase.expirationDate)),
};
}
api.getSubscriptionPaymentDetails = async function getDetails (userId, subscriptionPlan) {
if (!subscriptionPlan || !subscriptionPlan.additionalData) {
throw new NotAuthorized(shared.i18n.t('missingSubscription'));
}
const details = await findSubscriptionPurchase(subscriptionPlan.additionalData);
return {
customerId: details.purchase.originalTransactionId || details.purchase.transactionId,
purchaseDate: new Date(Number(details.purchase.purchaseDateMs)),
originalPurchaseDate: new Date(Number(details.purchase.originalPurchaseDateMs)),
expirationDate: details.isCanceled || details.isExpired ? details.expirationDate : null,
nextPaymentDate: details.isCanceled || details.isExpired ? null : details.expirationDate,
productId: details.purchase.productId,
transactionId: details.purchase.transactionId,
isCanceled: details.isCanceled,
isExpired: details.isExpired,
};
};
api.subscribe = async function subscribe (user, receipt, headers, nextPaymentProcessing) {
const details = await findSubscriptionPurchase(receipt);
const { purchase } = details;
let subCode; let subCode;
switch (purchase.productId) { // eslint-disable-line default-case switch (purchase.productId) { // eslint-disable-line default-case
@@ -250,37 +283,17 @@ api.noRenewSubscribe = async function noRenewSubscribe (options) {
api.cancelSubscribe = async function cancelSubscribe (user, headers) { api.cancelSubscribe = async function cancelSubscribe (user, headers) {
const { plan } = user.purchased; const { plan } = user.purchased;
if (plan.paymentMethod !== api.constants.PAYMENT_METHOD_APPLE) throw new NotAuthorized(shared.i18n.t('missingSubscription')); if (plan.paymentMethod !== api.constants.PAYMENT_METHOD_APPLE) throw new NotAuthorized(shared.i18n.t('missingSubscription'));
await iap.setup();
try { try {
const appleRes = await iap.validate(iap.APPLE, plan.additionalData); const details = await findSubscriptionPurchase(plan.additionalData, false);
if (!details.isCanceled && !details.isExpired) {
const isValidated = iap.isValidated(appleRes);
if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
const purchases = iap.getPurchaseData(appleRes);
if (purchases.length === 0) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
let newestDate;
let newestPurchase;
for (const purchaseData of purchases) {
const datePurchased = new Date(Number(purchaseData.purchaseDate));
if (!newestDate || datePurchased > newestDate) {
newestDate = datePurchased;
newestPurchase = purchaseData;
}
}
if (!iap.isCanceled(newestPurchase) && !iap.isExpired(newestPurchase)) {
throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID); throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID);
} }
await payments.cancelSubscription({ await payments.cancelSubscription({
user, user,
nextBill: new Date(Number(newestPurchase.expirationDate)), nextBill: new Date(Number(details.expirationDate)),
paymentMethod: this.constants.PAYMENT_METHOD_APPLE, paymentMethod: this.constants.PAYMENT_METHOD_APPLE,
headers, headers,
}); });

View File

@@ -72,6 +72,53 @@ api.verifyPurchase = async function verifyPurchase (options) {
return googleRes; return googleRes;
}; };
async function findSubscriptionPurchase (additionalData) {
const googleRes = await iap.validate(iap.GOOGLE, additionalData);
const isValidated = iap.isValidated(googleRes);
if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
const purchases = iap.getPurchaseData(googleRes);
if (purchases.length === 0) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
let purchase;
let newestDate;
for (const i in purchases) {
if (Object.prototype.hasOwnProperty.call(purchases, i)) {
const thisPurchase = purchases[i];
const purchaseDate = new Date(Number(thisPurchase.startTimeMillis));
if (!newestDate || purchaseDate > newestDate) {
newestDate = purchaseDate;
purchase = purchases[i];
}
}
}
return {
purchase,
isCanceled: iap.isCanceled(purchase),
isExpired: iap.isExpired(purchase),
expirationDate: new Date(Number(purchase.expirationDate)),
};
}
api.getSubscriptionPaymentDetails = async function getDetails (userId, subscriptionPlan) {
if (!subscriptionPlan || !subscriptionPlan.additionalData) {
throw new NotAuthorized(shared.i18n.t('missingSubscription'));
}
const details = await findSubscriptionPurchase(subscriptionPlan.additionalData);
return {
customerId: details.purchase.purchaseToken,
originalPurchaseDate: new Date(Number(details.purchase.startTimeMillis)),
expirationDate: details.isCanceled || details.isExpired ? details.expirationDate : null,
nextPaymentDate: details.isCanceled || details.isExpired ? null : details.expirationDate,
productId: details.purchase.productId,
transactionId: details.purchase.orderId,
isCanceled: details.isCanceled,
isExpired: details.isExpired,
};
};
api.subscribe = async function subscribe ( api.subscribe = async function subscribe (
sku, sku,
user, user,
@@ -213,22 +260,11 @@ api.cancelSubscribe = async function cancelSubscribe (user, headers) {
let dateTerminated; let dateTerminated;
try { try {
const googleRes = await iap.validate(iap.GOOGLE, plan.additionalData); const details = await findSubscriptionPurchase(plan.additionalData);
if (!details.isCanceled && !details.isExpired) {
const isValidated = iap.isValidated(googleRes); throw new NotAuthorized(this.constants.RESPONSE_STILL_VALID);
if (!isValidated) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
const purchases = iap.getPurchaseData(googleRes);
if (purchases.length === 0) throw new NotAuthorized(this.constants.RESPONSE_INVALID_RECEIPT);
for (const i in purchases) {
if (Object.prototype.hasOwnProperty.call(purchases, i)) {
const purchase = purchases[i];
if (purchase.autoRenewing !== false) return;
if (!dateTerminated || Number(purchase.expirationDate) > Number(dateTerminated)) {
dateTerminated = new Date(Number(purchase.expirationDate));
}
}
} }
dateTerminated = details.expirationDate;
} catch (err) { } catch (err) {
// Status:410 means that the subsctiption isn't active anymore and we can safely delete it // Status:410 means that the subsctiption isn't active anymore and we can safely delete it
if (err && err.message === 'Status:410') { if (err && err.message === 'Status:410') {