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, }