Files
musicland/functions/src/subscription/webhooks.js
T
2026-02-25 12:06:08 +01:00

802 lines
25 KiB
JavaScript

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