Files
musicland/functions/src/orders.js
T
2026-01-12 16:01:32 +01:00

305 lines
7.8 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,
}