const admin = require("firebase-admin"); const { HttpsError, onCall } = require("firebase-functions/https"); let FieldValue = null; try { ({ FieldValue } = require("firebase-admin/firestore")); } catch (error) { console.warn("[stripe] FieldValue import failed", error?.message); } const { REGION, refsList } = require("../index"); const STRIPE_MODE = "test"; const { getStripeClient, getReturnUrls, getReturnBaseUrl, buildCheckoutLineItems, ensureStripeCustomer, formatCheckoutSessionResponse, getPortalConfigurationId, mapStripeErrorToHttps, } = require("../helpers/stripe"); const paymentsCollection = admin.firestore().collection("payments"); const getServerTimestamp = () => { if (FieldValue?.serverTimestamp) { return FieldValue.serverTimestamp(); } const fallback = admin.firestore?.FieldValue; if (fallback?.serverTimestamp) { return fallback.serverTimestamp(); } throw new HttpsError( "failed-precondition", "Firestore FieldValue.serverTimestamp indisponible.", ); }; const normalizePaymentIntent = (paymentIntent) => ({ id: paymentIntent.id, object: paymentIntent.object, status: paymentIntent.status, currency: paymentIntent.currency, amount: paymentIntent.amount, amount_received: paymentIntent.amount_received, customer: paymentIntent.customer, created: paymentIntent.created, latest_charge: paymentIntent.latest_charge, metadata: paymentIntent.metadata, }); const createCheckoutSession = onCall({ region: REGION }, async (request) => { try { const uid = request?.auth?.uid; if (!uid) { throw new HttpsError( "unauthenticated", "Connecte-toi pour créer une session Stripe.", ); } const requestedUserId = typeof request?.data?.userID === "string" ? request.data.userID.trim() : null; if (requestedUserId && requestedUserId !== uid) { throw new HttpsError( "permission-denied", "Tu ne peux créer une session que pour ton propre compte.", ); } const productList = Array.isArray(request?.data?.productList) ? request.data.productList : []; const stripe = getStripeClient(); const { lineItems, summary, hasSubscription } = await buildCheckoutLineItems(productList, { stripe }); const mode = hasSubscription ? "subscription" : "payment"; const { successUrl, cancelUrl } = getReturnUrls(request?.data?.returnUrls); const { customerId } = await ensureStripeCustomer({ uid, stripe, refsList, createIfMissing: true, }); if (!customerId) { throw new HttpsError( "internal", "Impossible de retrouver le client Stripe associé.", ); } const session = await stripe.checkout.sessions.create({ mode, customer: customerId, line_items: lineItems, success_url: successUrl, cancel_url: cancelUrl, allow_promotion_codes: true, metadata: { firebaseUID: uid, }, }); await paymentsCollection.doc(session.id).set({ userId: uid, customerId, status: session.status || "created", mode, 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, }); return formatCheckoutSessionResponse(session); } catch (error) { console.error("[createCheckoutSession] error", error); if (error instanceof HttpsError) { throw error; } throw mapStripeErrorToHttps( error, "Création de la session Stripe impossible.", ); } }); const getPremiumStatus = onCall({ region: REGION }, async (request) => { try { const uid = request?.auth?.uid; if (!uid) { throw new HttpsError( "unauthenticated", "Connecte-toi pour consulter ton statut Stripe.", ); } const stripe = getStripeClient(); const { customerId } = await ensureStripeCustomer({ uid, stripe, refsList, createIfMissing: false, }); if (!customerId) { return { customerId: null, subscriptions: [], invoices: [], }; } const [subscriptions, invoices] = await Promise.all([ stripe.subscriptions.list({ customer: customerId, status: "all", expand: ["data.items.data.price"], limit: 20, }), stripe.invoices.list({ customer: customerId, limit: 20, }), ]); const formattedSubscriptions = subscriptions.data.map((subscription) => ({ id: subscription.id, status: subscription.status, created: subscription.created, current_period_start: subscription.current_period_start, current_period_end: subscription.current_period_end, cancel_at_period_end: subscription.cancel_at_period_end, items: subscription.items.data.map((item) => ({ id: item.id, quantity: item.quantity, price: item.price?.id, product: item.price?.product, unit_amount: item.price?.unit_amount, currency: item.price?.currency, recurring: item.price?.recurring, })), })); const formattedInvoices = invoices.data.map((invoice) => ({ id: invoice.id, status: invoice.status, amount_due: invoice.amount_due, amount_paid: invoice.amount_paid, amount_remaining: invoice.amount_remaining, currency: invoice.currency, hosted_invoice_url: invoice.hosted_invoice_url, invoice_pdf: invoice.invoice_pdf, created: invoice.created, })); return { customerId, subscriptions: formattedSubscriptions, invoices: formattedInvoices, }; } catch (error) { console.error("[getPremiumStatus] error", error); if (error instanceof HttpsError) { throw error; } throw mapStripeErrorToHttps( error, "Impossible de récupérer le statut Stripe.", ); } }); const createStripeCustomerPortalSession = onCall( { region: REGION }, async (request) => { try { const uid = request?.auth?.uid; if (!uid) { throw new HttpsError( "unauthenticated", "Connecte-toi pour ouvrir le portail client Stripe.", ); } const stripe = getStripeClient(); const { customerId } = await ensureStripeCustomer({ uid, stripe, refsList, createIfMissing: false, }); if (!customerId) { throw new HttpsError( "failed-precondition", "Aucun client Stripe associé à cet utilisateur.", ); } const portalConfigurationId = await getPortalConfigurationId(stripe); if (!portalConfigurationId) { const modeLabel = STRIPE_MODE === "prod" ? "production" : "test"; const envKeySuffix = STRIPE_MODE === "prod" ? "PROD" : "TEST"; throw new HttpsError( "failed-precondition", `Configure le portail client Stripe en mode ${modeLabel} ou renseigne STRIPE_PORTAL_CONFIGURATION_${envKeySuffix}.`, ); } const { successUrl } = getReturnUrls(); const portalSession = await stripe.billingPortal.sessions.create({ customer: customerId, return_url: successUrl, configuration: portalConfigurationId, }); return { id: portalSession.id, url: portalSession.url, created: portalSession.created, }; } catch (error) { console.error("[createStripeCustomerPortalSession] error", error); if (error instanceof HttpsError) { throw error; } throw mapStripeErrorToHttps( error, "Ouverture du portail Stripe impossible.", ); } }, ); const resolveStripeConnectAccountId = (userData) => { if (!userData || typeof userData !== "object") { return null; } const candidatePaths = [ ["stripeConnectAccountId"], ["stripeConnectId"], ["stripeAccountId"], ["stripeConnectAccount"], ["stripeAccount"], ["stripe", "connectAccountId"], ["stripe", "accountId"], ["providers", "stripeConnect", "accountId"], ]; for (const path of candidatePaths) { let current = userData; for (const key of path) { if (!current || typeof current !== "object") { current = null; break; } current = current[key]; } if (typeof current === "string" && current.trim()) { return current.trim(); } } return null; }; const ensureStripeConnectAccount = async ({ uid, stripe, userRef, userData, }) => { if (!uid || !stripe) { return { connectAccountId: null, userData, createdAccount: false }; } let connectAccountId = resolveStripeConnectAccountId(userData); if (connectAccountId) { return { connectAccountId, userData, createdAccount: false }; } let authRecord = null; try { authRecord = await admin.auth().getUser(uid); } catch (error) { console.warn( "[ensureStripeConnectAccount] Impossible de récupérer auth user", error, ); } const email = userData?.email || authRecord?.email || undefined; const accountParams = { type: "express", capabilities: { card_payments: { requested: true }, transfers: { requested: true }, }, metadata: { firebaseUID: uid, appMode: STRIPE_MODE || "test", }, }; if (email) { accountParams.email = email; } const account = await stripe.accounts.create(accountParams); connectAccountId = account?.id; if (!connectAccountId) { throw new HttpsError( "internal", "Stripe n’a pas renvoyé d’identifiant de compte Connect.", ); } const providersConnect = { ...(userData?.providers?.stripeConnect || {}), accountId: connectAccountId, }; if (userRef) { await userRef.set( { stripeConnectAccountId: connectAccountId, providers: { ...(userData?.providers || {}), stripeConnect: { ...providersConnect, updatedAt: getServerTimestamp(), }, }, }, { merge: true }, ); } return { connectAccountId, userData: { ...(userData || {}), stripeConnectAccountId: connectAccountId, providers: { ...(userData?.providers || {}), stripeConnect: providersConnect, }, }, createdAccount: true, }; }; const createStripeConnectLoginLink = onCall( { region: REGION }, async (request) => { try { const uid = request?.auth?.uid; if (!uid) { throw new HttpsError( "unauthenticated", "Connecte-toi pour ouvrir Stripe Connect.", ); } const userRef = refsList?.users?.doc(uid); const snapshot = userRef ? await userRef.get() : null; let userData = snapshot?.exists ? snapshot.data() : null; const stripe = getStripeClient(); const ensureResult = await ensureStripeConnectAccount({ uid, stripe, userRef, userData, }); const connectAccountId = ensureResult.connectAccountId; userData = ensureResult.userData; if (!connectAccountId) { throw new HttpsError( "failed-precondition", "Impossible de créer un compte Stripe Connect pour cet utilisateur.", ); } const redirectUrl = getReturnBaseUrl(); const loginLink = await stripe.accounts.createLoginLink( connectAccountId, redirectUrl ? { redirect_url: redirectUrl, } : undefined, ); if (!loginLink?.url) { throw new HttpsError( "internal", "Stripe Connect n’a pas renvoyé de lien de connexion.", ); } return { id: loginLink.id, url: loginLink.url, created: loginLink.created, connectAccountId, createdAccount: ensureResult.createdAccount, }; } catch (error) { console.error("[createStripeConnectLoginLink] error", error); if (error instanceof HttpsError) { throw error; } throw mapStripeErrorToHttps( error, "Ouverture de Stripe Connect impossible.", ); } }, ); const verifyStripePayment = onCall({ region: REGION }, async (request) => { try { const uid = request?.auth?.uid; if (!uid) { throw new HttpsError( "unauthenticated", "Connecte-toi pour vérifier un paiement Stripe.", ); } const rawPaymentId = typeof request?.data?.paymentId === "string" ? request.data.paymentId.trim() : ""; if (!rawPaymentId) { throw new HttpsError( "invalid-argument", "Fournis un identifiant de paiement Stripe.", ); } const stripe = getStripeClient(); const fetchPaymentIntent = async (paymentIntentId) => stripe.paymentIntents.retrieve(paymentIntentId, { expand: ["latest_charge"], }); const fetchCheckoutSession = async (sessionId) => stripe.checkout.sessions.retrieve(sessionId, { expand: ["payment_intent"], }); let paymentIntent = null; let checkoutSession = null; let paymentType = null; if (rawPaymentId.startsWith("pi_")) { paymentIntent = await fetchPaymentIntent(rawPaymentId); paymentType = "payment_intent"; } else if (rawPaymentId.startsWith("cs_")) { checkoutSession = await fetchCheckoutSession(rawPaymentId); paymentIntent = checkoutSession?.payment_intent && typeof checkoutSession.payment_intent === "object" ? checkoutSession.payment_intent : checkoutSession?.payment_intent ? await fetchPaymentIntent(checkoutSession.payment_intent) : null; paymentType = "checkout_session"; } else { try { paymentIntent = await fetchPaymentIntent(rawPaymentId); paymentType = "payment_intent"; } catch (intentError) { try { checkoutSession = await fetchCheckoutSession(rawPaymentId); paymentType = "checkout_session"; paymentIntent = checkoutSession?.payment_intent && typeof checkoutSession.payment_intent === "object" ? checkoutSession.payment_intent : checkoutSession?.payment_intent ? await fetchPaymentIntent(checkoutSession.payment_intent) : null; } catch (sessionError) { console.error("[verifyStripePayment] lookup failure", { intentError, sessionError, }); throw new HttpsError( "not-found", "Aucun paiement Stripe trouvé avec cet identifiant.", ); } } } if (!paymentIntent && !checkoutSession) { throw new HttpsError( "not-found", "Aucun paiement Stripe trouvé avec cet identifiant.", ); } const normalizedIntent = paymentIntent ? normalizePaymentIntent(paymentIntent) : null; const response = { type: paymentType, payment_intent: normalizedIntent, checkout_session: checkoutSession ? { id: checkoutSession.id, status: checkoutSession.status, payment_status: checkoutSession.payment_status, amount_total: checkoutSession.amount_total, amount_subtotal: checkoutSession.amount_subtotal, currency: checkoutSession.currency, customer: checkoutSession.customer, created: checkoutSession.created, expires_at: checkoutSession.expires_at, } : null, amount_received: normalizedIntent?.amount_received ?? null, status: normalizedIntent?.status ?? checkoutSession?.payment_status ?? checkoutSession?.status ?? null, currency: normalizedIntent?.currency ?? checkoutSession?.currency ?? null, }; const paymentDocId = paymentType === "checkout_session" ? checkoutSession?.id : normalizedIntent?.id; if (paymentDocId) { const paymentDocRef = paymentsCollection.doc(paymentDocId); const existing = await paymentDocRef.get(); if (existing.exists) { await paymentDocRef.set( { status: response.status, paymentStatus: checkoutSession?.payment_status, amountTotal: checkoutSession?.amount_total ?? normalizedIntent?.amount, amountReceived: normalizedIntent?.amount_received, currency: response.currency, updatedAt: getServerTimestamp(), }, { merge: true }, ); } } return response; } catch (error) { console.error("[verifyStripePayment] error", error); if (error instanceof HttpsError) { throw error; } throw mapStripeErrorToHttps(error, "Vérification du paiement impossible."); } }); module.exports = { createCheckoutSession, getPremiumStatus, createStripeCustomerPortalSession, createStripeConnectLoginLink, verifyStripePayment, };