Files
musicland/functions/src/subscription.js
T
Thomas Demirdjian 4bb083ea46 start adding payment
2025-11-04 14:46:12 +01:00

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;