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