419 lines
12 KiB
TypeScript
419 lines
12 KiB
TypeScript
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
import { AppState } from 'react-native'
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from 'react'
|
|
import { useAuth } from './auth-context'
|
|
import { supabase } from './supabase'
|
|
|
|
export type MobileSubscriptionTier = 'free' | 'pro' | 'pro_plus'
|
|
export type MobileBillingProvider =
|
|
| 'none'
|
|
| 'stripe'
|
|
| 'payple'
|
|
| 'google_play'
|
|
| 'app_store'
|
|
| 'admin'
|
|
|
|
export interface MobilePurchaseSummary {
|
|
id: string
|
|
platform: 'google_play' | 'app_store'
|
|
productId: string
|
|
state: 'pending' | 'purchased' | 'cancelled' | 'expired' | 'refunded' | 'on_hold' | 'paused'
|
|
purchaseAt: string | null
|
|
expiresAt: string | null
|
|
autoRenewing: boolean | null
|
|
verifiedAt: string
|
|
}
|
|
|
|
export interface EntitlementSnapshot {
|
|
tier: MobileSubscriptionTier
|
|
status: string
|
|
provider: MobileBillingProvider
|
|
paymentProvider: Exclude<MobileBillingProvider, 'admin'>
|
|
currentPeriodStart: string | null
|
|
currentPeriodEnd: string | null
|
|
cancelAt: string | null
|
|
autoRenewing: boolean | null
|
|
storeProductId: string | null
|
|
overageCredits: number
|
|
usageToday: Readonly<Record<string, number>>
|
|
purchases: readonly MobilePurchaseSummary[]
|
|
adFree: boolean
|
|
refreshedAt: string
|
|
}
|
|
|
|
interface EntitlementContextValue {
|
|
snapshot: EntitlementSnapshot
|
|
loading: boolean
|
|
stale: boolean
|
|
error: string | null
|
|
refresh: () => Promise<EntitlementSnapshot | null>
|
|
}
|
|
|
|
interface SubscriptionRow {
|
|
tier?: unknown
|
|
status?: unknown
|
|
provider?: unknown
|
|
payment_provider?: unknown
|
|
current_period_start?: unknown
|
|
current_period_end?: unknown
|
|
cancel_at?: unknown
|
|
auto_renewing?: unknown
|
|
store_product_id?: unknown
|
|
overage_credits?: unknown
|
|
}
|
|
|
|
interface UsageRow {
|
|
feature?: unknown
|
|
count?: unknown
|
|
}
|
|
|
|
interface PurchaseRow {
|
|
id?: unknown
|
|
platform?: unknown
|
|
product_id?: unknown
|
|
purchase_state?: unknown
|
|
purchase_at?: unknown
|
|
expires_at?: unknown
|
|
auto_renewing?: unknown
|
|
verified_at?: unknown
|
|
}
|
|
|
|
interface CacheEnvelope {
|
|
schemaVersion: 1
|
|
userId: string
|
|
savedAt: string
|
|
snapshot: EntitlementSnapshot
|
|
}
|
|
|
|
const ENTITLEMENT_CACHE_PREFIX = '@d3ro/mobile/entitlement-v1/'
|
|
const SUBSCRIPTION_COLUMNS = [
|
|
'tier',
|
|
'status',
|
|
'provider',
|
|
'payment_provider',
|
|
'current_period_start',
|
|
'current_period_end',
|
|
'cancel_at',
|
|
'auto_renewing',
|
|
'store_product_id',
|
|
'overage_credits',
|
|
].join(',')
|
|
const PURCHASE_COLUMNS = [
|
|
'id',
|
|
'platform',
|
|
'product_id',
|
|
'purchase_state',
|
|
'purchase_at',
|
|
'expires_at',
|
|
'auto_renewing',
|
|
'verified_at',
|
|
].join(',')
|
|
|
|
const EntitlementContext = createContext<EntitlementContextValue | null>(null)
|
|
|
|
function emptySnapshot(): EntitlementSnapshot {
|
|
return {
|
|
tier: 'free',
|
|
status: 'active',
|
|
provider: 'none',
|
|
paymentProvider: 'none',
|
|
currentPeriodStart: null,
|
|
currentPeriodEnd: null,
|
|
cancelAt: null,
|
|
autoRenewing: null,
|
|
storeProductId: null,
|
|
overageCredits: 0,
|
|
usageToday: {},
|
|
purchases: [],
|
|
adFree: false,
|
|
refreshedAt: new Date(0).toISOString(),
|
|
}
|
|
}
|
|
|
|
function cacheKey(userId: string): string {
|
|
return `${ENTITLEMENT_CACHE_PREFIX}${userId}`
|
|
}
|
|
|
|
function nullableIso(value: unknown): string | null {
|
|
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) return null
|
|
return new Date(value).toISOString()
|
|
}
|
|
|
|
function normalizeTier(value: unknown): MobileSubscriptionTier {
|
|
return value === 'pro' || value === 'pro_plus' ? value : 'free'
|
|
}
|
|
|
|
function normalizeProvider(value: unknown): MobileBillingProvider {
|
|
return value === 'stripe'
|
|
|| value === 'payple'
|
|
|| value === 'google_play'
|
|
|| value === 'app_store'
|
|
|| value === 'admin'
|
|
? value
|
|
: 'none'
|
|
}
|
|
|
|
function normalizePaymentProvider(value: unknown): Exclude<MobileBillingProvider, 'admin'> {
|
|
const provider = normalizeProvider(value)
|
|
return provider === 'admin' ? 'none' : provider
|
|
}
|
|
|
|
function normalizeNonNegative(value: unknown): number {
|
|
const numeric = Number(value)
|
|
return Number.isSafeInteger(numeric) && numeric >= 0 ? numeric : 0
|
|
}
|
|
|
|
function normalizePurchase(row: PurchaseRow): MobilePurchaseSummary | null {
|
|
const validPlatform = row.platform === 'google_play' || row.platform === 'app_store'
|
|
const validState = row.purchase_state === 'pending'
|
|
|| row.purchase_state === 'purchased'
|
|
|| row.purchase_state === 'cancelled'
|
|
|| row.purchase_state === 'expired'
|
|
|| row.purchase_state === 'refunded'
|
|
|| row.purchase_state === 'on_hold'
|
|
|| row.purchase_state === 'paused'
|
|
if (
|
|
typeof row.id !== 'string'
|
|
|| !validPlatform
|
|
|| typeof row.product_id !== 'string'
|
|
|| !validState
|
|
|| typeof row.verified_at !== 'string'
|
|
|| !Number.isFinite(Date.parse(row.verified_at))
|
|
) return null
|
|
|
|
return {
|
|
id: row.id,
|
|
platform: row.platform as MobilePurchaseSummary['platform'],
|
|
productId: row.product_id,
|
|
state: row.purchase_state as MobilePurchaseSummary['state'],
|
|
purchaseAt: nullableIso(row.purchase_at),
|
|
expiresAt: nullableIso(row.expires_at),
|
|
autoRenewing: typeof row.auto_renewing === 'boolean' ? row.auto_renewing : null,
|
|
verifiedAt: new Date(row.verified_at).toISOString(),
|
|
}
|
|
}
|
|
|
|
export function normalizeEntitlement(
|
|
subscription: SubscriptionRow | null,
|
|
usageRows: readonly UsageRow[],
|
|
purchaseRows: readonly PurchaseRow[],
|
|
now = new Date(),
|
|
): EntitlementSnapshot {
|
|
const tier = normalizeTier(subscription?.tier)
|
|
const status = typeof subscription?.status === 'string' && subscription.status
|
|
? subscription.status
|
|
: 'active'
|
|
const currentPeriodEnd = nullableIso(subscription?.current_period_end)
|
|
const periodValid = currentPeriodEnd === null || Date.parse(currentPeriodEnd) > now.getTime()
|
|
const statusAllowsPaidAccess = ['active', 'trialing', 'canceled', 'past_due'].includes(status)
|
|
const usageToday: Record<string, number> = {}
|
|
for (const row of usageRows) {
|
|
if (typeof row.feature === 'string' && row.feature) {
|
|
usageToday[row.feature] = normalizeNonNegative(row.count)
|
|
}
|
|
}
|
|
|
|
return {
|
|
tier,
|
|
status,
|
|
provider: normalizeProvider(subscription?.provider),
|
|
paymentProvider: normalizePaymentProvider(subscription?.payment_provider),
|
|
currentPeriodStart: nullableIso(subscription?.current_period_start),
|
|
currentPeriodEnd,
|
|
cancelAt: nullableIso(subscription?.cancel_at),
|
|
autoRenewing: typeof subscription?.auto_renewing === 'boolean'
|
|
? subscription.auto_renewing
|
|
: null,
|
|
storeProductId: typeof subscription?.store_product_id === 'string'
|
|
? subscription.store_product_id
|
|
: null,
|
|
overageCredits: normalizeNonNegative(subscription?.overage_credits),
|
|
usageToday,
|
|
purchases: purchaseRows
|
|
.map(normalizePurchase)
|
|
.filter((purchase): purchase is MobilePurchaseSummary => purchase !== null),
|
|
adFree: tier !== 'free' && statusAllowsPaidAccess && periodValid,
|
|
refreshedAt: now.toISOString(),
|
|
}
|
|
}
|
|
|
|
function isCachedSnapshot(value: unknown, userId: string): value is CacheEnvelope {
|
|
if (typeof value !== 'object' || value === null) return false
|
|
const envelope = value as Partial<CacheEnvelope>
|
|
return envelope.schemaVersion === 1
|
|
&& envelope.userId === userId
|
|
&& typeof envelope.savedAt === 'string'
|
|
&& typeof envelope.snapshot === 'object'
|
|
&& envelope.snapshot !== null
|
|
&& normalizeTier(envelope.snapshot.tier) === envelope.snapshot.tier
|
|
}
|
|
|
|
async function readCache(userId: string): Promise<EntitlementSnapshot | null> {
|
|
const serialized = await AsyncStorage.getItem(cacheKey(userId))
|
|
if (!serialized) return null
|
|
try {
|
|
const value: unknown = JSON.parse(serialized)
|
|
return isCachedSnapshot(value, userId) ? value.snapshot : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function writeCache(userId: string, snapshot: EntitlementSnapshot): Promise<void> {
|
|
const envelope: CacheEnvelope = {
|
|
schemaVersion: 1,
|
|
userId,
|
|
savedAt: new Date().toISOString(),
|
|
snapshot,
|
|
}
|
|
await AsyncStorage.setItem(cacheKey(userId), JSON.stringify(envelope))
|
|
}
|
|
|
|
export async function clearAllEntitlementCaches(): Promise<void> {
|
|
const keys = await AsyncStorage.getAllKeys()
|
|
const entitlementKeys = keys.filter((key) => key.startsWith(ENTITLEMENT_CACHE_PREFIX))
|
|
if (entitlementKeys.length > 0) await AsyncStorage.multiRemove(entitlementKeys)
|
|
}
|
|
|
|
export async function fetchEntitlementSnapshot(userId: string): Promise<EntitlementSnapshot> {
|
|
const date = new Date().toISOString().slice(0, 10)
|
|
const [subscriptionResult, usageResult, purchasesResult] = await Promise.all([
|
|
supabase
|
|
.from('subscriptions')
|
|
.select(SUBSCRIPTION_COLUMNS)
|
|
.eq('user_id', userId)
|
|
.maybeSingle(),
|
|
supabase
|
|
.from('daily_usage')
|
|
.select('feature, count')
|
|
.eq('user_id', userId)
|
|
.eq('date', date),
|
|
supabase
|
|
.from('iap_purchases')
|
|
.select(PURCHASE_COLUMNS)
|
|
.eq('user_id', userId)
|
|
.order('verified_at', { ascending: false })
|
|
.limit(20),
|
|
])
|
|
if (subscriptionResult.error) throw subscriptionResult.error
|
|
if (usageResult.error) throw usageResult.error
|
|
if (purchasesResult.error) throw purchasesResult.error
|
|
|
|
return normalizeEntitlement(
|
|
subscriptionResult.data as SubscriptionRow | null,
|
|
(usageResult.data ?? []) as UsageRow[],
|
|
(purchasesResult.data ?? []) as PurchaseRow[],
|
|
)
|
|
}
|
|
|
|
export function EntitlementProvider({ children }: { children: ReactNode }): React.ReactElement {
|
|
const { user } = useAuth()
|
|
const [snapshot, setSnapshot] = useState<EntitlementSnapshot>(emptySnapshot)
|
|
const [loading, setLoading] = useState(true)
|
|
const [stale, setStale] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const requestGeneration = useRef(0)
|
|
|
|
const refresh = useCallback(async (): Promise<EntitlementSnapshot | null> => {
|
|
const currentUserId = user?.id
|
|
const generation = ++requestGeneration.current
|
|
if (!currentUserId) {
|
|
setSnapshot(emptySnapshot())
|
|
setLoading(false)
|
|
setStale(false)
|
|
setError(null)
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const next = await fetchEntitlementSnapshot(currentUserId)
|
|
if (generation !== requestGeneration.current) return null
|
|
setSnapshot(next)
|
|
setStale(false)
|
|
setError(null)
|
|
setLoading(false)
|
|
await writeCache(currentUserId, next)
|
|
return next
|
|
} catch (candidate) {
|
|
if (generation !== requestGeneration.current) return null
|
|
setError(candidate instanceof Error ? candidate.message : 'entitlement_sync_failed')
|
|
setStale(true)
|
|
setLoading(false)
|
|
return null
|
|
}
|
|
}, [user?.id])
|
|
|
|
useEffect(() => {
|
|
const userId = user?.id
|
|
let cancelled = false
|
|
requestGeneration.current += 1
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
if (!userId) {
|
|
setSnapshot(emptySnapshot())
|
|
setStale(false)
|
|
setLoading(false)
|
|
return () => { cancelled = true }
|
|
}
|
|
|
|
void readCache(userId).then((cached) => {
|
|
if (cancelled || !cached) return
|
|
setSnapshot(cached)
|
|
setStale(true)
|
|
setLoading(false)
|
|
}).finally(() => {
|
|
if (!cancelled) void refresh()
|
|
})
|
|
|
|
const channel = supabase
|
|
.channel(`mobile-entitlement:${userId}`)
|
|
.on(
|
|
'postgres_changes',
|
|
{ event: 'INSERT', schema: 'public', table: 'subscriptions', filter: `user_id=eq.${userId}` },
|
|
() => { void refresh() },
|
|
)
|
|
.on(
|
|
'postgres_changes',
|
|
{ event: 'UPDATE', schema: 'public', table: 'subscriptions', filter: `user_id=eq.${userId}` },
|
|
() => { void refresh() },
|
|
)
|
|
.subscribe()
|
|
|
|
const appStateSubscription = AppState.addEventListener('change', (nextState) => {
|
|
if (nextState === 'active') void refresh()
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
requestGeneration.current += 1
|
|
appStateSubscription.remove()
|
|
void supabase.removeChannel(channel)
|
|
}
|
|
}, [refresh, user?.id])
|
|
|
|
const value = useMemo<EntitlementContextValue>(() => ({
|
|
snapshot,
|
|
loading,
|
|
stale,
|
|
error,
|
|
refresh,
|
|
}), [error, loading, refresh, snapshot, stale])
|
|
|
|
return <EntitlementContext.Provider value={value}>{children}</EntitlementContext.Provider>
|
|
}
|
|
|
|
export function useEntitlement(): EntitlementContextValue {
|
|
const value = useContext(EntitlementContext)
|
|
if (!value) throw new Error('useEntitlement must be used inside EntitlementProvider')
|
|
return value
|
|
}
|