stripe embedded and other fixes

This commit is contained in:
Thomas Demirdjian
2025-11-18 11:40:39 +01:00
parent 016f7c27ac
commit 7ab8685180
17 changed files with 2346 additions and 2504 deletions
+547
View File
@@ -0,0 +1,547 @@
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,
resolveStripeWebhookSecret,
getServerTimestamp,
toFirestoreTimestamp,
extractFirebaseUid,
buildEventSnapshot,
computeNextGrantTimestamp,
parseCoinsPerMonth,
getSubscriptionMetaFromPrice,
formatSubscriptionForClient,
buildSubscriptionPayload,
resolveUserContext,
upsertPaymentDocument,
} = require("./shared");
const {
SUBSCRIPTION_LEVEL_ALLOWANCES,
PREMIUM_SUBSCRIPTION_STATUSES,
} = require("./constants");
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 (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 },
);
}
}
}
};
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.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 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,
};