start adding payment

This commit is contained in:
Thomas Demirdjian
2025-11-04 14:46:12 +01:00
parent 02a2333a41
commit 4bb083ea46
24 changed files with 3507 additions and 7604 deletions
+481
View File
@@ -0,0 +1,481 @@
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,
};