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

482 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const admin = require("firebase-admin");
const { HttpsError } = require("firebase-functions/https");
const Stripe = require("stripe");
const { URL } = require("url");
const {
STRIPE_SECRET_KEY = "",
STRIPE_RETURN_URL = "",
STRIPE_PORTAL_CONFIGURATION = "",
STRIPE_MODE: CONFIG_STRIPE_MODE,
} = require("../config/keys");
const STRIPE_MODE =
typeof CONFIG_STRIPE_MODE === "string" && CONFIG_STRIPE_MODE.trim()
? CONFIG_STRIPE_MODE.trim()
: "test";
const requireEnv = (key) => {
const value = process.env?.[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
throw new Error(`${key} not configured`);
};
const STRIPE_API_VERSION = "2023-10-16";
const DEFAULT_TEST_RETURN_URL = "http://localhost:8081";
const ALLOWED_RETURN_SCHEMES = ["http", "https", "minuit"];
let cachedStripeClient = null;
let cachedPortalConfigurationId = null;
const resolveStripeSecretKey = () => {
const inlineKey =
typeof STRIPE_SECRET_KEY === "string" ? STRIPE_SECRET_KEY.trim() : "";
if (inlineKey) {
return inlineKey;
}
const required = requireEnv("STRIPE_SECRET_KEY");
if (typeof required === "string" && required.trim()) {
return required.trim();
}
throw new Error("STRIPE_SECRET_KEY not configured");
};
const getStripeClient = () => {
if (cachedStripeClient) {
return cachedStripeClient;
}
let secretKey;
try {
secretKey = resolveStripeSecretKey();
} catch (error) {
console.error("[getStripeClient] Missing STRIPE_SECRET_KEY", error);
throw new HttpsError(
"failed-precondition",
"Stripe nest pas configuré. Ajoute STRIPE_SECRET_KEY pour activer cette fonctionnalité.",
);
}
cachedStripeClient = new Stripe(secretKey, {
apiVersion: STRIPE_API_VERSION,
});
return cachedStripeClient;
};
const getReturnBaseUrl = () => {
const resolveBase = () => {
if (STRIPE_RETURN_URL) {
return STRIPE_RETURN_URL;
}
if (STRIPE_MODE !== "prod") {
return DEFAULT_TEST_RETURN_URL;
}
return requireEnv("STRIPE_RETURN_URL");
};
const rawBase = resolveBase();
const sanitizedBase = typeof rawBase === "string" ? rawBase.trim() : "";
if (!sanitizedBase) {
if (STRIPE_MODE !== "prod") {
return DEFAULT_TEST_RETURN_URL;
}
throw new Error("STRIPE_RETURN_URL not configured");
}
return sanitizedBase.endsWith("/")
? sanitizedBase.slice(0, -1)
: sanitizedBase;
};
const sanitizeReturnUrl = (value) => {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const validateScheme = (scheme) => {
if (!ALLOWED_RETURN_SCHEMES.includes(scheme)) {
throw new HttpsError(
"invalid-argument",
`Le schéma d'URL "${scheme}" n'est pas autorisé pour les retours Stripe.`,
);
}
};
try {
const parsedUrl = new URL(trimmed);
const scheme = parsedUrl.protocol.replace(":", "").toLowerCase();
validateScheme(scheme);
return trimmed;
} catch (_error) {
const schemeMatch = trimmed.match(/^([a-z][a-z0-9+\-.]*):\/\//i);
if (schemeMatch && schemeMatch[1]) {
const scheme = schemeMatch[1].toLowerCase();
validateScheme(scheme);
return trimmed;
}
throw new HttpsError(
"invalid-argument",
`URL de retour Stripe invalide: ${trimmed}`,
);
}
};
const getReturnUrls = (overrides) => {
if (overrides && typeof overrides === "object") {
const successOverride = sanitizeReturnUrl(overrides.successUrl);
const cancelOverride = sanitizeReturnUrl(overrides.cancelUrl);
if (!successOverride) {
throw new HttpsError(
"invalid-argument",
"successUrl est requis pour configurer les retours Stripe.",
);
}
return {
successUrl: successOverride,
cancelUrl: cancelOverride || successOverride,
};
}
const baseUrl = getReturnBaseUrl();
const joinPath = (url, path) => {
const trimmedUrl = url.endsWith("/") ? url.slice(0, -1) : url;
const trimmedPath = path.startsWith("/") ? path.slice(1) : path;
return `${trimmedUrl}/${trimmedPath}`;
};
const appendQuery = (url, query) =>
url.includes("?") ? `${url}&${query}` : `${url}?${query}`;
const successBase = joinPath(baseUrl, "payment-success");
const cancelBase = joinPath(baseUrl, "payment-error");
return {
successUrl: appendQuery(successBase, "session_id={CHECKOUT_SESSION_ID}"),
cancelUrl: appendQuery(cancelBase, "session_id={CHECKOUT_SESSION_ID}"),
};
};
const normalizeBoolean = (value) => value === true;
const buildCheckoutLineItems = async (productList, { stripe } = {}) => {
if (!Array.isArray(productList) || productList.length === 0) {
throw new HttpsError(
"invalid-argument",
"Au moins un produit est requis pour créer une session de paiement.",
);
}
const lineItems = [];
const summary = [];
let hasSubscription = false;
for (let index = 0; index < productList.length; index += 1) {
const rawItem = productList[index];
const item = rawItem && typeof rawItem === "object" ? rawItem : {};
const isRenewable = normalizeBoolean(item.isRenewable);
const rawQuantity =
typeof item.quantity === "number" && Number.isFinite(item.quantity)
? item.quantity
: parseInt(item.quantity, 10);
const quantity =
Number.isFinite(rawQuantity) && rawQuantity > 0 ? rawQuantity : 1;
const priceId = typeof item.priceID === "string" ? item.priceID.trim() : "";
if (isRenewable && !priceId) {
throw new HttpsError(
"invalid-argument",
`Un price ID Stripe est requis pour l'élément ${index + 1} (abonnement).`,
);
}
if (priceId) {
let stripePrice = null;
if (stripe) {
try {
stripePrice = await stripe.prices.retrieve(priceId);
} catch (error) {
console.error(
"[buildCheckoutLineItems] Unable to retrieve price",
priceId,
error,
);
throw new HttpsError(
"invalid-argument",
`Le price ID "${priceId}" est introuvable (élément ${index + 1}).`,
);
}
}
const priceIsRecurring =
stripePrice?.type === "recurring" || !!stripePrice?.recurring;
if (isRenewable && !priceIsRecurring) {
throw new HttpsError(
"invalid-argument",
`Le price ID "${priceId}" n'est pas compatible avec un abonnement.`,
);
}
const resolvedIsRenewable = priceIsRecurring ? true : isRenewable;
if (resolvedIsRenewable) {
hasSubscription = true;
}
lineItems.push({
price: priceId,
quantity,
});
summary.push({
type: "price",
priceID: priceId,
quantity,
isRenewable: resolvedIsRenewable,
});
continue;
}
const rawUnitAmount = Number(item.unitAmount);
const unitAmount = Math.round(rawUnitAmount);
if (!Number.isFinite(unitAmount) || unitAmount <= 0) {
throw new HttpsError(
"invalid-argument",
`Le montant indiqué pour l'élément ${index + 1} est invalide.`,
);
}
const currency =
typeof item.currency === "string"
? item.currency.trim().toLowerCase()
: "eur";
if (!/^[a-z]{3}$/.test(currency)) {
throw new HttpsError(
"invalid-argument",
`La devise indiquée pour l'élément ${index + 1} est invalide.`,
);
}
const label =
typeof item.label === "string" && item.label.trim()
? item.label.trim()
: "Paiement ponctuel";
lineItems.push({
price_data: {
currency,
product_data: {
name: label,
},
unit_amount: unitAmount,
},
quantity,
});
summary.push({
type: "custom",
currency,
unitAmount,
quantity,
isRenewable: false,
});
}
if (hasSubscription) {
const hasNonSubscription = summary.some((item) => !item.isRenewable);
if (hasNonSubscription) {
throw new HttpsError(
"invalid-argument",
"Impossible de mélanger abonnements et paiements ponctuels dans une seule session Checkout.",
);
}
}
return { lineItems, summary, hasSubscription };
};
const ensureStripeCustomer = async ({
uid,
stripe,
refsList,
createIfMissing = true,
}) => {
if (!uid) {
return { customerId: null, userData: null };
}
const userRef = refsList?.users?.doc(uid);
const snapshot = userRef ? await userRef.get() : null;
const userData = snapshot?.exists ? snapshot.data() : null;
let customerId = userData?.stripeCustomerId;
if (customerId) {
return { customerId, userData };
}
if (!createIfMissing) {
return { customerId: null, userData };
}
let authRecord = null;
try {
authRecord = await admin.auth().getUser(uid);
} catch (error) {
console.warn(
"[ensureStripeCustomer] Impossible de récupérer auth user",
error,
);
}
const email = userData?.email || authRecord?.email || undefined;
const nameFromProfile = [userData?.firstName, userData?.lastName]
.filter(Boolean)
.join(" ")
.trim();
const name = nameFromProfile || authRecord?.displayName || undefined;
const customer = await stripe.customers.create({
email,
name,
metadata: {
firebaseUID: uid,
appMode: STRIPE_MODE || "test",
},
});
customerId = customer.id;
if (userRef) {
await userRef.set(
{
stripeCustomerId: customerId,
},
{ merge: true },
);
}
return {
customerId,
userData: { ...userData, stripeCustomerId: customerId },
};
};
const formatCheckoutSessionResponse = (session) => ({
id: session.id,
object: session.object,
customer: session.customer,
customer_details: session.customer_details,
url: session.url,
mode: session.mode,
status: session.status,
payment_status: session.payment_status,
currency: session.currency,
amount_subtotal: session.amount_subtotal,
amount_total: session.amount_total,
created: session.created,
expires_at: session.expires_at,
});
const getPortalConfigurationId = async (stripe) => {
if (STRIPE_PORTAL_CONFIGURATION) {
return STRIPE_PORTAL_CONFIGURATION;
}
if (cachedPortalConfigurationId) {
return cachedPortalConfigurationId;
}
if (
!stripe ||
typeof stripe.billingPortal?.configurations?.list !== "function"
) {
throw new HttpsError(
"internal",
"Client Stripe indisponible pour la configuration du portail.",
);
}
try {
const configurations = await stripe.billingPortal.configurations.list({
limit: 100,
});
const defaultConfiguration =
configurations.data.find((config) => config.is_default) ||
configurations.data.find((config) => config.active);
if (defaultConfiguration?.id) {
cachedPortalConfigurationId = defaultConfiguration.id;
return cachedPortalConfigurationId;
}
} catch (error) {
console.warn(
"[getPortalConfigurationId] Impossible de lister les configurations de portail",
error?.message || error,
);
}
try {
const defaultReturnUrl = getReturnBaseUrl();
const createdConfiguration =
await stripe.billingPortal.configurations.create({
default_return_url: defaultReturnUrl,
business_profile: {
headline: "Minuit Starter",
},
});
if (createdConfiguration?.id) {
cachedPortalConfigurationId = createdConfiguration.id;
return cachedPortalConfigurationId;
}
} catch (error) {
console.warn(
"[getPortalConfigurationId] Impossible de créer une configuration de portail par défaut",
error?.message || error,
);
}
return null;
};
const mapStripeErrorToHttps = (error, fallbackMessage) => {
const message =
error?.raw?.message ||
error?.message ||
fallbackMessage ||
"Erreur Stripe.";
const statusCode = error?.statusCode || error?.raw?.statusCode;
const isClientError =
typeof statusCode === "number" && statusCode >= 400 && statusCode < 500;
const code = isClientError ? "failed-precondition" : "internal";
return new HttpsError(code, message);
};
module.exports = {
getStripeClient,
getReturnUrls,
getReturnBaseUrl,
buildCheckoutLineItems,
ensureStripeCustomer,
formatCheckoutSessionResponse,
getPortalConfigurationId,
mapStripeErrorToHttps,
};