diff --git a/src/state/purchases/subscriptions/api/index.ts b/src/state/purchases/subscriptions/api/index.ts new file mode 100644 index 0000000000..3ff6cdcf10 --- /dev/null +++ b/src/state/purchases/subscriptions/api/index.ts @@ -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[] +} diff --git a/src/state/purchases/subscriptions/api/types.ts b/src/state/purchases/subscriptions/api/types.ts new file mode 100644 index 0000000000..306555d7a7 --- /dev/null +++ b/src/state/purchases/subscriptions/api/types.ts @@ -0,0 +1,46 @@ +import {SubscriptionId} from '#/state/purchases/subscriptions/types' + +export type RawSubscriptionObjectBase = 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 + }> diff --git a/src/state/purchases/subscriptions/index.ts b/src/state/purchases/subscriptions/index.ts index ccdbce8ba0..4d1ffda395 100644 --- a/src/state/purchases/subscriptions/index.ts +++ b/src/state/purchases/subscriptions/index.ts @@ -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[] -} diff --git a/src/state/purchases/subscriptions/index.web.ts b/src/state/purchases/subscriptions/index.web.ts index a236054fd6..6424f18c0c 100644 --- a/src/state/purchases/subscriptions/index.web.ts +++ b/src/state/purchases/subscriptions/index.web.ts @@ -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({ 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 - }, -): 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[] -} diff --git a/src/state/purchases/subscriptions/types.ts b/src/state/purchases/subscriptions/types.ts new file mode 100644 index 0000000000..df209fedcd --- /dev/null +++ b/src/state/purchases/subscriptions/types.ts @@ -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[] +} diff --git a/src/state/purchases/subscriptions/types/index.ts b/src/state/purchases/subscriptions/types/index.ts deleted file mode 100644 index 8ebbb2cc34..0000000000 --- a/src/state/purchases/subscriptions/types/index.ts +++ /dev/null @@ -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[] -} diff --git a/src/state/purchases/subscriptions/types/stripe.ts b/src/state/purchases/subscriptions/types/stripe.ts deleted file mode 100644 index ea1f372f44..0000000000 --- a/src/state/purchases/subscriptions/types/stripe.ts +++ /dev/null @@ -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 -} diff --git a/src/state/purchases/subscriptions/util.ts b/src/state/purchases/subscriptions/util.ts index 558eb3c5cb..c1a112f743 100644 --- a/src/state/purchases/subscriptions/util.ts +++ b/src/state/purchases/subscriptions/util.ts @@ -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 })