Files
musicland/functions/src/stripe.js
T
2026-01-12 16:01:32 +01:00

563 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 na pas renvoyé didentifiant 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 na 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,
}