diff --git a/src/components/dialogs/BlueskyPlus/index.tsx b/src/components/dialogs/BlueskyPlus/index.tsx index f8defdcc37..df487dad68 100644 --- a/src/components/dialogs/BlueskyPlus/index.tsx +++ b/src/components/dialogs/BlueskyPlus/index.tsx @@ -5,10 +5,8 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigationState} from '@react-navigation/native' -import { - usePurchaseOffering, - useSubscriptionGroup, -} from '#/state/purchases/hooks/useSubscriptionGroup' +import {usePurchaseOffering} from '#/state/purchases/hooks/usePurchaseOffering' +import {useSubscriptionGroup} from '#/state/purchases/hooks/useSubscriptionGroup' import { SubscriptionGroupId, SubscriptionOfferingId, @@ -53,6 +51,8 @@ function DialogInner({control}: {control: Dialog.DialogControlProps}) { const {data: coreOffering} = useSubscriptionGroup(SubscriptionGroupId.Core) const {mutateAsync: purchaseOffering, isPending} = usePurchaseOffering() + console.log({coreOffering}) + const onPressSubscribe = async () => { try { if (!currentAccount) return diff --git a/src/screens/Subscriptions/index.tsx b/src/screens/Subscriptions/index.tsx index 16b897ad0c..bd102e05a5 100644 --- a/src/screens/Subscriptions/index.tsx +++ b/src/screens/Subscriptions/index.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {View} from 'react-native' +import {TextStyle,View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {NativeStackScreenProps} from '@react-navigation/native-stack' @@ -8,12 +8,16 @@ import {CommonNavigatorParams} from '#/lib/routes/types' import {isAndroid, isIOS, isWeb} from '#/platform/detection' import {PurchasesState, usePurchases} from '#/state/purchases' import {useManageSubscription} from '#/state/purchases/hooks/useManageSubscription' +import {useNativeUserState} from '#/state/purchases/hooks/useNativeUserState' import { PlatformId, SubscriptionGroupId, SubscriptionOfferingId, } from '#/state/purchases/types' -import {APISubscription} from '#/state/purchases/types' +import { + APISubscription, + NativePurchaseRestricted, +} from '#/state/purchases/types' import {CenteredView} from '#/view/com/util/Views' import {atoms as a, tokens, useTheme} from '#/alf' import {Admonition} from '#/components/Admonition' @@ -25,7 +29,7 @@ import {GradientFill} from '#/components/GradientFill' import {AndroidLogo} from '#/components/icons/AndroidLogo' import {AppleLogo} from '#/components/icons/AppleLogo' import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Rotate} from '#/components/icons/ArrowRotateCounterClockwise' -import {Full as BlueskyPlusLogo, Logotype} from '#/components/icons/BlueskyPlus' +import {Full as BlueskyPlusLogo} from '#/components/icons/BlueskyPlus' import {CheckThick_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock' import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' @@ -43,6 +47,7 @@ export type ScreenProps = NativeStackScreenProps< export function Subscriptions(_props: ScreenProps) { const {_} = useLingui() const purchases = usePurchases() + const {loading, restricted} = useNativeUserState() return ( @@ -51,12 +56,12 @@ export function Subscriptions(_props: ScreenProps) { - {purchases.status === 'loading' ? ( + {purchases.status === 'loading' || loading ? ( ) : purchases.status === 'error' ? ( ) : ( - + )} @@ -67,16 +72,48 @@ export function Subscriptions(_props: ScreenProps) { function Core({ state, + restricted, }: { state: Exclude + restricted: NativePurchaseRestricted }) { + const coreSubscriptions = state.subscriptions + .filter(s => s.group === SubscriptionGroupId.Core) + .filter(s => { + return ['active', 'paused'].includes(s.status) + }) + const isSubscribedToCore = coreSubscriptions.length > 0 + + return ( + + + + {restricted === 'yes' ? ( + + + Another account on this device is already subscribed to Bluesky+. + Please subscribe additional accounts through our web application. + + + ) : isSubscribedToCore ? null : ( + + )} + + {isSubscribedToCore ? ( + + {coreSubscriptions.map(sub => ( + + ))} + + ) : null} + + ) +} + +function Purchase() { const t = useTheme() const {_} = useLingui() const control = useDialogControl() - const coreSubscriptions = state.subscriptions.filter( - s => s.group === SubscriptionGroupId.Core, - ) - const isSubscribedToCore = !!coreSubscriptions.length const features = [ { @@ -122,277 +159,264 @@ function Core({ ] return ( - - {isSubscribedToCore ? ( - - ) : null} + <> + - {!isSubscribedToCore && ( - <> - {state.config.nativePurchaseRestricted === 'yes' ? ( - - - - Another account on this device is already subscribed to - Bluesky+. Please subscribe additional accounts through our web - application. - - - - ) : ( - <> - + + Building a better internet needs your support. + - - Building a better internet needs your support. - - - - - - Subscribing to Bluesky+ helps ensure that our work to build - an open, secure, and user-first internet can continue. - - - - Plus, you'll get access to exclusive features! - - - - - {features.map(f => ( - - - - - - {f.text} - - - ))} - - - - - - )} - - )} - - {/* - - - - Terms and Conditions - {' '} - ·{' '} - - Privacy Policy - {' '} - ·{' '} - - EULA - + + + + Subscribing to Bluesky+ helps ensure that our work to build an open, + secure, and user-first internet can continue. + + + + Plus, you'll get access to exclusive features! - */} - + + + {features.map(f => ( + + + + + + {f.text} + + + ))} + + + + + ) } -function CoreSubscriptions(props: {subscriptions: APISubscription[]}) { - const t = useTheme() +export function useSubscriptionUI({ + subscription: sub, +}: { + subscription: APISubscription +}) { const {_, i18n} = useLingui() - const {mutateAsync: manageSubscription} = useManageSubscription() - const copy = React.useMemo(() => { - return { - [SubscriptionOfferingId.CoreMonthly]: { - title: _(msg`Monthly`), - }, - [SubscriptionOfferingId.CoreAnnual]: { - title: _(msg`Annual`), - }, - } - }, [_]) - - return props.subscriptions.map(sub => { - const endDate = i18n.date(new Date(sub.periodEndsAt), {dateStyle: 'medium'}) - const StatusIcon = sub.renews ? Rotate : Clock - const c = copy[sub.offering] - const canManage = - (isWeb && sub.platform === PlatformId.Web) || - (isIOS && sub.platform === PlatformId.Ios) || - (isAndroid && sub.platform === PlatformId.Android) - const PlatformIcon = { + return React.useMemo(() => { + const periodEndsAt = i18n.date(new Date(sub.periodEndsAt), { + dateStyle: 'short', + }) + const title = { + [SubscriptionOfferingId.CoreMonthly]: _(msg`Monthly`), + [SubscriptionOfferingId.CoreAnnual]: _(msg`Annual`), + }[sub.offering] + const PlatformLogo = { [PlatformId.Ios]: AppleLogo, [PlatformId.Android]: AndroidLogo, [PlatformId.Web]: Globe, }[sub.platform] - return ( - - - - + const platformName = { + [PlatformId.Ios]: _('iOS'), + [PlatformId.Android]: _('Android'), + [PlatformId.Web]: _('Web'), + }[sub.platform] + const status = { + active: _(msg`Active`), + expired: _(msg`Expired`), + paused: _(msg`Paused`), + unknown: _(msg`Unknown`), + }[sub.status] + const renewalStatus = { + will_renew: _(msg`Renews ${periodEndsAt}`), + will_not_renew: { + active: _(msg`Expires ${periodEndsAt}`), + expired: _(msg`Expired ${periodEndsAt}`), + paused: _(msg`Expires ${periodEndsAt}`), + unknown: _(msg`Unknown`), + }[sub.status], + will_pause: _(msg`Pauses ${periodEndsAt}`), + unknown: _(msg`Unknown`), + }[sub.renewalStatus] + const RenewalStatusIcon = { + will_renew: Rotate, + will_not_renew: Clock, + will_pause: Clock, + unknown: Clock, + }[sub.renewalStatus] - - {c.title} - + return { + title, + status, + renewalStatus, + platformName, + PlatformLogo, + RenewalStatusIcon, + } + }, [_, i18n, sub]) +} - - {sub.renews ? Active : Cancelled} - - +export function Subscription({ + subscription: sub, +}: { + subscription: APISubscription +}) { + const t = useTheme() + const {_} = useLingui() + const {mutateAsync: manageSubscription} = useManageSubscription() + const ui = useSubscriptionUI({subscription: sub}) - - - {endDate} - + const canManage = + (isWeb && sub.platform === PlatformId.Web) || + (isIOS && sub.platform === PlatformId.Ios) || + (isAndroid && sub.platform === PlatformId.Android) + + const statusStyles = React.useMemo(() => { + return { + active: { + color: tokens.blueskyPlus.dark, + backgroundColor: tokens.blueskyPlus.light, + borderWidth: undefined, + borderColor: undefined, + }, + expired: { + color: t.atoms.text_contrast_medium.color, + backgroundColor: t.atoms.bg.backgroundColor, + borderWidth: a.border.borderWidth, + borderColor: t.atoms.border_contrast_high.borderColor, + }, + paused: { + color: t.atoms.bg.backgroundColor, + backgroundColor: t.atoms.text_contrast_medium.color, + borderWidth: undefined, + borderColor: undefined, + }, + unknown: { + color: t.atoms.text_contrast_medium.color, + backgroundColor: t.atoms.bg.backgroundColor, + borderWidth: a.border.borderWidth, + borderColor: t.atoms.border_contrast_high.borderColor, + }, + }[sub.status] + }, [t, sub.status]) + + return ( + + + + {ui.title} + + + {ui.status} + - + {canManage && ( + { + manageSubscription() + })} + style={[a.justify_center]}> + + Manage + + + )} + + + + - - - - Managed via web - - + + + {ui.platformName} + + - {canManage && ( - { - manageSubscription() - })} - style={[a.justify_center]}> - - Manage - - - )} + + + + {ui.renewalStatus} + - ) - }) + + ) } diff --git a/src/state/purchases/context.tsx b/src/state/purchases/context.tsx index 0912e418f1..efec6b151e 100644 --- a/src/state/purchases/context.tsx +++ b/src/state/purchases/context.tsx @@ -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({ diff --git a/src/state/purchases/hooks/useNativeUserState/index.ts b/src/state/purchases/hooks/useNativeUserState/index.ts index 150b46cc58..95fc9ae63e 100644 --- a/src/state/purchases/hooks/useNativeUserState/index.ts +++ b/src/state/purchases/hooks/useNativeUserState/index.ts @@ -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('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', } } diff --git a/src/state/purchases/hooks/useNativeUserState/index.web.ts b/src/state/purchases/hooks/useNativeUserState/index.web.ts index bf6269eec7..5bf494b96f 100644 --- a/src/state/purchases/hooks/useNativeUserState/index.web.ts +++ b/src/state/purchases/hooks/useNativeUserState/index.web.ts @@ -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', } } diff --git a/src/state/purchases/hooks/usePurchaseOffering/index.ts b/src/state/purchases/hooks/usePurchaseOffering/index.ts new file mode 100644 index 0000000000..04094693e9 --- /dev/null +++ b/src/state/purchases/hooks/usePurchaseOffering/index.ts @@ -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({ + 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 + } + }, + }) +} diff --git a/src/state/purchases/hooks/usePurchaseOffering/index.web.ts b/src/state/purchases/hooks/usePurchaseOffering/index.web.ts new file mode 100644 index 0000000000..d78e41f5a5 --- /dev/null +++ b/src/state/purchases/hooks/usePurchaseOffering/index.web.ts @@ -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) + }, + }) +} diff --git a/src/state/purchases/hooks/useSubscriptionGroup/index.ts b/src/state/purchases/hooks/useSubscriptionGroup/index.ts index fb9b342203..39b2a1c0b4 100644 --- a/src/state/purchases/hooks/useSubscriptionGroup/index.ts +++ b/src/state/purchases/hooks/useSubscriptionGroup/index.ts @@ -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] } diff --git a/src/state/purchases/hooks/useSubscriptionGroup/index.web.ts b/src/state/purchases/hooks/useSubscriptionGroup/index.web.ts index 7bf2177d9d..75a99d5bb6 100644 --- a/src/state/purchases/hooks/useSubscriptionGroup/index.web.ts +++ b/src/state/purchases/hooks/useSubscriptionGroup/index.web.ts @@ -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) - }, - }) -} diff --git a/src/state/purchases/index.tsx b/src/state/purchases/index.tsx index 568b51d42a..d9794f600a 100644 --- a/src/state/purchases/index.tsx +++ b/src/state/purchases/index.tsx @@ -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(() => { 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() { diff --git a/src/state/purchases/types.ts b/src/state/purchases/types.ts index 10c57349b3..3d9f835804 100644 --- a/src/state/purchases/types.ts +++ b/src/state/purchases/types.ts @@ -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 {