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, };