319 lines
8.1 KiB
JavaScript
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,
|
|
};
|