start adding payment
This commit is contained in:
@@ -3,3 +3,8 @@ exports.GEMINI_API_KEY = "AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y";
|
||||
exports.SUNO_API_KEY = "5a689606d3c59f58268b4963d5107361"; // api key
|
||||
|
||||
exports.RESEND_API_KEY = "re_";
|
||||
|
||||
exports.STRIPE_SECRET_KEY =
|
||||
"sk_test_51SPfcjCzf2o5bDRdUFGNrQYIE271EDfS2Ucn31f98Ublttcl1EBNRoOoJX1RfXXzHp7mKRGrIlCG24biiqUZ2YMh00s9WODluu";
|
||||
exports.STRIPE_WEBHOOK_SECRET = "whsec_pDrvXVjjMuZjtsnRaFVJrDmKO5QEtNkW";
|
||||
exports.STRIPE_RETURN_URL = "";
|
||||
|
||||
@@ -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 n’est 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,
|
||||
};
|
||||
Generated
-7511
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,8 @@
|
||||
"googleapis": "^131.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"resend": "^6.3.0",
|
||||
"sharp": "^0.33.4"
|
||||
"sharp": "^0.33.4",
|
||||
"stripe": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.15.0",
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
const admin = require("firebase-admin");
|
||||
const { HttpsError, onCall } = require("firebase-functions/https");
|
||||
|
||||
let FieldValue = null;
|
||||
try {
|
||||
({ FieldValue } = require("firebase-admin/firestore"));
|
||||
} catch (error) {
|
||||
console.warn("[stripe] FieldValue import failed", error?.message);
|
||||
}
|
||||
|
||||
const { REGION, refsList } = require("../index");
|
||||
const STRIPE_MODE = "test";
|
||||
const {
|
||||
getStripeClient,
|
||||
getReturnUrls,
|
||||
getReturnBaseUrl,
|
||||
buildCheckoutLineItems,
|
||||
ensureStripeCustomer,
|
||||
formatCheckoutSessionResponse,
|
||||
getPortalConfigurationId,
|
||||
mapStripeErrorToHttps,
|
||||
} = require("../helpers/stripe");
|
||||
|
||||
const paymentsCollection = admin.firestore().collection("payments");
|
||||
|
||||
const getServerTimestamp = () => {
|
||||
if (FieldValue?.serverTimestamp) {
|
||||
return FieldValue.serverTimestamp();
|
||||
}
|
||||
if (admin.firestore?.FieldValue?.serverTimestamp) {
|
||||
return admin.firestore.FieldValue.serverTimestamp();
|
||||
}
|
||||
throw new HttpsError(
|
||||
"failed-precondition",
|
||||
"Firestore FieldValue.serverTimestamp indisponible.",
|
||||
);
|
||||
};
|
||||
|
||||
const normalizePaymentIntent = (paymentIntent) => ({
|
||||
id: paymentIntent.id,
|
||||
object: paymentIntent.object,
|
||||
status: paymentIntent.status,
|
||||
currency: paymentIntent.currency,
|
||||
amount: paymentIntent.amount,
|
||||
amount_received: paymentIntent.amount_received,
|
||||
customer: paymentIntent.customer,
|
||||
created: paymentIntent.created,
|
||||
latest_charge: paymentIntent.latest_charge,
|
||||
metadata: paymentIntent.metadata,
|
||||
});
|
||||
|
||||
const createCheckoutSession = onCall({ region: REGION }, async (request) => {
|
||||
try {
|
||||
const uid = request?.auth?.uid;
|
||||
if (!uid) {
|
||||
throw new HttpsError(
|
||||
"unauthenticated",
|
||||
"Connecte-toi pour créer une session Stripe.",
|
||||
);
|
||||
}
|
||||
|
||||
const requestedUserId =
|
||||
typeof request?.data?.userID === "string"
|
||||
? request.data.userID.trim()
|
||||
: null;
|
||||
|
||||
if (requestedUserId && requestedUserId !== uid) {
|
||||
throw new HttpsError(
|
||||
"permission-denied",
|
||||
"Tu ne peux créer une session que pour ton propre compte.",
|
||||
);
|
||||
}
|
||||
|
||||
const productList = Array.isArray(request?.data?.productList)
|
||||
? request.data.productList
|
||||
: [];
|
||||
|
||||
const stripe = getStripeClient();
|
||||
const { lineItems, summary, hasSubscription } =
|
||||
await buildCheckoutLineItems(productList, { stripe });
|
||||
|
||||
const mode = hasSubscription ? "subscription" : "payment";
|
||||
const { successUrl, cancelUrl } = getReturnUrls(request?.data?.returnUrls);
|
||||
|
||||
const { customerId } = await ensureStripeCustomer({
|
||||
uid,
|
||||
stripe,
|
||||
refsList,
|
||||
createIfMissing: true,
|
||||
});
|
||||
|
||||
if (!customerId) {
|
||||
throw new HttpsError(
|
||||
"internal",
|
||||
"Impossible de retrouver le client Stripe associé.",
|
||||
);
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode,
|
||||
customer: customerId,
|
||||
line_items: lineItems,
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
allow_promotion_codes: true,
|
||||
metadata: {
|
||||
firebaseUID: uid,
|
||||
},
|
||||
});
|
||||
|
||||
await paymentsCollection.doc(session.id).set({
|
||||
userId: uid,
|
||||
customerId,
|
||||
status: session.status || "created",
|
||||
mode,
|
||||
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,
|
||||
});
|
||||
|
||||
return formatCheckoutSessionResponse(session);
|
||||
} catch (error) {
|
||||
console.error("[createCheckoutSession] error", error);
|
||||
if (error instanceof HttpsError) {
|
||||
throw error;
|
||||
}
|
||||
throw mapStripeErrorToHttps(
|
||||
error,
|
||||
"Création de la session Stripe impossible.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const getPremiumStatus = onCall({ region: REGION }, async (request) => {
|
||||
try {
|
||||
const uid = request?.auth?.uid;
|
||||
if (!uid) {
|
||||
throw new HttpsError(
|
||||
"unauthenticated",
|
||||
"Connecte-toi pour consulter ton statut Stripe.",
|
||||
);
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
const { customerId } = await ensureStripeCustomer({
|
||||
uid,
|
||||
stripe,
|
||||
refsList,
|
||||
createIfMissing: false,
|
||||
});
|
||||
|
||||
if (!customerId) {
|
||||
return {
|
||||
customerId: null,
|
||||
subscriptions: [],
|
||||
invoices: [],
|
||||
};
|
||||
}
|
||||
|
||||
const [subscriptions, invoices] = await Promise.all([
|
||||
stripe.subscriptions.list({
|
||||
customer: customerId,
|
||||
status: "all",
|
||||
expand: ["data.items.data.price"],
|
||||
limit: 20,
|
||||
}),
|
||||
stripe.invoices.list({
|
||||
customer: customerId,
|
||||
limit: 20,
|
||||
}),
|
||||
]);
|
||||
|
||||
const formattedSubscriptions = subscriptions.data.map((subscription) => ({
|
||||
id: subscription.id,
|
||||
status: subscription.status,
|
||||
created: subscription.created,
|
||||
current_period_start: subscription.current_period_start,
|
||||
current_period_end: subscription.current_period_end,
|
||||
cancel_at_period_end: subscription.cancel_at_period_end,
|
||||
items: subscription.items.data.map((item) => ({
|
||||
id: item.id,
|
||||
quantity: item.quantity,
|
||||
price: item.price?.id,
|
||||
product: item.price?.product,
|
||||
unit_amount: item.price?.unit_amount,
|
||||
currency: item.price?.currency,
|
||||
recurring: item.price?.recurring,
|
||||
})),
|
||||
}));
|
||||
|
||||
const formattedInvoices = invoices.data.map((invoice) => ({
|
||||
id: invoice.id,
|
||||
status: invoice.status,
|
||||
amount_due: invoice.amount_due,
|
||||
amount_paid: invoice.amount_paid,
|
||||
amount_remaining: invoice.amount_remaining,
|
||||
currency: invoice.currency,
|
||||
hosted_invoice_url: invoice.hosted_invoice_url,
|
||||
invoice_pdf: invoice.invoice_pdf,
|
||||
created: invoice.created,
|
||||
}));
|
||||
|
||||
return {
|
||||
customerId,
|
||||
subscriptions: formattedSubscriptions,
|
||||
invoices: formattedInvoices,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[getPremiumStatus] error", error);
|
||||
if (error instanceof HttpsError) {
|
||||
throw error;
|
||||
}
|
||||
throw mapStripeErrorToHttps(
|
||||
error,
|
||||
"Impossible de récupérer le statut Stripe.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const createStripeCustomerPortalSession = onCall(
|
||||
{ region: REGION },
|
||||
async (request) => {
|
||||
try {
|
||||
const uid = request?.auth?.uid;
|
||||
if (!uid) {
|
||||
throw new HttpsError(
|
||||
"unauthenticated",
|
||||
"Connecte-toi pour ouvrir le portail client Stripe.",
|
||||
);
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
const { customerId } = await ensureStripeCustomer({
|
||||
uid,
|
||||
stripe,
|
||||
refsList,
|
||||
createIfMissing: false,
|
||||
});
|
||||
|
||||
if (!customerId) {
|
||||
throw new HttpsError(
|
||||
"failed-precondition",
|
||||
"Aucun client Stripe associé à cet utilisateur.",
|
||||
);
|
||||
}
|
||||
|
||||
const portalConfigurationId = await getPortalConfigurationId(stripe);
|
||||
if (!portalConfigurationId) {
|
||||
const modeLabel = STRIPE_MODE === "prod" ? "production" : "test";
|
||||
const envKeySuffix = STRIPE_MODE === "prod" ? "PROD" : "TEST";
|
||||
throw new HttpsError(
|
||||
"failed-precondition",
|
||||
`Configure le portail client Stripe en mode ${modeLabel} ou renseigne STRIPE_PORTAL_CONFIGURATION_${envKeySuffix}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const { successUrl } = getReturnUrls();
|
||||
|
||||
const portalSession = await stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url: successUrl,
|
||||
configuration: portalConfigurationId,
|
||||
});
|
||||
|
||||
return {
|
||||
id: portalSession.id,
|
||||
url: portalSession.url,
|
||||
created: portalSession.created,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[createStripeCustomerPortalSession] error", error);
|
||||
if (error instanceof HttpsError) {
|
||||
throw error;
|
||||
}
|
||||
throw mapStripeErrorToHttps(
|
||||
error,
|
||||
"Ouverture du portail Stripe impossible.",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const resolveStripeConnectAccountId = (userData) => {
|
||||
if (!userData || typeof userData !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidatePaths = [
|
||||
["stripeConnectAccountId"],
|
||||
["stripeConnectId"],
|
||||
["stripeAccountId"],
|
||||
["stripeConnectAccount"],
|
||||
["stripeAccount"],
|
||||
["stripe", "connectAccountId"],
|
||||
["stripe", "accountId"],
|
||||
["providers", "stripeConnect", "accountId"],
|
||||
];
|
||||
|
||||
for (const path of candidatePaths) {
|
||||
let current = userData;
|
||||
for (const key of path) {
|
||||
if (!current || typeof current !== "object") {
|
||||
current = null;
|
||||
break;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
|
||||
if (typeof current === "string" && current.trim()) {
|
||||
return current.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const ensureStripeConnectAccount = async ({
|
||||
uid,
|
||||
stripe,
|
||||
userRef,
|
||||
userData,
|
||||
}) => {
|
||||
if (!uid || !stripe) {
|
||||
return { connectAccountId: null, userData, createdAccount: false };
|
||||
}
|
||||
|
||||
let connectAccountId = resolveStripeConnectAccountId(userData);
|
||||
if (connectAccountId) {
|
||||
return { connectAccountId, userData, createdAccount: false };
|
||||
}
|
||||
|
||||
let authRecord = null;
|
||||
try {
|
||||
authRecord = await admin.auth().getUser(uid);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[ensureStripeConnectAccount] Impossible de récupérer auth user",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
const email = userData?.email || authRecord?.email || undefined;
|
||||
|
||||
const accountParams = {
|
||||
type: "express",
|
||||
capabilities: {
|
||||
card_payments: { requested: true },
|
||||
transfers: { requested: true },
|
||||
},
|
||||
metadata: {
|
||||
firebaseUID: uid,
|
||||
appMode: STRIPE_MODE || "test",
|
||||
},
|
||||
};
|
||||
|
||||
if (email) {
|
||||
accountParams.email = email;
|
||||
}
|
||||
|
||||
const account = await stripe.accounts.create(accountParams);
|
||||
connectAccountId = account?.id;
|
||||
|
||||
if (!connectAccountId) {
|
||||
throw new HttpsError(
|
||||
"internal",
|
||||
"Stripe n’a pas renvoyé d’identifiant de compte Connect.",
|
||||
);
|
||||
}
|
||||
|
||||
const providersConnect = {
|
||||
...(userData?.providers?.stripeConnect || {}),
|
||||
accountId: connectAccountId,
|
||||
};
|
||||
|
||||
if (userRef) {
|
||||
await userRef.set(
|
||||
{
|
||||
stripeConnectAccountId: connectAccountId,
|
||||
providers: {
|
||||
...(userData?.providers || {}),
|
||||
stripeConnect: {
|
||||
...providersConnect,
|
||||
updatedAt: getServerTimestamp(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
connectAccountId,
|
||||
userData: {
|
||||
...(userData || {}),
|
||||
stripeConnectAccountId: connectAccountId,
|
||||
providers: {
|
||||
...(userData?.providers || {}),
|
||||
stripeConnect: providersConnect,
|
||||
},
|
||||
},
|
||||
createdAccount: true,
|
||||
};
|
||||
};
|
||||
|
||||
const createStripeConnectLoginLink = onCall(
|
||||
{ region: REGION },
|
||||
async (request) => {
|
||||
try {
|
||||
const uid = request?.auth?.uid;
|
||||
if (!uid) {
|
||||
throw new HttpsError(
|
||||
"unauthenticated",
|
||||
"Connecte-toi pour ouvrir Stripe Connect.",
|
||||
);
|
||||
}
|
||||
|
||||
const userRef = refsList?.users?.doc(uid);
|
||||
const snapshot = userRef ? await userRef.get() : null;
|
||||
let userData = snapshot?.exists ? snapshot.data() : null;
|
||||
|
||||
const stripe = getStripeClient();
|
||||
const ensureResult = await ensureStripeConnectAccount({
|
||||
uid,
|
||||
stripe,
|
||||
userRef,
|
||||
userData,
|
||||
});
|
||||
|
||||
const connectAccountId = ensureResult.connectAccountId;
|
||||
userData = ensureResult.userData;
|
||||
|
||||
if (!connectAccountId) {
|
||||
throw new HttpsError(
|
||||
"failed-precondition",
|
||||
"Impossible de créer un compte Stripe Connect pour cet utilisateur.",
|
||||
);
|
||||
}
|
||||
|
||||
const redirectUrl = getReturnBaseUrl();
|
||||
|
||||
const loginLink = await stripe.accounts.createLoginLink(
|
||||
connectAccountId,
|
||||
redirectUrl
|
||||
? {
|
||||
redirect_url: redirectUrl,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
if (!loginLink?.url) {
|
||||
throw new HttpsError(
|
||||
"internal",
|
||||
"Stripe Connect n’a pas renvoyé de lien de connexion.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: loginLink.id,
|
||||
url: loginLink.url,
|
||||
created: loginLink.created,
|
||||
connectAccountId,
|
||||
createdAccount: ensureResult.createdAccount,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[createStripeConnectLoginLink] error", error);
|
||||
if (error instanceof HttpsError) {
|
||||
throw error;
|
||||
}
|
||||
throw mapStripeErrorToHttps(
|
||||
error,
|
||||
"Ouverture de Stripe Connect impossible.",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const verifyStripePayment = onCall({ region: REGION }, async (request) => {
|
||||
try {
|
||||
const uid = request?.auth?.uid;
|
||||
if (!uid) {
|
||||
throw new HttpsError(
|
||||
"unauthenticated",
|
||||
"Connecte-toi pour vérifier un paiement Stripe.",
|
||||
);
|
||||
}
|
||||
|
||||
const rawPaymentId =
|
||||
typeof request?.data?.paymentId === "string"
|
||||
? request.data.paymentId.trim()
|
||||
: "";
|
||||
|
||||
if (!rawPaymentId) {
|
||||
throw new HttpsError(
|
||||
"invalid-argument",
|
||||
"Fournis un identifiant de paiement Stripe.",
|
||||
);
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
|
||||
const fetchPaymentIntent = async (paymentIntentId) =>
|
||||
stripe.paymentIntents.retrieve(paymentIntentId, {
|
||||
expand: ["latest_charge"],
|
||||
});
|
||||
|
||||
const fetchCheckoutSession = async (sessionId) =>
|
||||
stripe.checkout.sessions.retrieve(sessionId, {
|
||||
expand: ["payment_intent"],
|
||||
});
|
||||
|
||||
let paymentIntent = null;
|
||||
let checkoutSession = null;
|
||||
let paymentType = null;
|
||||
|
||||
if (rawPaymentId.startsWith("pi_")) {
|
||||
paymentIntent = await fetchPaymentIntent(rawPaymentId);
|
||||
paymentType = "payment_intent";
|
||||
} else if (rawPaymentId.startsWith("cs_")) {
|
||||
checkoutSession = await fetchCheckoutSession(rawPaymentId);
|
||||
paymentIntent =
|
||||
checkoutSession?.payment_intent &&
|
||||
typeof checkoutSession.payment_intent === "object"
|
||||
? checkoutSession.payment_intent
|
||||
: checkoutSession?.payment_intent
|
||||
? await fetchPaymentIntent(checkoutSession.payment_intent)
|
||||
: null;
|
||||
paymentType = "checkout_session";
|
||||
} else {
|
||||
try {
|
||||
paymentIntent = await fetchPaymentIntent(rawPaymentId);
|
||||
paymentType = "payment_intent";
|
||||
} catch (intentError) {
|
||||
try {
|
||||
checkoutSession = await fetchCheckoutSession(rawPaymentId);
|
||||
paymentType = "checkout_session";
|
||||
paymentIntent =
|
||||
checkoutSession?.payment_intent &&
|
||||
typeof checkoutSession.payment_intent === "object"
|
||||
? checkoutSession.payment_intent
|
||||
: checkoutSession?.payment_intent
|
||||
? await fetchPaymentIntent(checkoutSession.payment_intent)
|
||||
: null;
|
||||
} catch (sessionError) {
|
||||
console.error("[verifyStripePayment] lookup failure", {
|
||||
intentError,
|
||||
sessionError,
|
||||
});
|
||||
throw new HttpsError(
|
||||
"not-found",
|
||||
"Aucun paiement Stripe trouvé avec cet identifiant.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!paymentIntent && !checkoutSession) {
|
||||
throw new HttpsError(
|
||||
"not-found",
|
||||
"Aucun paiement Stripe trouvé avec cet identifiant.",
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedIntent = paymentIntent
|
||||
? normalizePaymentIntent(paymentIntent)
|
||||
: null;
|
||||
|
||||
const response = {
|
||||
type: paymentType,
|
||||
payment_intent: normalizedIntent,
|
||||
checkout_session: checkoutSession
|
||||
? {
|
||||
id: checkoutSession.id,
|
||||
status: checkoutSession.status,
|
||||
payment_status: checkoutSession.payment_status,
|
||||
amount_total: checkoutSession.amount_total,
|
||||
amount_subtotal: checkoutSession.amount_subtotal,
|
||||
currency: checkoutSession.currency,
|
||||
customer: checkoutSession.customer,
|
||||
created: checkoutSession.created,
|
||||
expires_at: checkoutSession.expires_at,
|
||||
}
|
||||
: null,
|
||||
amount_received: normalizedIntent?.amount_received ?? null,
|
||||
status:
|
||||
normalizedIntent?.status ??
|
||||
checkoutSession?.payment_status ??
|
||||
checkoutSession?.status ??
|
||||
null,
|
||||
currency: normalizedIntent?.currency ?? checkoutSession?.currency ?? null,
|
||||
};
|
||||
|
||||
const paymentDocId =
|
||||
paymentType === "checkout_session"
|
||||
? checkoutSession?.id
|
||||
: normalizedIntent?.id;
|
||||
|
||||
if (paymentDocId) {
|
||||
const paymentDocRef = paymentsCollection.doc(paymentDocId);
|
||||
const existing = await paymentDocRef.get();
|
||||
if (existing.exists) {
|
||||
await paymentDocRef.set(
|
||||
{
|
||||
status: response.status,
|
||||
paymentStatus: checkoutSession?.payment_status,
|
||||
amountTotal:
|
||||
checkoutSession?.amount_total ?? normalizedIntent?.amount,
|
||||
amountReceived: normalizedIntent?.amount_received,
|
||||
currency: response.currency,
|
||||
updatedAt: getServerTimestamp(),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("[verifyStripePayment] error", error);
|
||||
if (error instanceof HttpsError) {
|
||||
throw error;
|
||||
}
|
||||
throw mapStripeErrorToHttps(error, "Vérification du paiement impossible.");
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
createCheckoutSession,
|
||||
getPremiumStatus,
|
||||
createStripeCustomerPortalSession,
|
||||
createStripeConnectLoginLink,
|
||||
verifyStripePayment,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
+24
-2
@@ -18,7 +18,9 @@ const WELCOME_EMAIL_SUBJECT = "Bienvenue sur MusicLand";
|
||||
exports.testWelcomMail = onRequest(async (req, res) => {
|
||||
if (req.method !== "GET") {
|
||||
res.set("Allow", "GET");
|
||||
return res.status(405).json({ success: false, error: "Method not allowed" });
|
||||
return res
|
||||
.status(405)
|
||||
.json({ success: false, error: "Method not allowed" });
|
||||
}
|
||||
try {
|
||||
const targetEmail = req.query.email || "tdtomthomas@gmail.com";
|
||||
@@ -48,7 +50,27 @@ exports.testWelcomMail = onRequest(async (req, res) => {
|
||||
|
||||
exports.onUserCreated = onDocumentCreated("users/{userID}", async (event) => {
|
||||
try {
|
||||
const { email = "", firstName = "", lastName = "" } = event?.data?.data();
|
||||
const {
|
||||
email = "",
|
||||
firstName = "",
|
||||
lastName = "",
|
||||
} = event?.data?.data() || {};
|
||||
|
||||
try {
|
||||
await event.data.ref.set(
|
||||
{
|
||||
coins: admin.firestore.FieldValue.increment(10),
|
||||
coinWelcomeGrantedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true },
|
||||
);
|
||||
} catch (coinError) {
|
||||
console.warn(
|
||||
"[users-onUserCreated] Unable to grant welcome coins",
|
||||
event?.params?.userID,
|
||||
coinError?.message || coinError,
|
||||
);
|
||||
}
|
||||
if (email) {
|
||||
if (!resendClient) {
|
||||
console.warn("Resend API key not configured; skipping welcome email.");
|
||||
|
||||
Reference in New Issue
Block a user