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

258 lines
8.5 KiB
JavaScript

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