Files
musicland/functions/src/orders.js
T
Thomas Demirdjian d7d9211fbe clear lot of tickets
2025-11-07 17:33:06 +01:00

336 lines
8.3 KiB
JavaScript

const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const { onDocumentCreated } = require("firebase-functions/firestore");
const { HttpsError, onCall } = require("firebase-functions/https");
const { REGION, ALERT_TYPE } = require("../index");
const { sendNotification } = require("./notifications");
const {
ORDER_TYPES,
ORDER_STATUS,
ORDERS_COLLECTION,
createOrderDocument,
normalizeAmount,
} = require("./helpers/orders");
const USERS_COLLECTION = "users";
const formatCoinsText = (value) => {
if (typeof value !== "number" || Number.isNaN(value)) {
return null;
}
const absoluteValue = Math.abs(value);
if (!Number.isFinite(absoluteValue)) {
return null;
}
const formatted = Number.isInteger(absoluteValue)
? `${absoluteValue}`
: absoluteValue.toFixed(2);
const suffix = absoluteValue === 1 ? "crédit" : "crédits";
return `${formatted} ${suffix}`;
};
const buildOrderNotificationContent = ({ amount, orderType, balanceAfter }) => {
if (typeof amount !== "number" || Number.isNaN(amount) || amount === 0) {
return null;
}
const coinsText = formatCoinsText(amount);
if (!coinsText) {
return null;
}
const balanceText = formatCoinsText(balanceAfter);
const balanceSentence = balanceText
? ` Ton solde est maintenant de ${balanceText}.`
: "";
if (amount > 0) {
if (orderType === ORDER_TYPES.COINS) {
return {
title: "Crédits achetés",
message: `Ton achat de ${coinsText} est confirmé.${balanceSentence}`,
action: "PURCHASED",
};
}
if (orderType === ORDER_TYPES.GIFT) {
return {
title: "Crédits reçus",
message: `Tu as reçu ${coinsText}.${balanceSentence}`,
action: "EARNED",
};
}
return {
title: "Crédits ajoutés",
message: `Ton solde augmente de ${coinsText}.${balanceSentence}`,
action: "CREDITED",
};
}
const reason =
orderType === ORDER_TYPES.SONG ? " pour générer un nouveau son" : "";
return {
title: "Crédits dépensés",
message: `Tu as dépensé ${coinsText}${reason}.${balanceSentence}`,
action: "SPENT",
};
};
const notifyOrderApplied = async ({
userId,
orderId,
amount,
orderType,
balanceBefore,
balanceAfter,
metadata = {},
}) => {
const content = buildOrderNotificationContent({
amount,
orderType,
balanceAfter,
});
if (!content || !userId) {
return;
}
try {
await sendNotification({
sender: "SYSTEM",
receiver: userId,
receiverCollection: USERS_COLLECTION,
title: content.title,
message: content.message,
data: {
type: ALERT_TYPE?.CREDITS_UPDATED || "CREDITS_UPDATED",
orderId,
orderType: orderType || null,
amount,
balanceBefore,
balanceAfter,
action: content.action,
source:
typeof metadata?.source === "string" ? metadata.source : null,
metadata: metadata || {},
},
});
} catch (error) {
console.error(
"[orders-onOrderCreated] Failed to send notification",
orderId,
error,
);
}
};
const onOrderCreated = onDocumentCreated(
`${ORDERS_COLLECTION}/{orderId}`,
async (event) => {
const orderRef = event?.data?.ref;
const orderData = event?.data?.data();
if (!orderRef || !orderData) {
return;
}
if (orderData?.processedAt) {
return;
}
const userId =
typeof orderData.userId === "string" ? orderData.userId.trim() : "";
const amount = normalizeAmount(orderData.amount);
if (!userId) {
await orderRef.set(
{
status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(),
failureReason: "USER_NOT_FOUND",
},
{ merge: true },
);
return;
}
if (amount === null || amount === 0) {
await orderRef.set(
{
status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(),
failureReason: "INVALID_AMOUNT",
},
{ merge: true },
);
return;
}
const userRef = admin.firestore().collection(USERS_COLLECTION).doc(userId);
let notificationContext = null;
try {
await admin.firestore().runTransaction(async (transaction) => {
const userSnapshot = await transaction.get(userRef);
const userData = userSnapshot?.data() || {};
const currentBalanceValue = normalizeAmount(userData?.coins);
const currentBalance =
currentBalanceValue !== null ? currentBalanceValue : 0;
const nextBalance = currentBalance + amount;
if (
amount < 0 &&
nextBalance < 0 &&
orderData?.type === ORDER_TYPES.SONG
) {
transaction.set(
orderRef,
{
status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(),
failureReason: "INSUFFICIENT_FUNDS",
balanceBefore: currentBalance,
balanceAfter: currentBalance,
},
{ merge: true },
);
return;
}
if (userSnapshot?.exists) {
transaction.update(userRef, {
coins: nextBalance,
updatedAt: FieldValue.serverTimestamp(),
});
} else {
transaction.set(
userRef,
{
coins: nextBalance,
createdAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true },
);
}
transaction.set(
orderRef,
{
status: ORDER_STATUS.APPLIED,
processedAt: FieldValue.serverTimestamp(),
balanceBefore: currentBalance,
balanceAfter: nextBalance,
},
{ merge: true },
);
notificationContext = {
balanceBefore: currentBalance,
balanceAfter: nextBalance,
};
});
} catch (error) {
console.error(
"[orders-onOrderCreated] Failed to process order",
orderRef.id,
error,
);
await orderRef.set(
{
status: ORDER_STATUS.REJECTED,
processedAt: FieldValue.serverTimestamp(),
failureReason: "PROCESSING_ERROR",
errorMessage: error?.message || String(error),
},
{ merge: true },
);
return;
}
if (notificationContext) {
await notifyOrderApplied({
userId,
orderId: orderRef.id,
amount,
orderType:
typeof orderData?.type === "string" ? orderData.type : null,
balanceBefore: notificationContext.balanceBefore,
balanceAfter: notificationContext.balanceAfter,
metadata: orderData?.metadata || {},
});
}
},
);
const createSongOrder = onCall({ region: REGION }, async (request) => {
const { auth, data } = request || {};
if (!auth?.uid) {
throw new HttpsError("unauthenticated", "Authentification requise.");
}
const amount = normalizeAmount(data?.amount);
if (amount === null || amount >= 0) {
throw new HttpsError(
"invalid-argument",
"Le montant doit être négatif pour un achat de musique.",
);
}
const songId =
typeof data?.songId === "string" && data.songId.trim()
? data.songId.trim()
: null;
const userRef = admin.firestore().collection(USERS_COLLECTION).doc(auth.uid);
const userSnapshot = await userRef.get();
const currentCoinsValue = normalizeAmount(userSnapshot?.data()?.coins);
const currentCoins =
currentCoinsValue !== null ? currentCoinsValue : 0;
if (currentCoins + amount < 0) {
throw new HttpsError(
"failed-precondition",
"Crédits insuffisants pour finaliser l'opération.",
);
}
const metadata = {
source:
typeof data?.source === "string" && data.source.trim()
? data.source.trim()
: "music_generation",
};
if (typeof data?.requestId === "string" && data.requestId.trim()) {
metadata.requestId = data.requestId.trim();
}
const { orderId } = await createOrderDocument({
userId: auth.uid,
type: ORDER_TYPES.SONG,
amount,
songId,
createdBy: auth.uid,
metadata,
});
return { orderId };
});
module.exports = {
onOrderCreated,
createSongOrder,
};