const { HttpsError, onCall } = require("firebase-functions/https"); const { getStripeClient, buildCheckoutLineItems, ensureStripeCustomer, getReturnUrls, formatCheckoutSessionResponse, mapStripeErrorToHttps, } = require("../../helpers/stripe"); const { REGION } = require("./config"); const { ALL_SUBSCRIPTION_PRICE_IDS, COIN_PACK_PRODUCT_IDS, COIN_PACK_PRODUCT_MAP, } = require("./constants"); const { refsList, formatCoinPack, getSubscriptionMetaFromPrice, } = require("./shared"); const CHECKOUT_UI_MODES = new Set(["hosted", "embedded"]); const sanitizePriceId = (value) => { if (typeof value !== "string") { return ""; } return value.trim(); }; const resolveCheckoutUiMode = (request) => { if (!request || !request.data || typeof request.data.uiMode === "undefined") { return "hosted"; } if (typeof request.data.uiMode !== "string") { throw new HttpsError( "invalid-argument", 'uiMode doit être une chaîne de caractères ("hosted" ou "embedded").', ); } const normalizedUiMode = request.data.uiMode.trim().toLowerCase(); if (!CHECKOUT_UI_MODES.has(normalizedUiMode)) { throw new HttpsError( "invalid-argument", `uiMode "${request.data.uiMode}" n'est pas supporté pour Stripe Checkout.`, ); } return normalizedUiMode; }; const withCheckoutNavigationParams = ( baseParams, { uiMode, successUrl, cancelUrl }, ) => { if (uiMode === "embedded") { return { ...baseParams, ui_mode: "embedded", return_url: undefined, redirect_on_completion: "never", }; } return { ...baseParams, success_url: successUrl, cancel_url: cancelUrl, }; }; 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 uiMode = resolveCheckoutUiMode(request); const shouldProvideReturnUrls = uiMode !== "embedded"; const { successUrl, cancelUrl } = shouldProvideReturnUrls ? getReturnUrls(request?.data?.returnUrls) : { successUrl: null, cancelUrl: null }; 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( withCheckoutNavigationParams( { mode: "subscription", customer: customerId, line_items: lineItems, allow_promotion_codes: true, metadata: { firebaseUID: uid, priceId, purchaseType: "SUBSCRIPTION", subscriptionLevel: priceMeta.level || null, subscriptionBillingPeriod: priceMeta.billingPeriod || null, }, }, { uiMode, successUrl, cancelUrl, }, ), ); 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; } let coinPack = null; try { coinPack = formatCoinPack({ product, price: resolvedPrice, }); } catch (error) { console.error( "[subscription-createCoinPackCheckoutSession] invalid coin pack metadata", productId, error?.message || error, ); throw new HttpsError( "failed-precondition", "Le pack Stripe est mal configuré (metadata.coins manquant).", ); } if (!coinPack?.priceId) { throw new HttpsError( "failed-precondition", "Impossible de déterminer le prix Stripe pour ce pack.", ); } const uiMode = resolveCheckoutUiMode(request); const shouldProvideReturnUrls = uiMode !== "embedded"; const { successUrl, cancelUrl } = shouldProvideReturnUrls ? getReturnUrls(request?.data?.returnUrls) : { successUrl: null, cancelUrl: null }; 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( withCheckoutNavigationParams( { mode: "payment", customer: customerId, line_items: [ { price: coinPack.priceId, quantity: 1, }, ], 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, }, }, { uiMode, successUrl, cancelUrl, }, ), ); 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.", ); } }, ); module.exports = { createSubscriptionCheckoutSession, createCoinPackCheckoutSession, resolveCheckoutUiMode, };