const admin = require("firebase-admin"); const { HttpsError, onCall } = require("firebase-functions/https"); const { onRequest } = require("firebase-functions/v2/https"); const { FieldValue } = require("firebase-admin/firestore"); const { refList } = require("../index"); const refsList = refList; const { getStripeClient, buildCheckoutLineItems, ensureStripeCustomer, getReturnUrls, formatCheckoutSessionResponse, mapStripeErrorToHttps, } = require("../helpers/stripe"); const { STRIPE_WEBHOOK_SECRET } = require("../config/keys"); const REGION = process.env.FIREBASE_REGION || "europe-west1"; const SUBSCRIPTION_PRICE_IDS = { monthly: [ "price_1SPgitCzf2o5bDRdbnhLFx6f", "price_1SPgjCCzf2o5bDRdr08Xzp8u", "price_1SPgjaCzf2o5bDRdd9Xo2u26", ], annual: [ "price_1SPgkDCzf2o5bDRdNGLVNeQ3", "price_1SPgkXCzf2o5bDRdejBVxEBY", "price_1SPgkqCzf2o5bDRdIcUwTDrm", ], }; const ALL_SUBSCRIPTION_PRICE_IDS = Object.values(SUBSCRIPTION_PRICE_IDS).flat(); const SUBSCRIPTION_PRICE_METADATA = { price_1SPgitCzf2o5bDRdbnhLFx6f: { level: "starter", billingPeriod: "monthly", }, price_1SPgjCCzf2o5bDRdr08Xzp8u: { level: "pro", billingPeriod: "monthly", }, price_1SPgjaCzf2o5bDRdd9Xo2u26: { level: "premium", billingPeriod: "monthly", }, price_1SPgkDCzf2o5bDRdNGLVNeQ3: { level: "starter", billingPeriod: "annual", }, price_1SPgkXCzf2o5bDRdejBVxEBY: { level: "pro", billingPeriod: "annual", }, price_1SPgkqCzf2o5bDRdIcUwTDrm: { level: "premium", billingPeriod: "annual", }, }; const COIN_PACK_PRODUCTS = [ { productId: "prod_TMPPEXZ1wGk2cS", key: "starter", fallbackCoinAmount: 10, }, { productId: "prod_TMPQ5SNdS47gY6", key: "pro", fallbackCoinAmount: 40, }, { productId: "prod_TMPRpA0qQSHXj5", key: "premium", fallbackCoinAmount: 60, }, ]; const COIN_PACK_PRODUCT_IDS = COIN_PACK_PRODUCTS.map( (pack) => pack.productId, ); const COIN_PACK_PRODUCT_MAP = COIN_PACK_PRODUCTS.reduce( (acc, pack) => ({ ...acc, [pack.productId]: pack, }), {}, ); const paymentsCollection = admin.firestore().collection("payments"); let cachedStripeWebhookSecret = null; const PREMIUM_SUBSCRIPTION_STATUSES = new Set(["active", "trialing"]); const getServerTimestamp = () => { if (typeof FieldValue?.serverTimestamp === "function") { return FieldValue.serverTimestamp(); } if (admin.firestore?.FieldValue?.serverTimestamp) { return admin.firestore.FieldValue.serverTimestamp(); } throw new HttpsError( "failed-precondition", "Firestore FieldValue.serverTimestamp indisponible.", ); }; const resolveStripeWebhookSecret = () => { if (cachedStripeWebhookSecret) { return cachedStripeWebhookSecret; } const envSecret = typeof process?.env?.STRIPE_WEBHOOK_SECRET === "string" ? process.env.STRIPE_WEBHOOK_SECRET.trim() : ""; const inlineSecret = typeof STRIPE_WEBHOOK_SECRET === "string" ? STRIPE_WEBHOOK_SECRET.trim() : ""; const secret = envSecret || inlineSecret; if (!secret) { throw new Error("STRIPE_WEBHOOK_SECRET not configured"); } cachedStripeWebhookSecret = secret; return cachedStripeWebhookSecret; }; const toFirestoreTimestamp = (unixSeconds) => { if (typeof unixSeconds !== "number" || !Number.isFinite(unixSeconds)) { return null; } try { return admin.firestore.Timestamp.fromMillis(unixSeconds * 1000); } catch (error) { console.error( "[subscription-toFirestoreTimestamp] Conversion error", unixSeconds, error, ); return null; } }; const extractFirebaseUid = (metadata) => { if (!metadata || typeof metadata !== "object") { return null; } const candidates = [ metadata.firebaseUID, metadata.firebaseUid, metadata.uid, metadata.userId, ]; for (let index = 0; index < candidates.length; index += 1) { const candidate = candidates[index]; if (typeof candidate === "string" && candidate.trim()) { return candidate.trim(); } } return null; }; const findUserByStripeCustomerId = async (customerId) => { if (!customerId || !refList?.users) { return { uid: null, userRef: null, userData: null }; } try { const snapshot = await refList.users .where("stripeCustomerId", "==", customerId) .limit(1) .get(); const doc = snapshot?.docs?.[0]; if (doc?.id) { return { uid: doc.id, userRef: refList.users.doc(doc.id), userData: doc.data() || null, }; } } catch (error) { console.error( "[subscription-findUserByStripeCustomerId] Query failed", customerId, error, ); } return { uid: null, userRef: null, userData: null }; }; const resolveUserContext = async ({ metadata, customerId }) => { const metadataUid = extractFirebaseUid(metadata); if (metadataUid && refList?.users) { return { uid: metadataUid, userRef: refList.users.doc(metadataUid), userData: null, }; } return findUserByStripeCustomerId(customerId); }; const upsertPaymentDocument = async (docId, data = {}) => { if (!docId) { return null; } try { const docRef = paymentsCollection.doc(docId); const snapshot = await docRef.get(); const payload = { ...data, updatedAt: getServerTimestamp(), }; if (!snapshot.exists) { payload.createdAt = getServerTimestamp(); } await docRef.set(payload, { merge: true }); return docRef; } catch (error) { console.error( "[subscription-upsertPaymentDocument] Failed to persist payment", docId, error, ); return null; } }; const buildEventSnapshot = (type, referenceId) => ({ type: type || null, referenceId: referenceId || null, receivedAt: getServerTimestamp(), }); const buildSubscriptionPayload = (subscription) => { if (!subscription || typeof subscription !== "object") { return null; } const itemList = Array.isArray(subscription?.items?.data) ? subscription.items.data : []; const items = itemList.map((item) => { const stripePrice = item?.price && typeof item.price === "object" ? item.price : null; return { id: item?.id || null, priceId: stripePrice?.id || (typeof item?.price === "string" ? item.price : null) || null, productId: stripePrice?.product || null, currency: stripePrice?.currency || null, unitAmount: stripePrice?.unit_amount ?? null, recurring: stripePrice?.recurring || null, quantity: item?.quantity ?? null, }; }); const primaryItem = items[0] || null; return { id: subscription.id || null, status: subscription.status || null, customerId: subscription.customer || null, priceId: primaryItem?.priceId || null, productId: primaryItem?.productId || null, cancelAtPeriodEnd: subscription.cancel_at_period_end === true, cancelAt: toFirestoreTimestamp(subscription.cancel_at), canceledAt: toFirestoreTimestamp(subscription.canceled_at), createdAt: toFirestoreTimestamp(subscription.created), currentPeriodStart: toFirestoreTimestamp(subscription.current_period_start), currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end), endedAt: toFirestoreTimestamp(subscription.ended_at), trialStart: toFirestoreTimestamp(subscription.trial_start), trialEnd: toFirestoreTimestamp(subscription.trial_end), latestInvoiceId: subscription.latest_invoice || null, items, metadata: subscription.metadata || {}, }; }; const handleCheckoutSessionCompleted = async (session, event) => { if (!session || typeof session !== "object") { return; } const firebaseUid = extractFirebaseUid(session.metadata); const paymentDocRef = await upsertPaymentDocument(session.id, { userId: firebaseUid || null, customerId: session.customer || null, subscriptionId: session.subscription || null, invoiceId: session.invoice || null, status: session.status || "completed", paymentStatus: session.payment_status || null, mode: session.mode || null, amountSubtotal: session.amount_subtotal ?? null, amountTotal: session.amount_total ?? null, currency: session.currency || null, metadata: session.metadata || {}, completedAt: toFirestoreTimestamp(session.created), expiresAt: toFirestoreTimestamp(session.expires_at), paymentIntentId: typeof session.payment_intent === "string" ? session.payment_intent : null, lastEventType: event?.type || null, lastEventId: event?.id || null, lastEventAt: getServerTimestamp(), }); const { userRef } = await resolveUserContext({ metadata: session.metadata, customerId: session.customer, }); if (userRef) { const lastEvent = buildEventSnapshot(event?.type, session.id); if (firebaseUid) { lastEvent.uid = firebaseUid; } const userUpdate = { lastStripeWebhookEvent: lastEvent, }; if (session.customer) { userUpdate.stripeCustomerId = session.customer; } if (session.metadata?.subscriptionLevel) { userUpdate.premiumLevel = session.metadata.subscriptionLevel; } if (session.metadata?.subscriptionBillingPeriod) { userUpdate.premiumBillingPeriod = session.metadata.subscriptionBillingPeriod; } await userRef.set(userUpdate, { merge: true }); } if ( session.mode === "payment" && (session.payment_status === "paid" || session.payment_status === "no_payment_required") && session.metadata?.purchaseType === "COIN_PACK" && userRef ) { const coinAmountRaw = Number(session.metadata?.coinAmount || 0); const coinAmount = Number.isFinite(coinAmountRaw) ? coinAmountRaw : 0; if (coinAmount > 0 && paymentDocRef) { let paymentSnapshot = null; try { paymentSnapshot = await paymentDocRef.get(); } catch (error) { console.warn( "[subscription-handleCheckoutSessionCompleted] Unable to read payment doc", session.id, error?.message || error, ); } const alreadyGranted = Boolean( paymentSnapshot?.exists && paymentSnapshot.data()?.coinPackGrantedAt, ); if (!alreadyGranted) { await userRef.set( { coins: FieldValue.increment(coinAmount), }, { merge: true }, ); await paymentDocRef.set( { coinPackGrantedAt: getServerTimestamp(), coinPackGrantedAmount: coinAmount, coinPackGrantedKey: session.metadata?.coinPackKey || null, }, { merge: true }, ); } } } }; const handleCustomerSubscriptionEvent = async (subscription, event) => { if (!subscription || typeof subscription !== "object") { return; } const subscriptionPayload = buildSubscriptionPayload(subscription); await upsertPaymentDocument(subscription.id, { customerId: subscription.customer || null, subscriptionId: subscription.id || null, status: subscription.status || null, mode: "subscription", priceId: subscriptionPayload?.priceId || null, productId: subscriptionPayload?.productId || null, cancelAtPeriodEnd: subscriptionPayload?.cancelAtPeriodEnd ?? null, currentPeriodStart: subscriptionPayload?.currentPeriodStart || null, currentPeriodEnd: subscriptionPayload?.currentPeriodEnd || null, metadata: subscription.metadata || {}, 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", subscription.customer, event?.type, ); return; } const lastEvent = buildEventSnapshot(event?.type, subscription.id); if (uid) { lastEvent.uid = uid; } const priceMeta = getSubscriptionMetaFromPrice( subscriptionPayload?.priceId, ); const metadataLevel = subscription.metadata?.subscriptionLevel || null; const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null; const resolvedLevel = metadataLevel || priceMeta?.level || null; const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null; const isPremium = subscription.status ? PREMIUM_SUBSCRIPTION_STATUSES.has(subscription.status) : false; const userUpdate = { stripeSubscription: subscriptionPayload, stripeSubscriptionStatus: subscription.status || null, stripeSubscriptionUpdatedAt: getServerTimestamp(), isPremium, lastStripeWebhookEvent: lastEvent, }; if (subscriptionPayload?.customerId) { userUpdate.stripeCustomerId = subscriptionPayload.customerId; } if (resolvedLevel && isPremium) { userUpdate.premiumLevel = resolvedLevel; } else if (!isPremium) { userUpdate.premiumLevel = null; } if (isPremium && resolvedPeriod) { userUpdate.premiumBillingPeriod = resolvedPeriod; } else if (!isPremium) { userUpdate.premiumBillingPeriod = null; } await userRef.set(userUpdate, { merge: true }); }; const handleInvoiceEvent = async (invoice, event) => { if (!invoice || typeof invoice !== "object") { return; } const firebaseUid = extractFirebaseUid(invoice.metadata); const eventType = event?.type || null; await upsertPaymentDocument(invoice.id, { userId: firebaseUid || null, customerId: invoice.customer || null, subscriptionId: invoice.subscription || null, status: invoice.status || null, paymentStatus: eventType === "invoice.payment_failed" ? "failed" : invoice.status || null, amountDue: invoice.amount_due ?? null, amountPaid: invoice.amount_paid ?? null, amountRemaining: invoice.amount_remaining ?? null, currency: invoice.currency || null, invoiceNumber: invoice.number || null, hostedInvoiceUrl: invoice.hosted_invoice_url || null, invoicePdf: invoice.invoice_pdf || null, billingReason: invoice.billing_reason || null, metadata: invoice.metadata || {}, periodStart: toFirestoreTimestamp(invoice.period_start), periodEnd: toFirestoreTimestamp(invoice.period_end), paidAt: toFirestoreTimestamp(invoice.status_transitions?.paid_at), lastEventType: eventType, lastEventId: event?.id || null, lastEventAt: getServerTimestamp(), mode: "invoice", }); const { userRef } = await resolveUserContext({ metadata: invoice.metadata, customerId: invoice.customer, }); if (userRef) { const lastEvent = buildEventSnapshot(eventType, invoice.id); if (firebaseUid) { lastEvent.uid = firebaseUid; } await userRef.set( { lastStripeWebhookEvent: lastEvent, }, { merge: true }, ); } }; const handlePaymentIntentEvent = async (paymentIntent, event) => { if (!paymentIntent || typeof paymentIntent !== "object") { return; } const firebaseUid = extractFirebaseUid(paymentIntent.metadata); await upsertPaymentDocument(paymentIntent.id, { userId: firebaseUid || null, customerId: paymentIntent.customer || null, status: paymentIntent.status || null, paymentStatus: paymentIntent.status || null, amountTotal: paymentIntent.amount ?? null, amountReceived: paymentIntent.amount_received ?? null, currency: paymentIntent.currency || null, description: paymentIntent.description || null, metadata: paymentIntent.metadata || {}, latestChargeId: paymentIntent.latest_charge || null, paymentMethodId: typeof paymentIntent.payment_method === "string" ? paymentIntent.payment_method : null, createdAtFromStripe: toFirestoreTimestamp(paymentIntent.created), succeededAt: toFirestoreTimestamp( paymentIntent.status_transitions?.succeeded_at, ), canceledAt: toFirestoreTimestamp( paymentIntent.status_transitions?.canceled_at, ), lastEventType: event?.type || null, lastEventId: event?.id || null, lastEventAt: getServerTimestamp(), mode: "payment", }); const { userRef } = await resolveUserContext({ metadata: paymentIntent.metadata, customerId: paymentIntent.customer, }); if (userRef) { const lastEvent = buildEventSnapshot(event?.type, paymentIntent.id); if (firebaseUid) { lastEvent.uid = firebaseUid; } await userRef.set( { lastStripeWebhookEvent: lastEvent, }, { merge: true }, ); } }; const handleStripeWebhookEvent = async ({ event }) => { if (!event || typeof event !== "object") { return; } switch (event.type) { case "checkout.session.completed": await handleCheckoutSessionCompleted(event.data?.object, event); break; case "customer.subscription.created": case "customer.subscription.updated": case "customer.subscription.deleted": await handleCustomerSubscriptionEvent(event.data?.object, event); break; case "invoice.paid": case "invoice.payment_failed": await handleInvoiceEvent(event.data?.object, event); break; case "payment_intent.succeeded": case "payment_intent.payment_failed": await handlePaymentIntentEvent(event.data?.object, event); break; default: console.log( "[subscription-handleStripeWebhookEvent] Unhandled event type", event.type, ); } }; const handleStripeWebhook = onRequest({ region: REGION }, async (req, res) => { if (req.method !== "POST") { res.set("Allow", "POST"); res.status(405).send("Méthode non autorisée"); return; } let stripe = null; try { stripe = getStripeClient(); } catch (error) { console.error( "[subscription-handleStripeWebhook] Stripe client error", error, ); res.status(500).send("Client Stripe indisponible"); return; } const signature = req.headers["stripe-signature"]; if (!signature) { res.status(400).send("En-tête stripe-signature manquant"); return; } let rawBody = null; if (Buffer.isBuffer(req.rawBody)) { rawBody = req.rawBody; } else { console.warn( "[subscription-handleStripeWebhook] rawBody indisponible, utilisation d'un fallback JSON stringify", ); rawBody = Buffer.from(JSON.stringify(req.body || {})); } let event = null; try { const webhookSecret = resolveStripeWebhookSecret(); event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret); } catch (error) { console.error( "[subscription-handleStripeWebhook] Signature invalide", error?.message || error, ); res.status(400).send(`Signature invalide: ${error?.message || "Webhook"}`); return; } try { await handleStripeWebhookEvent({ event, stripe }); res.status(200).json({ received: true }); } catch (error) { console.error( "[subscription-handleStripeWebhook] Traitement échoué", event?.id, error, ); res.status(500).send("Erreur lors du traitement du webhook"); } }); const sanitizePriceId = (value) => { if (typeof value !== "string") { return ""; } return value.trim(); }; const formatPlan = (price, priceId) => { if (!price || typeof price !== "object") { return null; } const product = typeof price.product === "object" && price.product !== null ? price.product : {}; return { id: price.id || priceId, priceId: price.id || priceId, active: price.active !== false, currency: price.currency || "eur", unitAmount: price.unit_amount, unitAmountDecimal: price.unit_amount_decimal, transformQuantity: price.transform_quantity || null, recurring: price.recurring || null, nickname: price.nickname || null, billingScheme: price.billing_scheme || null, product: { id: product.id || null, name: product.name || "", description: product.description || "", metadata: product.metadata || {}, }, }; }; const formatCoinPack = ({ product, price, fallback = {} }) => { if (!product || typeof product !== "object") { return null; } const resolvedPrice = price || (typeof product.default_price === "object" && product.default_price) || null; const priceId = (resolvedPrice && resolvedPrice.id) || (typeof product.default_price === "string" ? product.default_price : null); const rawCoinAmount = product?.metadata?.coins ?? product?.metadata?.coinAmount ?? fallback.fallbackCoinAmount ?? 0; const coinAmount = typeof rawCoinAmount === "string" ? parseInt(rawCoinAmount, 10) : Number(rawCoinAmount); return { productId: product.id, priceId, name: product.name || "", description: product.description || "", coinAmount: Number.isFinite(coinAmount) && coinAmount > 0 ? coinAmount : 0, currency: resolvedPrice?.currency || (typeof resolvedPrice?.currency === "string" ? resolvedPrice.currency.toLowerCase() : "eur"), unitAmount: resolvedPrice?.unit_amount ?? null, metadata: product.metadata || {}, }; }; const getSubscriptionMetaFromPrice = (priceId) => { if (typeof priceId !== "string") { return null; } return SUBSCRIPTION_PRICE_METADATA[priceId] || null; }; const listSubscriptionPlans = onCall({ region: REGION }, async () => { try { const stripe = getStripeClient(); const entries = await Promise.all( Object.entries(SUBSCRIPTION_PRICE_IDS).map(async ([period, priceIds]) => { const periodPlans = await Promise.all( priceIds.map(async (priceId) => { try { const price = await stripe.prices.retrieve(priceId, { expand: ["product"], }); return formatPlan(price, priceId); } catch (error) { console.error( `[subscription-listSubscriptionPlans] Impossible de récupérer ${priceId}`, error?.message || error, ); return null; } }), ); return [period, periodPlans.filter(Boolean)]; }), ); return { plans: Object.fromEntries(entries), }; } catch (error) { console.error("[subscription-listSubscriptionPlans] error", error); throw mapStripeErrorToHttps( error, "Impossible de récupérer les abonnements Stripe.", ); } }); const listCoinPacks = onCall({ region: REGION }, async () => { try { const stripe = getStripeClient(); const packs = await Promise.all( COIN_PACK_PRODUCTS.map(async (pack) => { try { const product = await stripe.products.retrieve(pack.productId, { expand: ["default_price"], }); let resolvedPrice = null; if (typeof product?.default_price === "string") { resolvedPrice = await stripe.prices.retrieve( product.default_price, ); } else if ( product?.default_price && typeof product.default_price === "object" ) { resolvedPrice = product.default_price; } return formatCoinPack({ product, price: resolvedPrice, fallback: pack, }); } catch (error) { console.error( "[subscription-listCoinPacks] Unable to retrieve product", pack.productId, error?.message || error, ); return null; } }), ); return { packs: packs.filter(Boolean), }; } catch (error) { console.error("[subscription-listCoinPacks] error", error); throw mapStripeErrorToHttps( error, "Impossible de récupérer les packs de pièces.", ); } }); const createSubscriptionCheckoutSession = onCall( { region: REGION }, async (request) => { try { const uid = request?.auth?.uid; if (!uid) { throw new HttpsError( "unauthenticated", "Connecte-toi pour souscrire un abonnement.", ); } const rawPriceId = request?.data?.priceId; const priceId = sanitizePriceId(rawPriceId); if (!priceId) { throw new HttpsError( "invalid-argument", "Un identifiant de prix Stripe est requis.", ); } if (!ALL_SUBSCRIPTION_PRICE_IDS.includes(priceId)) { throw new HttpsError( "invalid-argument", `L'identifiant de prix ${priceId} n'est pas pris en charge.`, ); } const stripe = getStripeClient(); const priceMeta = getSubscriptionMetaFromPrice(priceId) || {}; const { lineItems, summary } = await buildCheckoutLineItems( [ { priceID: priceId, quantity: 1, isRenewable: true, }, ], { stripe }, ); const { successUrl, cancelUrl } = getReturnUrls( request?.data?.returnUrls, ); const { customerId } = await ensureStripeCustomer({ uid, stripe, refsList: refList, createIfMissing: true, }); if (!customerId) { throw new HttpsError( "failed-precondition", "Impossible de retrouver le client Stripe associé.", ); } const session = await stripe.checkout.sessions.create({ mode: "subscription", customer: customerId, line_items: lineItems, success_url: successUrl, cancel_url: cancelUrl, allow_promotion_codes: true, metadata: { firebaseUID: uid, priceId, purchaseType: "SUBSCRIPTION", subscriptionLevel: priceMeta.level || null, subscriptionBillingPeriod: priceMeta.billingPeriod || null, }, }); await paymentsCollection.doc(session.id).set( { userId: uid, customerId, status: session.status || "created", mode: "subscription", createdAt: getServerTimestamp(), updatedAt: getServerTimestamp(), sessionId: session.id, sessionUrl: session.url, lineItems: summary, amountSubtotal: session.amount_subtotal, amountTotal: session.amount_total, currency: session.currency, paymentStatus: session.payment_status, metadata: session.metadata || {}, }, { merge: true }, ); return formatCheckoutSessionResponse(session); } catch (error) { console.error( "[subscription-createSubscriptionCheckoutSession] error", error, ); if (error instanceof HttpsError) { throw error; } throw mapStripeErrorToHttps( error, "Impossible de créer la session d'abonnement Stripe.", ); } }, ); const createCoinPackCheckoutSession = onCall( { region: REGION }, async (request) => { try { const uid = request?.auth?.uid; if (!uid) { throw new HttpsError( "unauthenticated", "Connecte-toi pour acheter un pack de pièces.", ); } const rawProductId = request?.data?.productId; const productId = typeof rawProductId === "string" ? rawProductId.trim() : ""; if (!productId) { throw new HttpsError( "invalid-argument", "Un identifiant de produit Stripe est requis.", ); } if (!COIN_PACK_PRODUCT_IDS.includes(productId)) { throw new HttpsError( "invalid-argument", `Le produit ${productId} n'est pas un pack de pièces autorisé`, ); } const stripe = getStripeClient(); const product = await stripe.products.retrieve(productId, { expand: ["default_price"], }); let resolvedPrice = null; if (typeof product?.default_price === "string") { resolvedPrice = await stripe.prices.retrieve(product.default_price); } else if ( product?.default_price && typeof product.default_price === "object" ) { resolvedPrice = product.default_price; } const coinPack = formatCoinPack({ product, price: resolvedPrice, fallback: COIN_PACK_PRODUCT_MAP[productId], }); if (!coinPack?.priceId) { throw new HttpsError( "failed-precondition", "Impossible de déterminer le prix Stripe pour ce pack.", ); } const { successUrl, cancelUrl } = getReturnUrls( request?.data?.returnUrls, ); const { customerId } = await ensureStripeCustomer({ uid, stripe, refsList, createIfMissing: true, }); if (!customerId) { throw new HttpsError( "failed-precondition", "Impossible de retrouver le client Stripe associé.", ); } const session = await stripe.checkout.sessions.create({ mode: "payment", customer: customerId, line_items: [ { price: coinPack.priceId, quantity: 1, }, ], success_url: successUrl, cancel_url: cancelUrl, allow_promotion_codes: false, metadata: { firebaseUID: uid, purchaseType: "COIN_PACK", coinPackProductId: coinPack.productId, coinPackPriceId: coinPack.priceId, coinAmount: coinPack.coinAmount, coinPackKey: COIN_PACK_PRODUCT_MAP[productId]?.key || null, }, }); await paymentsCollection.doc(session.id).set( { userId: uid, customerId, status: session.status || "created", mode: "payment", createdAt: getServerTimestamp(), updatedAt: getServerTimestamp(), sessionId: session.id, sessionUrl: session.url, lineItems: [ { productId: coinPack.productId, priceId: coinPack.priceId, quantity: 1, coinAmount: coinPack.coinAmount, }, ], amountSubtotal: session.amount_subtotal, amountTotal: session.amount_total, currency: session.currency, paymentStatus: session.payment_status, metadata: session.metadata || {}, }, { merge: true }, ); return formatCheckoutSessionResponse(session); } catch (error) { console.error( "[subscription-createCoinPackCheckoutSession] error", error, ); if (error instanceof HttpsError) { throw error; } throw mapStripeErrorToHttps( error, "Impossible de créer la session d'achat de pièces.", ); } }, ); exports.listSubscriptionPlans = listSubscriptionPlans; exports.createSubscriptionCheckoutSession = createSubscriptionCheckoutSession; exports.listCoinPacks = listCoinPacks; exports.createCoinPackCheckoutSession = createCoinPackCheckoutSession; exports.handleStripeWebhook = handleStripeWebhook;