Files
musicland/functions/src/subscription/shared.js
T
2026-01-12 16:01:32 +01:00

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,
}