fix(iap): handle invalid google play store purchaseTokens

This commit is contained in:
julianpoyourow
2025-09-09 23:33:50 +00:00
parent f5bdd1907b
commit d82f4de095
9 changed files with 77 additions and 31 deletions

4
.vscode/launch.json vendored
View File

@@ -21,13 +21,13 @@
"protocol": "inspector",
"env": {
"DEBUG": "1",
"NODE_OPTIONS": "--dns-result-order=ipv4first",
"NODE_OPTIONS": "--dns-result-order=ipv4first"
},
"program": "${workspaceFolder}/node_modules/@playwright/test/cli.js",
"args": ["test","--config=${workspaceFolder}/packages/functional-tests/playwright.config.ts", "--project=local"],
"autoAttachChildProcesses": true,
"cwd":"${workspaceFolder}/packages/functional-tests",
"request": "launch",
"request": "launch"
}
]
}

View File

@@ -103,7 +103,7 @@ describe('GoogleIapPurchaseManager', () => {
};
jest
.spyOn(googleIapClient, 'getSubscriptions')
.spyOn(googleIapClient, 'getSubscription')
.mockResolvedValue(mockApiResponse);
jest.spyOn(repository, 'getPurchase').mockResolvedValue(undefined);
@@ -114,7 +114,7 @@ describe('GoogleIapPurchaseManager', () => {
const result = await manager.getFromPlayStoreApi(packageName, sku, token);
expect(googleIapClient.getSubscriptions).toHaveBeenCalledWith(
expect(googleIapClient.getSubscription).toHaveBeenCalledWith(
packageName,
sku,
token
@@ -124,7 +124,7 @@ describe('GoogleIapPurchaseManager', () => {
it('throws wrapped error when Firestore fails', async () => {
const token = faker.string.uuid();
jest.spyOn(googleIapClient, 'getSubscriptions').mockResolvedValue({});
jest.spyOn(googleIapClient, 'getSubscription').mockResolvedValue({});
jest.spyOn(repository, 'getPurchase').mockImplementation(() => {
throw new Error('Firestore failure');
});

View File

@@ -15,7 +15,7 @@ import {
} from './google-iap-purchase.repository';
import type { NotificationType } from './types';
import { GoogleIapClient } from './google-iap.client';
import { GoogleIapGetFromPlayStoreUnknownError } from './google-iap.error';
import { GoogleIapGetFromPlayStoreUnknownError, GoogleIapSubscriptionNotFoundError, GoogleIapSubscriptionPurchaseTokenInvalidError } from './google-iap.error';
import { REPLACED_PURCHASE_USERID_PLACEHOLDER } from './constants';
@Injectable()
@@ -66,11 +66,25 @@ export class GoogleIapPurchaseManager {
this.log.debug('queryCurrentSubscriptions.cache.update', {
purchaseToken: purchase.purchaseToken,
});
purchase = await this.getFromPlayStoreApi(
purchase.packageName,
purchase.sku,
purchase.purchaseToken
);
try {
purchase = await this.getFromPlayStoreApi(
purchase.packageName,
purchase.sku,
purchase.purchaseToken
);
} catch(e) {
if (e instanceof GoogleIapSubscriptionNotFoundError) {
this.log.error('queryCurrentSubscriptions.purchaseTokenNotFound', {
purchaseToken: purchase.purchaseToken,
});
} else if (e instanceof GoogleIapSubscriptionPurchaseTokenInvalidError) {
this.log.error('queryCurrentSubscriptions.invalidPurchaseToken', {
purchaseToken: purchase.purchaseToken,
});
} else {
throw e;
}
}
}
if (purchase.isAccountHold() || purchase.isPaused()) {
@@ -103,7 +117,7 @@ export class GoogleIapPurchaseManager {
triggerNotificationType?: NotificationType
): Promise<PlayStoreSubscriptionPurchase> {
// STEP 1. Query Play Developer API to verify the purchase token
const apiResponse = await this.googleIapClient.getSubscriptions(
const apiResponse = await this.googleIapClient.getSubscription(
packageName,
sku,
purchaseToken
@@ -209,7 +223,7 @@ export class GoogleIapPurchaseManager {
// Purchase record not found in Firestore. We'll try to fetch purchase detail from Play Developer API to backfill the missing cache
const apiResponse = await this.googleIapClient
.getSubscriptions(packageName, sku, purchaseToken)
.getSubscription(packageName, sku, purchaseToken)
.catch((err) => {
// We only log an warning to as there is chance that backfilling is impossible.
// For example: after a subscription upgrade, the new token has linkedPurchaseToken to be the token before upgrade.

View File

@@ -32,7 +32,7 @@ describe('GoogleIapClient', () => {
jest.restoreAllMocks();
});
describe('getSubscriptions', () => {
describe('getSubscription', () => {
it('should return subscription data', async () => {
const mockPackageName = faker.string.uuid();
const mockSku = faker.string.uuid();
@@ -48,7 +48,7 @@ describe('GoogleIapClient', () => {
)
.mockResolvedValue({ data: mockResponseData });
const result = await googleIapClient.getSubscriptions(
const result = await googleIapClient.getSubscription(
mockPackageName,
mockSku,
mockPurchaseToken
@@ -78,7 +78,7 @@ describe('GoogleIapClient', () => {
.mockRejectedValue(error);
await expect(
googleIapClient.getSubscriptions(
googleIapClient.getSubscription(
mockPackageName,
mockSku,
mockPurchaseToken

View File

@@ -13,6 +13,7 @@ import {
GoogleIapClientUnexpectedTypeError,
GoogleIapClientUnknownError,
GoogleIapSubscriptionNotFoundError,
GoogleIapSubscriptionPurchaseTokenInvalidError,
} from './google-iap.error';
@Injectable()
@@ -33,7 +34,7 @@ export class GoogleIapClient {
});
}
async getSubscriptions(
async getSubscription(
packageName: string,
sku: string,
purchaseToken: string
@@ -49,7 +50,10 @@ export class GoogleIapClient {
return apiResponse.data;
} catch (e) {
if (e instanceof Error && 'code' in e && e.code === 404) {
throw new GoogleIapSubscriptionNotFoundError(packageName, sku, e);
throw new GoogleIapSubscriptionNotFoundError(packageName, sku, purchaseToken, e);
}
if (e instanceof Error && 'code' in e && e.code === 410) {
throw new GoogleIapSubscriptionPurchaseTokenInvalidError(packageName, sku, purchaseToken, e);
}
throw this.convertError(e);

View File

@@ -16,12 +16,19 @@ export class GoogleIapError extends BaseError {
}
export class GoogleIapSubscriptionNotFoundError extends GoogleIapError {
constructor(packageName: string, sku: string, cause: Error) {
super('Google IAP subscription not found', { packageName, sku }, cause);
constructor(packageName: string, sku: string, purchaseToken: string, cause: Error) {
super('Google IAP subscription not found', { packageName, sku, purchaseToken }, cause);
this.name = 'GoogleIapSubscriptionNotFoundError';
}
}
export class GoogleIapSubscriptionPurchaseTokenInvalidError extends GoogleIapError {
constructor(packageName: string, sku: string, purchaseToken: string, cause: Error) {
super('Google IAP subscription purchase token invalid', { packageName, sku, purchaseToken }, cause);
this.name = 'GoogleIapSubscriptionPurchaseTokenInvalidError';
}
}
export class GoogleIapInvalidMessagePayloadError extends GoogleIapError {
constructor(messageData: string, cause: Error) {
super('Invalid message payload', { messageData }, cause);

View File

@@ -99,11 +99,21 @@ export class UserManager extends UserManagerBase {
this.log.info('queryCurrentSubscriptions.cache.update', {
purchaseToken: purchase.purchaseToken,
});
purchase = await this.purchaseManager.querySubscriptionPurchase(
purchase.packageName,
purchase.sku,
purchase.purchaseToken
);
try {
purchase = await this.purchaseManager.querySubscriptionPurchase(
purchase.packageName,
purchase.sku,
purchase.purchaseToken
);
} catch(e) {
if (e.name === PurchaseQueryError.INVALID_TOKEN) {
this.log.error('queryCurrentSubscriptions.invalidPurchaseToken', {
purchaseToken: purchase.purchaseToken,
});
} else {
throw e;
}
}
}
// Add the updated purchase to list to returned to clients

View File

@@ -223,7 +223,7 @@ export class PurchaseManager {
private convertPlayAPIErrorToLibraryError(playError: any): Error {
const libraryError = new Error(playError.message);
if (playError.code === 404) {
if (playError.code === 404 || playError.code === 410) {
libraryError.name = PurchaseQueryError.INVALID_TOKEN;
} else {
// Unexpected error occurred. It's likely an issue with Service Account

View File

@@ -10,6 +10,7 @@ import {
PlayStoreSubscriptionPurchase,
} from './subscription-purchase';
import { SkuType } from './types/purchases';
import { PurchaseQueryError } from './types/errors';
export class UserManager {
constructor(
@@ -63,11 +64,21 @@ export class UserManager {
this.log.info('queryCurrentSubscriptions.cache.update', {
purchaseToken: purchase.purchaseToken,
});
purchase = await this.purchaseManager.querySubscriptionPurchase(
purchase.packageName,
purchase.sku,
purchase.purchaseToken
);
try {
purchase = await this.purchaseManager.querySubscriptionPurchase(
purchase.packageName,
purchase.sku,
purchase.purchaseToken
);
} catch(e) {
if (e.name === PurchaseQueryError.INVALID_TOKEN) {
this.log.error('queryCurrentSubscriptions.invalidPurchaseToken', {
purchaseToken: purchase.purchaseToken,
});
} else {
throw e;
}
}
}
// Add the updated purchase to list to returned to clients