Closer/functions/src/billing/entitlementLogic.ts

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
}