add orders collecrtion for coins and subscriptions page
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
const admin = require("firebase-admin");
|
||||
|
||||
const ORDER_TYPES = {
|
||||
GIFT: "GIFT",
|
||||
SONG: "SONG",
|
||||
COINS: "COINS",
|
||||
};
|
||||
|
||||
const ORDER_STATUS = {
|
||||
PENDING: "PENDING",
|
||||
APPLIED: "APPLIED",
|
||||
REJECTED: "REJECTED",
|
||||
};
|
||||
|
||||
const ORDERS_COLLECTION = "orders";
|
||||
|
||||
const isFiniteNumber = (value) => {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return true;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const normalizeAmount = (amount) => {
|
||||
if (!isFiniteNumber(amount)) {
|
||||
return null;
|
||||
}
|
||||
return Number(amount);
|
||||
};
|
||||
|
||||
const createOrderDocument = async ({
|
||||
userId,
|
||||
type,
|
||||
amount,
|
||||
songId = null,
|
||||
createdBy = "system",
|
||||
metadata = {},
|
||||
orderId = null,
|
||||
}) => {
|
||||
if (!userId || typeof userId !== "string") {
|
||||
throw new Error("[orders] Missing userId when creating order");
|
||||
}
|
||||
|
||||
if (!Object.values(ORDER_TYPES).includes(type)) {
|
||||
throw new Error(`[orders] Invalid order type "${type}"`);
|
||||
}
|
||||
|
||||
const normalizedAmount = normalizeAmount(amount);
|
||||
|
||||
if (normalizedAmount === null || normalizedAmount === 0) {
|
||||
throw new Error("[orders] Invalid order amount");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
userId,
|
||||
type,
|
||||
amount: normalizedAmount,
|
||||
songId: type === ORDER_TYPES.SONG ? songId || null : null,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
createdBy: createdBy || "system",
|
||||
status: ORDER_STATUS.PENDING,
|
||||
metadata: metadata || {},
|
||||
};
|
||||
|
||||
const collectionRef = admin.firestore().collection(ORDERS_COLLECTION);
|
||||
const orderRef = orderId ? collectionRef.doc(orderId) : collectionRef.doc();
|
||||
|
||||
if (orderId) {
|
||||
const existingSnapshot = await orderRef.get();
|
||||
if (existingSnapshot.exists) {
|
||||
return { orderRef, orderId: orderRef.id };
|
||||
}
|
||||
}
|
||||
|
||||
await orderRef.set(payload);
|
||||
|
||||
return { orderRef, orderId: orderRef.id };
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
ORDER_TYPES,
|
||||
ORDER_STATUS,
|
||||
ORDERS_COLLECTION,
|
||||
createOrderDocument,
|
||||
normalizeAmount,
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
const admin = require("firebase-admin");
|
||||
const { onDocumentCreated } = require("firebase-functions/firestore");
|
||||
const { HttpsError, onCall } = require("firebase-functions/https");
|
||||
|
||||
const { REGION } = require("../index");
|
||||
const {
|
||||
ORDER_TYPES,
|
||||
ORDER_STATUS,
|
||||
ORDERS_COLLECTION,
|
||||
createOrderDocument,
|
||||
normalizeAmount,
|
||||
} = require("./helpers/orders");
|
||||
|
||||
const USERS_COLLECTION = "users";
|
||||
|
||||
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: admin.firestore.FieldValue.serverTimestamp(),
|
||||
failureReason: "USER_NOT_FOUND",
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (amount === null || amount === 0) {
|
||||
await orderRef.set(
|
||||
{
|
||||
status: ORDER_STATUS.REJECTED,
|
||||
processedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
failureReason: "INVALID_AMOUNT",
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const userRef = admin.firestore().collection(USERS_COLLECTION).doc(userId);
|
||||
|
||||
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: admin.firestore.FieldValue.serverTimestamp(),
|
||||
failureReason: "INSUFFICIENT_FUNDS",
|
||||
balanceBefore: currentBalance,
|
||||
balanceAfter: currentBalance,
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userSnapshot?.exists) {
|
||||
transaction.update(userRef, {
|
||||
coins: nextBalance,
|
||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
});
|
||||
} else {
|
||||
transaction.set(
|
||||
userRef,
|
||||
{
|
||||
coins: nextBalance,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
}
|
||||
|
||||
transaction.set(
|
||||
orderRef,
|
||||
{
|
||||
status: ORDER_STATUS.APPLIED,
|
||||
processedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
balanceBefore: currentBalance,
|
||||
balanceAfter: nextBalance,
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[orders-onOrderCreated] Failed to process order",
|
||||
orderRef.id,
|
||||
error,
|
||||
);
|
||||
|
||||
await orderRef.set(
|
||||
{
|
||||
status: ORDER_STATUS.REJECTED,
|
||||
processedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
failureReason: "PROCESSING_ERROR",
|
||||
errorMessage: error?.message || String(error),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
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 { orderId } = await createOrderDocument({
|
||||
userId: auth.uid,
|
||||
type: ORDER_TYPES.SONG,
|
||||
amount,
|
||||
songId,
|
||||
createdBy: auth.uid,
|
||||
metadata: {
|
||||
source: data?.source || "music_generation",
|
||||
requestId:
|
||||
typeof data?.requestId === "string" ? data.requestId : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return { orderId };
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
onOrderCreated,
|
||||
createSongOrder,
|
||||
};
|
||||
@@ -4,6 +4,7 @@ const { onRequest } = require("firebase-functions/v2/https");
|
||||
const { FieldValue } = require("firebase-admin/firestore");
|
||||
const { refList } = require("../index");
|
||||
const refsList = refList;
|
||||
const { ORDER_TYPES, createOrderDocument } = require("./helpers/orders");
|
||||
const {
|
||||
getStripeClient,
|
||||
buildCheckoutLineItems,
|
||||
@@ -76,9 +77,7 @@ const COIN_PACK_PRODUCTS = [
|
||||
},
|
||||
];
|
||||
|
||||
const COIN_PACK_PRODUCT_IDS = COIN_PACK_PRODUCTS.map(
|
||||
(pack) => pack.productId,
|
||||
);
|
||||
const COIN_PACK_PRODUCT_IDS = COIN_PACK_PRODUCTS.map((pack) => pack.productId);
|
||||
|
||||
const COIN_PACK_PRODUCT_MAP = COIN_PACK_PRODUCTS.reduce(
|
||||
(acc, pack) => ({
|
||||
@@ -324,7 +323,7 @@ const handleCheckoutSessionCompleted = async (session, event) => {
|
||||
lastEventAt: getServerTimestamp(),
|
||||
});
|
||||
|
||||
const { userRef } = await resolveUserContext({
|
||||
const { uid, userRef } = await resolveUserContext({
|
||||
metadata: session.metadata,
|
||||
customerId: session.customer,
|
||||
});
|
||||
@@ -378,17 +377,23 @@ const handleCheckoutSessionCompleted = async (session, event) => {
|
||||
}
|
||||
|
||||
const alreadyGranted = Boolean(
|
||||
paymentSnapshot?.exists &&
|
||||
paymentSnapshot.data()?.coinPackGrantedAt,
|
||||
paymentSnapshot?.exists && paymentSnapshot.data()?.coinPackGrantedAt,
|
||||
);
|
||||
|
||||
if (!alreadyGranted) {
|
||||
await userRef.set(
|
||||
{
|
||||
coins: FieldValue.increment(coinAmount),
|
||||
const targetUserId = uid || firebaseUid || userRef.id;
|
||||
|
||||
await createOrderDocument({
|
||||
userId: targetUserId,
|
||||
type: ORDER_TYPES.COINS,
|
||||
amount: coinAmount,
|
||||
metadata: {
|
||||
source: "STRIPE_CHECKOUT",
|
||||
paymentId: session.id || null,
|
||||
coinPackKey: session.metadata?.coinPackKey || null,
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
orderId: `stripe_${session.id}`,
|
||||
});
|
||||
|
||||
await paymentDocRef.set(
|
||||
{
|
||||
@@ -409,9 +414,26 @@ const handleCustomerSubscriptionEvent = async (subscription, event) => {
|
||||
}
|
||||
|
||||
const subscriptionPayload = buildSubscriptionPayload(subscription);
|
||||
if (!subscriptionPayload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { uid, userRef } = await resolveUserContext({
|
||||
metadata: subscription.metadata,
|
||||
customerId: subscription.customer,
|
||||
});
|
||||
|
||||
let resolvedCustomerId = subscriptionPayload?.customerId || null;
|
||||
if (!resolvedCustomerId && subscription.customer) {
|
||||
resolvedCustomerId = subscription.customer;
|
||||
}
|
||||
|
||||
const fallbackUid = extractFirebaseUid(subscription.metadata);
|
||||
const resolvedUid = uid || fallbackUid || null;
|
||||
|
||||
await upsertPaymentDocument(subscription.id, {
|
||||
customerId: subscription.customer || null,
|
||||
userId: resolvedUid,
|
||||
customerId: resolvedCustomerId,
|
||||
subscriptionId: subscription.id || null,
|
||||
status: subscription.status || null,
|
||||
mode: "subscription",
|
||||
@@ -420,17 +442,15 @@ const handleCustomerSubscriptionEvent = async (subscription, event) => {
|
||||
cancelAtPeriodEnd: subscriptionPayload?.cancelAtPeriodEnd ?? null,
|
||||
currentPeriodStart: subscriptionPayload?.currentPeriodStart || null,
|
||||
currentPeriodEnd: subscriptionPayload?.currentPeriodEnd || null,
|
||||
metadata: subscription.metadata || {},
|
||||
metadata: {
|
||||
...subscription.metadata,
|
||||
stripeEventType: event?.type || null,
|
||||
},
|
||||
lastEventType: event?.type || null,
|
||||
lastEventId: event?.id || null,
|
||||
lastEventAt: getServerTimestamp(),
|
||||
});
|
||||
|
||||
const { uid, userRef } = await resolveUserContext({
|
||||
metadata: subscription.metadata,
|
||||
customerId: subscription.customer,
|
||||
});
|
||||
|
||||
if (!userRef) {
|
||||
console.warn(
|
||||
"[subscription-handleCustomerSubscriptionEvent] User not resolved",
|
||||
@@ -441,13 +461,11 @@ const handleCustomerSubscriptionEvent = async (subscription, event) => {
|
||||
}
|
||||
|
||||
const lastEvent = buildEventSnapshot(event?.type, subscription.id);
|
||||
if (uid) {
|
||||
lastEvent.uid = uid;
|
||||
if (resolvedUid) {
|
||||
lastEvent.uid = resolvedUid;
|
||||
}
|
||||
|
||||
const priceMeta = getSubscriptionMetaFromPrice(
|
||||
subscriptionPayload?.priceId,
|
||||
);
|
||||
const priceMeta = getSubscriptionMetaFromPrice(subscriptionPayload?.priceId);
|
||||
const metadataLevel = subscription.metadata?.subscriptionLevel || null;
|
||||
const metadataPeriod =
|
||||
subscription.metadata?.subscriptionBillingPeriod || null;
|
||||
@@ -468,6 +486,8 @@ const handleCustomerSubscriptionEvent = async (subscription, event) => {
|
||||
|
||||
if (subscriptionPayload?.customerId) {
|
||||
userUpdate.stripeCustomerId = subscriptionPayload.customerId;
|
||||
} else if (resolvedCustomerId) {
|
||||
userUpdate.stripeCustomerId = resolvedCustomerId;
|
||||
}
|
||||
|
||||
if (resolvedLevel && isPremium) {
|
||||
@@ -733,9 +753,7 @@ const formatCoinPack = ({ product, price, fallback = {} }) => {
|
||||
|
||||
const priceId =
|
||||
(resolvedPrice && resolvedPrice.id) ||
|
||||
(typeof product.default_price === "string"
|
||||
? product.default_price
|
||||
: null);
|
||||
(typeof product.default_price === "string" ? product.default_price : null);
|
||||
|
||||
const rawCoinAmount =
|
||||
product?.metadata?.coins ??
|
||||
@@ -753,8 +771,7 @@ const formatCoinPack = ({ product, price, fallback = {} }) => {
|
||||
priceId,
|
||||
name: product.name || "",
|
||||
description: product.description || "",
|
||||
coinAmount:
|
||||
Number.isFinite(coinAmount) && coinAmount > 0 ? coinAmount : 0,
|
||||
coinAmount: Number.isFinite(coinAmount) && coinAmount > 0 ? coinAmount : 0,
|
||||
currency:
|
||||
resolvedPrice?.currency ||
|
||||
(typeof resolvedPrice?.currency === "string"
|
||||
@@ -824,9 +841,7 @@ const listCoinPacks = onCall({ region: REGION }, async () => {
|
||||
|
||||
let resolvedPrice = null;
|
||||
if (typeof product?.default_price === "string") {
|
||||
resolvedPrice = await stripe.prices.retrieve(
|
||||
product.default_price,
|
||||
);
|
||||
resolvedPrice = await stripe.prices.retrieve(product.default_price);
|
||||
} else if (
|
||||
product?.default_price &&
|
||||
typeof product.default_price === "object"
|
||||
|
||||
+10
-7
@@ -4,6 +4,7 @@ const {
|
||||
onDocumentCreated,
|
||||
} = require("firebase-functions/firestore");
|
||||
const { refList } = require("../index");
|
||||
const { ORDER_TYPES, createOrderDocument } = require("./helpers/orders");
|
||||
const { deleteFolder } = require("../helpers/firebase");
|
||||
const { Resend } = require("resend");
|
||||
const { welcomeTemplate } = require("../helpers/email");
|
||||
@@ -56,14 +57,16 @@ exports.onUserCreated = onDocumentCreated("users/{userID}", async (event) => {
|
||||
lastName = "",
|
||||
} = event?.data?.data() || {};
|
||||
|
||||
const userId = event?.params?.userID;
|
||||
|
||||
try {
|
||||
await event.data.ref.set(
|
||||
{
|
||||
coins: admin.firestore.FieldValue.increment(10),
|
||||
coinWelcomeGrantedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
await createOrderDocument({
|
||||
userId,
|
||||
type: ORDER_TYPES.GIFT,
|
||||
amount: 10,
|
||||
metadata: { reason: "WELCOME_BONUS" },
|
||||
orderId: `welcome_${userId}`,
|
||||
});
|
||||
} catch (coinError) {
|
||||
console.warn(
|
||||
"[users-onUserCreated] Unable to grant welcome coins",
|
||||
|
||||
Reference in New Issue
Block a user