Refactor for new API contract

This commit is contained in:
Eric Bailey
2024-10-29 14:12:34 -05:00
parent 3a8d1e229d
commit 05284a6584
7 changed files with 224 additions and 122 deletions
+23 -11
View File
@@ -28,7 +28,11 @@ export function Subscriptions(_props: ScreenProps) {
const {_} = useLingui() const {_} = useLingui()
// const {data: entitlements} = useEntitlements() // const {data: entitlements} = useEntitlements()
const {data: subscriptions, isError} = useMainSubscriptions() const {data: subscriptions, isError, error} = useMainSubscriptions()
if (error) {
console.log(error)
}
return ( return (
<Layout.Screen> <Layout.Screen>
@@ -96,17 +100,21 @@ export function MainSubscription({
style={[a.p_lg, a.rounded_md, a.border, t.atoms.border_contrast_low]}> style={[a.p_lg, a.rounded_md, a.border, t.atoms.border_contrast_low]}>
<View style={[a.gap_sm, a.pb_xl]}> <View style={[a.gap_sm, a.pb_xl]}>
{subscriptions.available.monthly.map(s => ( {subscriptions.available.monthly.map(s => (
<View key={s.id}> <View key={s.subscription.id}>
<Button <Button
label={s.id} label={s.subscription.id}
size="small" size="small"
variant="solid" variant="solid"
color={s.status === 'active' ? 'primary' : 'secondary'} color={
s.subscription.state?.status === 'active'
? 'primary'
: 'secondary'
}
onPress={() => { onPress={() => {
purchaseSubscription(s.product) purchaseSubscription(s)
}}> }}>
<ButtonText> <ButtonText>
{s.id} ({s.price.formatted}) {s.subscription.id} ({s.price?.formatted})
</ButtonText> </ButtonText>
</Button> </Button>
</View> </View>
@@ -114,17 +122,21 @@ export function MainSubscription({
</View> </View>
<View style={[a.gap_sm]}> <View style={[a.gap_sm]}>
{subscriptions.available.annual.map(s => ( {subscriptions.available.annual.map(s => (
<View key={s.id}> <View key={s.subscription.id}>
<Button <Button
label={s.id} label={s.subscription.id}
size="small" size="small"
variant="solid" variant="solid"
color={s.status === 'active' ? 'primary' : 'secondary'} color={
s.subscription.state?.status === 'active'
? 'primary'
: 'secondary'
}
onPress={() => { onPress={() => {
purchaseSubscription(s.product) purchaseSubscription(s)
}}> }}>
<ButtonText> <ButtonText>
{s.id} ({s.price.formatted}) {s.subscription.id} ({s.price?.formatted})
</ButtonText> </ButtonText>
</Button> </Button>
</View> </View>
@@ -1,5 +1,5 @@
import {RawSubscriptionObject} from '#/state/purchases/subscriptions/api/types' import {Subscription} from '#/state/purchases/subscriptions/api/types'
import {EntitlementId} from '#/state/purchases/subscriptions/types' import {Entitlement} from '#/state/purchases/subscriptions/types'
export async function getMainSubscriptions({ export async function getMainSubscriptions({
did, did,
@@ -23,8 +23,8 @@ export async function getMainSubscriptions({
const json = await res.json() const json = await res.json()
return json as { return json as {
active: RawSubscriptionObject[] active: Subscription[]
available: RawSubscriptionObject[] available: Subscription[]
} }
} }
@@ -40,5 +40,5 @@ export async function getEntitlements({did}: {did: string}) {
const {entitlements} = await res.json() const {entitlements} = await res.json()
return entitlements as {id: EntitlementId}[] return entitlements as Entitlement[]
} }
+56 -40
View File
@@ -1,20 +1,11 @@
import {SubscriptionId} from '#/state/purchases/subscriptions/types' import {
EntitlementId,
SubscriptionId,
} from '#/state/purchases/subscriptions/types'
export type RawSubscriptionObjectBase<T> = T & { export type SubscriptionState = {
id: SubscriptionId
storeId: string
lookupKey: string
checkoutId: string
interval: 'monthly' | 'annual'
active: boolean active: boolean
autoRenewStatus: entitlements: {id: EntitlementId}[]
| 'will_renew'
| 'will_not_renew'
| 'will_change_product'
| 'will_pause'
| 'requires_price_increase_consent'
| 'has_already_renewed'
| null
status: status:
| 'trialing' | 'trialing'
| 'active' | 'active'
@@ -24,31 +15,56 @@ export type RawSubscriptionObjectBase<T> = T & {
| 'paused' | 'paused'
| 'unknown' | 'unknown'
| 'incomplete' | 'incomplete'
| null renewalStatus:
entitlements: { | 'will_renew'
created_at: number | 'will_not_renew'
display_name: string | 'will_change_product'
id: string | 'will_pause'
lookup_key: string | 'requires_price_increase_consent'
object: 'entitlement' | 'has_already_renewed'
project_id: string startedAt: number
}[] periodStart: number
startedAt: number | null periodEnd: number
periodStart: number | null
periodEnd: number | null
} }
export type RawSubscriptionObject = /**
| RawSubscriptionObjectBase<{ * Response from our API
platform: 'android' */
provider: 'play_store' export type Subscription =
}> | {
| RawSubscriptionObjectBase<{ id: SubscriptionId
platform: 'ios' platform: 'android' | 'ios'
provider: 'app_store' interval: 'monthly' | 'annual'
}> state?: SubscriptionState
| RawSubscriptionObjectBase<{ store: {
type: 'play_store' | 'app_store'
productId: string
productLookupKey: string
}
}
| {
id: SubscriptionId
platform: 'web' platform: 'web'
provider: 'stripe' interval: 'monthly' | 'annual'
price: number state: undefined
}> store: {
type: 'stripe'
productId: string
price: number
priceId: string
subscriptionId: undefined
}
}
| {
id: SubscriptionId
platform: 'web'
interval: 'monthly' | 'annual'
state: SubscriptionState
store: {
type: 'stripe'
productId: string
price: number
priceId: string
subscriptionId: string
}
}
+52 -19
View File
@@ -1,8 +1,10 @@
import Purchases from 'react-native-purchases' import Purchases from 'react-native-purchases'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useCurrencyFormatter} from '#/lib/currency'
import {isAndroid} from '#/platform/detection' import {isAndroid} from '#/platform/detection'
import {getMainSubscriptions} from '#/state/purchases/subscriptions/api' import {getMainSubscriptions} from '#/state/purchases/subscriptions/api'
import {Subscription as APISubscription} from '#/state/purchases/subscriptions/api/types'
import { import {
Subscription, Subscription,
Subscriptions, Subscriptions,
@@ -13,6 +15,7 @@ import {useSession} from '#/state/session'
export function useMainSubscriptions() { export function useMainSubscriptions() {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const did = currentAccount!.did const did = currentAccount!.did
const currencyFormatter = useCurrencyFormatter()
return useQuery<Subscriptions>({ return useQuery<Subscriptions>({
queryKey: ['availableSubscriptions', did], queryKey: ['availableSubscriptions', did],
@@ -26,30 +29,54 @@ export function useMainSubscriptions() {
.filter(s => s.platform !== 'web') .filter(s => s.platform !== 'web')
.filter(s => s.platform === platform) .filter(s => s.platform === platform)
const lookupKeys = Array.from( const lookupKeys = Array.from(
new Set(platformSubscriptions.map(s => s.lookupKey)), new Set(platformSubscriptions.map(s => s.store.productLookupKey)),
) )
const products = await Purchases.getProducts(lookupKeys) const products = await Purchases.getProducts(lookupKeys)
const subscriptions: Subscription[] = platformSubscriptions
.map(sub => { function decorateSubscription(
const productData = products.find(p => p.identifier === sub.storeId) sub: APISubscription,
if (!productData) return undefined ): Subscription | undefined {
const subscription = { if (sub.platform === 'web') {
...sub, return {
id: sub.id,
platform: sub.platform,
interval: sub.interval,
subscription: sub,
price: { price: {
value: productData.price * 100, // convert to cents value: sub.store.price, // convert to cents
formatted: productData.priceString, formatted: currencyFormatter.format(sub.store.price / 100),
},
product: {
platform,
data: productData,
}, },
product: sub.store.priceId,
} }
return subscription }
})
const productData = products.find(
p => p.identifier === sub.store.productId,
)
if (!productData) return undefined
return {
id: sub.id,
platform: sub.platform,
interval: sub.interval,
subscription: sub,
price: {
value: productData.price * 100, // convert to cents
formatted: productData.priceString,
},
product: productData,
}
}
const subscriptions = platformSubscriptions
.map(decorateSubscription)
.filter(Boolean) as Subscription[]
const active = rawSubscriptions.active
.map(decorateSubscription)
.filter(Boolean) as Subscription[] .filter(Boolean) as Subscription[]
return { return {
active: rawSubscriptions.active, active: active,
available: organizeMainSubscriptionsByTier(subscriptions), available: organizeMainSubscriptionsByTier(subscriptions),
} }
}, },
@@ -61,17 +88,23 @@ export function usePurchaseSubscription() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
async mutationFn(product: Subscription['product']) { async mutationFn(subscription: Subscription) {
if (product.platform === 'web') { if (subscription.platform === 'web') {
throw new Error('Cannot purchase web subscription on native') throw new Error('Cannot purchase web subscription on native')
} }
if (!subscription.product) {
throw new Error('Subscription product not found')
}
// TODO don't do this here // TODO don't do this here
Purchases.addCustomerInfoUpdateListener(_info => { Purchases.addCustomerInfoUpdateListener(_info => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['availableSubscriptions', currentAccount!.did], queryKey: ['availableSubscriptions', currentAccount!.did],
}) })
}) })
return Purchases.purchaseStoreProduct(product.data)
return Purchases.purchaseStoreProduct(subscription.product)
}, },
}) })
} }
+49 -14
View File
@@ -25,23 +25,57 @@ export function useMainSubscriptions() {
const platformSubscriptions = rawSubscriptions.available.filter( const platformSubscriptions = rawSubscriptions.available.filter(
s => s.platform === 'web', s => s.platform === 'web',
) )
const subscriptions = platformSubscriptions.map(sub => { const subscriptions: Subscription[] = platformSubscriptions.map(sub => {
const subscription: Subscription = { return {
...sub, id: sub.id,
platform: 'web',
interval: sub.interval,
subscription: sub,
price: { price: {
value: sub.price, value: sub.store.price,
formatted: currencyFormatter.format(sub.price / 100), formatted: currencyFormatter.format(sub.store.price / 100),
},
product: {
platform: 'web',
data: sub.checkoutId,
}, },
// TODO may need to have si_id too
product: sub.store.priceId,
} }
return subscription })
const active: Subscription[] = rawSubscriptions.active.map(sub => {
if (sub.platform === 'web') {
const s: Subscription = {
id: sub.id,
platform: 'web',
interval: sub.interval,
subscription: sub,
price: {
value: sub.store.price,
formatted: currencyFormatter.format(sub.store.price / 100),
},
// TODO may need to have si_id too
product: sub.store.priceId,
}
return s
}
const s: Subscription = {
id: sub.id,
platform: sub.platform,
interval: sub.interval,
subscription: sub,
price: {
value: 0,
formatted: '',
},
product: undefined,
}
return s
}) })
return { return {
active: rawSubscriptions.active, /**
* TODO decorate this too
*/
active: active,
available: organizeMainSubscriptionsByTier(subscriptions), available: organizeMainSubscriptionsByTier(subscriptions),
} }
}, },
@@ -51,10 +85,11 @@ export function useMainSubscriptions() {
export function usePurchaseSubscription() { export function usePurchaseSubscription() {
const {currentAccount} = useSession() const {currentAccount} = useSession()
return useMutation({ return useMutation({
async mutationFn(product: Subscription['product']) { async mutationFn(subscription: Subscription) {
if (product.platform !== 'web') { if (subscription.platform !== 'web') {
throw new Error('Cannot purchase native subscription on web') throw new Error('Cannot purchase native subscription on web')
} }
if (!currentAccount || !currentAccount.email) { if (!currentAccount || !currentAccount.email) {
throw new Error('No account or email') throw new Error('No account or email')
} }
@@ -64,7 +99,7 @@ export function usePurchaseSubscription() {
{ {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
price: product.data, price: subscription.product,
// TODO should NOT use query, use auth and our db state // TODO should NOT use query, use auth and our db state
user: currentAccount.did, user: currentAccount.did,
email: currentAccount.email, email: currentAccount.email,
+35 -28
View File
@@ -1,9 +1,6 @@
import {PurchasesStoreProduct} from 'react-native-purchases' import {PurchasesStoreProduct} from 'react-native-purchases'
import { import {Subscription as APISubscription} from '#/state/purchases/subscriptions/api/types'
RawSubscriptionObject,
RawSubscriptionObjectBase,
} from '#/state/purchases/subscriptions/api/types'
export enum SubscriptionId { export enum SubscriptionId {
Main0MonthlyAuto = 'main:0:monthly:auto', Main0MonthlyAuto = 'main:0:monthly:auto',
@@ -20,46 +17,56 @@ export enum EntitlementId {
Main2 = 'main:2', Main2 = 'main:2',
} }
export type Entitlement = {
id: EntitlementId
}
export type Subscription = export type Subscription =
| RawSubscriptionObjectBase<{ | {
id: SubscriptionId
platform: 'android' platform: 'android'
provider: 'play_store' interval: 'monthly' | 'annual'
price: { /**
* Response from our API
*/
subscription: APISubscription
price?: {
value: number value: number
formatted: string formatted: string
} }
product: { product?: PurchasesStoreProduct
platform: 'android' }
data: PurchasesStoreProduct | {
} id: SubscriptionId
}>
| RawSubscriptionObjectBase<{
platform: 'ios' platform: 'ios'
provider: 'app_store' interval: 'monthly' | 'annual'
price: { /**
* Response from our API
*/
subscription: APISubscription
price?: {
value: number value: number
formatted: string formatted: string
} }
product: { product?: PurchasesStoreProduct
platform: 'ios' }
data: PurchasesStoreProduct | {
} id: SubscriptionId
}>
| RawSubscriptionObjectBase<{
platform: 'web' platform: 'web'
provider: 'stripe' interval: 'monthly' | 'annual'
/**
* Response from our API
*/
subscription: APISubscription
price: { price: {
value: number value: number
formatted: string formatted: string
} }
product: { product: string
platform: 'web' }
data: string
}
}>
export type Subscriptions = { export type Subscriptions = {
active: RawSubscriptionObject[] active: Subscription[]
available: { available: {
monthly: Subscription[] monthly: Subscription[]
annual: Subscription[] annual: Subscription[]
+4 -5
View File
@@ -2,15 +2,14 @@ import {Subscription, Subscriptions} from './types'
export function organizeMainSubscriptionsByTier( export function organizeMainSubscriptionsByTier(
subscriptions: Subscription[], subscriptions: Subscription[],
): Subscriptions['subscriptions'] { ): Subscriptions['available'] {
const result: Subscriptions['subscriptions'] = { const result: Subscriptions['available'] = {
monthly: [], monthly: [],
annual: [], annual: [],
} }
for (const subscription of subscriptions) { for (const sub of subscriptions) {
const {interval} = subscription result[sub.interval].push(sub)
result[interval].push(subscription)
} }
result.monthly = result.monthly.sort((a, b) => { result.monthly = result.monthly.sort((a, b) => {