d3ro-voice/apps/mobile-rn/src/lib/entitlement-context.tsx
2026-08-29 18:33:45 +09:00

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
}