Clean up subs hooks and state

This commit is contained in:
Eric Bailey
2024-10-27 12:54:41 -05:00
parent 71524bb17b
commit 4cfdd287e2
8 changed files with 220 additions and 286 deletions
@@ -0,0 +1,22 @@
import {RawSubscriptionObject} from '#/state/purchases/subscriptions/api/types'
export async function getSubscriptions({
did,
platform,
}: {
did: string
platform: 'web' | 'ios' | 'android'
}) {
const res = await fetch(
`https://bsky-purchases.ngrok.io/subscriptions?user=${did}&platform=${platform}`,
)
if (!res.ok) {
console.error('error fetching subscriptions', res.status, await res.text())
return []
}
const {subscriptions} = await res.json()
return subscriptions as RawSubscriptionObject[]
}
@@ -0,0 +1,46 @@
import {SubscriptionId} from '#/state/purchases/subscriptions/types'
export type RawSubscriptionObjectBase<T> = T & {
id: SubscriptionId
storeId: string
lookupKey: string
checkoutId: string
interval: 'monthly' | 'annual'
autoRenew: boolean
status:
| 'trialing'
| 'active'
| 'expired'
| 'in_grace_period'
| 'in_billing_retry'
| '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
}
export type RawSubscriptionObject =
| RawSubscriptionObjectBase<{
platform: 'android'
provider: 'play_store'
}>
| RawSubscriptionObjectBase<{
platform: 'ios'
provider: 'app_store'
}>
| RawSubscriptionObjectBase<{
platform: 'web'
provider: 'stripe'
price: number
}>
+41 -31
View File
@@ -1,14 +1,13 @@
import Purchases, {PurchasesPackage} from 'react-native-purchases'
import Purchases from 'react-native-purchases'
import {useMutation, useQuery} from '@tanstack/react-query'
import {isAndroid} from '#/platform/detection'
import {getSubscriptions} from '#/state/purchases/subscriptions/api'
import {
Subscription,
Subscriptions,
} from '#/state/purchases/subscriptions/types'
import {
identifierToSubscriptionInfo,
organizeSubscriptionsByTier,
} from '#/state/purchases/subscriptions/util'
import {organizeSubscriptionsByTier} from '#/state/purchases/subscriptions/util'
import {useSession} from '#/state/session'
export function useAvailableSubscriptions() {
@@ -19,40 +18,51 @@ export function useAvailableSubscriptions() {
queryKey: ['availableSubscriptions', did],
async queryFn() {
Purchases.logIn(did)
const offerings = await Purchases.getOfferings()
const tierOfferings = Object.values(offerings.all).filter(offering => {
return offering.identifier.includes('bsky_tier')
Purchases.setEmail(currentAccount!.email!)
const platform = isAndroid ? 'android' : ('ios' as const)
const rawSubscriptions = await getSubscriptions({
did: currentAccount!.did,
platform,
})
const packages = tierOfferings.flatMap(
offering => offering.availablePackages,
const platformSubscriptions = rawSubscriptions
.filter(s => s.platform !== 'web')
.filter(s => s.platform === platform)
const lookupKeys = Array.from(
new Set(platformSubscriptions.map(s => s.lookupKey)),
)
return organizeSubscriptionsByTier(normalizePackages(packages))
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,
price: {
value: productData.price * 100, // convert to cents
formatted: productData.priceString,
},
product: {
platform,
data: productData,
},
}
return subscription
})
.filter(Boolean) as Subscription[]
return organizeSubscriptionsByTier(subscriptions)
},
})
}
export function usePurchaseSubscription() {
return useMutation({
async mutationFn(pkg: PurchasesPackage) {
return Purchases.purchasePackage(pkg)
async mutationFn(product: Subscription['product']) {
if (product.platform === 'web') {
throw new Error('Cannot purchase web subscription on native')
}
return Purchases.purchaseStoreProduct(product.data)
},
})
}
function normalizePackages(pkgs: PurchasesPackage[]): Subscription[] {
return pkgs
.map(p => {
const info = identifierToSubscriptionInfo(p.product.identifier)
if (!info) return
const subscription: Subscription = {
info,
price: {
formatted: p.product.priceString,
value: p.product.price,
},
raw: p,
}
return subscription
})
.filter(Boolean) as Subscription[]
}
+49 -50
View File
@@ -2,18 +2,12 @@ import {Linking} from 'react-native'
import {useMutation, useQuery} from '@tanstack/react-query'
import {useCurrencyFormatter} from '#/lib/currency'
import {getSubscriptions} from '#/state/purchases/subscriptions/api'
import {
Subscription,
Subscriptions,
} from '#/state/purchases/subscriptions/types'
import {
StripePrice,
StripeProduct,
} from '#/state/purchases/subscriptions/types/stripe'
import {
identifierToSubscriptionInfo,
organizeSubscriptionsByTier,
} from '#/state/purchases/subscriptions/util'
import {organizeSubscriptionsByTier} from '#/state/purchases/subscriptions/util'
import {useSession} from '#/state/session'
import {BSKY_PURCHASES_API} from '#/env'
@@ -24,55 +18,60 @@ export function useAvailableSubscriptions() {
return useQuery<Subscriptions>({
queryKey: ['availableSubscriptions', currentAccount!.did],
async queryFn() {
const res = await fetch(`${BSKY_PURCHASES_API}/getWebOffers`).then(res =>
res.json(),
)
return organizeSubscriptionsByTier(
normalizeProducts(res, {currencyFormatter}),
const rawSubscriptions = await getSubscriptions({
did: currentAccount!.did,
platform: 'web',
})
const platformSubscriptions = rawSubscriptions.filter(
s => s.platform === 'web',
)
const subscriptions = platformSubscriptions.map(sub => {
const subscription: Subscription = {
...sub,
price: {
value: sub.price,
formatted: currencyFormatter.format(sub.price / 100),
},
product: {
platform: 'web',
data: sub.checkoutId,
},
}
return subscription
})
return organizeSubscriptionsByTier(subscriptions)
},
})
}
export function usePurchaseSubscription() {
const {currentAccount} = useSession()
return useMutation({
async mutationFn(priceObject: any) {
Linking.openURL(
`${BSKY_PURCHASES_API}/initCheckout/${priceObject.price_id}`,
)
async mutationFn(product: Subscription['product']) {
if (product.platform !== 'web') {
throw new Error('Cannot purchase native subscription on web')
}
if (!currentAccount || !currentAccount.email) {
throw new Error('No account or email')
}
const {checkoutUrl} = await fetch(
`${BSKY_PURCHASES_API}/createCheckout`,
{
method: 'POST',
body: JSON.stringify({
price: product.data,
// TODO should NOT use query, use auth and our db state
user: currentAccount.did,
email: currentAccount.email,
}),
headers: {
'Content-Type': 'application/json',
},
},
).then(res => res.json())
Linking.openURL(checkoutUrl)
},
})
}
function normalizeProducts(
products: {product: StripeProduct; price: StripePrice}[],
options: {
currencyFormatter: ReturnType<typeof useCurrencyFormatter>
},
): Subscription[] {
return products
.map(({product, price}) => {
const info = identifierToSubscriptionInfo(product.id)
if (!info) return
const priceObj =
price.currency_options[options.currencyFormatter.currency]
const value = priceObj.unit_amount
const formatted = options.currencyFormatter.format(value / 100)
const subscription: Subscription = {
info,
price: {
value,
formatted,
},
raw: {
price_id: price.id,
},
}
return subscription
})
.filter(Boolean) as Subscription[]
}
@@ -0,0 +1,55 @@
import {PurchasesStoreProduct} from 'react-native-purchases'
import {RawSubscriptionObjectBase} from '#/state/purchases/subscriptions/api/types'
export enum SubscriptionId {
Tier0MonthlyAuto = '0:monthly:auto',
Tier0AnnualAuto = '0:annual:auto',
Tier1MonthlyAuto = '1:monthly:auto',
Tier1AnnualAuto = '1:annual:auto',
Tier2MonthlyAuto = '2:monthly:auto',
Tier2AnnualAuto = '2:annual:auto',
}
export type Subscription =
| RawSubscriptionObjectBase<{
platform: 'android'
provider: 'play_store'
price: {
value: number
formatted: string
}
product: {
platform: 'android'
data: PurchasesStoreProduct
}
}>
| RawSubscriptionObjectBase<{
platform: 'ios'
provider: 'app_store'
price: {
value: number
formatted: string
}
product: {
platform: 'ios'
data: PurchasesStoreProduct
}
}>
| RawSubscriptionObjectBase<{
platform: 'web'
provider: 'stripe'
price: {
value: number
formatted: string
}
product: {
platform: 'web'
data: string
}
}>
export type Subscriptions = {
monthly: Subscription[]
annual: Subscription[]
}
@@ -1,28 +0,0 @@
export enum SubscriptionIdentifier {
Tier0MonthlyAuto = '0:monthly:auto',
Tier0AnnualAuto = '0:annual:auto',
Tier1MonthlyAuto = '1:monthly:auto',
Tier1AnnualAuto = '1:annual:auto',
Tier2MonthlyAuto = '2:monthly:auto',
Tier2AnnualAuto = '2:annual:auto',
}
export type SubscriptionInfo = {
identifier: SubscriptionIdentifier
interval: 'monthly' | 'annual'
autoRenew: boolean
}
export type Subscription = {
info: SubscriptionInfo
price: {
formatted: string
value: number
}
raw: any
}
export type Subscriptions = {
monthly: Subscription[]
annual: Subscription[]
}
@@ -1,60 +0,0 @@
export type StripeProduct = {
// main stuff
id: string
default_price: string // ID of Price obj
name: string
active: boolean
// other stuff
object: string
attributes: unknown[]
created: number
description: string | null
features: unknown[]
images: unknown[]
livemode: boolean
marketing_features: unknown[]
metadata: unknown
package_dimensions: unknown | null
shippable: unknown | null
statement_descriptor: unknown | null
tax_code: string
type: 'service' | string
unit_label: unknown | null
updated: number
url: string | null
}
export type StripePrice = {
// main stuff
id: string
product: string // Product obj associated
currency_options: {
[key: string]: {
custom_unit_amount: number | null
tax_behavior: 'inclusive' | string
unit_amount: number
unit_amount_decimal: string
}
}
active: boolean
recurring: {
interval: 'month' | 'year' | string
interval_count: number
usage_type: 'licensed' | string
}
object: 'price' | string
type: 'recurring' | string
// other stuff
metadata: unknown
unit_amount: number // default Price in cents
currency: string // default currency
tax_behavior: 'inclusive' | string // default tax behavior
billing_scheme: 'per_unit' | string
created: number
custom_unit_amount: number | null
livemode: boolean
lookup_key: string | null
nickname: string | null
tiers_mode: string | null
transform_quantity: string | null
}
+7 -117
View File
@@ -1,114 +1,4 @@
import {
Subscription,
SubscriptionIdentifier,
SubscriptionInfo,
Subscriptions,
} from '#/state/purchases/subscriptions/types'
export function identifierToSubscriptionInfo(
identifier: string,
): SubscriptionInfo | undefined {
switch (identifier) {
/*
* Android
*/
case 'bsky_tier_0:monthly-auto': {
return {
identifier: SubscriptionIdentifier.Tier0MonthlyAuto,
interval: 'monthly',
autoRenew: true,
}
}
case 'bsky_tier_0:annual-auto': {
return {
identifier: SubscriptionIdentifier.Tier0AnnualAuto,
interval: 'annual',
autoRenew: true,
}
}
case 'bsky_tier_1:monthly-auto': {
return {
identifier: SubscriptionIdentifier.Tier1MonthlyAuto,
interval: 'monthly',
autoRenew: true,
}
}
case 'bsky_tier_1:annual-auto': {
return {
identifier: SubscriptionIdentifier.Tier1AnnualAuto,
interval: 'annual',
autoRenew: true,
}
}
case 'bsky_tier_2:monthly-auto': {
return {
identifier: SubscriptionIdentifier.Tier2MonthlyAuto,
interval: 'monthly',
autoRenew: true,
}
}
case 'bsky_tier_2:annual-auto': {
return {
identifier: SubscriptionIdentifier.Tier2AnnualAuto,
interval: 'annual',
autoRenew: true,
}
}
/*
* Stripe
*/
case 'prod_R2eNjNa6mB1Jlu': {
return {
identifier: SubscriptionIdentifier.Tier0MonthlyAuto,
interval: 'monthly',
autoRenew: true,
}
}
case 'prod_R37Zg28EeQ9XAz': {
return {
identifier: SubscriptionIdentifier.Tier0AnnualAuto,
interval: 'annual',
autoRenew: true,
}
}
case 'prod_R67eiEMuIf59w7': {
return {
identifier: SubscriptionIdentifier.Tier1MonthlyAuto,
interval: 'monthly',
autoRenew: true,
}
}
case 'prod_R67fjfexQ7THQ0': {
return {
identifier: SubscriptionIdentifier.Tier1AnnualAuto,
interval: 'annual',
autoRenew: true,
}
}
case 'prod_R67ftsSVu2U1D6': {
return {
identifier: SubscriptionIdentifier.Tier2MonthlyAuto,
interval: 'monthly',
autoRenew: true,
}
}
case 'prod_R67gClEZy1cry2': {
return {
identifier: SubscriptionIdentifier.Tier2AnnualAuto,
interval: 'annual',
autoRenew: true,
}
}
/*
* Fallback
*/
default: {
return undefined
}
}
}
import {Subscription, Subscriptions} from './types'
export function organizeSubscriptionsByTier(
subscriptions: Subscription[],
@@ -119,18 +9,18 @@ export function organizeSubscriptionsByTier(
}
for (const subscription of subscriptions) {
const {info} = subscription
result[info.interval].push(subscription)
const {interval} = subscription
result[interval].push(subscription)
}
result.monthly = result.monthly.sort((a, b) => {
const _a = parseInt(a.info.identifier.slice(0, 1))
const _b = parseInt(b.info.identifier.slice(0, 1))
const _a = parseInt(a.id.slice(0, 1))
const _b = parseInt(b.id.slice(0, 1))
return _a - _b
})
result.annual = result.annual.sort((a, b) => {
const _a = parseInt(a.info.identifier.slice(0, 1))
const _b = parseInt(b.info.identifier.slice(0, 1))
const _a = parseInt(a.id.slice(0, 1))
const _b = parseInt(b.id.slice(0, 1))
return _a - _b
})