Skip to content

Commit a27db57

Browse files
committed
fix(mobile): use product IDs for Apple IAP verification
1 parent dd2c933 commit a27db57

3 files changed

Lines changed: 143 additions & 16 deletions

File tree

apps/mobile/src/providers/AppleIAPProvider.tsx

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ import { proxyEnv } from "@/src/lib/proxy-env"
1414
import { queryClient } from "@/src/lib/query-client"
1515
import { toast } from "@/src/lib/toast"
1616

17+
import {
18+
buildAppleVerificationRequest,
19+
isKnownAppleSubscriptionPurchase,
20+
selectSignedTransactionInfo,
21+
} from "./apple-iap-purchase"
22+
1723
const billingSubscriptionQueryKey = ["billingSubscription"]
1824

1925
type BillingSubscriptionResponse = {
@@ -123,40 +129,45 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => {
123129

124130
const verifyPurchase = useCallback(
125131
async (purchase: Purchase) => {
126-
const productId = purchase.id
127-
const jwsRepresentation =
128-
purchase.purchaseToken ||
129-
(await getTransactionJwsIOS(productId).catch(() => null)) ||
130-
(await validateReceipt({ apple: { sku: productId } })
131-
.then((result) => ("jwsRepresentation" in result ? result.jwsRepresentation : undefined))
132-
.catch(() => {}))
133-
134-
if (!jwsRepresentation) {
135-
throw new Error(t("subscription.actions.upgrade_error"))
132+
const productId = purchase.productId
133+
let signedTransactionInfo = selectSignedTransactionInfo(purchase.purchaseToken)
134+
135+
if (!signedTransactionInfo) {
136+
signedTransactionInfo = selectSignedTransactionInfo(
137+
await getTransactionJwsIOS(productId).catch(() => null),
138+
)
139+
}
140+
141+
if (!signedTransactionInfo) {
142+
signedTransactionInfo = selectSignedTransactionInfo(
143+
await validateReceipt({ apple: { sku: productId } })
144+
.then((result) =>
145+
"jwsRepresentation" in result ? result.jwsRepresentation : undefined,
146+
)
147+
.catch(() => undefined),
148+
)
136149
}
137150

138151
const response = await followClient.request<{
139152
code: number
140153
data: BillingSubscriptionResponse
141154
}>("/billing/apple/verify", {
142155
method: "POST",
143-
body: {
144-
signedTransactionInfo: jwsRepresentation,
145-
},
156+
body: buildAppleVerificationRequest(purchase, signedTransactionInfo),
146157
})
147158

148159
if (response.code !== 0) {
149160
throw new Error("Failed to verify Apple subscription")
150161
}
151162
},
152-
[t, validateReceipt],
163+
[validateReceipt],
153164
)
154165

155166
useEffect(() => {
156167
if (
157168
Platform.OS !== "ios" ||
158169
!currentPurchase ||
159-
!knownSubscriptionIds.has(currentPurchase.id)
170+
!isKnownAppleSubscriptionPurchase(currentPurchase, knownSubscriptionIds)
160171
) {
161172
return
162173
}
@@ -280,7 +291,7 @@ export const AppleIAPProvider = ({ children }: PropsWithChildren) => {
280291
await new Promise((resolve) => setTimeout(resolve, 300))
281292

282293
const restoredPurchases = availablePurchasesRef.current.filter((purchase) =>
283-
knownSubscriptionIds.has(purchase.id),
294+
isKnownAppleSubscriptionPurchase(purchase, knownSubscriptionIds),
284295
)
285296

286297
if (restoredPurchases.length === 0) {
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, expect, it } from "vitest"
2+
3+
import {
4+
buildAppleVerificationRequest,
5+
isCompactJws,
6+
isKnownAppleSubscriptionPurchase,
7+
selectSignedTransactionInfo,
8+
} from "./apple-iap-purchase"
9+
10+
describe("Apple IAP purchase identifiers", () => {
11+
it("matches subscriptions by product ID instead of transaction ID", () => {
12+
const knownSubscriptionIds = new Set(["is.follow.basic.monthly"])
13+
const purchase = {
14+
id: "2000001234567890",
15+
productId: "is.follow.basic.monthly",
16+
}
17+
18+
expect(isKnownAppleSubscriptionPurchase(purchase, knownSubscriptionIds)).toBe(true)
19+
})
20+
21+
it("builds verification hints from transaction identifiers", () => {
22+
const request = buildAppleVerificationRequest(
23+
{
24+
id: "2000001234567890",
25+
originalTransactionIdentifierIOS: "2000001000000000",
26+
productId: "is.follow.basic.monthly",
27+
transactionId: "2000001234567890",
28+
},
29+
"header.payload.signature",
30+
)
31+
32+
expect(request).toEqual({
33+
originalTransactionId: "2000001000000000",
34+
signedTransactionInfo: "header.payload.signature",
35+
transactionId: "2000001234567890",
36+
})
37+
})
38+
39+
it("falls back to the purchase ID when transactionId is absent", () => {
40+
expect(
41+
buildAppleVerificationRequest({
42+
id: "2000001234567890",
43+
productId: "is.follow.basic.monthly",
44+
}),
45+
).toEqual({
46+
originalTransactionId: undefined,
47+
signedTransactionInfo: undefined,
48+
transactionId: "2000001234567890",
49+
})
50+
})
51+
52+
it("does not submit a transaction ID as signed transaction info", () => {
53+
expect(isCompactJws("2000001234567890")).toBe(false)
54+
expect(selectSignedTransactionInfo("2000001234567890", null, "header.payload.signature")).toBe(
55+
"header.payload.signature",
56+
)
57+
expect(
58+
buildAppleVerificationRequest(
59+
{
60+
id: "2000001234567890",
61+
productId: "is.follow.basic.monthly",
62+
purchaseToken: "2000001234567890",
63+
},
64+
"2000001234567890",
65+
),
66+
).toEqual({
67+
originalTransactionId: undefined,
68+
signedTransactionInfo: undefined,
69+
transactionId: "2000001234567890",
70+
})
71+
})
72+
})
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
export type ApplePurchaseIdentity = {
2+
id: string
3+
originalTransactionIdentifierIOS?: string | null
4+
productId: string
5+
purchaseToken?: string | null
6+
transactionId?: string | null
7+
}
8+
9+
export type AppleVerificationRequest = {
10+
originalTransactionId?: string
11+
signedTransactionInfo?: string
12+
transactionId?: string
13+
}
14+
15+
const compactJwsSegmentPattern = /^[\w-]+$/
16+
17+
export const isCompactJws = (value?: string | null): value is string => {
18+
if (!value) {
19+
return false
20+
}
21+
22+
const segments = value.split(".")
23+
return (
24+
segments.length === 3 &&
25+
segments.every((segment) => segment.length > 0 && compactJwsSegmentPattern.test(segment))
26+
)
27+
}
28+
29+
export const selectSignedTransactionInfo = (...candidates: Array<string | null | undefined>) =>
30+
candidates.find(isCompactJws)
31+
32+
export const isKnownAppleSubscriptionPurchase = (
33+
purchase: Pick<ApplePurchaseIdentity, "productId">,
34+
knownSubscriptionIds: ReadonlySet<string>,
35+
) => knownSubscriptionIds.has(purchase.productId)
36+
37+
export const buildAppleVerificationRequest = (
38+
purchase: ApplePurchaseIdentity,
39+
signedTransactionInfo?: string | null,
40+
): AppleVerificationRequest => ({
41+
originalTransactionId: purchase.originalTransactionIdentifierIOS || undefined,
42+
signedTransactionInfo: isCompactJws(signedTransactionInfo) ? signedTransactionInfo : undefined,
43+
transactionId: purchase.transactionId || purchase.id || undefined,
44+
})

0 commit comments

Comments
 (0)