170 lines
4.6 KiB
TypeScript
170 lines
4.6 KiB
TypeScript
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<RevenueCatEventType> = new Set([
|
|
'INITIAL_PURCHASE',
|
|
'RENEWAL',
|
|
'PRODUCT_CHANGE',
|
|
'TRANSFER',
|
|
'UNCANCELLATION',
|
|
])
|
|
|
|
// Events that remove premium access.
|
|
export const PREMIUM_REVOKED_TYPES: Set<RevenueCatEventType> = 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<void> {
|
|
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<EntitlementState> {
|
|
const ref = entitlementsRef(userId)
|
|
const snap = await ref.get()
|
|
const data = snap.data() as Partial<EntitlementState> | 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
|
|
}
|