258 lines
8.5 KiB
JavaScript
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 })
|
|
}
|
|
)
|