const { onRequest } = require('firebase-functions/v2/https') const { HttpsError } = require('firebase-functions/https') const { ORDER_TYPES, createOrderDocument } = require('../helpers/orders') const { getStripeClient } = require('../../helpers/stripe') const { REGION } = require('./config') const { paymentsCollection, refsList, resolveStripeWebhookSecret, getServerTimestamp, toFirestoreTimestamp, extractFirebaseUid, buildEventSnapshot, computeNextGrantTimestamp, parseCoinsPerMonth, getSubscriptionMetaFromPrice, buildSubscriptionPayload, resolveUserContext, upsertPaymentDocument, } = require('./shared') const { PREMIUM_SUBSCRIPTION_STATUSES } = require('./constants') const isPaidCheckoutSession = (session) => { if (!session || typeof session !== 'object') { return false } if (typeof session.payment_status !== 'string') { return false } const normalizedStatus = session.payment_status.trim().toLowerCase() return normalizedStatus === 'paid' || normalizedStatus === 'no_payment_required' } const handleCheckoutSessionCompleted = async (session, event, { stripe } = {}) => { if (!session || typeof session !== 'object') { return } const firebaseUid = extractFirebaseUid(session.metadata) const paymentDocRef = await upsertPaymentDocument(session.id, { userId: firebaseUid || null, customerId: session.customer || null, subscriptionId: session.subscription || null, invoiceId: session.invoice || null, status: session.status || 'completed', paymentStatus: session.payment_status || null, mode: session.mode || null, amountSubtotal: session.amount_subtotal ?? null, amountTotal: session.amount_total ?? null, currency: session.currency || null, metadata: session.metadata || {}, completedAt: toFirestoreTimestamp(session.created), expiresAt: toFirestoreTimestamp(session.expires_at), paymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : null, lastEventType: event?.type || null, lastEventId: event?.id || null, lastEventAt: getServerTimestamp(), }) const { uid, userRef } = await resolveUserContext({ metadata: session.metadata, customerId: session.customer, }) if (userRef) { const lastEvent = buildEventSnapshot(event?.type, session.id) if (firebaseUid) { lastEvent.uid = firebaseUid } const userUpdate = { lastStripeWebhookEvent: lastEvent, } if (isPaidCheckoutSession(session)) { userUpdate.hasPurchased = true } if (session.customer) { userUpdate.stripeCustomerId = session.customer } if (session.metadata?.subscriptionLevel) { userUpdate.premiumLevel = session.metadata.subscriptionLevel } if (session.metadata?.subscriptionBillingPeriod) { userUpdate.premiumBillingPeriod = session.metadata.subscriptionBillingPeriod } await userRef.set(userUpdate, { merge: true }) } if ( stripe && session.mode === 'subscription' && typeof session.subscription === 'string' && session.subscription ) { try { const subscription = await stripe.subscriptions.retrieve(session.subscription, { expand: ['items.data.price.product'], }) if (subscription) { await handleCustomerSubscriptionEvent(subscription, event, { stripe }) } } catch (error) { console.error( '[subscription-handleCheckoutSessionCompleted] Unable to sync subscription', session.subscription, error ) } } if ( session.mode === 'payment' && (session.payment_status === 'paid' || session.payment_status === 'no_payment_required') && session.metadata?.purchaseType === 'COIN_PACK' && userRef ) { const coinAmountRaw = Number(session.metadata?.coinAmount || 0) const coinAmount = Number.isFinite(coinAmountRaw) ? coinAmountRaw : 0 if (coinAmount > 0 && paymentDocRef) { let paymentSnapshot = null try { paymentSnapshot = await paymentDocRef.get() } catch (error) { console.warn( '[subscription-handleCheckoutSessionCompleted] Unable to read payment doc', session.id, error?.message || error ) } const alreadyGranted = Boolean( paymentSnapshot?.exists && paymentSnapshot.data()?.coinPackGrantedAt ) if (!alreadyGranted) { const targetUserId = uid || firebaseUid || userRef.id await createOrderDocument({ userId: targetUserId, type: ORDER_TYPES.COINS, amount: coinAmount, metadata: { source: 'STRIPE_CHECKOUT', paymentId: session.id || null, coinPackKey: session.metadata?.coinPackKey || null, }, orderId: `stripe_${session.id}`, }) await paymentDocRef.set( { coinPackGrantedAt: getServerTimestamp(), coinPackGrantedAmount: coinAmount, coinPackGrantedKey: session.metadata?.coinPackKey || null, }, { merge: true } ) } } } if ( session.mode === 'payment' && (session.payment_status === 'paid' || session.payment_status === 'no_payment_required') && session.metadata?.purchaseType === 'SONG_DOWNLOAD' ) { const rawProjectId = session.metadata?.projectId const projectId = typeof rawProjectId === 'string' ? rawProjectId.trim() : '' if (projectId && paymentDocRef) { let paymentSnapshot = null try { paymentSnapshot = await paymentDocRef.get() } catch (error) { console.warn( '[subscription-handleCheckoutSessionCompleted] Unable to read payment doc', session.id, error?.message || error ) } const alreadyGranted = Boolean( paymentSnapshot?.exists && paymentSnapshot.data()?.downloadGrantedAt ) if (!alreadyGranted) { const projectRef = refsList?.projects?.doc(projectId) || null let shouldGrant = true if (projectRef) { try { const projectSnapshot = await projectRef.get() if (!projectSnapshot.exists) { shouldGrant = false } else { const projectData = projectSnapshot.data() || {} const ownerId = typeof projectData?.userId === 'string' ? projectData.userId.trim() : '' const targetUserId = uid || firebaseUid || userRef?.id || null if (ownerId && targetUserId && ownerId !== targetUserId) { console.warn( '[subscription-handleCheckoutSessionCompleted] Project owner mismatch', projectId ) shouldGrant = false } } } catch (error) { console.warn( '[subscription-handleCheckoutSessionCompleted] Unable to read project', projectId, error?.message || error ) } } else { shouldGrant = false } if (shouldGrant && projectRef) { await projectRef.set( { downloadPurchase: { status: 'paid', paymentId: session.id || null, paymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : null, amount: session.amount_total ?? null, currency: session.currency || null, paidAt: getServerTimestamp(), }, }, { merge: true } ) await paymentDocRef.set( { downloadGrantedAt: getServerTimestamp(), downloadProjectId: projectId, }, { merge: true } ) } } } } if ( session.mode === 'payment' && (session.payment_status === 'paid' || session.payment_status === 'no_payment_required') && session.metadata?.purchaseType === 'PLAYBACK_DOWNLOAD' ) { const rawProjectId = session.metadata?.projectId const projectId = typeof rawProjectId === 'string' ? rawProjectId.trim() : '' if (projectId && paymentDocRef) { let paymentSnapshot = null try { paymentSnapshot = await paymentDocRef.get() } catch (error) { console.warn( '[subscription-handleCheckoutSessionCompleted] Unable to read payment doc', session.id, error?.message || error ) } const alreadyGranted = Boolean( paymentSnapshot?.exists && paymentSnapshot.data()?.playbackDownloadGrantedAt ) if (!alreadyGranted) { const projectRef = refsList?.projects?.doc(projectId) || null let shouldGrant = true if (projectRef) { try { const projectSnapshot = await projectRef.get() if (!projectSnapshot.exists) { shouldGrant = false } else { const projectData = projectSnapshot.data() || {} const ownerId = typeof projectData?.userId === 'string' ? projectData.userId.trim() : '' const targetUserId = uid || firebaseUid || userRef?.id || null if (ownerId && targetUserId && ownerId !== targetUserId) { console.warn( '[subscription-handleCheckoutSessionCompleted] Project owner mismatch', projectId ) shouldGrant = false } } } catch (error) { console.warn( '[subscription-handleCheckoutSessionCompleted] Unable to read project', projectId, error?.message || error ) } } else { shouldGrant = false } if (shouldGrant && projectRef) { await projectRef.set( { playbackDownloadPurchase: { status: 'paid', paymentId: session.id || null, paymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : null, amount: session.amount_total ?? null, currency: session.currency || null, paidAt: getServerTimestamp(), }, }, { merge: true } ) await paymentDocRef.set( { playbackDownloadGrantedAt: getServerTimestamp(), playbackDownloadProjectId: projectId, }, { merge: true } ) } } } } } const handleCustomerSubscriptionEvent = async (subscription, event, { stripe } = {}) => { if (!subscription || typeof subscription !== 'object') { return } const subscriptionPayload = buildSubscriptionPayload(subscription) if (!subscriptionPayload) { return } const { uid, userRef, userData: resolvedUserData, } = await resolveUserContext({ metadata: subscription.metadata, customerId: subscription.customer, }) let userData = resolvedUserData || null if (!userData && userRef) { try { const snapshot = await userRef.get() userData = snapshot.exists ? snapshot.data() || null : null } catch (error) { console.warn( '[subscription-handleCustomerSubscriptionEvent] Unable to read user', subscription.customer, error?.message || error ) } } let resolvedCustomerId = subscriptionPayload?.customerId || null if (!resolvedCustomerId && subscription.customer) { resolvedCustomerId = subscription.customer } const fallbackUid = extractFirebaseUid(subscription.metadata) const resolvedUid = uid || fallbackUid || null await upsertPaymentDocument(subscription.id, { userId: resolvedUid, customerId: resolvedCustomerId, subscriptionId: subscription.id || null, status: subscription.status || null, mode: 'subscription', priceId: subscriptionPayload?.priceId || null, productId: subscriptionPayload?.productId || null, cancelAtPeriodEnd: subscriptionPayload?.cancelAtPeriodEnd ?? null, currentPeriodStart: subscriptionPayload?.currentPeriodStart || null, currentPeriodEnd: subscriptionPayload?.currentPeriodEnd || null, metadata: { ...subscription.metadata, stripeEventType: event?.type || null, }, lastEventType: event?.type || null, lastEventId: event?.id || null, lastEventAt: getServerTimestamp(), }) if (!userRef) { console.warn('[subscription-handleCustomerSubscriptionEvent] User not resolved', { subscriptionId: subscription?.id || null, customerId: subscription?.customer || null, metadataKeys: Object.keys(subscription?.metadata || {}), eventType: event?.type || null, }) return } const lastEvent = buildEventSnapshot(event?.type, subscription.id) if (resolvedUid) { lastEvent.uid = resolvedUid } const priceMeta = getSubscriptionMetaFromPrice(subscriptionPayload?.priceId) const metadataLevel = subscription.metadata?.subscriptionLevel || null const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null const resolvedLevel = metadataLevel || priceMeta?.level || null const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null const isPremium = subscription.status ? PREMIUM_SUBSCRIPTION_STATUSES.has(subscription.status) : false const primaryItem = Array.isArray(subscriptionPayload?.items) ? subscriptionPayload.items[0] : null let coinsPerMonth = null const productMetadata = primaryItem?.price && typeof primaryItem.price === 'object' && primaryItem.price.product && typeof primaryItem.price.product === 'object' ? primaryItem.price.product.metadata : null coinsPerMonth = parseCoinsPerMonth(productMetadata || {}) if (coinsPerMonth === null && stripe && primaryItem?.price?.id) { try { const priceWithProduct = await stripe.prices.retrieve(primaryItem.price.id, { expand: ['product'], }) coinsPerMonth = parseCoinsPerMonth(priceWithProduct?.product?.metadata || {}) } catch (error) { console.warn( '[subscription-handleCustomerSubscriptionEvent] Unable to retrieve price product metadata', primaryItem.price.id, error?.message || error ) } } let subscriptionNextGrantAt = null if (coinsPerMonth !== null && subscriptionPayload?.currentPeriodEnd) { subscriptionNextGrantAt = computeNextGrantTimestamp(subscriptionPayload.currentPeriodEnd, 1) } const userUpdate = { lastStripeWebhookEvent: lastEvent, stripeCustomerId: resolvedCustomerId, stripeSubscription: { id: subscription.id, priceId: subscriptionPayload?.priceId || null, productId: subscriptionPayload?.productId || null, status: subscription.status || null, level: resolvedLevel, billingPeriod: resolvedPeriod, cancelAtPeriodEnd: subscriptionPayload?.cancelAtPeriodEnd ?? null, currentPeriodStart: subscriptionPayload?.currentPeriodStart || null, currentPeriodEnd: subscriptionPayload?.currentPeriodEnd || null, }, premiumLevel: isPremium ? resolvedLevel : null, premiumBillingPeriod: isPremium ? resolvedPeriod : null, subscriptionNextGrantAt, } if (isPremium) { userUpdate.hasPurchased = true } if (!isPremium) { userUpdate.subscriptionNextGrantAt = null } await userRef.set(userUpdate, { merge: true }) } const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => { if (!invoice || typeof invoice !== 'object') { return } const firebaseUid = extractFirebaseUid(invoice.metadata) const eventType = event?.type || null const paymentDocRef = paymentsCollection.doc(invoice.id) let resolvedSubscriptionId = typeof invoice.subscription === 'string' && invoice.subscription ? invoice.subscription : null if (!resolvedSubscriptionId) { const lineSubscriptionId = Array.isArray(invoice?.lines?.data) ? invoice.lines.data .map((line) => typeof line?.subscription === 'string' && line.subscription ? line.subscription : null ) .find((value) => value) : null if (lineSubscriptionId) { resolvedSubscriptionId = lineSubscriptionId console.log('[subscription-handleInvoiceEvent] Subscription resolved from invoice line', { invoiceId: invoice?.id || null, subscriptionId: resolvedSubscriptionId, }) } } console.log('[subscription-handleInvoiceEvent] Received invoice webhook', { eventType, invoiceId: invoice?.id || null, subscriptionId: invoice?.subscription || null, resolvedSubscriptionId: resolvedSubscriptionId || null, customerId: invoice?.customer || null, status: invoice?.status || null, billingReason: invoice?.billing_reason || null, attemptCount: invoice?.attempt_count ?? null, }) await upsertPaymentDocument(invoice.id, { userId: firebaseUid || null, customerId: invoice.customer || null, subscriptionId: resolvedSubscriptionId, status: invoice.status || null, paymentStatus: eventType === 'invoice.payment_failed' ? 'failed' : invoice.status || null, amountDue: invoice.amount_due ?? null, amountPaid: invoice.amount_paid ?? null, amountRemaining: invoice.amount_remaining ?? null, currency: invoice.currency || null, invoiceNumber: invoice.number || null, hostedInvoiceUrl: invoice.hosted_invoice_url || null, invoicePdf: invoice.invoice_pdf || null, billingReason: invoice.billing_reason || null, metadata: invoice.metadata || {}, periodStart: toFirestoreTimestamp(invoice.period_start), periodEnd: toFirestoreTimestamp(invoice.period_end), paidAt: toFirestoreTimestamp(invoice.status_transitions?.paid_at), lastEventType: eventType, lastEventId: event?.id || null, lastEventAt: getServerTimestamp(), mode: 'invoice', }) const { uid, userRef, userData: resolvedUserData, } = await resolveUserContext({ metadata: invoice.metadata, customerId: invoice.customer, }) if (!userRef) { console.warn('[subscription-handleInvoiceEvent] User context not resolved', { invoiceId: invoice?.id || null, customerId: invoice?.customer || null, firebaseUid: firebaseUid || null, metadataKeys: Object.keys(invoice?.metadata || {}), }) return } let userData = resolvedUserData || null if (!userData) { try { const snapshot = await userRef.get() userData = snapshot.exists ? snapshot.data() || null : null } catch (error) { console.warn( '[subscription-handleInvoiceEvent] Unable to read user', invoice.customer, error?.message || error ) } } const lastEvent = buildEventSnapshot(eventType, invoice.id) if (firebaseUid) { lastEvent.uid = firebaseUid } const userUpdate = { lastStripeWebhookEvent: lastEvent, } const billingReason = invoice.billing_reason || null const isInvoicePaid = invoice.status === 'paid' if (billingReason === 'subscription_create' || billingReason === 'subscription_cycle') { if (isInvoicePaid && invoice.subscription) { userUpdate.subscriptionLastInvoiceAt = getServerTimestamp() } } await userRef.set(userUpdate, { merge: true }) const subscriptionLine = Array.isArray(invoice?.lines?.data) ? invoice.lines.data.find( (line) => line && typeof line === 'object' && (line.type === 'subscription' || line.price) ) : null const priceId = typeof subscriptionLine?.price?.id === 'string' ? subscriptionLine.price.id : typeof subscriptionLine?.price === 'string' ? subscriptionLine.price : null const priceMeta = getSubscriptionMetaFromPrice(priceId) || {} const billingPeriod = priceMeta.billingPeriod || (subscriptionLine?.price?.recurring?.interval === 'year' ? 'annual' : subscriptionLine?.price?.recurring?.interval === 'month' ? 'monthly' : null) const coinsPerMonth = priceMeta.coinsPerMonth ?? parseCoinsPerMonth(subscriptionLine?.price?.product?.metadata || {}) ?? null const coinsToGrant = billingPeriod === 'annual' && typeof coinsPerMonth === 'number' ? coinsPerMonth * 12 : null const targetSubscriptionId = resolvedSubscriptionId || (typeof invoice.subscription === 'string' ? invoice.subscription : null) || (typeof subscriptionLine?.subscription === 'string' ? subscriptionLine.subscription : null) const shouldGrantUpfront = isInvoicePaid && coinsToGrant && (billingReason === 'subscription_create' || billingReason === 'subscription_cycle') if (shouldGrantUpfront && targetSubscriptionId) { let grantSnapshot = null try { grantSnapshot = await paymentDocRef.get() } catch (error) { console.warn( '[subscription-handleInvoiceEvent] Unable to read payment doc before grant', invoice?.id || null, error?.message || error ) } const alreadyGranted = grantSnapshot?.exists && Boolean(grantSnapshot.data()?.subscriptionCoinsGrantedAt) if (!alreadyGranted) { const targetUserId = uid || firebaseUid || userRef.id const orderId = `subscription_${targetSubscriptionId}_invoice_${invoice.id}` try { const { orderId: processedOrderId } = await createOrderDocument({ userId: targetUserId, type: ORDER_TYPES.SUBSCRIPTION, amount: coinsToGrant, metadata: { source: 'STRIPE_INVOICE', billingPeriod, coinsPerMonth, invoiceId: invoice.id || null, subscriptionId: targetSubscriptionId, grantStrategy: 'upfront', }, orderId, }) await paymentDocRef.set( { subscriptionCoinsGrantedAt: getServerTimestamp(), subscriptionCoinsGrantAmount: coinsToGrant, subscriptionCoinsGrantOrderId: processedOrderId, subscriptionCoinsGrantSource: 'invoice_upfront', }, { merge: true } ) await userRef.set( { subscriptionLastGrantAt: getServerTimestamp(), subscriptionLastGrantAmount: coinsToGrant, subscriptionLastGrantOrderId: processedOrderId, subscriptionLastGrantSource: 'invoice_upfront', subscriptionNextGrantAt: null, subscriptionGrantInterval: null, subscriptionGrantStrategy: 'upfront', subscriptionCoinsPerMonth: coinsPerMonth, }, { merge: true } ) } catch (error) { console.error( '[subscription-handleInvoiceEvent] Unable to grant upfront subscription coins', { invoiceId: invoice?.id || null, subscriptionId: targetSubscriptionId, error: error?.message || error, } ) } } } } const handleStripeWebhookEvent = async ({ event, stripe }) => { if (!event || typeof event !== 'object') { return } const eventType = event.type switch (eventType) { case 'checkout.session.completed': await handleCheckoutSessionCompleted(event.data?.object, event, { stripe, }) break case 'customer.subscription.created': case 'customer.subscription.updated': case 'customer.subscription.deleted': await handleCustomerSubscriptionEvent(event.data?.object, event, { stripe, }) break case 'invoice.payment_succeeded': case 'invoice.payment_failed': case 'invoice.finalized': await handleInvoiceEvent(event.data?.object, event, { stripe }) break default: console.log('[subscription-handleStripeWebhookEvent] Unhandled event type', eventType) } } const handleStripeWebhook = onRequest({ region: REGION }, async (req, res) => { if (req.method !== 'POST') { res.status(405).send('Method Not Allowed') return } const signature = req.headers['stripe-signature'] if (!signature) { res.status(400).send('Missing Stripe signature') return } let rawBody = req.rawBody if (!rawBody && req.body) { rawBody = Buffer.from(JSON.stringify(req.body)) } if (!rawBody) { res.status(400).send('Missing request body') return } let stripe = null try { stripe = getStripeClient() } catch (error) { console.error('[subscription-handleStripeWebhook] Stripe client error', error) res.status(500).send('Client Stripe indisponible') return } try { const event = stripe.webhooks.constructEvent(rawBody, signature, resolveStripeWebhookSecret()) await handleStripeWebhookEvent({ event, stripe }) res.status(200).send({ received: true }) } catch (error) { console.error('[subscription-handleStripeWebhook] error', error) if (error instanceof HttpsError) { res.status(400).send(error.message) return } res.status(500).send('Erreur lors du traitement du webhook') } }) module.exports = { handleStripeWebhook, }