802 lines
25 KiB
JavaScript
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,
|
|
}
|