640 lines
17 KiB
JavaScript
640 lines
17 KiB
JavaScript
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 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,
|
||
};
|