Closer/server/src/services/entitlement.ts

112 lines
3.7 KiB
TypeScript
Raw Normal View History

import { db } from '../config/firebase'
import { RevenueCatEvent } from '../types'
import { getEnvValue } from '../config/env'
import * as admin from 'firebase-admin'
// 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')
}
const PREMIUM_ACTIVE_TYPES = new Set([
'INITIAL_PURCHASE',
'RENEWAL',
'PRODUCT_CHANGE',
'TRANSFER',
'UNCANCELLATION',
])
const PREMIUM_REVOKED_TYPES = new Set([
'EXPIRATION',
'CANCELLATION',
'SUBSCRIBER_ALIAS',
])
const PREMIUM_ENTITLEMENT_ID = 'closer_premium'
/**
* 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 | Date | undefined
if (!expiresAt) return false
const now = new Date()
const expirationDate = typeof (expiresAt as any).toDate === 'function'
? (expiresAt as FirebaseFirestore.Timestamp).toDate()
: (expiresAt as Date)
return expirationDate > now
}
export async function syncEntitlement(event: RevenueCatEvent): Promise<void> {
const { type, app_user_id: uid, id: eventId, product_id } = event.event
// Idempotency: atomically create the event record. If it already exists,
// another concurrent request processed it and we abort cleanly.
const eventRef = db().collection('entitlement_events').doc(eventId)
try {
await eventRef.create({ processedAt: new Date() })
} catch (err: any) {
if (err?.code === 6 || err?.message?.includes('ALREADY_EXISTS')) {
console.log(`[entitlement] skipping duplicate event: ${eventId}`)
return
}
throw err
}
// Validate product_id against allowlist
if (!PRODUCT_ID_ALLOWLIST.has(product_id)) {
console.log(`[entitlement] ignored event for unknown product_id: ${product_id}`)
return
}
// 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
}
const userRef = db().collection('users').doc(uid)
if (PREMIUM_ACTIVE_TYPES.has(type)) {
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 = admin.firestore.Timestamp.fromMillis(expirationAtMs)
}
await userRef.update(updates)
console.log(`[entitlement] hasPremium=true for ${uid} (${type})`)
return
}
if (PREMIUM_REVOKED_TYPES.has(type)) {
const updates: { hasPremium: false; premiumExpiresAt: null } = { hasPremium: false, premiumExpiresAt: null }
await userRef.update(updates)
console.log(`[entitlement] hasPremium=false for ${uid} (${type})`)
return
}
console.log(`[entitlement] ignored event type: ${type}`)
}