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