import * as admin from 'firebase-admin' // RevenueCat event types we care about. type RevenueCatEventType = | 'INITIAL_PURCHASE' | 'RENEWAL' | 'PRODUCT_CHANGE' | 'TRANSFER' | 'UNCANCELLATION' | 'EXPIRATION' | 'CANCELLATION' | 'BILLING_ISSUE' | 'SUBSCRIBER_ALIAS' export interface EntitlementEvent { id: string type: RevenueCatEventType app_user_id: string product_id: string period_type?: 'normal' | 'trial' | 'intro' expiration_at_ms?: number is_family_share?: boolean entitlement_id?: string entitlement_ids?: string[] store?: 'app_store' | 'play_store' | 'mac_store' | 'stripe' | 'unknown' environment?: 'SANDBOX' | 'PRODUCTION' } export interface EntitlementState { premium: boolean expiresAt: admin.firestore.Timestamp | null updatedAt: admin.firestore.Timestamp } // Events that should grant or keep premium access active. export const PREMIUM_ACTIVE_TYPES: Set = new Set([ 'INITIAL_PURCHASE', 'RENEWAL', 'PRODUCT_CHANGE', 'TRANSFER', 'UNCANCELLATION', ]) // Events that remove premium access. export const PREMIUM_REVOKED_TYPES: Set = new Set([ 'EXPIRATION', 'CANCELLATION', 'BILLING_ISSUE', 'SUBSCRIBER_ALIAS', ]) // Premium entitlement identifier used by the app. const PREMIUM_ENTITLEMENT_ID = 'closer_premium' function getDb(): admin.firestore.Firestore { return admin.firestore() } function entitlementsRef(userId: string) { return getDb().collection('users').doc(userId).collection('entitlements').doc('premium') } function now(): admin.firestore.Timestamp { return admin.firestore.Timestamp.now() } export function isPremiumEntitlement(event: EntitlementEvent): boolean { const entitlementId = event.entitlement_id const entitlementIds = event.entitlement_ids ?? [] if (entitlementId === PREMIUM_ENTITLEMENT_ID) return true if (entitlementIds.includes(PREMIUM_ENTITLEMENT_ID)) return true return false } /** * Apply a RevenueCat entitlement event to Firestore. * Writes state to users/{userId}/entitlements. */ export async function applyEntitlementEvent(event: EntitlementEvent): Promise { const { type, app_user_id: userId, id: eventId, product_id: productId } = event // Idempotency: create a processed event marker; if it exists, skip. const eventRef = getDb().collection('entitlement_events').doc(eventId) try { await eventRef.create({ processedAt: now() }) } catch (err: any) { if (err?.code === 6 || err?.message?.includes('ALREADY_EXISTS')) { console.log(`[entitlement] skipping duplicate event: ${eventId}`) return } throw err } if (!isPremiumEntitlement(event)) { console.log(`[entitlement] ignored event for non-premium entitlement: ${eventId}`) return } const ref = entitlementsRef(userId) if (PREMIUM_ACTIVE_TYPES.has(type)) { const expiresAt = event.expiration_at_ms ? admin.firestore.Timestamp.fromMillis(event.expiration_at_ms) : null await ref.set({ premium: true, expiresAt, updatedAt: now(), productId, eventType: type, } as EntitlementState & { productId: string; eventType: RevenueCatEventType }) console.log(`[entitlement] premium=true for ${userId} (${type})`) return } if (PREMIUM_REVOKED_TYPES.has(type)) { await ref.set({ premium: false, expiresAt: null, updatedAt: now(), productId, eventType: type, } as EntitlementState & { productId: string; eventType: RevenueCatEventType }) console.log(`[entitlement] premium=false for ${userId} (${type})`) return } console.log(`[entitlement] ignored event type: ${type}`) } /** * Recompute and rewrite the entitlement document for a user. * * In production this should query RevenueCat; for now it ensures the * Firestore document is consistent and reflects the latest stored values. */ export async function applyEntitlementSync(userId: string): Promise { const ref = entitlementsRef(userId) const snap = await ref.get() const data = snap.data() as Partial | undefined const updatedAt = now() let premium = false let expiresAt: admin.firestore.Timestamp | null = null if (data?.premium === true) { const storedExpiresAt = data.expiresAt ?? null if (storedExpiresAt instanceof admin.firestore.Timestamp) { premium = storedExpiresAt.toMillis() > Date.now() expiresAt = storedExpiresAt } else { // No expiration means a currently active, non-expiring entitlement. premium = true } } const state: EntitlementState = { premium, expiresAt: premium ? expiresAt : null, updatedAt, } await ref.set(state, { merge: true }) return state }