fix(mobile): use product IDs for Apple IAP verification
This commit is contained in:
parent
dd2c933936
commit
a27db571b3
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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<string | null | undefined>) =>
|
||||
candidates.find(isCompactJws)
|
||||
|
||||
export const isKnownAppleSubscriptionPurchase = (
|
||||
purchase: Pick<ApplePurchaseIdentity, "productId">,
|
||||
knownSubscriptionIds: ReadonlySet<string>,
|
||||
) => 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,
|
||||
})
|
||||
Loading…
Reference in New Issue