2026-06-16 01:17:58 -05:00
|
|
|
import { db } from '../config/firebase'
|
|
|
|
|
import { RevenueCatEvent } from '../types'
|
2026-06-16 21:53:53 -05:00
|
|
|
import { getEnvValue } from '../config/env'
|
|
|
|
|
|
|
|
|
|
// Product IDs that grant premium access (comma-separated, configurable via env)
|
|
|
|
|
const DEFAULT_PREMIUM_PRODUCT_IDS = 'closer_premium_monthly,closer_premium_yearly'
|
|
|
|
|
const PRODUCT_ID_ALLOWLIST = new Set(
|
|
|
|
|
getEnvValue('REVENUECAT_PREMIUM_PRODUCT_IDS')
|
|
|
|
|
.split(',')
|
|
|
|
|
.map(id => id.trim())
|
|
|
|
|
.filter(id => id.length > 0)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if (PRODUCT_ID_ALLOWLIST.size === 0) {
|
|
|
|
|
console.warn('[entitlement] No product IDs in allowlist - entitlement validation will be bypassed')
|
|
|
|
|
}
|
2026-06-16 01:17:58 -05:00
|
|
|
|
|
|
|
|
const PREMIUM_ACTIVE_TYPES = new Set([
|
|
|
|
|
'INITIAL_PURCHASE',
|
|
|
|
|
'RENEWAL',
|
|
|
|
|
'PRODUCT_CHANGE',
|
|
|
|
|
'TRANSFER',
|
|
|
|
|
'UNCANCELLATION',
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
const PREMIUM_REVOKED_TYPES = new Set([
|
|
|
|
|
'EXPIRATION',
|
|
|
|
|
'CANCELLATION',
|
|
|
|
|
'SUBSCRIBER_ALIAS',
|
|
|
|
|
])
|
|
|
|
|
|
2026-06-16 21:45:04 -05:00
|
|
|
const PREMIUM_ENTITLEMENT_ID = 'closer_premium'
|
|
|
|
|
|
2026-06-16 21:53:53 -05:00
|
|
|
/**
|
|
|
|
|
* Checks if premium is currently active for a user.
|
|
|
|
|
* Verifies both hasPremium flag AND expiration timestamp.
|
|
|
|
|
*/
|
|
|
|
|
export async function verifyPremiumActive(uid: string): Promise<boolean> {
|
|
|
|
|
const userDoc = await db().collection('users').doc(uid).get()
|
|
|
|
|
const data = userDoc.data()
|
|
|
|
|
if (!data) return false
|
|
|
|
|
|
|
|
|
|
// hasPremium must be true AND expiration must be in the future
|
|
|
|
|
if (!data.hasPremium) return false
|
|
|
|
|
|
|
|
|
|
const expiresAt = data.premiumExpiresAt as FirebaseFirestore.Timestamp | undefined
|
|
|
|
|
if (!expiresAt) return false
|
|
|
|
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
const expirationDate = expiresAt.toDate()
|
|
|
|
|
return expirationDate > now
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 01:17:58 -05:00
|
|
|
export async function syncEntitlement(event: RevenueCatEvent): Promise<void> {
|
2026-06-16 21:53:53 -05:00
|
|
|
const { type, app_user_id: uid, id: eventId, product_id } = event.event
|
2026-06-16 21:45:04 -05:00
|
|
|
|
|
|
|
|
// Check idempotency - skip if we've already processed this event
|
|
|
|
|
const eventRef = db().collection('entitlement_events').doc(eventId)
|
|
|
|
|
const eventDoc = await eventRef.get()
|
|
|
|
|
if (eventDoc.exists) {
|
|
|
|
|
console.log(`[entitlement] skipping duplicate event: ${eventId}`)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Store event for idempotency (with 7-day TTL via Firestore rule or scheduled cleanup)
|
|
|
|
|
await eventRef.set({ processedAt: new Date() })
|
|
|
|
|
|
2026-06-16 21:53:53 -05:00
|
|
|
// Validate product_id against allowlist
|
|
|
|
|
if (!PRODUCT_ID_ALLOWLIST.has(product_id)) {
|
|
|
|
|
console.log(`[entitlement] ignored event for unknown product_id: ${product_id}`)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 21:45:04 -05:00
|
|
|
// Verify entitlement_id matches expected premium entitlement
|
|
|
|
|
const entitlementId = event.event.entitlement?.identifier || event.event.entitlement_id
|
|
|
|
|
if (entitlementId !== PREMIUM_ENTITLEMENT_ID) {
|
|
|
|
|
console.log(`[entitlement] ignored event for wrong entitlement: ${entitlementId} (expected: ${PREMIUM_ENTITLEMENT_ID})`)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-06-16 01:17:58 -05:00
|
|
|
|
|
|
|
|
if (PREMIUM_ACTIVE_TYPES.has(type)) {
|
2026-06-16 21:53:53 -05:00
|
|
|
const updates: { hasPremium: true; premiumExpiresAt?: FirebaseFirestore.Timestamp } = { hasPremium: true }
|
|
|
|
|
|
|
|
|
|
// Store expiration timestamp if provided
|
|
|
|
|
const expirationAtMs = event.event.expiration_at_ms
|
|
|
|
|
if (expirationAtMs !== undefined) {
|
|
|
|
|
updates.premiumExpiresAt = new db().firestore.Timestamp.fromMillis(expirationAtMs)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await db().collection('users').doc(uid).update(updates)
|
2026-06-16 01:17:58 -05:00
|
|
|
console.log(`[entitlement] hasPremium=true for ${uid} (${type})`)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (PREMIUM_REVOKED_TYPES.has(type)) {
|
2026-06-16 21:53:53 -05:00
|
|
|
const updates: { hasPremium: false; premiumExpiresAt?: null } = { hasPremium: false }
|
|
|
|
|
|
|
|
|
|
// Clear expiration timestamp for revocation events
|
|
|
|
|
updates.premiumExpiresAt = null as any
|
|
|
|
|
|
|
|
|
|
await db().collection('users').doc(uid).update(updates)
|
2026-06-16 01:17:58 -05:00
|
|
|
console.log(`[entitlement] hasPremium=false for ${uid} (${type})`)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`[entitlement] ignored event type: ${type}`)
|
|
|
|
|
}
|