1127 lines
31 KiB
JavaScript
1127 lines
31 KiB
JavaScript
const admin = require("firebase-admin");
|
|
const { HttpsError, onCall } = require("firebase-functions/https");
|
|
const { onRequest } = require("firebase-functions/v2/https");
|
|
const { FieldValue } = require("firebase-admin/firestore");
|
|
const { refList } = require("../index");
|
|
const refsList = refList;
|
|
const {
|
|
getStripeClient,
|
|
buildCheckoutLineItems,
|
|
ensureStripeCustomer,
|
|
getReturnUrls,
|
|
formatCheckoutSessionResponse,
|
|
mapStripeErrorToHttps,
|
|
} = require("../helpers/stripe");
|
|
const { STRIPE_WEBHOOK_SECRET } = require("../config/keys");
|
|
|
|
const REGION = process.env.FIREBASE_REGION || "europe-west1";
|
|
|
|
const SUBSCRIPTION_PRICE_IDS = {
|
|
monthly: [
|
|
"price_1SPgitCzf2o5bDRdbnhLFx6f",
|
|
"price_1SPgjCCzf2o5bDRdr08Xzp8u",
|
|
"price_1SPgjaCzf2o5bDRdd9Xo2u26",
|
|
],
|
|
annual: [
|
|
"price_1SPgkDCzf2o5bDRdNGLVNeQ3",
|
|
"price_1SPgkXCzf2o5bDRdejBVxEBY",
|
|
"price_1SPgkqCzf2o5bDRdIcUwTDrm",
|
|
],
|
|
};
|
|
|
|
const ALL_SUBSCRIPTION_PRICE_IDS = Object.values(SUBSCRIPTION_PRICE_IDS).flat();
|
|
|
|
const SUBSCRIPTION_PRICE_METADATA = {
|
|
price_1SPgitCzf2o5bDRdbnhLFx6f: {
|
|
level: "starter",
|
|
billingPeriod: "monthly",
|
|
},
|
|
price_1SPgjCCzf2o5bDRdr08Xzp8u: {
|
|
level: "pro",
|
|
billingPeriod: "monthly",
|
|
},
|
|
price_1SPgjaCzf2o5bDRdd9Xo2u26: {
|
|
level: "premium",
|
|
billingPeriod: "monthly",
|
|
},
|
|
price_1SPgkDCzf2o5bDRdNGLVNeQ3: {
|
|
level: "starter",
|
|
billingPeriod: "annual",
|
|
},
|
|
price_1SPgkXCzf2o5bDRdejBVxEBY: {
|
|
level: "pro",
|
|
billingPeriod: "annual",
|
|
},
|
|
price_1SPgkqCzf2o5bDRdIcUwTDrm: {
|
|
level: "premium",
|
|
billingPeriod: "annual",
|
|
},
|
|
};
|
|
|
|
const COIN_PACK_PRODUCTS = [
|
|
{
|
|
productId: "prod_TMPPEXZ1wGk2cS",
|
|
key: "starter",
|
|
fallbackCoinAmount: 10,
|
|
},
|
|
{
|
|
productId: "prod_TMPQ5SNdS47gY6",
|
|
key: "pro",
|
|
fallbackCoinAmount: 40,
|
|
},
|
|
{
|
|
productId: "prod_TMPRpA0qQSHXj5",
|
|
key: "premium",
|
|
fallbackCoinAmount: 60,
|
|
},
|
|
];
|
|
|
|
const COIN_PACK_PRODUCT_IDS = COIN_PACK_PRODUCTS.map(
|
|
(pack) => pack.productId,
|
|
);
|
|
|
|
const COIN_PACK_PRODUCT_MAP = COIN_PACK_PRODUCTS.reduce(
|
|
(acc, pack) => ({
|
|
...acc,
|
|
[pack.productId]: pack,
|
|
}),
|
|
{},
|
|
);
|
|
|
|
const paymentsCollection = admin.firestore().collection("payments");
|
|
let cachedStripeWebhookSecret = null;
|
|
const PREMIUM_SUBSCRIPTION_STATUSES = new Set(["active", "trialing"]);
|
|
|
|
const getServerTimestamp = () => {
|
|
if (typeof FieldValue?.serverTimestamp === "function") {
|
|
return FieldValue.serverTimestamp();
|
|
}
|
|
if (admin.firestore?.FieldValue?.serverTimestamp) {
|
|
return admin.firestore.FieldValue.serverTimestamp();
|
|
}
|
|
throw new HttpsError(
|
|
"failed-precondition",
|
|
"Firestore FieldValue.serverTimestamp indisponible.",
|
|
);
|
|
};
|
|
|
|
const resolveStripeWebhookSecret = () => {
|
|
if (cachedStripeWebhookSecret) {
|
|
return cachedStripeWebhookSecret;
|
|
}
|
|
|
|
const envSecret =
|
|
typeof process?.env?.STRIPE_WEBHOOK_SECRET === "string"
|
|
? process.env.STRIPE_WEBHOOK_SECRET.trim()
|
|
: "";
|
|
const inlineSecret =
|
|
typeof STRIPE_WEBHOOK_SECRET === "string"
|
|
? STRIPE_WEBHOOK_SECRET.trim()
|
|
: "";
|
|
|
|
const secret = envSecret || inlineSecret;
|
|
if (!secret) {
|
|
throw new Error("STRIPE_WEBHOOK_SECRET not configured");
|
|
}
|
|
|
|
cachedStripeWebhookSecret = secret;
|
|
return cachedStripeWebhookSecret;
|
|
};
|
|
|
|
const toFirestoreTimestamp = (unixSeconds) => {
|
|
if (typeof unixSeconds !== "number" || !Number.isFinite(unixSeconds)) {
|
|
return null;
|
|
}
|
|
try {
|
|
return admin.firestore.Timestamp.fromMillis(unixSeconds * 1000);
|
|
} catch (error) {
|
|
console.error(
|
|
"[subscription-toFirestoreTimestamp] Conversion error",
|
|
unixSeconds,
|
|
error,
|
|
);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const extractFirebaseUid = (metadata) => {
|
|
if (!metadata || typeof metadata !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const candidates = [
|
|
metadata.firebaseUID,
|
|
metadata.firebaseUid,
|
|
metadata.uid,
|
|
metadata.userId,
|
|
];
|
|
|
|
for (let index = 0; index < candidates.length; index += 1) {
|
|
const candidate = candidates[index];
|
|
if (typeof candidate === "string" && candidate.trim()) {
|
|
return candidate.trim();
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const findUserByStripeCustomerId = async (customerId) => {
|
|
if (!customerId || !refList?.users) {
|
|
return { uid: null, userRef: null, userData: null };
|
|
}
|
|
|
|
try {
|
|
const snapshot = await refList.users
|
|
.where("stripeCustomerId", "==", customerId)
|
|
.limit(1)
|
|
.get();
|
|
|
|
const doc = snapshot?.docs?.[0];
|
|
if (doc?.id) {
|
|
return {
|
|
uid: doc.id,
|
|
userRef: refList.users.doc(doc.id),
|
|
userData: doc.data() || null,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
"[subscription-findUserByStripeCustomerId] Query failed",
|
|
customerId,
|
|
error,
|
|
);
|
|
}
|
|
|
|
return { uid: null, userRef: null, userData: null };
|
|
};
|
|
|
|
const resolveUserContext = async ({ metadata, customerId }) => {
|
|
const metadataUid = extractFirebaseUid(metadata);
|
|
if (metadataUid && refList?.users) {
|
|
return {
|
|
uid: metadataUid,
|
|
userRef: refList.users.doc(metadataUid),
|
|
userData: null,
|
|
};
|
|
}
|
|
|
|
return findUserByStripeCustomerId(customerId);
|
|
};
|
|
|
|
const upsertPaymentDocument = async (docId, data = {}) => {
|
|
if (!docId) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const docRef = paymentsCollection.doc(docId);
|
|
const snapshot = await docRef.get();
|
|
|
|
const payload = {
|
|
...data,
|
|
updatedAt: getServerTimestamp(),
|
|
};
|
|
|
|
if (!snapshot.exists) {
|
|
payload.createdAt = getServerTimestamp();
|
|
}
|
|
|
|
await docRef.set(payload, { merge: true });
|
|
return docRef;
|
|
} catch (error) {
|
|
console.error(
|
|
"[subscription-upsertPaymentDocument] Failed to persist payment",
|
|
docId,
|
|
error,
|
|
);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const buildEventSnapshot = (type, referenceId) => ({
|
|
type: type || null,
|
|
referenceId: referenceId || null,
|
|
receivedAt: getServerTimestamp(),
|
|
});
|
|
|
|
const buildSubscriptionPayload = (subscription) => {
|
|
if (!subscription || typeof subscription !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const itemList = Array.isArray(subscription?.items?.data)
|
|
? subscription.items.data
|
|
: [];
|
|
|
|
const items = itemList.map((item) => {
|
|
const stripePrice =
|
|
item?.price && typeof item.price === "object" ? item.price : null;
|
|
return {
|
|
id: item?.id || null,
|
|
priceId:
|
|
stripePrice?.id ||
|
|
(typeof item?.price === "string" ? item.price : null) ||
|
|
null,
|
|
productId: stripePrice?.product || null,
|
|
currency: stripePrice?.currency || null,
|
|
unitAmount: stripePrice?.unit_amount ?? null,
|
|
recurring: stripePrice?.recurring || null,
|
|
quantity: item?.quantity ?? null,
|
|
};
|
|
});
|
|
|
|
const primaryItem = items[0] || null;
|
|
|
|
return {
|
|
id: subscription.id || null,
|
|
status: subscription.status || null,
|
|
customerId: subscription.customer || null,
|
|
priceId: primaryItem?.priceId || null,
|
|
productId: primaryItem?.productId || null,
|
|
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
|
|
cancelAt: toFirestoreTimestamp(subscription.cancel_at),
|
|
canceledAt: toFirestoreTimestamp(subscription.canceled_at),
|
|
createdAt: toFirestoreTimestamp(subscription.created),
|
|
currentPeriodStart: toFirestoreTimestamp(subscription.current_period_start),
|
|
currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end),
|
|
endedAt: toFirestoreTimestamp(subscription.ended_at),
|
|
trialStart: toFirestoreTimestamp(subscription.trial_start),
|
|
trialEnd: toFirestoreTimestamp(subscription.trial_end),
|
|
latestInvoiceId: subscription.latest_invoice || null,
|
|
items,
|
|
metadata: subscription.metadata || {},
|
|
};
|
|
};
|
|
|
|
const handleCheckoutSessionCompleted = async (session, event) => {
|
|
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 { 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 (
|
|
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) {
|
|
await userRef.set(
|
|
{
|
|
coins: FieldValue.increment(coinAmount),
|
|
},
|
|
{ merge: true },
|
|
);
|
|
|
|
await paymentDocRef.set(
|
|
{
|
|
coinPackGrantedAt: getServerTimestamp(),
|
|
coinPackGrantedAmount: coinAmount,
|
|
coinPackGrantedKey: session.metadata?.coinPackKey || null,
|
|
},
|
|
{ merge: true },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleCustomerSubscriptionEvent = async (subscription, event) => {
|
|
if (!subscription || typeof subscription !== "object") {
|
|
return;
|
|
}
|
|
|
|
const subscriptionPayload = buildSubscriptionPayload(subscription);
|
|
|
|
await upsertPaymentDocument(subscription.id, {
|
|
customerId: subscription.customer || null,
|
|
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 || {},
|
|
lastEventType: event?.type || null,
|
|
lastEventId: event?.id || null,
|
|
lastEventAt: getServerTimestamp(),
|
|
});
|
|
|
|
const { uid, userRef } = await resolveUserContext({
|
|
metadata: subscription.metadata,
|
|
customerId: subscription.customer,
|
|
});
|
|
|
|
if (!userRef) {
|
|
console.warn(
|
|
"[subscription-handleCustomerSubscriptionEvent] User not resolved",
|
|
subscription.customer,
|
|
event?.type,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const lastEvent = buildEventSnapshot(event?.type, subscription.id);
|
|
if (uid) {
|
|
lastEvent.uid = uid;
|
|
}
|
|
|
|
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 userUpdate = {
|
|
stripeSubscription: subscriptionPayload,
|
|
stripeSubscriptionStatus: subscription.status || null,
|
|
stripeSubscriptionUpdatedAt: getServerTimestamp(),
|
|
isPremium,
|
|
lastStripeWebhookEvent: lastEvent,
|
|
};
|
|
|
|
if (subscriptionPayload?.customerId) {
|
|
userUpdate.stripeCustomerId = subscriptionPayload.customerId;
|
|
}
|
|
|
|
if (resolvedLevel && isPremium) {
|
|
userUpdate.premiumLevel = resolvedLevel;
|
|
} else if (!isPremium) {
|
|
userUpdate.premiumLevel = null;
|
|
}
|
|
|
|
if (isPremium && resolvedPeriod) {
|
|
userUpdate.premiumBillingPeriod = resolvedPeriod;
|
|
} else if (!isPremium) {
|
|
userUpdate.premiumBillingPeriod = null;
|
|
}
|
|
|
|
await userRef.set(userUpdate, { merge: true });
|
|
};
|
|
|
|
const handleInvoiceEvent = async (invoice, event) => {
|
|
if (!invoice || typeof invoice !== "object") {
|
|
return;
|
|
}
|
|
|
|
const firebaseUid = extractFirebaseUid(invoice.metadata);
|
|
const eventType = event?.type || null;
|
|
|
|
await upsertPaymentDocument(invoice.id, {
|
|
userId: firebaseUid || null,
|
|
customerId: invoice.customer || null,
|
|
subscriptionId: invoice.subscription || null,
|
|
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 { userRef } = await resolveUserContext({
|
|
metadata: invoice.metadata,
|
|
customerId: invoice.customer,
|
|
});
|
|
|
|
if (userRef) {
|
|
const lastEvent = buildEventSnapshot(eventType, invoice.id);
|
|
if (firebaseUid) {
|
|
lastEvent.uid = firebaseUid;
|
|
}
|
|
await userRef.set(
|
|
{
|
|
lastStripeWebhookEvent: lastEvent,
|
|
},
|
|
{ merge: true },
|
|
);
|
|
}
|
|
};
|
|
|
|
const handlePaymentIntentEvent = async (paymentIntent, event) => {
|
|
if (!paymentIntent || typeof paymentIntent !== "object") {
|
|
return;
|
|
}
|
|
|
|
const firebaseUid = extractFirebaseUid(paymentIntent.metadata);
|
|
|
|
await upsertPaymentDocument(paymentIntent.id, {
|
|
userId: firebaseUid || null,
|
|
customerId: paymentIntent.customer || null,
|
|
status: paymentIntent.status || null,
|
|
paymentStatus: paymentIntent.status || null,
|
|
amountTotal: paymentIntent.amount ?? null,
|
|
amountReceived: paymentIntent.amount_received ?? null,
|
|
currency: paymentIntent.currency || null,
|
|
description: paymentIntent.description || null,
|
|
metadata: paymentIntent.metadata || {},
|
|
latestChargeId: paymentIntent.latest_charge || null,
|
|
paymentMethodId:
|
|
typeof paymentIntent.payment_method === "string"
|
|
? paymentIntent.payment_method
|
|
: null,
|
|
createdAtFromStripe: toFirestoreTimestamp(paymentIntent.created),
|
|
succeededAt: toFirestoreTimestamp(
|
|
paymentIntent.status_transitions?.succeeded_at,
|
|
),
|
|
canceledAt: toFirestoreTimestamp(
|
|
paymentIntent.status_transitions?.canceled_at,
|
|
),
|
|
lastEventType: event?.type || null,
|
|
lastEventId: event?.id || null,
|
|
lastEventAt: getServerTimestamp(),
|
|
mode: "payment",
|
|
});
|
|
|
|
const { userRef } = await resolveUserContext({
|
|
metadata: paymentIntent.metadata,
|
|
customerId: paymentIntent.customer,
|
|
});
|
|
|
|
if (userRef) {
|
|
const lastEvent = buildEventSnapshot(event?.type, paymentIntent.id);
|
|
if (firebaseUid) {
|
|
lastEvent.uid = firebaseUid;
|
|
}
|
|
await userRef.set(
|
|
{
|
|
lastStripeWebhookEvent: lastEvent,
|
|
},
|
|
{ merge: true },
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleStripeWebhookEvent = async ({ event }) => {
|
|
if (!event || typeof event !== "object") {
|
|
return;
|
|
}
|
|
|
|
switch (event.type) {
|
|
case "checkout.session.completed":
|
|
await handleCheckoutSessionCompleted(event.data?.object, event);
|
|
break;
|
|
case "customer.subscription.created":
|
|
case "customer.subscription.updated":
|
|
case "customer.subscription.deleted":
|
|
await handleCustomerSubscriptionEvent(event.data?.object, event);
|
|
break;
|
|
case "invoice.paid":
|
|
case "invoice.payment_failed":
|
|
await handleInvoiceEvent(event.data?.object, event);
|
|
break;
|
|
case "payment_intent.succeeded":
|
|
case "payment_intent.payment_failed":
|
|
await handlePaymentIntentEvent(event.data?.object, event);
|
|
break;
|
|
default:
|
|
console.log(
|
|
"[subscription-handleStripeWebhookEvent] Unhandled event type",
|
|
event.type,
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleStripeWebhook = onRequest({ region: REGION }, async (req, res) => {
|
|
if (req.method !== "POST") {
|
|
res.set("Allow", "POST");
|
|
res.status(405).send("Méthode non autorisée");
|
|
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;
|
|
}
|
|
|
|
const signature = req.headers["stripe-signature"];
|
|
if (!signature) {
|
|
res.status(400).send("En-tête stripe-signature manquant");
|
|
return;
|
|
}
|
|
|
|
let rawBody = null;
|
|
if (Buffer.isBuffer(req.rawBody)) {
|
|
rawBody = req.rawBody;
|
|
} else {
|
|
console.warn(
|
|
"[subscription-handleStripeWebhook] rawBody indisponible, utilisation d'un fallback JSON stringify",
|
|
);
|
|
rawBody = Buffer.from(JSON.stringify(req.body || {}));
|
|
}
|
|
|
|
let event = null;
|
|
try {
|
|
const webhookSecret = resolveStripeWebhookSecret();
|
|
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
|
} catch (error) {
|
|
console.error(
|
|
"[subscription-handleStripeWebhook] Signature invalide",
|
|
error?.message || error,
|
|
);
|
|
res.status(400).send(`Signature invalide: ${error?.message || "Webhook"}`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await handleStripeWebhookEvent({ event, stripe });
|
|
res.status(200).json({ received: true });
|
|
} catch (error) {
|
|
console.error(
|
|
"[subscription-handleStripeWebhook] Traitement échoué",
|
|
event?.id,
|
|
error,
|
|
);
|
|
res.status(500).send("Erreur lors du traitement du webhook");
|
|
}
|
|
});
|
|
|
|
const sanitizePriceId = (value) => {
|
|
if (typeof value !== "string") {
|
|
return "";
|
|
}
|
|
return value.trim();
|
|
};
|
|
|
|
const formatPlan = (price, priceId) => {
|
|
if (!price || typeof price !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const product =
|
|
typeof price.product === "object" && price.product !== null
|
|
? price.product
|
|
: {};
|
|
|
|
return {
|
|
id: price.id || priceId,
|
|
priceId: price.id || priceId,
|
|
active: price.active !== false,
|
|
currency: price.currency || "eur",
|
|
unitAmount: price.unit_amount,
|
|
unitAmountDecimal: price.unit_amount_decimal,
|
|
transformQuantity: price.transform_quantity || null,
|
|
recurring: price.recurring || null,
|
|
nickname: price.nickname || null,
|
|
billingScheme: price.billing_scheme || null,
|
|
product: {
|
|
id: product.id || null,
|
|
name: product.name || "",
|
|
description: product.description || "",
|
|
metadata: product.metadata || {},
|
|
},
|
|
};
|
|
};
|
|
|
|
const formatCoinPack = ({ product, price, fallback = {} }) => {
|
|
if (!product || typeof product !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const resolvedPrice =
|
|
price ||
|
|
(typeof product.default_price === "object" && product.default_price) ||
|
|
null;
|
|
|
|
const priceId =
|
|
(resolvedPrice && resolvedPrice.id) ||
|
|
(typeof product.default_price === "string"
|
|
? product.default_price
|
|
: null);
|
|
|
|
const rawCoinAmount =
|
|
product?.metadata?.coins ??
|
|
product?.metadata?.coinAmount ??
|
|
fallback.fallbackCoinAmount ??
|
|
0;
|
|
|
|
const coinAmount =
|
|
typeof rawCoinAmount === "string"
|
|
? parseInt(rawCoinAmount, 10)
|
|
: Number(rawCoinAmount);
|
|
|
|
return {
|
|
productId: product.id,
|
|
priceId,
|
|
name: product.name || "",
|
|
description: product.description || "",
|
|
coinAmount:
|
|
Number.isFinite(coinAmount) && coinAmount > 0 ? coinAmount : 0,
|
|
currency:
|
|
resolvedPrice?.currency ||
|
|
(typeof resolvedPrice?.currency === "string"
|
|
? resolvedPrice.currency.toLowerCase()
|
|
: "eur"),
|
|
unitAmount: resolvedPrice?.unit_amount ?? null,
|
|
metadata: product.metadata || {},
|
|
};
|
|
};
|
|
|
|
const getSubscriptionMetaFromPrice = (priceId) => {
|
|
if (typeof priceId !== "string") {
|
|
return null;
|
|
}
|
|
return SUBSCRIPTION_PRICE_METADATA[priceId] || null;
|
|
};
|
|
|
|
const listSubscriptionPlans = onCall({ region: REGION }, async () => {
|
|
try {
|
|
const stripe = getStripeClient();
|
|
|
|
const entries = await Promise.all(
|
|
Object.entries(SUBSCRIPTION_PRICE_IDS).map(async ([period, priceIds]) => {
|
|
const periodPlans = await Promise.all(
|
|
priceIds.map(async (priceId) => {
|
|
try {
|
|
const price = await stripe.prices.retrieve(priceId, {
|
|
expand: ["product"],
|
|
});
|
|
return formatPlan(price, priceId);
|
|
} catch (error) {
|
|
console.error(
|
|
`[subscription-listSubscriptionPlans] Impossible de récupérer ${priceId}`,
|
|
error?.message || error,
|
|
);
|
|
return null;
|
|
}
|
|
}),
|
|
);
|
|
|
|
return [period, periodPlans.filter(Boolean)];
|
|
}),
|
|
);
|
|
|
|
return {
|
|
plans: Object.fromEntries(entries),
|
|
};
|
|
} catch (error) {
|
|
console.error("[subscription-listSubscriptionPlans] error", error);
|
|
throw mapStripeErrorToHttps(
|
|
error,
|
|
"Impossible de récupérer les abonnements Stripe.",
|
|
);
|
|
}
|
|
});
|
|
|
|
const listCoinPacks = onCall({ region: REGION }, async () => {
|
|
try {
|
|
const stripe = getStripeClient();
|
|
|
|
const packs = await Promise.all(
|
|
COIN_PACK_PRODUCTS.map(async (pack) => {
|
|
try {
|
|
const product = await stripe.products.retrieve(pack.productId, {
|
|
expand: ["default_price"],
|
|
});
|
|
|
|
let resolvedPrice = null;
|
|
if (typeof product?.default_price === "string") {
|
|
resolvedPrice = await stripe.prices.retrieve(
|
|
product.default_price,
|
|
);
|
|
} else if (
|
|
product?.default_price &&
|
|
typeof product.default_price === "object"
|
|
) {
|
|
resolvedPrice = product.default_price;
|
|
}
|
|
|
|
return formatCoinPack({
|
|
product,
|
|
price: resolvedPrice,
|
|
fallback: pack,
|
|
});
|
|
} catch (error) {
|
|
console.error(
|
|
"[subscription-listCoinPacks] Unable to retrieve product",
|
|
pack.productId,
|
|
error?.message || error,
|
|
);
|
|
return null;
|
|
}
|
|
}),
|
|
);
|
|
|
|
return {
|
|
packs: packs.filter(Boolean),
|
|
};
|
|
} catch (error) {
|
|
console.error("[subscription-listCoinPacks] error", error);
|
|
throw mapStripeErrorToHttps(
|
|
error,
|
|
"Impossible de récupérer les packs de pièces.",
|
|
);
|
|
}
|
|
});
|
|
|
|
const createSubscriptionCheckoutSession = onCall(
|
|
{ region: REGION },
|
|
async (request) => {
|
|
try {
|
|
const uid = request?.auth?.uid;
|
|
if (!uid) {
|
|
throw new HttpsError(
|
|
"unauthenticated",
|
|
"Connecte-toi pour souscrire un abonnement.",
|
|
);
|
|
}
|
|
|
|
const rawPriceId = request?.data?.priceId;
|
|
const priceId = sanitizePriceId(rawPriceId);
|
|
if (!priceId) {
|
|
throw new HttpsError(
|
|
"invalid-argument",
|
|
"Un identifiant de prix Stripe est requis.",
|
|
);
|
|
}
|
|
|
|
if (!ALL_SUBSCRIPTION_PRICE_IDS.includes(priceId)) {
|
|
throw new HttpsError(
|
|
"invalid-argument",
|
|
`L'identifiant de prix ${priceId} n'est pas pris en charge.`,
|
|
);
|
|
}
|
|
|
|
const stripe = getStripeClient();
|
|
|
|
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {};
|
|
|
|
const { lineItems, summary } = await buildCheckoutLineItems(
|
|
[
|
|
{
|
|
priceID: priceId,
|
|
quantity: 1,
|
|
isRenewable: true,
|
|
},
|
|
],
|
|
{ stripe },
|
|
);
|
|
|
|
const { successUrl, cancelUrl } = getReturnUrls(
|
|
request?.data?.returnUrls,
|
|
);
|
|
|
|
const { customerId } = await ensureStripeCustomer({
|
|
uid,
|
|
stripe,
|
|
refsList: refList,
|
|
createIfMissing: true,
|
|
});
|
|
|
|
if (!customerId) {
|
|
throw new HttpsError(
|
|
"failed-precondition",
|
|
"Impossible de retrouver le client Stripe associé.",
|
|
);
|
|
}
|
|
|
|
const session = await stripe.checkout.sessions.create({
|
|
mode: "subscription",
|
|
customer: customerId,
|
|
line_items: lineItems,
|
|
success_url: successUrl,
|
|
cancel_url: cancelUrl,
|
|
allow_promotion_codes: true,
|
|
metadata: {
|
|
firebaseUID: uid,
|
|
priceId,
|
|
purchaseType: "SUBSCRIPTION",
|
|
subscriptionLevel: priceMeta.level || null,
|
|
subscriptionBillingPeriod: priceMeta.billingPeriod || null,
|
|
},
|
|
});
|
|
|
|
await paymentsCollection.doc(session.id).set(
|
|
{
|
|
userId: uid,
|
|
customerId,
|
|
status: session.status || "created",
|
|
mode: "subscription",
|
|
createdAt: getServerTimestamp(),
|
|
updatedAt: getServerTimestamp(),
|
|
sessionId: session.id,
|
|
sessionUrl: session.url,
|
|
lineItems: summary,
|
|
amountSubtotal: session.amount_subtotal,
|
|
amountTotal: session.amount_total,
|
|
currency: session.currency,
|
|
paymentStatus: session.payment_status,
|
|
metadata: session.metadata || {},
|
|
},
|
|
{ merge: true },
|
|
);
|
|
|
|
return formatCheckoutSessionResponse(session);
|
|
} catch (error) {
|
|
console.error(
|
|
"[subscription-createSubscriptionCheckoutSession] error",
|
|
error,
|
|
);
|
|
if (error instanceof HttpsError) {
|
|
throw error;
|
|
}
|
|
throw mapStripeErrorToHttps(
|
|
error,
|
|
"Impossible de créer la session d'abonnement Stripe.",
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
const createCoinPackCheckoutSession = onCall(
|
|
{ region: REGION },
|
|
async (request) => {
|
|
try {
|
|
const uid = request?.auth?.uid;
|
|
if (!uid) {
|
|
throw new HttpsError(
|
|
"unauthenticated",
|
|
"Connecte-toi pour acheter un pack de pièces.",
|
|
);
|
|
}
|
|
|
|
const rawProductId = request?.data?.productId;
|
|
const productId =
|
|
typeof rawProductId === "string" ? rawProductId.trim() : "";
|
|
|
|
if (!productId) {
|
|
throw new HttpsError(
|
|
"invalid-argument",
|
|
"Un identifiant de produit Stripe est requis.",
|
|
);
|
|
}
|
|
|
|
if (!COIN_PACK_PRODUCT_IDS.includes(productId)) {
|
|
throw new HttpsError(
|
|
"invalid-argument",
|
|
`Le produit ${productId} n'est pas un pack de pièces autorisé`,
|
|
);
|
|
}
|
|
|
|
const stripe = getStripeClient();
|
|
|
|
const product = await stripe.products.retrieve(productId, {
|
|
expand: ["default_price"],
|
|
});
|
|
|
|
let resolvedPrice = null;
|
|
if (typeof product?.default_price === "string") {
|
|
resolvedPrice = await stripe.prices.retrieve(product.default_price);
|
|
} else if (
|
|
product?.default_price &&
|
|
typeof product.default_price === "object"
|
|
) {
|
|
resolvedPrice = product.default_price;
|
|
}
|
|
|
|
const coinPack = formatCoinPack({
|
|
product,
|
|
price: resolvedPrice,
|
|
fallback: COIN_PACK_PRODUCT_MAP[productId],
|
|
});
|
|
|
|
if (!coinPack?.priceId) {
|
|
throw new HttpsError(
|
|
"failed-precondition",
|
|
"Impossible de déterminer le prix Stripe pour ce pack.",
|
|
);
|
|
}
|
|
|
|
const { successUrl, cancelUrl } = getReturnUrls(
|
|
request?.data?.returnUrls,
|
|
);
|
|
|
|
const { customerId } = await ensureStripeCustomer({
|
|
uid,
|
|
stripe,
|
|
refsList,
|
|
createIfMissing: true,
|
|
});
|
|
|
|
if (!customerId) {
|
|
throw new HttpsError(
|
|
"failed-precondition",
|
|
"Impossible de retrouver le client Stripe associé.",
|
|
);
|
|
}
|
|
|
|
const session = await stripe.checkout.sessions.create({
|
|
mode: "payment",
|
|
customer: customerId,
|
|
line_items: [
|
|
{
|
|
price: coinPack.priceId,
|
|
quantity: 1,
|
|
},
|
|
],
|
|
success_url: successUrl,
|
|
cancel_url: cancelUrl,
|
|
allow_promotion_codes: false,
|
|
metadata: {
|
|
firebaseUID: uid,
|
|
purchaseType: "COIN_PACK",
|
|
coinPackProductId: coinPack.productId,
|
|
coinPackPriceId: coinPack.priceId,
|
|
coinAmount: coinPack.coinAmount,
|
|
coinPackKey: COIN_PACK_PRODUCT_MAP[productId]?.key || null,
|
|
},
|
|
});
|
|
|
|
await paymentsCollection.doc(session.id).set(
|
|
{
|
|
userId: uid,
|
|
customerId,
|
|
status: session.status || "created",
|
|
mode: "payment",
|
|
createdAt: getServerTimestamp(),
|
|
updatedAt: getServerTimestamp(),
|
|
sessionId: session.id,
|
|
sessionUrl: session.url,
|
|
lineItems: [
|
|
{
|
|
productId: coinPack.productId,
|
|
priceId: coinPack.priceId,
|
|
quantity: 1,
|
|
coinAmount: coinPack.coinAmount,
|
|
},
|
|
],
|
|
amountSubtotal: session.amount_subtotal,
|
|
amountTotal: session.amount_total,
|
|
currency: session.currency,
|
|
paymentStatus: session.payment_status,
|
|
metadata: session.metadata || {},
|
|
},
|
|
{ merge: true },
|
|
);
|
|
|
|
return formatCheckoutSessionResponse(session);
|
|
} catch (error) {
|
|
console.error(
|
|
"[subscription-createCoinPackCheckoutSession] error",
|
|
error,
|
|
);
|
|
if (error instanceof HttpsError) {
|
|
throw error;
|
|
}
|
|
throw mapStripeErrorToHttps(
|
|
error,
|
|
"Impossible de créer la session d'achat de pièces.",
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
exports.listSubscriptionPlans = listSubscriptionPlans;
|
|
exports.createSubscriptionCheckoutSession = createSubscriptionCheckoutSession;
|
|
exports.listCoinPacks = listCoinPacks;
|
|
exports.createCoinPackCheckoutSession = createCoinPackCheckoutSession;
|
|
exports.handleStripeWebhook = handleStripeWebhook;
|