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 currentPeriodStart: string | null currentPeriodEnd: string | null cancelAt: string | null autoRenewing: boolean | null storeProductId: string | null overageCredits: number usageToday: Readonly> purchases: readonly MobilePurchaseSummary[] adFree: boolean refreshedAt: string } interface EntitlementContextValue { snapshot: EntitlementSnapshot loading: boolean stale: boolean error: string | null refresh: () => Promise } 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(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 { 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 = {} 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 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 { 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 { const envelope: CacheEnvelope = { schemaVersion: 1, userId, savedAt: new Date().toISOString(), snapshot, } await AsyncStorage.setItem(cacheKey(userId), JSON.stringify(envelope)) } export async function clearAllEntitlementCaches(): Promise { 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 { 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(emptySnapshot) const [loading, setLoading] = useState(true) const [stale, setStale] = useState(false) const [error, setError] = useState(null) const requestGeneration = useRef(0) const refresh = useCallback(async (): Promise => { 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(() => ({ snapshot, loading, stale, error, refresh, }), [error, loading, refresh, snapshot, stale]) return {children} } export function useEntitlement(): EntitlementContextValue { const value = useContext(EntitlementContext) if (!value) throw new Error('useEntitlement must be used inside EntitlementProvider') return value }