Files
musicland/functions/src/stripe.js
T
2025-11-05 15:39:15 +01:00

640 lines
17 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, 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();
}
const fallback = admin.firestore?.FieldValue;
if (fallback?.serverTimestamp) {
return fallback.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 na pas renvoyé didentifiant 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 na 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,
};