feat: fixes and formatter

This commit is contained in:
2026-01-12 16:01:32 +01:00
parent 85c6084351
commit 11e632acff
353 changed files with 23315 additions and 27361 deletions
+246 -323
View File
@@ -1,15 +1,15 @@
const admin = require("firebase-admin");
const { HttpsError, onCall } = require("firebase-functions/https");
const admin = require('firebase-admin')
const { HttpsError, onCall } = require('firebase-functions/https')
let FieldValue = null;
let FieldValue = null
try {
({ FieldValue } = require("firebase-admin/firestore"));
;({ FieldValue } = require('firebase-admin/firestore'))
} catch (error) {
console.warn("[stripe] FieldValue import failed", error?.message);
console.warn('[stripe] FieldValue import failed', error?.message)
}
const { REGION, refsList } = require("../index");
const STRIPE_MODE = "test";
const { REGION, refsList } = require('../index')
const STRIPE_MODE = 'test'
const {
getStripeClient,
getReturnUrls,
@@ -19,23 +19,20 @@ const {
formatCheckoutSessionResponse,
getPortalConfigurationId,
mapStripeErrorToHttps,
} = require("../helpers/stripe");
} = require('../helpers/stripe')
const paymentsCollection = admin.firestore().collection("payments");
const paymentsCollection = admin.firestore().collection('payments')
const getServerTimestamp = () => {
if (FieldValue?.serverTimestamp) {
return FieldValue.serverTimestamp();
return FieldValue.serverTimestamp()
}
const fallback = admin.firestore?.FieldValue;
const fallback = admin.firestore?.FieldValue
if (fallback?.serverTimestamp) {
return fallback.serverTimestamp();
return fallback.serverTimestamp()
}
throw new HttpsError(
"failed-precondition",
"Firestore FieldValue.serverTimestamp indisponible.",
);
};
throw new HttpsError('failed-precondition', 'Firestore FieldValue.serverTimestamp indisponible.')
}
const normalizePaymentIntent = (paymentIntent) => ({
id: paymentIntent.id,
@@ -48,53 +45,44 @@ const normalizePaymentIntent = (paymentIntent) => ({
created: paymentIntent.created,
latest_charge: paymentIntent.latest_charge,
metadata: paymentIntent.metadata,
});
})
const createCheckoutSession = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid;
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour créer une session Stripe.",
);
throw new HttpsError('unauthenticated', 'Connecte-toi pour créer une session Stripe.')
}
const requestedUserId =
typeof request?.data?.userID === "string"
? request.data.userID.trim()
: null;
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.",
);
'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 productList = Array.isArray(request?.data?.productList) ? request.data.productList : []
const stripe = getStripeClient();
const { lineItems, summary, hasSubscription } =
await buildCheckoutLineItems(productList, { stripe });
const stripe = getStripeClient()
const { lineItems, summary, hasSubscription } = await buildCheckoutLineItems(productList, {
stripe,
})
const mode = hasSubscription ? "subscription" : "payment";
const { successUrl, cancelUrl } = getReturnUrls(request?.data?.returnUrls);
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é.",
);
throw new HttpsError('internal', 'Impossible de retrouver le client Stripe associé.')
}
const session = await stripe.checkout.sessions.create({
@@ -107,12 +95,12 @@ const createCheckoutSession = onCall({ region: REGION }, async (request) => {
metadata: {
firebaseUID: uid,
},
});
})
await paymentsCollection.doc(session.id).set({
userId: uid,
customerId,
status: session.status || "created",
status: session.status || 'created',
mode,
createdAt: getServerTimestamp(),
updatedAt: getServerTimestamp(),
@@ -123,59 +111,53 @@ const createCheckoutSession = onCall({ region: REGION }, async (request) => {
amountTotal: session.amount_total,
currency: session.currency,
paymentStatus: session.payment_status,
});
})
return formatCheckoutSessionResponse(session);
return formatCheckoutSessionResponse(session)
} catch (error) {
console.error("[createCheckoutSession] error", error);
console.error('[createCheckoutSession] error', error)
if (error instanceof HttpsError) {
throw error;
throw error
}
throw mapStripeErrorToHttps(
error,
"Création de la session Stripe impossible.",
);
throw mapStripeErrorToHttps(error, 'Création de la session Stripe impossible.')
}
});
})
const getPremiumStatus = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid;
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour consulter ton statut Stripe.",
);
throw new HttpsError('unauthenticated', 'Connecte-toi pour consulter ton statut Stripe.')
}
const stripe = getStripeClient();
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"],
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,
@@ -193,7 +175,7 @@ const getPremiumStatus = onCall({ region: REGION }, async (request) => {
currency: item.price?.currency,
recurring: item.price?.recurring,
})),
}));
}))
const formattedInvoices = invoices.data.map((invoice) => ({
id: invoice.id,
@@ -205,179 +187,153 @@ const getPremiumStatus = onCall({ region: REGION }, async (request) => {
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);
console.error('[getPremiumStatus] error', error)
if (error instanceof HttpsError) {
throw error;
throw error
}
throw mapStripeErrorToHttps(
error,
"Impossible de récupérer le statut Stripe.",
);
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 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;
if (!userData || typeof userData !== 'object') {
return null
}
const candidatePaths = [
["stripeConnectAccountId"],
["stripeConnectId"],
["stripeAccountId"],
["stripeConnectAccount"],
["stripeAccount"],
["stripe", "connectAccountId"],
["stripe", "accountId"],
["providers", "stripeConnect", "accountId"],
];
['stripeConnectAccountId'],
['stripeConnectId'],
['stripeAccountId'],
['stripeConnectAccount'],
['stripeAccount'],
['stripe', 'connectAccountId'],
['stripe', 'accountId'],
['providers', 'stripeConnect', 'accountId'],
]
for (const path of candidatePaths) {
let current = userData;
let current = userData
for (const key of path) {
if (!current || typeof current !== "object") {
current = null;
break;
if (!current || typeof current !== 'object') {
current = null
break
}
current = current[key];
current = current[key]
}
if (typeof current === "string" && current.trim()) {
return current.trim();
if (typeof current === 'string' && current.trim()) {
return current.trim()
}
}
return null;
};
return null
}
const ensureStripeConnectAccount = async ({
uid,
stripe,
userRef,
userData,
}) => {
const ensureStripeConnectAccount = async ({ uid, stripe, userRef, userData }) => {
if (!uid || !stripe) {
return { connectAccountId: null, userData, createdAccount: false };
return { connectAccountId: null, userData, createdAccount: false }
}
let connectAccountId = resolveStripeConnectAccountId(userData);
let connectAccountId = resolveStripeConnectAccountId(userData)
if (connectAccountId) {
return { connectAccountId, userData, createdAccount: false };
return { connectAccountId, userData, createdAccount: false }
}
let authRecord = null;
let authRecord = null
try {
authRecord = await admin.auth().getUser(uid);
authRecord = await admin.auth().getUser(uid)
} catch (error) {
console.warn(
"[ensureStripeConnectAccount] Impossible de récupérer auth user",
error,
);
console.warn('[ensureStripeConnectAccount] Impossible de récupérer auth user', error)
}
const email = userData?.email || authRecord?.email || undefined;
const email = userData?.email || authRecord?.email || undefined
const accountParams = {
type: "express",
type: 'express',
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
metadata: {
firebaseUID: uid,
appMode: STRIPE_MODE || "test",
appMode: STRIPE_MODE || 'test',
},
};
if (email) {
accountParams.email = email;
}
const account = await stripe.accounts.create(accountParams);
connectAccountId = account?.id;
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.",
);
throw new HttpsError('internal', 'Stripe na pas renvoyé didentifiant de compte Connect.')
}
const providersConnect = {
...(userData?.providers?.stripeConnect || {}),
accountId: connectAccountId,
};
}
if (userRef) {
await userRef.set(
@@ -391,8 +347,8 @@ const ensureStripeConnectAccount = async ({
},
},
},
{ merge: true },
);
{ merge: true }
)
}
return {
@@ -406,170 +362,140 @@ const ensureStripeConnectAccount = async ({
},
},
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 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;
const uid = request?.auth?.uid
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour vérifier un paiement Stripe.",
);
throw new HttpsError('unauthenticated', 'Connecte-toi pour vérifier un paiement Stripe.')
}
const rawPaymentId =
typeof request?.data?.paymentId === "string"
? request.data.paymentId.trim()
: "";
typeof request?.data?.paymentId === 'string' ? request.data.paymentId.trim() : ''
if (!rawPaymentId) {
throw new HttpsError(
"invalid-argument",
"Fournis un identifiant de paiement Stripe.",
);
throw new HttpsError('invalid-argument', 'Fournis un identifiant de paiement Stripe.')
}
const stripe = getStripeClient();
const stripe = getStripeClient()
const fetchPaymentIntent = async (paymentIntentId) =>
stripe.paymentIntents.retrieve(paymentIntentId, {
expand: ["latest_charge"],
});
expand: ['latest_charge'],
})
const fetchCheckoutSession = async (sessionId) =>
stripe.checkout.sessions.retrieve(sessionId, {
expand: ["payment_intent"],
});
expand: ['payment_intent'],
})
let paymentIntent = null;
let checkoutSession = null;
let paymentType = null;
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);
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 && typeof checkoutSession.payment_intent === 'object'
? checkoutSession.payment_intent
: checkoutSession?.payment_intent
? await fetchPaymentIntent(checkoutSession.payment_intent)
: null;
paymentType = "checkout_session";
: null
paymentType = 'checkout_session'
} else {
try {
paymentIntent = await fetchPaymentIntent(rawPaymentId);
paymentType = "payment_intent";
paymentIntent = await fetchPaymentIntent(rawPaymentId)
paymentType = 'payment_intent'
} catch (intentError) {
try {
checkoutSession = await fetchCheckoutSession(rawPaymentId);
paymentType = "checkout_session";
checkoutSession = await fetchCheckoutSession(rawPaymentId)
paymentType = 'checkout_session'
paymentIntent =
checkoutSession?.payment_intent &&
typeof checkoutSession.payment_intent === "object"
checkoutSession?.payment_intent && typeof checkoutSession.payment_intent === 'object'
? checkoutSession.payment_intent
: checkoutSession?.payment_intent
? await fetchPaymentIntent(checkoutSession.payment_intent)
: null;
: null
} catch (sessionError) {
console.error("[verifyStripePayment] lookup failure", {
console.error('[verifyStripePayment] lookup failure', {
intentError,
sessionError,
});
throw new HttpsError(
"not-found",
"Aucun paiement Stripe trouvé avec cet identifiant.",
);
})
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.",
);
throw new HttpsError('not-found', 'Aucun paiement Stripe trouvé avec cet identifiant.')
}
const normalizedIntent = paymentIntent
? normalizePaymentIntent(paymentIntent)
: null;
const normalizedIntent = paymentIntent ? normalizePaymentIntent(paymentIntent) : null
const response = {
type: paymentType,
@@ -594,41 +520,38 @@ const verifyStripePayment = onCall({ region: REGION }, async (request) => {
checkoutSession?.status ??
null,
currency: normalizedIntent?.currency ?? checkoutSession?.currency ?? null,
};
}
const paymentDocId =
paymentType === "checkout_session"
? checkoutSession?.id
: normalizedIntent?.id;
paymentType === 'checkout_session' ? checkoutSession?.id : normalizedIntent?.id
if (paymentDocId) {
const paymentDocRef = paymentsCollection.doc(paymentDocId);
const existing = await paymentDocRef.get();
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,
amountTotal: checkoutSession?.amount_total ?? normalizedIntent?.amount,
amountReceived: normalizedIntent?.amount_received,
currency: response.currency,
updatedAt: getServerTimestamp(),
},
{ merge: true },
);
{ merge: true }
)
}
}
return response;
return response
} catch (error) {
console.error("[verifyStripePayment] error", error);
console.error('[verifyStripePayment] error', error)
if (error instanceof HttpsError) {
throw error;
throw error
}
throw mapStripeErrorToHttps(error, "Vérification du paiement impossible.");
throw mapStripeErrorToHttps(error, 'Vérification du paiement impossible.')
}
});
})
module.exports = {
createCheckoutSession,
@@ -636,4 +559,4 @@ module.exports = {
createStripeCustomerPortalSession,
createStripeConnectLoginLink,
verifyStripePayment,
};
}