const admin = require("firebase-admin"); const { HttpsError } = require("firebase-functions/https"); const Stripe = require("stripe"); const { URL } = require("url"); const { STRIPE_SECRET_KEY = "", STRIPE_RETURN_URL = "", STRIPE_PORTAL_CONFIGURATION = "", STRIPE_MODE: CONFIG_STRIPE_MODE, } = require("../config/keys"); const STRIPE_MODE = typeof CONFIG_STRIPE_MODE === "string" && CONFIG_STRIPE_MODE.trim() ? CONFIG_STRIPE_MODE.trim() : "test"; const requireEnv = (key) => { const value = process.env?.[key]; if (typeof value === "string" && value.trim()) { return value.trim(); } throw new Error(`${key} not configured`); }; const STRIPE_API_VERSION = "2023-10-16"; const DEFAULT_TEST_RETURN_URL = "http://localhost:8081"; const ALLOWED_RETURN_SCHEMES = ["http", "https", "minuit"]; let cachedStripeClient = null; let cachedPortalConfigurationId = null; const resolveStripeSecretKey = () => { const inlineKey = typeof STRIPE_SECRET_KEY === "string" ? STRIPE_SECRET_KEY.trim() : ""; if (inlineKey) { return inlineKey; } const required = requireEnv("STRIPE_SECRET_KEY"); if (typeof required === "string" && required.trim()) { return required.trim(); } throw new Error("STRIPE_SECRET_KEY not configured"); }; const getStripeClient = () => { if (cachedStripeClient) { return cachedStripeClient; } let secretKey; try { secretKey = resolveStripeSecretKey(); } catch (error) { console.error("[getStripeClient] Missing STRIPE_SECRET_KEY", error); throw new HttpsError( "failed-precondition", "Stripe n’est pas configuré. Ajoute STRIPE_SECRET_KEY pour activer cette fonctionnalité.", ); } cachedStripeClient = new Stripe(secretKey, { apiVersion: STRIPE_API_VERSION, }); return cachedStripeClient; }; const getReturnBaseUrl = () => { const resolveBase = () => { if (STRIPE_RETURN_URL) { return STRIPE_RETURN_URL; } if (STRIPE_MODE !== "prod") { return DEFAULT_TEST_RETURN_URL; } return requireEnv("STRIPE_RETURN_URL"); }; const rawBase = resolveBase(); const sanitizedBase = typeof rawBase === "string" ? rawBase.trim() : ""; if (!sanitizedBase) { if (STRIPE_MODE !== "prod") { return DEFAULT_TEST_RETURN_URL; } throw new Error("STRIPE_RETURN_URL not configured"); } return sanitizedBase.endsWith("/") ? sanitizedBase.slice(0, -1) : sanitizedBase; }; const sanitizeReturnUrl = (value) => { if (typeof value !== "string") { return null; } const trimmed = value.trim(); if (!trimmed) { return null; } const validateScheme = (scheme) => { if (!ALLOWED_RETURN_SCHEMES.includes(scheme)) { throw new HttpsError( "invalid-argument", `Le schéma d'URL "${scheme}" n'est pas autorisé pour les retours Stripe.`, ); } }; try { const parsedUrl = new URL(trimmed); const scheme = parsedUrl.protocol.replace(":", "").toLowerCase(); validateScheme(scheme); return trimmed; } catch (_error) { const schemeMatch = trimmed.match(/^([a-z][a-z0-9+\-.]*):\/\//i); if (schemeMatch && schemeMatch[1]) { const scheme = schemeMatch[1].toLowerCase(); validateScheme(scheme); return trimmed; } throw new HttpsError( "invalid-argument", `URL de retour Stripe invalide: ${trimmed}`, ); } }; const getReturnUrls = (overrides) => { if (overrides && typeof overrides === "object") { const successOverride = sanitizeReturnUrl(overrides.successUrl); const cancelOverride = sanitizeReturnUrl(overrides.cancelUrl); if (!successOverride) { throw new HttpsError( "invalid-argument", "successUrl est requis pour configurer les retours Stripe.", ); } return { successUrl: successOverride, cancelUrl: cancelOverride || successOverride, }; } const baseUrl = getReturnBaseUrl(); const joinPath = (url, path) => { const trimmedUrl = url.endsWith("/") ? url.slice(0, -1) : url; const trimmedPath = path.startsWith("/") ? path.slice(1) : path; return `${trimmedUrl}/${trimmedPath}`; }; const appendQuery = (url, query) => url.includes("?") ? `${url}&${query}` : `${url}?${query}`; const successBase = joinPath(baseUrl, "payment-success"); const cancelBase = joinPath(baseUrl, "payment-error"); return { successUrl: appendQuery(successBase, "session_id={CHECKOUT_SESSION_ID}"), cancelUrl: appendQuery(cancelBase, "session_id={CHECKOUT_SESSION_ID}"), }; }; const normalizeBoolean = (value) => value === true; const buildCheckoutLineItems = async (productList, { stripe } = {}) => { if (!Array.isArray(productList) || productList.length === 0) { throw new HttpsError( "invalid-argument", "Au moins un produit est requis pour créer une session de paiement.", ); } const lineItems = []; const summary = []; let hasSubscription = false; for (let index = 0; index < productList.length; index += 1) { const rawItem = productList[index]; const item = rawItem && typeof rawItem === "object" ? rawItem : {}; const isRenewable = normalizeBoolean(item.isRenewable); const rawQuantity = typeof item.quantity === "number" && Number.isFinite(item.quantity) ? item.quantity : parseInt(item.quantity, 10); const quantity = Number.isFinite(rawQuantity) && rawQuantity > 0 ? rawQuantity : 1; const priceId = typeof item.priceID === "string" ? item.priceID.trim() : ""; if (isRenewable && !priceId) { throw new HttpsError( "invalid-argument", `Un price ID Stripe est requis pour l'élément ${index + 1} (abonnement).`, ); } if (priceId) { let stripePrice = null; if (stripe) { try { stripePrice = await stripe.prices.retrieve(priceId); } catch (error) { console.error( "[buildCheckoutLineItems] Unable to retrieve price", priceId, error, ); throw new HttpsError( "invalid-argument", `Le price ID "${priceId}" est introuvable (élément ${index + 1}).`, ); } } const priceIsRecurring = stripePrice?.type === "recurring" || !!stripePrice?.recurring; if (isRenewable && !priceIsRecurring) { throw new HttpsError( "invalid-argument", `Le price ID "${priceId}" n'est pas compatible avec un abonnement.`, ); } const resolvedIsRenewable = priceIsRecurring ? true : isRenewable; if (resolvedIsRenewable) { hasSubscription = true; } lineItems.push({ price: priceId, quantity, }); summary.push({ type: "price", priceID: priceId, quantity, isRenewable: resolvedIsRenewable, }); continue; } const rawUnitAmount = Number(item.unitAmount); const unitAmount = Math.round(rawUnitAmount); if (!Number.isFinite(unitAmount) || unitAmount <= 0) { throw new HttpsError( "invalid-argument", `Le montant indiqué pour l'élément ${index + 1} est invalide.`, ); } const currency = typeof item.currency === "string" ? item.currency.trim().toLowerCase() : "eur"; if (!/^[a-z]{3}$/.test(currency)) { throw new HttpsError( "invalid-argument", `La devise indiquée pour l'élément ${index + 1} est invalide.`, ); } const label = typeof item.label === "string" && item.label.trim() ? item.label.trim() : "Paiement ponctuel"; lineItems.push({ price_data: { currency, product_data: { name: label, }, unit_amount: unitAmount, }, quantity, }); summary.push({ type: "custom", currency, unitAmount, quantity, isRenewable: false, }); } if (hasSubscription) { const hasNonSubscription = summary.some((item) => !item.isRenewable); if (hasNonSubscription) { throw new HttpsError( "invalid-argument", "Impossible de mélanger abonnements et paiements ponctuels dans une seule session Checkout.", ); } } return { lineItems, summary, hasSubscription }; }; const ensureStripeCustomer = async ({ uid, stripe, refsList, createIfMissing = true, }) => { if (!uid) { return { customerId: null, userData: null }; } const userRef = refsList?.users?.doc(uid); const snapshot = userRef ? await userRef.get() : null; const userData = snapshot?.exists ? snapshot.data() : null; let customerId = userData?.stripeCustomerId; if (customerId) { return { customerId, userData }; } if (!createIfMissing) { return { customerId: null, userData }; } let authRecord = null; try { authRecord = await admin.auth().getUser(uid); } catch (error) { console.warn( "[ensureStripeCustomer] Impossible de récupérer auth user", error, ); } const email = userData?.email || authRecord?.email || undefined; const nameFromProfile = [userData?.firstName, userData?.lastName] .filter(Boolean) .join(" ") .trim(); const name = nameFromProfile || authRecord?.displayName || undefined; const customer = await stripe.customers.create({ email, name, metadata: { firebaseUID: uid, appMode: STRIPE_MODE || "test", }, }); customerId = customer.id; if (userRef) { await userRef.set( { stripeCustomerId: customerId, }, { merge: true }, ); } return { customerId, userData: { ...userData, stripeCustomerId: customerId }, }; }; const formatCheckoutSessionResponse = (session) => ({ id: session.id, object: session.object, customer: session.customer, customer_details: session.customer_details, url: session.url, mode: session.mode, status: session.status, payment_status: session.payment_status, currency: session.currency, amount_subtotal: session.amount_subtotal, amount_total: session.amount_total, created: session.created, expires_at: session.expires_at, client_secret: session.client_secret || null, }); const getPortalConfigurationId = async (stripe) => { if (STRIPE_PORTAL_CONFIGURATION) { return STRIPE_PORTAL_CONFIGURATION; } if (cachedPortalConfigurationId) { return cachedPortalConfigurationId; } if ( !stripe || typeof stripe.billingPortal?.configurations?.list !== "function" ) { throw new HttpsError( "internal", "Client Stripe indisponible pour la configuration du portail.", ); } try { const configurations = await stripe.billingPortal.configurations.list({ limit: 100, }); const defaultConfiguration = configurations.data.find((config) => config.is_default) || configurations.data.find((config) => config.active); if (defaultConfiguration?.id) { cachedPortalConfigurationId = defaultConfiguration.id; return cachedPortalConfigurationId; } } catch (error) { console.warn( "[getPortalConfigurationId] Impossible de lister les configurations de portail", error?.message || error, ); } try { const defaultReturnUrl = getReturnBaseUrl(); const createdConfiguration = await stripe.billingPortal.configurations.create({ default_return_url: defaultReturnUrl, business_profile: { headline: "Minuit Starter", }, }); if (createdConfiguration?.id) { cachedPortalConfigurationId = createdConfiguration.id; return cachedPortalConfigurationId; } } catch (error) { console.warn( "[getPortalConfigurationId] Impossible de créer une configuration de portail par défaut", error?.message || error, ); } return null; }; const mapStripeErrorToHttps = (error, fallbackMessage) => { const message = error?.raw?.message || error?.message || fallbackMessage || "Erreur Stripe."; const statusCode = error?.statusCode || error?.raw?.statusCode; const isClientError = typeof statusCode === "number" && statusCode >= 400 && statusCode < 500; const code = isClientError ? "failed-precondition" : "internal"; return new HttpsError(code, message); }; module.exports = { getStripeClient, getReturnUrls, getReturnBaseUrl, buildCheckoutLineItems, ensureStripeCustomer, formatCheckoutSessionResponse, getPortalConfigurationId, mapStripeErrorToHttps, };