stripe embedded and other fixes
This commit is contained in:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user