This commit is contained in:
Eric Bailey
2024-11-25 10:11:24 -06:00
parent 0cf44f7749
commit 2e2b650209
11 changed files with 514 additions and 405 deletions
+2 -6
View File
@@ -1,8 +1,6 @@
import React from 'react'
import {APIEntitlement,APISubscription} from '#/state/purchases/types'
export type NativePurchaseRestricted = 'yes' | 'no' | 'unknown'
import {APIEntitlement, APISubscription} from '#/state/purchases/types'
export type PurchasesState =
| {
@@ -17,9 +15,7 @@ export type PurchasesState =
email?: string
subscriptions: APISubscription[]
entitlements: APIEntitlement[]
config: {
nativePurchaseRestricted: NativePurchaseRestricted
}
config: {}
}
export const Context = React.createContext<PurchasesState>({
@@ -1,40 +1,57 @@
import React from 'react'
import Purchases, {PURCHASES_ERROR_CODE} from 'react-native-purchases'
import {useQuery} from '@tanstack/react-query'
import {NativePurchaseRestricted} from '#/state/purchases/context'
import {NativePurchaseRestricted} from '#/state/purchases/types'
import {useSession} from '#/state/session'
export function useNativeUserState() {
const {currentAccount} = useSession()
const [restricted, setRestricted] =
React.useState<NativePurchaseRestricted>('unknown')
React.useEffect(() => {
async function check() {
if (!currentAccount?.did) return
// reset on each run
setRestricted('unknown')
const {data, isFetching} = useQuery<{
restricted: NativePurchaseRestricted
}>({
queryKey: ['purchases-native-user-state', currentAccount?.did],
placeholderData: {restricted: 'unknown'},
/**
* Once we fetch this, no need to do so again for the current session. We
* don't do any transfer logic between accounts, so this doesn't change
* during the course of a user session.
*/
staleTime: Infinity,
async queryFn() {
if (!currentAccount) {
return {restricted: 'unknown'}
}
try {
// MUST ensure we're the correct user first
await Purchases.logIn(currentAccount?.did)
await Purchases.logIn(currentAccount.did)
await Purchases.restorePurchases()
setRestricted('no')
return {restricted: 'no'}
} catch (e: any) {
/**
* @see https://www.revenuecat.com/docs/test-and-launch/errors#-receipt_already_in_use
*/
if (e.code === PURCHASES_ERROR_CODE.RECEIPT_ALREADY_IN_USE_ERROR) {
setRestricted('yes')
switch (e.code) {
/**
* Indicates there are active subscriptions for another account on
* this device. User cannot make additional purchases on this device.
*
* @see https://www.revenuecat.com/docs/test-and-launch/errors#-receipt_already_in_use
*/
case PURCHASES_ERROR_CODE.RECEIPT_ALREADY_IN_USE_ERROR:
return {restricted: 'yes'}
default:
/**
* Any other error is considered unknown. Let the user proceed.
* Additional errors can occur at time of purchase and should be
* handled there.
*/
return {restricted: 'no'}
}
}
}
check()
}, [currentAccount?.did])
},
})
return {
restricted,
loading: isFetching,
restricted: data?.restricted ?? 'unknown',
}
}
@@ -1,5 +1,11 @@
export function useNativeUserState() {
import {NativePurchaseRestricted} from '#/state/purchases/types'
export function useNativeUserState(): {
loading: boolean
restricted: NativePurchaseRestricted
} {
return {
restricted: false,
loading: false,
restricted: 'no',
}
}
@@ -0,0 +1,49 @@
import Purchases, {PURCHASES_ERROR_CODE} from 'react-native-purchases'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {
PlatformId,
ReceiptAlreadyInUseError,
SubscriptionOffering,
} from '#/state/purchases/types'
type Params = {
did: string
email: string
offering: SubscriptionOffering
}
export function usePurchaseOffering() {
const {_} = useLingui()
return useMutation<void, Error | ReceiptAlreadyInUseError, Params>({
async mutationFn({did, email, offering}: Params) {
if (offering.platform === PlatformId.Web) {
throw new Error('Unsupported platform')
}
try {
await Purchases.logIn(did)
await Purchases.setEmail(email)
await Purchases.purchaseStoreProduct(offering.package)
} catch (e: any) {
/**
* @see https://www.revenuecat.com/docs/test-and-launch/errors#-receipt_already_in_use
*/
if (e.code === PURCHASES_ERROR_CODE.RECEIPT_ALREADY_IN_USE_ERROR) {
throw new ReceiptAlreadyInUseError(
_(
msg`This device has existing active subscriptions for a different account. Multiple account subscriptions are not possible on native platforms at this time.`,
),
// @ts-ignore
{cause: e},
)
}
throw e
}
},
})
}
@@ -0,0 +1,44 @@
import {Linking} from 'react-native'
import {useMutation} from '@tanstack/react-query'
import {api} from '#/state/purchases/api'
import {PlatformId, SubscriptionOffering} from '#/state/purchases/types'
import {IS_DEV} from '#/env'
export function usePurchaseOffering() {
return useMutation({
async mutationFn({
did,
email,
offering,
}: {
did: string
email: string
offering: SubscriptionOffering
}) {
if (offering.platform !== PlatformId.Web) {
throw new Error('Unsupported platform')
}
const {data, error} = await api<{
checkoutUrl: string
}>('/checkout/create', {
method: 'POST',
json: {
did,
email,
price: offering.package.priceId,
redirectUrl: IS_DEV
? `http://localhost:19006/subscriptions`
: `https://bsky.app/subscriptions`,
},
}).json()
if (error || !data) {
throw new Error(`Failed to create checkout URL`)
}
Linking.openURL(data.checkoutUrl)
},
})
}
@@ -1,10 +1,10 @@
import Purchases, {PURCHASES_ERROR_CODE} from 'react-native-purchases'
import {useMutation,useQuery} from '@tanstack/react-query'
import Purchases from 'react-native-purchases'
import {useQuery} from '@tanstack/react-query'
import {isIOS} from '#/platform/detection'
import {isAndroid} from '#/platform/detection'
import {api} from '#/state/purchases/api'
import {
parseOfferingId,
APIOffering,
PlatformId,
SubscriptionGroupId,
SubscriptionOffering,
@@ -14,67 +14,56 @@ export function useSubscriptionGroup(group: SubscriptionGroupId) {
return useQuery<{offerings: SubscriptionOffering[]}>({
queryKey: ['subscription-group', group],
async queryFn() {
const platform = isIOS ? 'ios' : 'android'
const {data, error, response} = await api(
`/subscriptions/${group}?platform=${platform}`,
).json()
const platform = isAndroid ? 'android' : 'ios'
const {data, error} = await api<{
offerings: APIOffering[]
}>(`/subscriptions/${group}?platform=${platform}`).json()
if (error || !data) {
console.log(error, response)
throw new Error('Failed to fetch subscription group')
throw new Error(`Failed to fetch subscription group`)
}
const {offerings} = data
const productIds = offerings.map((o: any) => {
return isIOS ? o.productId : o.productId.split(':')[0]
})
// console.log({ offers: false })
// const offers = await Purchases.getOfferings()
// console.log({ offers })
const products = await Purchases.getProducts(productIds)
const revenueCatIdentifiers = offerings.map(o =>
isAndroid ? parseIdentifierFromAndroidProductId(o.product) : o.product,
)
const products = await Purchases.getProducts(revenueCatIdentifiers)
const parsed: SubscriptionOffering[] = []
for (const o of offerings) {
if (o.platform === PlatformId.Web) continue
const product = products.find(p => p.identifier === o.product)
if (!product) continue
parsed.push({
id: o.id,
platform: o.platform,
package: product,
})
}
return {
offerings: offerings.map((o: any) => {
const product = products.find(
(p: any) => p.identifier === o.productId,
)
return {
id: parseOfferingId(o.id),
platform: isIOS ? PlatformId.Ios : PlatformId.Android,
price: product?.priceString,
package: product,
}
}),
offerings: parsed,
}
},
})
}
export function usePurchaseOffering() {
return useMutation({
async mutationFn({
did,
email,
offering,
}: {
did: string
email: string
offering: SubscriptionOffering
}) {
if (offering.platform === PlatformId.Web) {
throw new Error('Unsupported platform')
}
/**
* Whereas iOS has separate product IDs for each subscription product, Android
* has a single ID with a suffixed payment plan e.g. `monthly` and `annual` for
* our core offerings.
*
* However, the full "identifier" is concatenated, so we just pass around the
* full thing and parse from there.
*/
function parseIdentifierFromAndroidProductId(productId: string) {
if (!productId.includes(':')) {
throw new Error(
`Expected Android product ID to contain a colon: ${productId}`,
)
}
try {
await Purchases.logIn(did)
await Purchases.setEmail(email)
await Purchases.purchaseStoreProduct(offering.package)
} catch (e: any) {
if (e.code === PURCHASES_ERROR_CODE.RECEIPT_ALREADY_IN_USE_ERROR) {
console.log('recipt error', e)
}
throw e
}
},
})
return productId.split(':')[0]
}
@@ -1,75 +1,43 @@
import {Linking} from 'react-native'
import {useMutation,useQuery} from '@tanstack/react-query'
import {useQuery} from '@tanstack/react-query'
import {api} from '#/state/purchases/api'
import {
parseOfferingId,
APIOffering,
PlatformId,
SubscriptionGroupId,
SubscriptionOffering,
} from '#/state/purchases/types'
import {IS_DEV} from '#/env'
export function useSubscriptionGroup(group: SubscriptionGroupId) {
return useQuery<{offerings: SubscriptionOffering[]}>({
queryKey: ['subscription-group', group],
async queryFn() {
const {data, error} = await api(
`/subscriptions/${group}?platform=web`,
).json()
const {data, error} = await api<{
offerings: APIOffering[]
}>(`/subscriptions/${group}?platform=web`).json()
if (error || !data) {
throw new Error('Failed to fetch subscription group')
}
const {offerings} = data
return {
offerings: offerings.map((o: any) => ({
id: parseOfferingId(o.id),
platform: PlatformId.Web,
const parsed: SubscriptionOffering[] = []
for (const o of offerings) {
if (o.platform !== PlatformId.Web) continue
parsed.push({
id: o.id,
platform: o.platform,
package: {
priceId: o.productId,
priceId: o.product,
},
})),
})
}
return {
offerings: parsed,
}
},
})
}
export function usePurchaseOffering() {
return useMutation({
async mutationFn({
did,
email,
offering,
}: {
did: string
email: string
offering: SubscriptionOffering
}) {
if (offering.platform !== PlatformId.Web) {
throw new Error('Unsupported platform')
}
const {data, error} = await api<{
checkoutUrl: string
}>('/checkout/create', {
method: 'POST',
json: {
did,
email,
price: offering.package.priceId,
redirectUrl: IS_DEV
? `http://localhost:19006/subscriptions`
: `https://bsky.app/subscriptions`,
},
}).json()
if (error || !data) {
throw error
}
Linking.openURL(data.checkoutUrl)
},
})
}
+2 -6
View File
@@ -2,7 +2,6 @@ import React from 'react'
import {Context, PurchasesState} from '#/state/purchases/context'
import {useNativeEventsListener} from '#/state/purchases/hooks/useNativeEventsListener'
import {useNativeUserState} from '#/state/purchases/hooks/useNativeUserState'
import {usePurchasesState} from '#/state/purchases/hooks/usePurchasesState'
export type {PurchasesState} from '#/state/purchases/context'
@@ -13,7 +12,6 @@ export function Provider({children}: {children: React.ReactNode}) {
error: purchasesStateError,
refetch,
} = usePurchasesState()
const {restricted} = useNativeUserState()
const ctx = React.useMemo<PurchasesState>(() => {
if (purchasesStateError) {
return {
@@ -30,12 +28,10 @@ export function Provider({children}: {children: React.ReactNode}) {
email: purchases?.email,
subscriptions: purchases?.subscriptions ?? [],
entitlements: purchases?.entitlements ?? [],
config: {
nativePurchaseRestricted: restricted,
},
config: {},
}
}
}, [purchases, purchasesStateError, restricted])
}, [purchases, purchasesStateError])
useNativeEventsListener({
onCustomerInfoUpdated() {
+25 -5
View File
@@ -1,7 +1,7 @@
import type {PurchasesStoreProduct} from 'react-native-purchases'
/**
* Primitives
* PRIMITIVES
*/
export enum EntitlementId {
@@ -15,7 +15,7 @@ export enum PlatformId {
}
/**
* Subscription primitives
* SUBSCRIPTION PRIMITIVES
*/
export enum SubscriptionGroupId {
@@ -41,15 +41,18 @@ export type SubscriptionOffering =
}
}
export type NativePurchaseRestricted = 'yes' | 'no' | 'unknown'
/**
* API types for our toy server
* API TYPES FROM OUR TOY SERVER
*/
export type APISubscription = {
status: 'active' | 'paused' | 'expired' | 'unknown'
renewalStatus: 'will_renew' | 'will_not_renew' | 'will_pause' | 'unknown'
group: SubscriptionGroupId
platform: PlatformId
offering: SubscriptionOfferingId
renews: boolean
periodDtartsAt: string
periodEndsAt: string
purchasedAt: string
@@ -59,8 +62,25 @@ export type APIEntitlement = {
id: EntitlementId
}
export type APIOffering = {
id: SubscriptionOfferingId
platform: PlatformId
product: string
}
/**
* Parsers
* CUSTOM ERROR TYPES
*/
/**
* Thrown if the device already has active subscriptions for other accounts.
*
* @see https://www.revenuecat.com/docs/test-and-launch/errors#-receipt_already_in_use
*/
export class ReceiptAlreadyInUseError extends Error {}
/**
* PARSERS
*/
export function parseOfferingId(id: string): SubscriptionOfferingId {