typescript/src/services/google-play-v2.ts (144 lines of code) (raw):

import { androidpublisher, auth } from '@googleapis/androidpublisher'; import type S3 from 'aws-sdk/clients/s3'; import { Stage } from '../utils/appIdentity'; import aws = require('../utils/aws'); import { mapAndroidProductId } from '../utils/mapAndroidProductId'; export type GoogleSubscription = { // Time at which the subscription was granted. Not set for pending subscriptions (subscription was created but awaiting payment during signup) startTime: Date | null; // Time at which the subscription expired or will expire unless the access is extended (eg. renews) expiryTime: Date; // The time at which the subscription was canceled by the user. The user might still have access to the subscription after this time (defer to `expiryTime` above) userCancellationTime: Date | null; // If the subscription is currently set to auto-renew, e.g. the user has not canceled the subscription autoRenewing: boolean; // The purchased product ID (for example, 'guardian.subscription.annual.meteroffer'.) Note that this was previously referred to as the `subscriptionId` productId: string; // Subscription period, specified in ISO 8601 format (P1M, P6M, P1Y, etc.) billingPeriodDuration: string; // Whether the subscription is currently benefitting from a free trial freeTrial: boolean; // Whether the subscription was taken out as a test purchase testPurchase: boolean; // Obfuscated external account ID obfuscatedExternalAccountId?: string; // The raw response from Google rawResponse: unknown; }; // Given a `purchaseToken` and `packageName`, attempts to build a `GoogleSubscription` by: // 1. Looking up the `SubscriptionPurchaseV2` from the `android-publisher` API: https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2/get // 2. Assuming that the purchase is of _exactly one_ subscription product // 3. Looking up detailed information about the purchased subscription product from the `android-publisher` API: // https://developers.google.com/android-publisher/api-ref/rest/v3/monetization.subscriptions/get // 4. Applying heuristics to attempt to determine whether the subscription is currently beneffiting from a free trial (see detailed discussion below.) export async function fetchGoogleSubscriptionV2( purchaseToken: string, packageName: string, ): Promise<GoogleSubscription> { try { const client = await initialiseAndroidPublisherClient(); const purchase = await client.purchases.subscriptionsv2.get({ packageName, token: purchaseToken, }); // A subscription purchase refers to one or many underlying products ("line items".) However, by convention (and by // constraining/controling the UX within the app), we will always assume that a subscription purchase refers to exactly // one product. if (purchase.data.lineItems?.length != 1) { throw Error( 'The subscription purchase must refer to exactly one product', ); } const product = purchase.data.lineItems[0]; const startTime = purchase.data.startTime ?? null; const expiryTime = product.expiryTime; if (!expiryTime) { throw Error('The subscription purchase does not have an expiry time'); } const userCancellationTime = purchase.data.canceledStateContext?.userInitiatedCancellation ?.cancelTime ?? null; const autoRenewing = product.autoRenewingPlan?.autoRenewEnabled ?? false; const testPurchase = purchase.data.testPurchase ? true : false; const productId = product.productId; if (!productId) { throw Error('The product does not have an ID'); } const basePlanId = product.offerDetails?.basePlanId; if (!basePlanId) { throw Error('Unable to determine the base plan for the product'); } const subscription = await client.monetization.subscriptions.get({ packageName, productId, }); const basePlan = subscription.data.basePlans?.find( (x) => x.basePlanId == basePlanId, ); if (!basePlan) { throw Error('Unable to determine the base plan for the product'); } const billingPeriodDuration = basePlan.autoRenewingBasePlanType?.billingPeriodDuration ?? basePlan.prepaidBasePlanType?.billingPeriodDuration; if (!billingPeriodDuration) { throw Error( 'Unable to determine a billing period duration for the base plan', ); } const offerId = product.offerDetails?.offerId ?? null; const latestOrderId = purchase.data.latestOrderId; if (!latestOrderId) { throw Error( 'An order ID is expected to be associated with the purchase, but was not present', ); } const obfuscatedExternalAccountId = purchase.data.externalAccountIdentifiers?.obfuscatedExternalAccountId ?? undefined; return { startTime: parseNullableDate(startTime), expiryTime: new Date(expiryTime), userCancellationTime: parseNullableDate(userCancellationTime), autoRenewing, // Map the product_id for test Feast purchases for easy identification downstream productId: mapAndroidProductId(productId, packageName, testPurchase), billingPeriodDuration, freeTrial: isFreeTrial(offerId, latestOrderId), testPurchase, obfuscatedExternalAccountId, rawResponse: purchase.data, }; } catch (error: any) { if (error?.status == 400 || error?.status == 404 || error?.status == 410) { console.error( `fetchGoogleSubscriptionV2 error: invalid purchase token; subscription not found; or no such package name (status = ${error.status})`, error, ); } else { console.error(`fetchGoogleSubscriptionV2 error:`, error); } throw error; } } // Determining if a subscription currently benefits from a free trial is quite indirect in version 2 of the API, as compared // to in version 1 (https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptions/get), where a // `paymentState` value of 2 would directly signal the free trial status. Unfortunately, `paymentState` is not present in // version 2 (https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2/get), so the free // trial status must be inferred as follows: // // 1. The `offerId` associated with the purchased subscription must be non-null, so as to be able to refer to an offer which // confers a free trial // 2. However, if present in the first place, the `offerId` remains associated with the purchased subscription even after the // free trial period has elapsed. Therefore, the most recent "transaction ID" (here, `latestOrderId`) must be inspected to // determine whether it refers to the first or subsequent transactions: // - The first transaction for a subscription will have an ID of the form: GPA.XXXX-XXXX-XXXX-XXXXX // - Subsequent transaction IDs for the same subscription purchase will have the form: GPA.XXXX-XXXX-XXXX-XXXXX..N // (where N is a zero-indexed, incrementing reference to the sequence of transactions against a subscription purchase) // 3. Therefore, if the `offerId` is non-null, and the most recent transaction ID refers to an initial (non-subsequent) // transaction, the subscription must be in a free trial state // // See: https://github.com/android/play-billing-samples/issues/585#issuecomment-1788695432 // See: https://stackoverflow.com/a/76867605 // See: https://developer.android.com/google/play/billing/compatibility (search in-page for "paymentState".) function isFreeTrial(offerId: string | null, latestOrderId: string): boolean { return offerId !== null && !latestOrderId.includes('..'); } function parseNullableDate(date: string | null): Date | null { return date === null ? null : new Date(date); } async function initialiseAndroidPublisherClient() { const accessToken = await getAccessToken(getParams(Stage)); const authClient = new auth.OAuth2({ credentials: { access_token: accessToken.token }, }); return androidpublisher({ version: 'v3', auth: authClient }); } interface AccessToken { token: string; date: Date; } function getParams(stage: string): S3.Types.GetObjectRequest { return { Bucket: 'gu-mobile-access-tokens', Key: `${stage}/google-play-developer-api/access_token.json`, }; } function getAccessToken( params: S3.Types.GetObjectRequest, ): Promise<AccessToken> { return aws.s3 .getObject(params) .promise() .then((s3OutPut) => { if (s3OutPut.Body) { return JSON.parse(s3OutPut.Body.toString()); } else { throw Error('S3 output body was not defined'); } }) .catch((error) => { console.log(`Failed to get access token from S3 due to: ${error}`); throw error; }); }