Skip to content

Commit

Permalink
Use purchases.subscriptionsv2 for Android Feast subscriptions (#1545)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
tjmw authored Jun 10, 2024
1 parent 3fc5b12 commit 799fd84
Show file tree
Hide file tree
Showing 3 changed files with 72 additions and 25 deletions.
44 changes: 36 additions & 8 deletions typescript/src/feast/update-subs/google.ts
Original file line number Diff line number Diff line change
@@ -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<Subscription[]>,
fetchSubscriptionDetails: (purchaseToken: string, packageName: string) => Promise<GoogleSubscription>,
putSubscription: (subscription: Subscription) => Promise<Subscription>,
): (event: SQSEvent) => Promise<string> => (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) {
Expand Down Expand Up @@ -42,9 +70,9 @@ export const buildHandler = (

return Promise.all(promises)
.then(_ => "OK")
})
});

export const handler = buildHandler(
getGoogleSubResponse,
fetchGoogleSubscriptionV2,
putSubscription,
);
14 changes: 11 additions & 3 deletions typescript/src/services/google-play-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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),
Expand All @@ -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) {
Expand Down Expand Up @@ -195,4 +203,4 @@ function getAccessToken(params: S3.Types.GetObjectRequest) : Promise<AccessToken
console.log(`Failed to get access token from S3 due to: ${error}`);
throw error
})
}
}
39 changes: 25 additions & 14 deletions typescript/tests/feast/update-subs/google.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { buildHandler } from "../../../src/feast/update-subs/google";
import { Subscription } from "../../../src/models/subscription";
import { GoogleResponseBody } from "../../../src/services/google-play";
import { plusDays } from "../../../src/utils/dates";
import { dateToSecondTimestamp, plusDays, thirtyMonths } from "../../../src/utils/dates";
import { buildSqsEvent } from "./test-helpers";

// Without this, the test error with: ENOENT: no such file or directory, open 'node:url'
// I'm not sure why.
jest.mock("../../../src/services/google-play-v2", () => 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";
Expand All @@ -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);
});
Expand Down

0 comments on commit 799fd84

Please sign in to comment.