functions
This commit is contained in:
+142
-64
@@ -1,36 +1,17 @@
|
||||
const admin = require("firebase-admin");
|
||||
const { onSchedule } = require("firebase-functions/v2/scheduler");
|
||||
const { refList } = require("../index");
|
||||
|
||||
const firestore = refList.projects.firestore;
|
||||
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 MAX_RECIPIENTS = 10;
|
||||
const WEIGHTS = Array.from({ length: MAX_RECIPIENTS }, (_, idx) => MAX_RECIPIENTS - idx);
|
||||
|
||||
const buildPreviousMonthContext = (referenceDate) => {
|
||||
const current = referenceDate ? new Date(referenceDate) : new Date();
|
||||
current.setHours(1, 0, 0, 0);
|
||||
current.setDate(1);
|
||||
|
||||
const target = new Date(current);
|
||||
target.setMonth(target.getMonth() - 1);
|
||||
|
||||
const month = target.getMonth();
|
||||
const year = target.getFullYear();
|
||||
const monthKey = `${year}-${String(month + 1).padStart(2, "0")}`;
|
||||
const rangeStart = new Date(year, month, 1, 0, 0, 0, 0);
|
||||
const rangeEnd = new Date(year, month + 1, 0, 23, 59, 59, 999);
|
||||
|
||||
return {
|
||||
year,
|
||||
month: month + 1,
|
||||
monthKey,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
};
|
||||
};
|
||||
|
||||
exports.distributeMonthlyPayouts = onSchedule(
|
||||
{
|
||||
@@ -40,64 +21,164 @@ exports.distributeMonthlyPayouts = onSchedule(
|
||||
async (event) => {
|
||||
const { scheduleTime } = event;
|
||||
const context = buildPreviousMonthContext(
|
||||
scheduleTime ? new Date(scheduleTime) : new Date()
|
||||
scheduleTime ? new Date(scheduleTime) : new Date(),
|
||||
);
|
||||
const monthKey = context.monthKey;
|
||||
const monthKey = buildMonthKey(context.rangeStart);
|
||||
const now = admin.firestore.Timestamp.now();
|
||||
|
||||
const statsSnapshot = await firestore
|
||||
.collectionGroup("monthly")
|
||||
const statsSnapshot = await db
|
||||
.collectionGroup("monthlyListens")
|
||||
.where("monthKey", "==", monthKey)
|
||||
.orderBy("streams", "desc")
|
||||
.limit(MAX_RECIPIENTS)
|
||||
.get();
|
||||
|
||||
const topEntries = statsSnapshot.docs
|
||||
.map((doc, index) => {
|
||||
const totalsDocRef = refList.projectStreamStatsMonthlyTotals.doc(monthKey);
|
||||
const totalsSnapshot = await totalsDocRef.get();
|
||||
|
||||
const entries = _.chain(statsSnapshot.docs)
|
||||
.map((doc) => {
|
||||
const data = doc.data() || {};
|
||||
return {
|
||||
rank: index + 1,
|
||||
projectId: data.projectId || doc.ref.parent.parent?.id || null,
|
||||
userId: data.userId || null,
|
||||
streams: typeof data.streams === "number" ? data.streams : 0,
|
||||
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);
|
||||
.filter((entry) => entry.projectId && entry.streams > 0)
|
||||
.orderBy(["streams"], ["desc"])
|
||||
.value();
|
||||
|
||||
const payoutPool = Number(
|
||||
(DISTRIBUTION_REVENUE_BASELINE * DISTRIBUTION_RATIO).toFixed(2)
|
||||
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,
|
||||
);
|
||||
|
||||
const weights = WEIGHTS.slice(0, topEntries.length);
|
||||
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
|
||||
|
||||
let allocations = topEntries.map((entry, idx) => {
|
||||
if (!weightSum) return 0;
|
||||
const rawAmount = (payoutPool * weights[idx]) / weightSum;
|
||||
return Number(rawAmount.toFixed(2));
|
||||
let allocations = _.map(entries, (entry) => {
|
||||
if (!totalStreams) return 0;
|
||||
const rawAmount = (payoutPool * entry.streams) / totalStreams;
|
||||
return _.round(rawAmount, 2);
|
||||
});
|
||||
|
||||
if (allocations.length > 0) {
|
||||
const allocatedTotal = Number(
|
||||
allocations.reduce((sum, amount) => sum + amount, 0).toFixed(2)
|
||||
);
|
||||
const remainder = Number((payoutPool - allocatedTotal).toFixed(2));
|
||||
const allocatedTotal = _.round(_.sum(allocations), 2);
|
||||
const remainder = _.round(payoutPool - allocatedTotal, 2);
|
||||
if (remainder !== 0) {
|
||||
allocations[0] = Number((allocations[0] + remainder).toFixed(2));
|
||||
allocations[0] = _.round(allocations[0] + remainder, 2);
|
||||
}
|
||||
}
|
||||
|
||||
const payouts = topEntries.map((entry, idx) => ({
|
||||
rank: entry.rank,
|
||||
const payouts = entries.map((entry, idx) => ({
|
||||
rank: idx + 1,
|
||||
projectId: entry.projectId,
|
||||
userId: entry.userId,
|
||||
streams: entry.streams,
|
||||
weight: weights[idx],
|
||||
share: totalStreams ? _.round(entry.streams / totalStreams, 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: admin.firestore.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,
|
||||
@@ -109,19 +190,16 @@ exports.distributeMonthlyPayouts = onSchedule(
|
||||
revenueBaseline: DISTRIBUTION_REVENUE_BASELINE,
|
||||
payoutRatio: DISTRIBUTION_RATIO,
|
||||
payoutPool,
|
||||
totalStreams: topEntries.reduce((sum, entry) => sum + entry.streams, 0),
|
||||
totalStreams,
|
||||
totalRecipients: payouts.length,
|
||||
totalAllocated: payouts.reduce(
|
||||
(sum, payout) => Number((sum + payout.amount).toFixed(2)),
|
||||
0
|
||||
),
|
||||
totalAllocated: _.round(_.sumBy(payouts, "amount"), 2),
|
||||
payouts,
|
||||
status: payouts.length ? "computed" : "no-data",
|
||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
computedAt: now,
|
||||
};
|
||||
|
||||
const docRef = firestore.collection("monthlyPayouts").doc(monthKey);
|
||||
const docRef = refList.monthlyPayouts.doc(monthKey);
|
||||
await docRef.set(summary, { merge: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user