379 lines
9.9 KiB
JavaScript
379 lines
9.9 KiB
JavaScript
const admin = require('firebase-admin')
|
|
const { FieldValue } = require('firebase-admin/firestore')
|
|
|
|
const { refList } = require('../../index')
|
|
const { STRIPE_WEBHOOK_SECRET } = require('../../config/keys')
|
|
const {
|
|
SUBSCRIPTION_LEVEL_ALLOWANCES,
|
|
SUBSCRIPTION_PRICE_METADATA,
|
|
COIN_PACK_PRODUCT_MAP,
|
|
} = require('./constants')
|
|
|
|
const refsList = refList
|
|
const paymentsCollection = admin.firestore().collection('payments')
|
|
let cachedStripeWebhookSecret = null
|
|
|
|
const toFiniteNumber = (value) => {
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
return value
|
|
}
|
|
if (typeof value === 'string') {
|
|
const normalized = value.trim().replace(',', '.')
|
|
if (!normalized) {
|
|
return null
|
|
}
|
|
const parsed = Number(normalized)
|
|
return Number.isFinite(parsed) ? parsed : null
|
|
}
|
|
return null
|
|
}
|
|
|
|
const parseCoinsPerMonth = (metadata) => {
|
|
if (!metadata || typeof metadata !== 'object') {
|
|
return null
|
|
}
|
|
if (!Object.prototype.hasOwnProperty.call(metadata, 'coinsPerMonth')) {
|
|
return null
|
|
}
|
|
const candidateValue = toFiniteNumber(metadata.coinsPerMonth)
|
|
if (candidateValue === null || candidateValue <= 0) {
|
|
return null
|
|
}
|
|
return Math.round(candidateValue)
|
|
}
|
|
|
|
const parseCoinAmount = (metadata) => {
|
|
if (!metadata || typeof metadata !== 'object') {
|
|
return null
|
|
}
|
|
if (!Object.prototype.hasOwnProperty.call(metadata, 'coins')) {
|
|
return null
|
|
}
|
|
const candidateValue = toFiniteNumber(metadata.coins)
|
|
if (candidateValue === null || candidateValue <= 0) {
|
|
return null
|
|
}
|
|
return Math.round(candidateValue)
|
|
}
|
|
|
|
const toDateSafe = (value) => {
|
|
if (!value) {
|
|
return null
|
|
}
|
|
if (value instanceof Date) {
|
|
return value
|
|
}
|
|
if (typeof value?.toDate === 'function') {
|
|
try {
|
|
return value.toDate()
|
|
} catch (_error) {
|
|
return null
|
|
}
|
|
}
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
if (value > 1e12) {
|
|
return new Date(value)
|
|
}
|
|
return new Date(value * 1000)
|
|
}
|
|
return null
|
|
}
|
|
|
|
const addMonths = (date, months = 1) => {
|
|
if (!(date instanceof Date) || !Number.isFinite(months)) {
|
|
return null
|
|
}
|
|
const result = new Date(date.getTime())
|
|
const initialDay = result.getDate()
|
|
result.setMonth(result.getMonth() + months)
|
|
if (result.getDate() !== initialDay) {
|
|
result.setDate(0)
|
|
}
|
|
return result
|
|
}
|
|
|
|
const computeNextGrantTimestamp = (base, months = 1) => {
|
|
const baseDate = toDateSafe(base)
|
|
if (!baseDate) {
|
|
return null
|
|
}
|
|
const nextDate = addMonths(baseDate, months)
|
|
if (!nextDate) {
|
|
return null
|
|
}
|
|
return admin.firestore.Timestamp.fromDate(nextDate)
|
|
}
|
|
|
|
const getServerTimestamp = () => {
|
|
if (typeof FieldValue?.serverTimestamp === 'function') {
|
|
return FieldValue.serverTimestamp()
|
|
}
|
|
const fallback = admin.firestore?.FieldValue
|
|
if (typeof fallback?.serverTimestamp === 'function') {
|
|
return fallback.serverTimestamp()
|
|
}
|
|
throw new Error('Firestore FieldValue.serverTimestamp indisponible.')
|
|
}
|
|
|
|
const resolveStripeWebhookSecret = () => {
|
|
if (cachedStripeWebhookSecret) {
|
|
return cachedStripeWebhookSecret
|
|
}
|
|
|
|
const envSecret =
|
|
typeof process?.env?.STRIPE_WEBHOOK_SECRET === 'string'
|
|
? process.env.STRIPE_WEBHOOK_SECRET.trim()
|
|
: ''
|
|
const inlineSecret = typeof STRIPE_WEBHOOK_SECRET === 'string' ? STRIPE_WEBHOOK_SECRET.trim() : ''
|
|
|
|
const secret = envSecret || inlineSecret
|
|
if (!secret) {
|
|
throw new Error('STRIPE_WEBHOOK_SECRET not configured')
|
|
}
|
|
|
|
cachedStripeWebhookSecret = secret
|
|
return cachedStripeWebhookSecret
|
|
}
|
|
|
|
const toFirestoreTimestamp = (unixSeconds) => {
|
|
if (typeof unixSeconds !== 'number' || !Number.isFinite(unixSeconds)) {
|
|
return null
|
|
}
|
|
try {
|
|
return admin.firestore.Timestamp.fromMillis(unixSeconds * 1000)
|
|
} catch (error) {
|
|
console.error('[subscription-toFirestoreTimestamp] Conversion error', unixSeconds, error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
const extractFirebaseUid = (metadata) => {
|
|
if (!metadata || typeof metadata !== 'object') {
|
|
return null
|
|
}
|
|
|
|
const candidates = [metadata.firebaseUID, metadata.firebaseUid, metadata.uid, metadata.userId]
|
|
|
|
for (let index = 0; index < candidates.length; index += 1) {
|
|
const candidate = candidates[index]
|
|
if (typeof candidate === 'string' && candidate.trim()) {
|
|
return candidate.trim()
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
const formatCoinPack = ({ product, price }) => {
|
|
if (!product || typeof product !== 'object') {
|
|
return null
|
|
}
|
|
|
|
const resolvedPrice =
|
|
price || (typeof product.default_price === 'object' && product.default_price) || null
|
|
|
|
const priceId =
|
|
(resolvedPrice && resolvedPrice.id) ||
|
|
(typeof product.default_price === 'string' ? product.default_price : null)
|
|
|
|
const coinAmount = parseCoinAmount(product?.metadata || {})
|
|
if (coinAmount === null) {
|
|
throw new Error(`[formatCoinPack] Missing metadata.coins on product ${product.id}`)
|
|
}
|
|
|
|
return {
|
|
productId: product.id,
|
|
priceId,
|
|
name: product.name || '',
|
|
description: product.description || '',
|
|
coinAmount,
|
|
currency:
|
|
resolvedPrice?.currency ||
|
|
(typeof resolvedPrice?.currency === 'string' ? resolvedPrice.currency.toLowerCase() : 'eur'),
|
|
unitAmount: resolvedPrice?.unit_amount ?? null,
|
|
metadata: product.metadata || {},
|
|
}
|
|
}
|
|
|
|
const getSubscriptionMetaFromPrice = (priceId) => {
|
|
if (typeof priceId !== 'string') {
|
|
return null
|
|
}
|
|
return SUBSCRIPTION_PRICE_METADATA[priceId] || null
|
|
}
|
|
|
|
const buildEventSnapshot = (eventType, entityId) => {
|
|
const now = admin.firestore.Timestamp.now()
|
|
return {
|
|
eventType: eventType || null,
|
|
entityId: entityId || null,
|
|
syncedAt: now,
|
|
}
|
|
}
|
|
|
|
const formatSubscriptionForClient = (subscription) => {
|
|
if (!subscription || typeof subscription !== 'object') {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
id: subscription.id,
|
|
status: subscription.status,
|
|
customer: subscription.customer,
|
|
currentPeriodStart: toFirestoreTimestamp(subscription.current_period_start),
|
|
currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end),
|
|
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
|
|
created: toFirestoreTimestamp(subscription.created),
|
|
items: Array.isArray(subscription.items?.data)
|
|
? subscription.items.data.map((item) => ({
|
|
id: item.id,
|
|
price: item.price,
|
|
quantity: item.quantity,
|
|
}))
|
|
: [],
|
|
metadata: subscription.metadata || {},
|
|
}
|
|
}
|
|
|
|
const buildSubscriptionPayload = (subscription) => {
|
|
if (!subscription || typeof subscription !== 'object') {
|
|
return null
|
|
}
|
|
|
|
const items = Array.isArray(subscription.items?.data)
|
|
? subscription.items.data.map((item) => ({
|
|
id: item.id,
|
|
priceId: item.price?.id || null,
|
|
productId: item.price?.product || null,
|
|
quantity: item.quantity || 0,
|
|
price: item.price || null,
|
|
}))
|
|
: []
|
|
|
|
const primaryItem = items[0] || null
|
|
const productId = primaryItem?.productId || null
|
|
const priceId = primaryItem?.priceId || null
|
|
|
|
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}
|
|
const metadataLevel = subscription.metadata?.subscriptionLevel || null
|
|
const resolvedLevel = metadataLevel || priceMeta?.level || null
|
|
const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null
|
|
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null
|
|
|
|
const customerId = typeof subscription.customer === 'string' ? subscription.customer : null
|
|
|
|
return {
|
|
id: subscription.id,
|
|
customerId,
|
|
productId,
|
|
priceId,
|
|
items,
|
|
level: resolvedLevel,
|
|
billingPeriod: resolvedPeriod,
|
|
status: subscription.status || null,
|
|
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
|
|
currentPeriodStart: toFirestoreTimestamp(subscription.current_period_start),
|
|
currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end),
|
|
}
|
|
}
|
|
|
|
const resolveUserContext = async ({ metadata, customerId }) => {
|
|
const firebaseUid = extractFirebaseUid(metadata)
|
|
|
|
if (firebaseUid) {
|
|
const userRef = refsList?.users?.doc(firebaseUid) || null
|
|
if (userRef) {
|
|
try {
|
|
const snapshot = await userRef.get()
|
|
if (snapshot.exists) {
|
|
return {
|
|
uid: firebaseUid,
|
|
userRef,
|
|
userData: snapshot.data() || null,
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn(
|
|
'[subscription-resolveUserContext] Unable to read user',
|
|
firebaseUid,
|
|
error?.message || error
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (customerId) {
|
|
try {
|
|
const snapshot = await refsList.users
|
|
.where('stripeCustomerId', '==', customerId)
|
|
.limit(1)
|
|
.get()
|
|
if (!snapshot.empty) {
|
|
const doc = snapshot.docs[0]
|
|
return {
|
|
uid: doc.id,
|
|
userRef: doc.ref,
|
|
userData: doc.data() || null,
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn(
|
|
'[subscription-resolveUserContext] Unable to query by customer',
|
|
customerId,
|
|
error?.message || error
|
|
)
|
|
}
|
|
}
|
|
|
|
return {
|
|
uid: firebaseUid || null,
|
|
userRef: null,
|
|
userData: null,
|
|
}
|
|
}
|
|
|
|
const upsertPaymentDocument = async (docId, data = {}) => {
|
|
if (!docId) {
|
|
return null
|
|
}
|
|
|
|
const docRef = paymentsCollection.doc(docId)
|
|
try {
|
|
await docRef.set(
|
|
{
|
|
...data,
|
|
updatedAt: getServerTimestamp(),
|
|
createdAt: getServerTimestamp(),
|
|
},
|
|
{ merge: true }
|
|
)
|
|
} catch (error) {
|
|
console.error('[subscription-upsertPaymentDocument] Failed to persist payment', docId, error)
|
|
}
|
|
return docRef
|
|
}
|
|
|
|
module.exports = {
|
|
admin,
|
|
refsList,
|
|
paymentsCollection,
|
|
toFiniteNumber,
|
|
parseCoinsPerMonth,
|
|
parseCoinAmount,
|
|
toDateSafe,
|
|
addMonths,
|
|
computeNextGrantTimestamp,
|
|
getServerTimestamp,
|
|
resolveStripeWebhookSecret,
|
|
toFirestoreTimestamp,
|
|
extractFirebaseUid,
|
|
formatCoinPack,
|
|
getSubscriptionMetaFromPrice,
|
|
buildEventSnapshot,
|
|
formatSubscriptionForClient,
|
|
buildSubscriptionPayload,
|
|
resolveUserContext,
|
|
upsertPaymentDocument,
|
|
COIN_PACK_PRODUCT_MAP,
|
|
}
|