Compare commits

...

3 Commits

Author SHA1 Message Date
Kalista Payne 19cacd0b89 fi x(lint): more whitespace, unary, etc 2025-06-24 10:59:59 -05:00
Kalista Payne 6ebe431920 fix(lint): whitespace, loop awaits 2025-06-24 10:41:35 -05:00
Hafiz d815b1e02a fix(payments): credit gems before marking IAP receipt consumed & add retry
Previously we marked the IAP receipt consumed in the DB before attempting
to credit gems. If crediting failed (e.g. transient DB/network error),
the token was "burned" and the Android/iOS client would consume it on
their side, making retries impossible and causing players to pay without
receiving gems.

This change:
- Introduces "safeBuySkuItem" in googlePlay.js/apple.js with a 3-attempt
  exponential-backoff wrapper around payments.buySkuItem.
- Swaps the order in verifyPurchase/noRenewSubscribe: first call
  'safeBuySkuItem', then 'IapPurchaseReceipt.create' only on success.
- Ensures any failure bubbles up before the receipt is marked consumed.

Now purchases are retried on transient failures and receipts are only
burned once delivery succeeds.
2025-06-18 12:39:17 -05:00
2 changed files with 50 additions and 17 deletions
+24 -9
View File
@@ -22,6 +22,20 @@ api.constants = {
RESPONSE_NO_ITEM_PURCHASED: 'NO_ITEM_PURCHASED',
};
/* eslint-disable no-await-in-loop */
async function safeBuySkuItem (opts) {
const maxRetries = 3;
for (let i = 1; i <= maxRetries; i += 1) {
try {
await payments.buySkuItem(opts);
return;
} catch (err) {
if (i === maxRetries) throw err;
await new Promise(r => setTimeout(r, 1000 * 2 ** i));
}
}
}
api.verifyPurchase = async function verifyPurchase (options) {
const {
gift, user, receipt, headers,
@@ -54,25 +68,26 @@ api.verifyPurchase = async function verifyPurchase (options) {
}).exec();
if (!existingReceipt) {
await IapPurchaseReceipt.create({ // eslint-disable-line no-await-in-loop
_id: token,
consumed: true,
// This should always be the buying user even for a gift.
userId: user._id,
});
await payments.buySkuItem({ // eslint-disable-line no-await-in-loop
await safeBuySkuItem({
user,
gift,
paymentMethod: api.constants.PAYMENT_METHOD_APPLE,
sku: purchaseData.productId,
headers,
});
await IapPurchaseReceipt.create({
_id: token,
consumed: true,
// This should always be the buying user even for a gift.
userId: user._id,
});
}
}
return appleRes;
};
/* eslint-enable no-await-in-loop */
api.subscribe = async function subscribe (user, receipt, headers, nextPaymentProcessing) {
await iap.setup();
@@ -245,8 +260,8 @@ api.noRenewSubscribe = async function noRenewSubscribe (options) {
if (!correctReceipt) throw new NotAuthorized(api.constants.RESPONSE_INVALID_ITEM);
return appleRes;
/* eslint-enable no-await-in-loop */
};
/* eslint-enable no-await-in-loop */
api.cancelSubscribe = async function cancelSubscribe (user, headers) {
const { plan } = user.purchased;
+26 -8
View File
@@ -21,6 +21,22 @@ api.constants = {
RESPONSE_STILL_VALID: 'SUBSCRIPTION_STILL_VALID',
};
/* eslint-disable no-await-in-loop */
async function safeBuySkuItem (opts) {
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
try {
await payments.buySkuItem(opts);
return;
} catch (err) {
if (attempt === maxRetries) throw err;
// exponential backoff: 1s, 2s, 4s
await new Promise(res => setTimeout(res, 1000 * 2 ** attempt));
}
}
}
/* eslint-enable no-await-in-loop */
api.verifyPurchase = async function verifyPurchase (options) {
const {
gift, user, receipt, signature, headers,
@@ -54,14 +70,8 @@ api.verifyPurchase = async function verifyPurchase (options) {
}).exec();
if (existingReceipt) throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
await IapPurchaseReceipt.create({
_id: token,
consumed: true,
// This should always be the buying user even for a gift.
userId: user._id,
});
await payments.buySkuItem({ // eslint-disable-line no-await-in-loop
// Credit the purchase (with retry logic) before ever touching the receipt record
await safeBuySkuItem({
user,
gift,
paymentMethod: api.constants.PAYMENT_METHOD_GOOGLE,
@@ -69,6 +79,14 @@ api.verifyPurchase = async function verifyPurchase (options) {
headers,
});
// Only once purchase have been successfully credited, mark the purchase token as consumed
await IapPurchaseReceipt.create({
_id: token,
consumed: true,
// This should always be the buying user even for a gift.
userId: user._id,
});
return googleRes;
};