From 799fd842c6007168699de0ba4b1105ce958c69c0 Mon Sep 17 00:00:00 2001 From: Tom Wey Date: Mon, 10 Jun 2024 11:25:53 +0100 Subject: [PATCH] Use purchases.subscriptionsv2 for Android Feast subscriptions (#1545) Use the purchases.subscriptionsv2 [1] endpoint to retrieve data about Android Feast subs, instead of purchases.subscriptions v1 [2] (which we use for the live app). The reason for the migration is that when using the v1 endpoint, we rely on being able to map a product ID to a billing duration. This mapping is defined here [3]. This isn't very robust (in the past, new products have been added in the Play store, but not reflected here, meaning we get rows without a billing period). Furthermore, the was the Feast app has been configured in the Play store, it's not actually possible to map things this way. Instead, if we use the v2 endpoint, we can retrieve the billing period using Play store APIs. Some back story on the v2 endpont: Tom Wadeson did some work to implement the v2 endpoint. In prod there's a test which uses the v2 endpoint in parallel with the v1 endpoint for x% of requests to the subscriptions endpoint. There's a draft PR #1338 to use the new endpoint everywhere, but not merged yet. Using this for Feast feels like a nice step in the migration path as we're using it for real but in a focused context. [1]: https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2 [2]: https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptions [3]: https://github.com/guardian/mobile-purchases/blob/bb1632ea1e53d0fb2ca0e870b67677558a4ea2b9/typescript/src/services/productBillingPeriod.ts#L4 --- typescript/src/feast/update-subs/google.ts | 44 +++++++++++++++---- typescript/src/services/google-play-v2.ts | 14 ++++-- .../tests/feast/update-subs/google.test.ts | 39 ++++++++++------ 3 files changed, 72 insertions(+), 25 deletions(-) diff --git a/typescript/src/feast/update-subs/google.ts b/typescript/src/feast/update-subs/google.ts index f52b44e51..1d8aeb751 100644 --- a/typescript/src/feast/update-subs/google.ts +++ b/typescript/src/feast/update-subs/google.ts @@ -1,20 +1,48 @@ import { SQSEvent, SQSRecord } from "aws-lambda"; -import { getGoogleSubResponse } from "../../update-subs/google"; import { Subscription } from '../../models/subscription'; import { ProcessingError } from "../../models/processingError"; import { GracefulProcessingError } from "../../models/GracefulProcessingError"; import { putSubscription } from "../../update-subs/updatesub"; +import { GoogleSubscription, fetchGoogleSubscriptionV2 } from "../../services/google-play-v2"; +import { GoogleSubscriptionReference } from "../../models/subscriptionReference"; +import { fromGooglePackageName } from "../../services/appToPlatform"; +import { dateToSecondTimestamp, thirtyMonths } from "../../utils/dates"; + +const googleSubscriptionToSubscription = ( + purchaseToken: string, + packageName: string, + googleSubscription: GoogleSubscription +): Subscription => { + return new Subscription( + purchaseToken, + googleSubscription.startTime?.toISOString() ?? "", + googleSubscription.expiryTime.toISOString(), + googleSubscription.userCancellationTime?.toISOString(), + googleSubscription.autoRenewing, + googleSubscription.productId, + fromGooglePackageName(packageName), + googleSubscription.freeTrial, + googleSubscription.billingPeriodDuration, + googleSubscription, + undefined, + null, + dateToSecondTimestamp(thirtyMonths(googleSubscription.expiryTime)), + ) +}; export const buildHandler = ( - fetchSubscriptionDetails: (record: SQSRecord) => Promise, + fetchSubscriptionDetails: (purchaseToken: string, packageName: string) => Promise, putSubscription: (subscription: Subscription) => Promise, -): (event: SQSEvent) => Promise => (async (event: SQSEvent) => { +) => (async (event: SQSEvent) => { const promises = event.Records.map(async (sqsRecord: SQSRecord) => { try { - const subscriptions = await fetchSubscriptionDetails(sqsRecord); // Subscription[] - await Promise.all(subscriptions.map(sub => putSubscription(sub))); + // TODO: parse this using zod to get validation + const subRef = JSON.parse(sqsRecord.body) as GoogleSubscriptionReference; + const subscriptionFromGoogle = await fetchSubscriptionDetails(subRef.purchaseToken, subRef.packageName); + const subscription = googleSubscriptionToSubscription(subRef.purchaseToken, subRef.packageName, subscriptionFromGoogle); + await putSubscription(subscription); - console.log(`Processed ${subscriptions.length} subscriptions: ${subscriptions.map(s => s.subscriptionId)}`); + console.log(`Processed subscription: ${subscription.subscriptionId}`); return "OK" } catch (error) { @@ -42,9 +70,9 @@ export const buildHandler = ( return Promise.all(promises) .then(_ => "OK") -}) +}); export const handler = buildHandler( - getGoogleSubResponse, + fetchGoogleSubscriptionV2, putSubscription, ); diff --git a/typescript/src/services/google-play-v2.ts b/typescript/src/services/google-play-v2.ts index 5aab56c5c..0be7beeff 100644 --- a/typescript/src/services/google-play-v2.ts +++ b/typescript/src/services/google-play-v2.ts @@ -20,7 +20,11 @@ export type GoogleSubscription = { // Whether the subscription is currently benefitting from a free trial freeTrial: boolean // Whether the subscription was taken out as a test purchase - testPurchase: boolean + 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: @@ -111,6 +115,8 @@ export async function fetchGoogleSubscriptionV2( 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), @@ -119,7 +125,9 @@ export async function fetchGoogleSubscriptionV2( productId, billingPeriodDuration, freeTrial: isFreeTrial(offerId, latestOrderId), - testPurchase + testPurchase, + obfuscatedExternalAccountId, + rawResponse: purchase.data, } } catch (error: any) { if (error?.status == 400 || error?.status == 404 || error?.status == 410) { @@ -195,4 +203,4 @@ function getAccessToken(params: S3.Types.GetObjectRequest) : Promise jest.fn()); + describe("The Feast Android subscription updater", () => { it("Should fetch the subscription associated with the reference from Google and persist to Dynamo", async () => { const packageName = "uk.co.guardian.feast"; @@ -14,38 +17,46 @@ describe("The Feast Android subscription updater", () => { purchaseToken, subscriptionId, }]); - const subscriptionFromGoogle: GoogleResponseBody = { + const startTime = plusDays(new Date(), -1); + const expiryTime = plusDays(new Date(), 30); + const googleSubscription = { + startTime, + expiryTime, + userCancellationTime: null, autoRenewing: true, - expiryTimeMillis: plusDays(new Date(), 30).getTime().toString(), - paymentState: 1, - startTimeMillis: plusDays(new Date(), -1).getTime().toString(), - userCancellationTimeMillis: "", + productId: subscriptionId, + billingPeriodDuration: "P1M", + freeTrial: false, + testPurchase: false, + obfuscatedExternalAccountId: "aaaa-bbbb-cccc-dddd", + rawResponse: "test-raw-response", }; const subscription = new Subscription( purchaseToken, - plusDays(new Date(), -1).toISOString(), // start date - plusDays(new Date(), 30).toISOString(), // expiry date + startTime.toISOString(), // start date + expiryTime.toISOString(), // expiry date undefined, // cancellation date true, // auto renewing subscriptionId, "android-feast", false, // free trial - "monthly", - subscriptionFromGoogle, + "P1M", + googleSubscription, undefined, // receipt null, // apple payload - undefined, // ttl + dateToSecondTimestamp(thirtyMonths(googleSubscription.expiryTime)) // ttl ); - const stubFetchSubscriptionsFromGoogle = () => Promise.resolve([subscription]); + const mockFetchSubscriptionsFromGoogle = jest.fn(() => Promise.resolve(googleSubscription)); const mockStoreSubscriptionInDynamo = jest.fn((subscription: Subscription) => Promise.resolve(subscription)) const handler = buildHandler( - stubFetchSubscriptionsFromGoogle, + mockFetchSubscriptionsFromGoogle, mockStoreSubscriptionInDynamo, ); const result = await handler(event); expect(result).toEqual("OK"); + expect(mockFetchSubscriptionsFromGoogle).toHaveBeenCalledWith(purchaseToken, packageName); expect(mockStoreSubscriptionInDynamo.mock.calls.length).toEqual(1); expect(mockStoreSubscriptionInDynamo).toHaveBeenCalledWith(subscription); });