diff --git a/apps/mobile/src/providers/AppleIAPProvider.tsx b/apps/mobile/src/providers/AppleIAPProvider.tsx index da6fdc213..5505d6b9d 100644 --- a/apps/mobile/src/providers/AppleIAPProvider.tsx +++ b/apps/mobile/src/providers/AppleIAPProvider.tsx @@ -14,6 +14,12 @@ import { proxyEnv } from "@/src/lib/proxy-env" import { queryClient } from "@/src/lib/query-client" import { toast } from "@/src/lib/toast" +import { + buildAppleVerificationRequest, + isKnownAppleSubscriptionPurchase, + selectSignedTransactionInfo, +} from "./apple-iap-purchase" + const billingSubscriptionQueryKey = ["billingSubscription"] type BillingSubscriptionResponse = { @@ -123,16 +129,23 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => { const verifyPurchase = useCallback( async (purchase: Purchase) => { - const productId = purchase.id - const jwsRepresentation = - purchase.purchaseToken || - (await getTransactionJwsIOS(productId).catch(() => null)) || - (await validateReceipt({ apple: { sku: productId } }) - .then((result) => ("jwsRepresentation" in result ? result.jwsRepresentation : undefined)) - .catch(() => {})) + const productId = purchase.productId + let signedTransactionInfo = selectSignedTransactionInfo(purchase.purchaseToken) - if (!jwsRepresentation) { - throw new Error(t("subscription.actions.upgrade_error")) + if (!signedTransactionInfo) { + signedTransactionInfo = selectSignedTransactionInfo( + await getTransactionJwsIOS(productId).catch(() => null), + ) + } + + if (!signedTransactionInfo) { + signedTransactionInfo = selectSignedTransactionInfo( + await validateReceipt({ apple: { sku: productId } }) + .then((result) => + "jwsRepresentation" in result ? result.jwsRepresentation : undefined, + ) + .catch(() => undefined), + ) } const response = await followClient.request<{ @@ -140,23 +153,21 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => { data: BillingSubscriptionResponse }>("/billing/apple/verify", { method: "POST", - body: { - signedTransactionInfo: jwsRepresentation, - }, + body: buildAppleVerificationRequest(purchase, signedTransactionInfo), }) if (response.code !== 0) { throw new Error("Failed to verify Apple subscription") } }, - [t, validateReceipt], + [validateReceipt], ) useEffect(() => { if ( Platform.OS !== "ios" || !currentPurchase || - !knownSubscriptionIds.has(currentPurchase.id) + !isKnownAppleSubscriptionPurchase(currentPurchase, knownSubscriptionIds) ) { return } @@ -280,7 +291,7 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => { await new Promise((resolve) => setTimeout(resolve, 300)) const restoredPurchases = availablePurchasesRef.current.filter((purchase) => - knownSubscriptionIds.has(purchase.id), + isKnownAppleSubscriptionPurchase(purchase, knownSubscriptionIds), ) if (restoredPurchases.length === 0) { diff --git a/apps/mobile/src/providers/apple-iap-purchase.test.ts b/apps/mobile/src/providers/apple-iap-purchase.test.ts new file mode 100644 index 000000000..d07f33fee --- /dev/null +++ b/apps/mobile/src/providers/apple-iap-purchase.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest" + +import { + buildAppleVerificationRequest, + isCompactJws, + isKnownAppleSubscriptionPurchase, + selectSignedTransactionInfo, +} from "./apple-iap-purchase" + +describe("Apple IAP purchase identifiers", () => { + it("matches subscriptions by product ID instead of transaction ID", () => { + const knownSubscriptionIds = new Set(["is.follow.basic.monthly"]) + const purchase = { + id: "2000001234567890", + productId: "is.follow.basic.monthly", + } + + expect(isKnownAppleSubscriptionPurchase(purchase, knownSubscriptionIds)).toBe(true) + }) + + it("builds verification hints from transaction identifiers", () => { + const request = buildAppleVerificationRequest( + { + id: "2000001234567890", + originalTransactionIdentifierIOS: "2000001000000000", + productId: "is.follow.basic.monthly", + transactionId: "2000001234567890", + }, + "header.payload.signature", + ) + + expect(request).toEqual({ + originalTransactionId: "2000001000000000", + signedTransactionInfo: "header.payload.signature", + transactionId: "2000001234567890", + }) + }) + + it("falls back to the purchase ID when transactionId is absent", () => { + expect( + buildAppleVerificationRequest({ + id: "2000001234567890", + productId: "is.follow.basic.monthly", + }), + ).toEqual({ + originalTransactionId: undefined, + signedTransactionInfo: undefined, + transactionId: "2000001234567890", + }) + }) + + it("does not submit a transaction ID as signed transaction info", () => { + expect(isCompactJws("2000001234567890")).toBe(false) + expect(selectSignedTransactionInfo("2000001234567890", null, "header.payload.signature")).toBe( + "header.payload.signature", + ) + expect( + buildAppleVerificationRequest( + { + id: "2000001234567890", + productId: "is.follow.basic.monthly", + purchaseToken: "2000001234567890", + }, + "2000001234567890", + ), + ).toEqual({ + originalTransactionId: undefined, + signedTransactionInfo: undefined, + transactionId: "2000001234567890", + }) + }) +}) diff --git a/apps/mobile/src/providers/apple-iap-purchase.ts b/apps/mobile/src/providers/apple-iap-purchase.ts new file mode 100644 index 000000000..6b498a9a5 --- /dev/null +++ b/apps/mobile/src/providers/apple-iap-purchase.ts @@ -0,0 +1,44 @@ +export type ApplePurchaseIdentity = { + id: string + originalTransactionIdentifierIOS?: string | null + productId: string + purchaseToken?: string | null + transactionId?: string | null +} + +export type AppleVerificationRequest = { + originalTransactionId?: string + signedTransactionInfo?: string + transactionId?: string +} + +const compactJwsSegmentPattern = /^[\w-]+$/ + +export const isCompactJws = (value?: string | null): value is string => { + if (!value) { + return false + } + + const segments = value.split(".") + return ( + segments.length === 3 && + segments.every((segment) => segment.length > 0 && compactJwsSegmentPattern.test(segment)) + ) +} + +export const selectSignedTransactionInfo = (...candidates: Array) => + candidates.find(isCompactJws) + +export const isKnownAppleSubscriptionPurchase = ( + purchase: Pick, + knownSubscriptionIds: ReadonlySet, +) => knownSubscriptionIds.has(purchase.productId) + +export const buildAppleVerificationRequest = ( + purchase: ApplePurchaseIdentity, + signedTransactionInfo?: string | null, +): AppleVerificationRequest => ({ + originalTransactionId: purchase.originalTransactionIdentifierIOS || undefined, + signedTransactionInfo: isCompactJws(signedTransactionInfo) ? signedTransactionInfo : undefined, + transactionId: purchase.transactionId || purchase.id || undefined, +})