const admin = require('firebase-admin') const { FieldValue } = require('firebase-admin/firestore') const { onSchedule } = require('firebase-functions/v2/scheduler') const _ = require('lodash') const { refList, db, ALERT_TYPE } = require('../index') const { batchFirestore } = require('../helpers/firebase') const { BATCH_TYPE } = require('../config/types') const { buildMonthKey, buildPreviousMonthContext } = require('../helpers/stats') const { sendNotification } = require('./notifications') const DISTRIBUTION_REVENUE_BASELINE = 1000 const DISTRIBUTION_RATIO = 0.3 const ACTIVE_SUBSCRIPTION_STATUSES = new Set(['active']) const hasActiveSubscription = (userData) => { if (!userData || typeof userData !== 'object') { return false } const isPremium = userData.isPremium === true if (!isPremium) { return false } const status = typeof userData.stripeSubscriptionStatus === 'string' ? userData.stripeSubscriptionStatus.trim().toLowerCase() : null if (status && ACTIVE_SUBSCRIPTION_STATUSES.has(status)) { return true } const billingPeriod = typeof userData.premiumBillingPeriod === 'string' ? userData.premiumBillingPeriod.trim().toLowerCase() : null if (billingPeriod === 'monthly' || billingPeriod === 'annual') { // Fallback: billing period is set only for active subscribers. return true } return false } exports.distributeMonthlyPayouts = onSchedule( { schedule: '0 1 1 * *', timeZone: 'Europe/Paris', }, async (event) => { const { scheduleTime } = event const context = buildPreviousMonthContext(scheduleTime ? new Date(scheduleTime) : new Date()) const monthKey = buildMonthKey(context.rangeStart) const now = admin.firestore.Timestamp.now() const statsSnapshot = await db .collectionGroup('monthlyListens') .where('monthKey', '==', monthKey) .orderBy('streams', 'desc') .get() const totalsDocRef = refList.projectStreamStatsMonthlyTotals.doc(monthKey) const totalsSnapshot = await totalsDocRef.get() const entries = _.chain(statsSnapshot.docs) .map((doc) => { const data = doc.data() || {} return { projectId: _.get(data, 'projectId') || doc.ref.parent.parent?.id || null, userId: _.get(data, 'userId', null), streams: _.toFinite(_.get(data, 'streams', 0)), statsDocPath: doc.ref.path, } }) .filter((entry) => entry.projectId && entry.streams > 0) .orderBy(['streams'], ['desc']) .value() const userEligibilityMap = {} const userIds = _.uniq(entries.map((entry) => entry.userId).filter((userId) => !!userId)) if (userIds.length) { const chunkSize = 300 for (let index = 0; index < userIds.length; index += chunkSize) { const chunk = userIds.slice(index, index + chunkSize) const snapshots = await Promise.all( chunk.map(async (userId) => { try { return await refList.users.doc(userId).get() } catch (error) { console.warn('[distributeMonthlyPayouts] Unable to load user profile', { userId, error: error?.message || String(error), }) return null } }) ) snapshots.forEach((snapshot, snapshotIndex) => { const userId = chunk[snapshotIndex] if (snapshot?.exists) { userEligibilityMap[userId] = hasActiveSubscription(snapshot.data()) } else { userEligibilityMap[userId] = false } }) } } const eligibleEntries = entries.filter( (entry) => !!entry.userId && userEligibilityMap[entry.userId] === true ) const eligibleTotalStreams = _.sumBy(eligibleEntries, 'streams') const payoutsTotalStreamsFromDocs = _.sumBy(entries, 'streams') const totalsData = totalsSnapshot.exists ? totalsSnapshot.data() : null let totalStreams = _.toFinite(_.get(totalsData, 'totalStreams', 0)) if (!totalStreams || totalStreams < payoutsTotalStreamsFromDocs) { totalStreams = payoutsTotalStreamsFromDocs } const payoutPool = _.round(DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO, 2) let allocations = _.map(eligibleEntries, (entry) => { if (!eligibleTotalStreams) return 0 const rawAmount = (payoutPool * entry.streams) / eligibleTotalStreams return _.round(rawAmount, 2) }) if (allocations.length > 0) { const allocatedTotal = _.round(_.sum(allocations), 2) const remainder = _.round(payoutPool - allocatedTotal, 2) if (remainder !== 0) { allocations[0] = _.round(allocations[0] + remainder, 2) } } const payouts = eligibleEntries.map((entry, idx) => ({ rank: idx + 1, projectId: entry.projectId, userId: entry.userId, streams: entry.streams, share: eligibleTotalStreams ? _.round(entry.streams / eligibleTotalStreams, 6) : 0, amount: allocations[idx], statsDocPath: entry.statsDocPath, })) const userDocs = _(payouts) .filter((payout) => !!payout.userId) .groupBy('userId') .map((projects, userId) => { const sortedProjects = _.orderBy(projects, ['amount'], ['desc']).map((project) => ({ projectId: project.projectId, rank: project.rank, amount: project.amount, streams: project.streams, share: project.share, statsDocPath: project.statsDocPath, })) return { userId, totalAmount: _.round(_.sumBy(projects, 'amount'), 2), totalStreams: _.sumBy(projects, 'streams'), projects: sortedProjects, } }) .value() if (userDocs.length) { const docs = userDocs.map((userData) => { const docId = `${monthKey}_${userData.userId}` return { ref: refList.monthlyPayoutEntries.doc(docId), data: { monthKey, userId: userData.userId, payoutMonth: context.month, totalAmount: userData.totalAmount, totalStreams: userData.totalStreams, projects: userData.projects, computedAt: now, updatedAt: FieldValue.serverTimestamp(), }, } }) await batchFirestore({ docs, type: BATCH_TYPE.UPDATE, }) const payoutLabel = `${String(context.month).padStart(2, '0')}/${context.year}` await Promise.all( userDocs.map(async (userData) => { const receiverId = typeof userData.userId === 'string' && userData.userId.trim() ? userData.userId.trim() : null const amount = Number(userData.totalAmount) || 0 if (!receiverId || amount <= 0) { return null } const amountLabel = amount.toFixed(2) const message = `Tes revenus de ${payoutLabel} (${amountLabel} €) sont disponibles.` try { await sendNotification({ sender: 'SYSTEM', receiver: receiverId, receiverCollection: 'users', title: 'Revenus disponibles', message, data: { type: ALERT_TYPE?.PAYOUT_AVAILABLE, monthKey, month: context.month, year: context.year, totalAmount: amount, totalStreams: userData.totalStreams, projects: userData.projects, }, }) } catch (notifError) { console.log('[distributeMonthlyPayouts] Failed to send payout notification:', { userId: receiverId, error: notifError?.message || String(notifError), }) } return null }) ) } const summary = { monthKey, month: context.month, year: context.year, range: { start: admin.firestore.Timestamp.fromDate(context.rangeStart), end: admin.firestore.Timestamp.fromDate(context.rangeEnd), }, revenueBaseline: DISTRIBUTION_REVENUE_BASELINE, payoutRatio: DISTRIBUTION_RATIO, payoutPool, totalStreams, eligibleTotalStreams, totalRecipients: payouts.length, totalEntries: entries.length, eligibleEntries: eligibleEntries.length, totalAllocated: _.round(_.sumBy(payouts, 'amount'), 2), payouts, status: payouts.length ? 'computed' : 'no-data', updatedAt: FieldValue.serverTimestamp(), computedAt: now, } const docRef = refList.monthlyPayouts.doc(monthKey) await docRef.set(summary, { merge: true }) } )