Files
musicland/functions/src/subscription/checkout.js
T
Thomas Demirdjian fe9b2b9e8d fix return url
2025-11-18 14:09:32 +01:00

319 lines
8.1 KiB
JavaScript

const { HttpsError, onCall } = require("firebase-functions/https");
const {
getStripeClient,
buildCheckoutLineItems,
ensureStripeCustomer,
getReturnUrls,
formatCheckoutSessionResponse,
mapStripeErrorToHttps,
} = require("../../helpers/stripe");
const { REGION } = require("./config");
const {
ALL_SUBSCRIPTION_PRICE_IDS,
COIN_PACK_PRODUCT_IDS,
COIN_PACK_PRODUCT_MAP,
} = require("./constants");
const {
refsList,
formatCoinPack,
getSubscriptionMetaFromPrice,
} = require("./shared");
const CHECKOUT_UI_MODES = new Set(["hosted", "embedded"]);
const sanitizePriceId = (value) => {
if (typeof value !== "string") {
return "";
}
return value.trim();
};
const resolveCheckoutUiMode = (request) => {
if (!request || !request.data || typeof request.data.uiMode === "undefined") {
return "hosted";
}
if (typeof request.data.uiMode !== "string") {
throw new HttpsError(
"invalid-argument",
'uiMode doit être une chaîne de caractères ("hosted" ou "embedded").',
);
}
const normalizedUiMode = request.data.uiMode.trim().toLowerCase();
if (!CHECKOUT_UI_MODES.has(normalizedUiMode)) {
throw new HttpsError(
"invalid-argument",
`uiMode "${request.data.uiMode}" n'est pas supporté pour Stripe Checkout.`,
);
}
return normalizedUiMode;
};
const withCheckoutNavigationParams = (
baseParams,
{ uiMode, successUrl, cancelUrl },
) => {
if (uiMode === "embedded") {
return {
...baseParams,
ui_mode: "embedded",
return_url: undefined,
redirect_on_completion: "never",
};
}
return {
...baseParams,
success_url: successUrl,
cancel_url: cancelUrl,
};
};
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 uiMode = resolveCheckoutUiMode(request);
const shouldProvideReturnUrls = uiMode !== "embedded";
const { successUrl, cancelUrl } = shouldProvideReturnUrls
? getReturnUrls(request?.data?.returnUrls)
: { successUrl: null, cancelUrl: null };
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(
withCheckoutNavigationParams(
{
mode: "subscription",
customer: customerId,
line_items: lineItems,
allow_promotion_codes: true,
metadata: {
firebaseUID: uid,
priceId,
purchaseType: "SUBSCRIPTION",
subscriptionLevel: priceMeta.level || null,
subscriptionBillingPeriod: priceMeta.billingPeriod || null,
},
},
{
uiMode,
successUrl,
cancelUrl,
},
),
);
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;
}
let coinPack = null;
try {
coinPack = formatCoinPack({
product,
price: resolvedPrice,
});
} catch (error) {
console.error(
"[subscription-createCoinPackCheckoutSession] invalid coin pack metadata",
productId,
error?.message || error,
);
throw new HttpsError(
"failed-precondition",
"Le pack Stripe est mal configuré (metadata.coins manquant).",
);
}
if (!coinPack?.priceId) {
throw new HttpsError(
"failed-precondition",
"Impossible de déterminer le prix Stripe pour ce pack.",
);
}
const uiMode = resolveCheckoutUiMode(request);
const shouldProvideReturnUrls = uiMode !== "embedded";
const { successUrl, cancelUrl } = shouldProvideReturnUrls
? getReturnUrls(request?.data?.returnUrls)
: { successUrl: null, cancelUrl: null };
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(
withCheckoutNavigationParams(
{
mode: "payment",
customer: customerId,
line_items: [
{
price: coinPack.priceId,
quantity: 1,
},
],
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,
},
},
{
uiMode,
successUrl,
cancelUrl,
},
),
);
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.",
);
}
},
);
module.exports = {
createSubscriptionCheckoutSession,
createCoinPackCheckoutSession,
resolveCheckoutUiMode,
};