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