stripe embedded and other fixes

This commit is contained in:
Thomas Demirdjian
2025-11-18 11:40:39 +01:00
parent 016f7c27ac
commit 7ab8685180
17 changed files with 2346 additions and 2504 deletions
+1
View File
@@ -390,6 +390,7 @@ const formatCheckoutSessionResponse = (session) => ({
amount_total: session.amount_total, amount_total: session.amount_total,
created: session.created, created: session.created,
expires_at: session.expires_at, expires_at: session.expires_at,
client_secret: session.client_secret || null,
}); });
const getPortalConfigurationId = async (stripe) => { const getPortalConfigurationId = async (stripe) => {
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
const { HttpsError, onCall } = require("firebase-functions/https");
const { getStripeClient, mapStripeErrorToHttps } = require("../../helpers/stripe");
const { REGION } = require("./config");
const {
SUBSCRIPTION_PRICE_IDS,
SUBSCRIPTION_PRICE_METADATA,
COIN_PACK_PRODUCTS,
} = require("./constants");
const { parseCoinsPerMonth, formatCoinPack } = require("./shared");
const formatPlan = (price, priceId) => {
if (!price || typeof price !== "object") {
return null;
}
const product =
typeof price.product === "object" && price.product !== null
? price.product
: {};
return {
id: price.id || priceId,
priceId: price.id || priceId,
active: price.active !== false,
currency: price.currency || "eur",
unitAmount: price.unit_amount,
unitAmountDecimal: price.unit_amount_decimal,
transformQuantity: price.transform_quantity || null,
recurring: price.recurring || null,
nickname: price.nickname || null,
billingScheme: price.billing_scheme || null,
coinsPerMonth:
parseCoinsPerMonth(product?.metadata || {}) ||
(SUBSCRIPTION_PRICE_METADATA[price.id || priceId]?.coinsPerMonth ?? null),
metadata: price.metadata || {},
product: {
id: product.id || null,
name: product.name || "",
description: product.description || "",
metadata: product.metadata || {},
},
};
};
const listSubscriptionPlans = onCall({ region: REGION }, async () => {
try {
const stripe = getStripeClient();
const entries = await Promise.all(
Object.entries(SUBSCRIPTION_PRICE_IDS).map(async ([period, priceIds]) => {
const periodPlans = await Promise.all(
priceIds.map(async (priceId) => {
try {
const price = await stripe.prices.retrieve(priceId, {
expand: ["product"],
});
return formatPlan(price, priceId);
} catch (error) {
console.error(
`[subscription-listSubscriptionPlans] Impossible de récupérer ${priceId}`,
error?.message || error,
);
return null;
}
}),
);
return [period, periodPlans.filter(Boolean)];
}),
);
return {
plans: Object.fromEntries(entries),
};
} catch (error) {
console.error("[subscription-listSubscriptionPlans] error", error);
throw mapStripeErrorToHttps(
error,
"Impossible de récupérer les abonnements Stripe.",
);
}
});
const listCoinPacks = onCall({ region: REGION }, async () => {
try {
const stripe = getStripeClient();
const packs = await Promise.all(
COIN_PACK_PRODUCTS.map(async (pack) => {
try {
const product = await stripe.products.retrieve(pack.productId, {
expand: ["default_price"],
});
let resolvedPrice = null;
if (typeof product?.default_price === "string") {
resolvedPrice = await stripe.prices.retrieve(product.default_price);
} else if (
product?.default_price &&
typeof product.default_price === "object"
) {
resolvedPrice = product.default_price;
}
const formatted = formatCoinPack({
product,
price: resolvedPrice,
});
return {
...formatted,
coinPackKey: pack.key || null,
};
} catch (error) {
console.error(
"[subscription-listCoinPacks] Unable to retrieve product",
pack.productId,
error?.message || error,
);
return null;
}
}),
);
return {
packs: packs.filter(Boolean),
};
} catch (error) {
console.error("[subscription-listCoinPacks] error", error);
throw mapStripeErrorToHttps(
error,
"Impossible de récupérer les packs de pièces.",
);
}
});
module.exports = {
listSubscriptionPlans,
listCoinPacks,
};
+317
View File
@@ -0,0 +1,317 @@
const { HttpsError, onCall } = require("firebase-functions/https");
const {
getStripeClient,
buildCheckoutLineItems,
ensureStripeCustomer,
getReturnUrls,
formatCheckoutSessionResponse,
mapStripeErrorToHttps,
} = require("../../helpers/stripe");
const { REGION } = require("./config");
const {
ALL_SUBSCRIPTION_PRICE_IDS,
COIN_PACK_PRODUCT_IDS,
COIN_PACK_PRODUCT_MAP,
} = require("./constants");
const {
refsList,
formatCoinPack,
getSubscriptionMetaFromPrice,
} = require("./shared");
const CHECKOUT_UI_MODES = new Set(["hosted", "embedded"]);
const sanitizePriceId = (value) => {
if (typeof value !== "string") {
return "";
}
return value.trim();
};
const resolveCheckoutUiMode = (request) => {
if (!request || !request.data || typeof request.data.uiMode === "undefined") {
return "hosted";
}
if (typeof request.data.uiMode !== "string") {
throw new HttpsError(
"invalid-argument",
'uiMode doit être une chaîne de caractères ("hosted" ou "embedded").',
);
}
const normalizedUiMode = request.data.uiMode.trim().toLowerCase();
if (!CHECKOUT_UI_MODES.has(normalizedUiMode)) {
throw new HttpsError(
"invalid-argument",
`uiMode "${request.data.uiMode}" n'est pas supporté pour Stripe Checkout.`,
);
}
return normalizedUiMode;
};
const withCheckoutNavigationParams = (
baseParams,
{ uiMode, successUrl, cancelUrl },
) => {
if (uiMode === "embedded") {
return {
...baseParams,
ui_mode: "embedded",
return_url: undefined,
};
}
return {
...baseParams,
success_url: successUrl,
cancel_url: cancelUrl,
};
};
const createSubscriptionCheckoutSession = onCall(
{ region: REGION },
async (request) => {
try {
const uid = request?.auth?.uid;
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour souscrire un abonnement.",
);
}
const rawPriceId = request?.data?.priceId;
const priceId = sanitizePriceId(rawPriceId);
if (!priceId) {
throw new HttpsError(
"invalid-argument",
"Un identifiant de prix Stripe est requis.",
);
}
if (!ALL_SUBSCRIPTION_PRICE_IDS.includes(priceId)) {
throw new HttpsError(
"invalid-argument",
`L'identifiant de prix ${priceId} n'est pas pris en charge.`,
);
}
const stripe = getStripeClient();
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {};
const { lineItems, summary } = await buildCheckoutLineItems(
[
{
priceID: priceId,
quantity: 1,
isRenewable: true,
},
],
{ stripe },
);
const uiMode = resolveCheckoutUiMode(request);
const shouldProvideReturnUrls = uiMode !== "embedded";
const { successUrl, cancelUrl } = shouldProvideReturnUrls
? getReturnUrls(request?.data?.returnUrls)
: { successUrl: null, cancelUrl: null };
const { customerId } = await ensureStripeCustomer({
uid,
stripe,
refsList,
createIfMissing: true,
});
if (!customerId) {
throw new HttpsError(
"failed-precondition",
"Impossible de retrouver le client Stripe associé.",
);
}
const session = await stripe.checkout.sessions.create(
withCheckoutNavigationParams(
{
mode: "subscription",
customer: customerId,
line_items: lineItems,
allow_promotion_codes: true,
metadata: {
firebaseUID: uid,
priceId,
purchaseType: "SUBSCRIPTION",
subscriptionLevel: priceMeta.level || null,
subscriptionBillingPeriod: priceMeta.billingPeriod || null,
},
},
{
uiMode,
successUrl,
cancelUrl,
},
),
);
return formatCheckoutSessionResponse(session);
} catch (error) {
console.error(
"[subscription-createSubscriptionCheckoutSession] error",
error,
);
if (error instanceof HttpsError) {
throw error;
}
throw mapStripeErrorToHttps(
error,
"Impossible de créer la session d'abonnement Stripe.",
);
}
},
);
const createCoinPackCheckoutSession = onCall(
{ region: REGION },
async (request) => {
try {
const uid = request?.auth?.uid;
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour acheter un pack de pièces.",
);
}
const rawProductId = request?.data?.productId;
const productId =
typeof rawProductId === "string" ? rawProductId.trim() : "";
if (!productId) {
throw new HttpsError(
"invalid-argument",
"Un identifiant de produit Stripe est requis.",
);
}
if (!COIN_PACK_PRODUCT_IDS.includes(productId)) {
throw new HttpsError(
"invalid-argument",
`Le produit ${productId} n'est pas un pack de pièces autorisé`,
);
}
const stripe = getStripeClient();
const product = await stripe.products.retrieve(productId, {
expand: ["default_price"],
});
let resolvedPrice = null;
if (typeof product?.default_price === "string") {
resolvedPrice = await stripe.prices.retrieve(product.default_price);
} else if (
product?.default_price &&
typeof product.default_price === "object"
) {
resolvedPrice = product.default_price;
}
let coinPack = null;
try {
coinPack = formatCoinPack({
product,
price: resolvedPrice,
});
} catch (error) {
console.error(
"[subscription-createCoinPackCheckoutSession] invalid coin pack metadata",
productId,
error?.message || error,
);
throw new HttpsError(
"failed-precondition",
"Le pack Stripe est mal configuré (metadata.coins manquant).",
);
}
if (!coinPack?.priceId) {
throw new HttpsError(
"failed-precondition",
"Impossible de déterminer le prix Stripe pour ce pack.",
);
}
const uiMode = resolveCheckoutUiMode(request);
const shouldProvideReturnUrls = uiMode !== "embedded";
const { successUrl, cancelUrl } = shouldProvideReturnUrls
? getReturnUrls(request?.data?.returnUrls)
: { successUrl: null, cancelUrl: null };
const { customerId } = await ensureStripeCustomer({
uid,
stripe,
refsList,
createIfMissing: true,
});
if (!customerId) {
throw new HttpsError(
"failed-precondition",
"Impossible de retrouver le client Stripe associé.",
);
}
const session = await stripe.checkout.sessions.create(
withCheckoutNavigationParams(
{
mode: "payment",
customer: customerId,
line_items: [
{
price: coinPack.priceId,
quantity: 1,
},
],
allow_promotion_codes: false,
metadata: {
firebaseUID: uid,
purchaseType: "COIN_PACK",
coinPackProductId: coinPack.productId,
coinPackPriceId: coinPack.priceId,
coinAmount: coinPack.coinAmount,
coinPackKey: COIN_PACK_PRODUCT_MAP[productId]?.key || null,
},
},
{
uiMode,
successUrl,
cancelUrl,
},
),
);
return formatCheckoutSessionResponse(session);
} catch (error) {
console.error(
"[subscription-createCoinPackCheckoutSession] error",
error,
);
if (error instanceof HttpsError) {
throw error;
}
throw mapStripeErrorToHttps(
error,
"Impossible de créer la session d'achat de pièces.",
);
}
},
);
module.exports = {
createSubscriptionCheckoutSession,
createCoinPackCheckoutSession,
resolveCheckoutUiMode,
};
+5
View File
@@ -0,0 +1,5 @@
const REGION = process.env.FIREBASE_REGION || "europe-west1";
module.exports = {
REGION,
};
+96
View File
@@ -0,0 +1,96 @@
const SUBSCRIPTION_PRICE_IDS = {
monthly: [
"price_1SPgitCzf2o5bDRdbnhLFx6f",
"price_1SPgjCCzf2o5bDRdr08Xzp8u",
"price_1SPgjaCzf2o5bDRdd9Xo2u26",
],
annual: [
"price_1SPgkDCzf2o5bDRdNGLVNeQ3",
"price_1SPgkXCzf2o5bDRdejBVxEBY",
"price_1SPgkqCzf2o5bDRdIcUwTDrm",
],
};
const ALL_SUBSCRIPTION_PRICE_IDS = Object.values(SUBSCRIPTION_PRICE_IDS).flat();
const SUBSCRIPTION_LEVEL_ALLOWANCES = {
starter: 10,
pro: 40,
premium: 60,
};
const SUBSCRIPTION_PRICE_METADATA = {
price_1SPgitCzf2o5bDRdbnhLFx6f: {
level: "starter",
billingPeriod: "monthly",
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.starter,
},
price_1SPgjCCzf2o5bDRdr08Xzp8u: {
level: "pro",
billingPeriod: "monthly",
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.pro,
},
price_1SPgjaCzf2o5bDRdd9Xo2u26: {
level: "premium",
billingPeriod: "monthly",
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.premium,
},
price_1SPgkDCzf2o5bDRdNGLVNeQ3: {
level: "starter",
billingPeriod: "annual",
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.starter,
},
price_1SPgkXCzf2o5bDRdejBVxEBY: {
level: "pro",
billingPeriod: "annual",
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.pro,
},
price_1SPgkqCzf2o5bDRdIcUwTDrm: {
level: "premium",
billingPeriod: "annual",
coinsPerMonth: SUBSCRIPTION_LEVEL_ALLOWANCES.premium,
},
};
const COIN_PACK_PRODUCTS = [
{ productId: "prod_TMPPEXZ1wGk2cS", key: "starter" },
{ productId: "prod_TMPQ5SNdS47gY6", key: "pro" },
{ productId: "prod_TMPRpA0qQSHXj5", key: "premium" },
];
const COIN_PACK_PRODUCT_IDS = COIN_PACK_PRODUCTS.map((pack) => pack.productId);
const COIN_PACK_PRODUCT_MAP = COIN_PACK_PRODUCTS.reduce(
(acc, pack) => ({
...acc,
[pack.productId]: pack,
}),
{},
);
const PREMIUM_SUBSCRIPTION_STATUSES = new Set(["active", "trialing"]);
const CANCELABLE_SUBSCRIPTION_STATUSES = new Set([
"trialing",
"active",
"past_due",
"unpaid",
]);
const ACTIVE_SUBSCRIPTION_STATUSES = new Set([
"trialing",
"active",
"past_due",
"unpaid",
]);
module.exports = {
SUBSCRIPTION_PRICE_IDS,
ALL_SUBSCRIPTION_PRICE_IDS,
SUBSCRIPTION_LEVEL_ALLOWANCES,
SUBSCRIPTION_PRICE_METADATA,
COIN_PACK_PRODUCTS,
COIN_PACK_PRODUCT_IDS,
COIN_PACK_PRODUCT_MAP,
PREMIUM_SUBSCRIPTION_STATUSES,
CANCELABLE_SUBSCRIPTION_STATUSES,
ACTIVE_SUBSCRIPTION_STATUSES,
};
+22
View File
@@ -0,0 +1,22 @@
const { listSubscriptionPlans, listCoinPacks } = require("./catalog");
const {
createSubscriptionCheckoutSession,
createCoinPackCheckoutSession,
} = require("./checkout");
const {
cancelActiveSubscription,
getActiveSubscription,
} = require("./management");
const { handleStripeWebhook } = require("./webhooks");
const { processAnnualSubscriptionAllowances } = require("./schedule");
module.exports = {
listSubscriptionPlans,
createSubscriptionCheckoutSession,
cancelActiveSubscription,
getActiveSubscription,
listCoinPacks,
createCoinPackCheckoutSession,
handleStripeWebhook,
processAnnualSubscriptionAllowances,
};
+213
View File
@@ -0,0 +1,213 @@
const { HttpsError, onCall } = require("firebase-functions/https");
const { getStripeClient, mapStripeErrorToHttps } = require("../../helpers/stripe");
const { REGION } = require("./config");
const {
CANCELABLE_SUBSCRIPTION_STATUSES,
ACTIVE_SUBSCRIPTION_STATUSES,
} = require("./constants");
const {
refsList,
formatSubscriptionForClient,
resolveUserContext,
} = require("./shared");
const cancelActiveSubscription = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid;
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour gérer ton abonnement.",
);
}
const stripe = getStripeClient();
const userRef = refsList?.users?.doc(uid) || null;
const snapshot = userRef ? await userRef.get() : null;
const userData = snapshot?.exists ? snapshot.data() || {} : {};
const inputSubscriptionId =
typeof request?.data?.subscriptionId === "string"
? request.data.subscriptionId.trim()
: "";
let subscriptionId =
inputSubscriptionId ||
userData?.stripeSubscription?.id ||
userData?.stripeSubscription?.subscriptionId ||
null;
const customerId = userData?.stripeCustomerId || null;
if (!subscriptionId && customerId) {
try {
const response = await stripe.subscriptions.list({
customer: customerId,
status: "all",
limit: 5,
});
const { data: subscriptionList = [] } = response || {};
const activeSubscription = subscriptionList.find(
(candidate) =>
candidate?.status &&
CANCELABLE_SUBSCRIPTION_STATUSES.has(candidate.status),
);
if (activeSubscription?.id) {
subscriptionId = activeSubscription.id;
}
} catch (error) {
console.warn(
"[subscription-cancelActiveSubscription] Unable to list subscriptions",
customerId,
error?.message || error,
);
}
}
if (!subscriptionId) {
throw new HttpsError(
"failed-precondition",
"Aucun abonnement actif à annuler.",
);
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
if (!subscription) {
throw new HttpsError("not-found", "Abonnement introuvable côté Stripe.");
}
if (subscription.status === "canceled") {
return {
subscriptionId: subscription.id,
status: subscription.status,
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
currentPeriodEnd: subscription.current_period_end || null,
alreadyCanceled: true,
};
}
if (subscription.cancel_at_period_end === true) {
return {
subscriptionId: subscription.id,
status: subscription.status,
cancelAtPeriodEnd: true,
currentPeriodEnd: subscription.current_period_end || null,
alreadyCanceled: false,
};
}
const updatedSubscription = await stripe.subscriptions.update(
subscriptionId,
{
cancel_at_period_end: true,
},
);
return {
subscriptionId: updatedSubscription.id,
status: updatedSubscription.status,
cancelAtPeriodEnd: updatedSubscription.cancel_at_period_end === true,
currentPeriodEnd: updatedSubscription.current_period_end || null,
alreadyCanceled: false,
};
} catch (error) {
console.error("[subscription-cancelActiveSubscription] error", error);
if (error instanceof HttpsError) {
throw error;
}
throw mapStripeErrorToHttps(
error,
"Impossible d'annuler l'abonnement Stripe.",
);
}
});
const getActiveSubscription = onCall({ region: REGION }, async (request) => {
try {
const uid = request?.auth?.uid;
if (!uid) {
throw new HttpsError(
"unauthenticated",
"Connecte-toi pour récupérer ton abonnement.",
);
}
const stripe = getStripeClient();
const {
uid: resolvedUid,
userRef,
userData,
} = await resolveUserContext({
metadata: request?.data?.metadata || {},
customerId: null,
});
const lookupUid = resolvedUid || uid;
const lookupRef = userRef || refsList?.users?.doc(lookupUid) || null;
const snapshot = lookupRef ? await lookupRef.get() : null;
const data = snapshot?.exists ? snapshot.data() || {} : userData || {};
const subscriptionId =
data?.stripeSubscription?.id ||
data?.stripeSubscription?.subscriptionId ||
null;
const customerId = data?.stripeCustomerId || null;
if (!subscriptionId && !customerId) {
return {
subscription: null,
customerId: null,
};
}
if (subscriptionId) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ["items.data.price.product"],
});
if (subscription) {
return {
subscription: formatSubscriptionForClient(subscription),
customerId: subscription.customer || customerId || null,
};
}
}
if (customerId) {
const response = await stripe.subscriptions.list({
customer: customerId,
status: "all",
limit: 5,
expand: ["data.items.data.price.product"],
});
const [subscription] = response?.data || [];
if (subscription && ACTIVE_SUBSCRIPTION_STATUSES.has(subscription.status)) {
return {
subscription: formatSubscriptionForClient(subscription),
customerId,
};
}
}
return {
subscription: null,
customerId,
};
} catch (error) {
console.error("[subscription-getActiveSubscription] error", error);
if (error instanceof HttpsError) {
throw error;
}
throw mapStripeErrorToHttps(
error,
"Impossible de récupérer l'abonnement Stripe.",
);
}
});
module.exports = {
cancelActiveSubscription,
getActiveSubscription,
};
+178
View File
@@ -0,0 +1,178 @@
const { onSchedule } = require("firebase-functions/v2/scheduler");
const { ORDER_TYPES, createOrderDocument } = require("../helpers/orders");
const { batchFirestore } = require("../../helpers/firebase");
const { BATCH_TYPE } = require("../../config/types");
const {
admin,
refsList,
computeNextGrantTimestamp,
getServerTimestamp,
} = require("./shared");
const { ACTIVE_SUBSCRIPTION_STATUSES } = require("./constants");
const processAnnualSubscriptionAllowances = onSchedule(
{
schedule: "30 3 * * *",
timeZone: "Europe/Paris",
},
async () => {
const nowTimestamp = admin.firestore.Timestamp.now();
const pageSize = 200;
let lastDoc = null;
let processedUsers = 0;
let grantsCreated = 0;
let docsToUpdate = [];
const flushUpdates = async () => {
if (!docsToUpdate.length) {
return;
}
await batchFirestore({
docs: docsToUpdate,
type: BATCH_TYPE.UPDATE,
});
docsToUpdate = [];
};
try {
while (true) {
let query = refsList.users
.where("premiumBillingPeriod", "==", "annual")
.where("subscriptionGrantInterval", "==", "monthly")
.where("subscriptionNextGrantAt", "<=", nowTimestamp)
.orderBy("subscriptionNextGrantAt")
.limit(pageSize);
if (lastDoc) {
query = query.startAfter(lastDoc);
}
const snapshot = await query.get();
if (snapshot.empty) {
break;
}
for (const doc of snapshot.docs) {
processedUsers += 1;
const data = doc.data() || {};
const coinsPerMonth = Number(data.subscriptionCoinsPerMonth || 0);
if (!Number.isFinite(coinsPerMonth) || coinsPerMonth <= 0) {
continue;
}
const status =
typeof data.stripeSubscriptionStatus === "string"
? data.stripeSubscriptionStatus.toLowerCase()
: null;
if (status && !ACTIVE_SUBSCRIPTION_STATUSES.has(status)) {
continue;
}
const nextGrantAt = data.subscriptionNextGrantAt;
if (!nextGrantAt || typeof nextGrantAt.toDate !== "function") {
continue;
}
const nextGrantDate = nextGrantAt.toDate();
if (!nextGrantDate || nextGrantDate > new Date()) {
continue;
}
const subscriptionInfo =
data.stripeSubscription &&
typeof data.stripeSubscription === "object"
? data.stripeSubscription
: {};
const subscriptionId =
subscriptionInfo.id || data.stripeSubscriptionId || null;
const orderId = subscriptionId
? `subscription_${subscriptionId}_sched_${nextGrantAt.seconds}`
: `subscription_${doc.id}_sched_${nextGrantAt.seconds}`;
try {
const { orderId: processedOrderId } = await createOrderDocument({
userId: doc.id,
type: ORDER_TYPES.SUBSCRIPTION,
amount: coinsPerMonth,
metadata: {
source: "STRIPE_SUBSCRIPTION",
schedule: "annual_scheduler",
subscriptionId,
scheduledGrantAt: nextGrantDate.toISOString(),
},
orderId,
});
let nextGrantTimestamp = computeNextGrantTimestamp(nextGrantAt, 1);
const currentPeriodEnd = subscriptionInfo.currentPeriodEnd;
if (
nextGrantTimestamp &&
currentPeriodEnd &&
typeof currentPeriodEnd.toDate === "function"
) {
const periodEndDate = currentPeriodEnd.toDate();
const nextGrantFutureDate = nextGrantTimestamp.toDate();
if (periodEndDate && nextGrantFutureDate > periodEndDate) {
nextGrantTimestamp = null;
}
}
docsToUpdate.push({
ref: doc.ref,
data: {
subscriptionLastGrantAt: getServerTimestamp(),
subscriptionLastGrantAmount: coinsPerMonth,
subscriptionLastGrantOrderId: processedOrderId,
subscriptionLastGrantSource: "annual_scheduler",
subscriptionNextGrantAt: nextGrantTimestamp || null,
subscriptionGrantInterval: nextGrantTimestamp ? "monthly" : null,
},
});
grantsCreated += 1;
if (docsToUpdate.length >= 450) {
await flushUpdates();
}
} catch (error) {
console.error(
"[subscription-processAnnualSubscriptionAllowances] Unable to create order",
{
userId: doc.id,
subscriptionId,
error: error?.message || error,
},
);
}
}
lastDoc = snapshot.docs[snapshot.docs.length - 1];
if (snapshot.size < pageSize) {
break;
}
}
await flushUpdates();
console.log(
"[subscription-processAnnualSubscriptionAllowances] completed",
{
processedUsers,
grantsCreated,
},
);
} catch (error) {
console.error(
"[subscription-processAnnualSubscriptionAllowances] error",
error,
);
throw error;
}
},
);
module.exports = {
processAnnualSubscriptionAllowances,
};
+401
View File
@@ -0,0 +1,401 @@
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const { refList } = require("../../index");
const { STRIPE_WEBHOOK_SECRET } = require("../../config/keys");
const {
SUBSCRIPTION_LEVEL_ALLOWANCES,
SUBSCRIPTION_PRICE_METADATA,
COIN_PACK_PRODUCT_MAP,
} = require("./constants");
const refsList = refList;
const paymentsCollection = admin.firestore().collection("payments");
let cachedStripeWebhookSecret = null;
const toFiniteNumber = (value) => {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const normalized = value.trim().replace(",", ".");
if (!normalized) {
return null;
}
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
};
const parseCoinsPerMonth = (metadata) => {
if (!metadata || typeof metadata !== "object") {
return null;
}
if (!Object.prototype.hasOwnProperty.call(metadata, "coinsPerMonth")) {
return null;
}
const candidateValue = toFiniteNumber(metadata.coinsPerMonth);
if (candidateValue === null || candidateValue <= 0) {
return null;
}
return Math.round(candidateValue);
};
const parseCoinAmount = (metadata) => {
if (!metadata || typeof metadata !== "object") {
return null;
}
if (!Object.prototype.hasOwnProperty.call(metadata, "coins")) {
return null;
}
const candidateValue = toFiniteNumber(metadata.coins);
if (candidateValue === null || candidateValue <= 0) {
return null;
}
return Math.round(candidateValue);
};
const toDateSafe = (value) => {
if (!value) {
return null;
}
if (value instanceof Date) {
return value;
}
if (typeof value?.toDate === "function") {
try {
return value.toDate();
} catch (_error) {
return null;
}
}
if (typeof value === "number" && Number.isFinite(value)) {
if (value > 1e12) {
return new Date(value);
}
return new Date(value * 1000);
}
return null;
};
const addMonths = (date, months = 1) => {
if (!(date instanceof Date) || !Number.isFinite(months)) {
return null;
}
const result = new Date(date.getTime());
const initialDay = result.getDate();
result.setMonth(result.getMonth() + months);
if (result.getDate() !== initialDay) {
result.setDate(0);
}
return result;
};
const computeNextGrantTimestamp = (base, months = 1) => {
const baseDate = toDateSafe(base);
if (!baseDate) {
return null;
}
const nextDate = addMonths(baseDate, months);
if (!nextDate) {
return null;
}
return admin.firestore.Timestamp.fromDate(nextDate);
};
const getServerTimestamp = () => {
if (typeof FieldValue?.serverTimestamp === "function") {
return FieldValue.serverTimestamp();
}
const fallback = admin.firestore?.FieldValue;
if (typeof fallback?.serverTimestamp === "function") {
return fallback.serverTimestamp();
}
throw new Error("Firestore FieldValue.serverTimestamp indisponible.");
};
const resolveStripeWebhookSecret = () => {
if (cachedStripeWebhookSecret) {
return cachedStripeWebhookSecret;
}
const envSecret =
typeof process?.env?.STRIPE_WEBHOOK_SECRET === "string"
? process.env.STRIPE_WEBHOOK_SECRET.trim()
: "";
const inlineSecret =
typeof STRIPE_WEBHOOK_SECRET === "string"
? STRIPE_WEBHOOK_SECRET.trim()
: "";
const secret = envSecret || inlineSecret;
if (!secret) {
throw new Error("STRIPE_WEBHOOK_SECRET not configured");
}
cachedStripeWebhookSecret = secret;
return cachedStripeWebhookSecret;
};
const toFirestoreTimestamp = (unixSeconds) => {
if (typeof unixSeconds !== "number" || !Number.isFinite(unixSeconds)) {
return null;
}
try {
return admin.firestore.Timestamp.fromMillis(unixSeconds * 1000);
} catch (error) {
console.error("[subscription-toFirestoreTimestamp] Conversion error", unixSeconds, error);
return null;
}
};
const extractFirebaseUid = (metadata) => {
if (!metadata || typeof metadata !== "object") {
return null;
}
const candidates = [
metadata.firebaseUID,
metadata.firebaseUid,
metadata.uid,
metadata.userId,
];
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index];
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
}
}
return null;
};
const formatCoinPack = ({ product, price }) => {
if (!product || typeof product !== "object") {
return null;
}
const resolvedPrice =
price ||
(typeof product.default_price === "object" && product.default_price) ||
null;
const priceId =
(resolvedPrice && resolvedPrice.id) ||
(typeof product.default_price === "string" ? product.default_price : null);
const coinAmount = parseCoinAmount(product?.metadata || {});
if (coinAmount === null) {
throw new Error(
`[formatCoinPack] Missing metadata.coins on product ${product.id}`,
);
}
return {
productId: product.id,
priceId,
name: product.name || "",
description: product.description || "",
coinAmount,
currency:
resolvedPrice?.currency ||
(typeof resolvedPrice?.currency === "string"
? resolvedPrice.currency.toLowerCase()
: "eur"),
unitAmount: resolvedPrice?.unit_amount ?? null,
metadata: product.metadata || {},
};
};
const getSubscriptionMetaFromPrice = (priceId) => {
if (typeof priceId !== "string") {
return null;
}
return SUBSCRIPTION_PRICE_METADATA[priceId] || null;
};
const buildEventSnapshot = (eventType, entityId) => {
const now = admin.firestore.Timestamp.now();
return {
eventType: eventType || null,
entityId: entityId || null,
syncedAt: now,
};
};
const formatSubscriptionForClient = (subscription) => {
if (!subscription || typeof subscription !== "object") {
return null;
}
return {
id: subscription.id,
status: subscription.status,
customer: subscription.customer,
currentPeriodStart: toFirestoreTimestamp(
subscription.current_period_start,
),
currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end),
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
created: toFirestoreTimestamp(subscription.created),
items: Array.isArray(subscription.items?.data)
? subscription.items.data.map((item) => ({
id: item.id,
price: item.price,
quantity: item.quantity,
}))
: [],
metadata: subscription.metadata || {},
};
};
const buildSubscriptionPayload = (subscription) => {
if (!subscription || typeof subscription !== "object") {
return null;
}
const items = Array.isArray(subscription.items?.data)
? subscription.items.data.map((item) => ({
id: item.id,
priceId: item.price?.id || null,
productId: item.price?.product || null,
quantity: item.quantity || 0,
price: item.price || null,
}))
: [];
const primaryItem = items[0] || null;
const productId = primaryItem?.productId || null;
const priceId = primaryItem?.priceId || null;
const priceMeta = getSubscriptionMetaFromPrice(priceId) || {};
const metadataLevel = subscription.metadata?.subscriptionLevel || null;
const resolvedLevel = metadataLevel || priceMeta?.level || null;
const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null;
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null;
const customerId =
typeof subscription.customer === "string" ? subscription.customer : null;
return {
id: subscription.id,
customerId,
productId,
priceId,
items,
level: resolvedLevel,
billingPeriod: resolvedPeriod,
status: subscription.status || null,
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
currentPeriodStart: toFirestoreTimestamp(
subscription.current_period_start,
),
currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end),
};
};
const resolveUserContext = async ({ metadata, customerId }) => {
const firebaseUid = extractFirebaseUid(metadata);
if (firebaseUid) {
const userRef = refsList?.users?.doc(firebaseUid) || null;
if (userRef) {
try {
const snapshot = await userRef.get();
if (snapshot.exists) {
return {
uid: firebaseUid,
userRef,
userData: snapshot.data() || null,
};
}
} catch (error) {
console.warn(
"[subscription-resolveUserContext] Unable to read user",
firebaseUid,
error?.message || error,
);
}
}
}
if (customerId) {
try {
const snapshot = await refsList.users
.where("stripeCustomerId", "==", customerId)
.limit(1)
.get();
if (!snapshot.empty) {
const doc = snapshot.docs[0];
return {
uid: doc.id,
userRef: doc.ref,
userData: doc.data() || null,
};
}
} catch (error) {
console.warn(
"[subscription-resolveUserContext] Unable to query by customer",
customerId,
error?.message || error,
);
}
}
return {
uid: firebaseUid || null,
userRef: null,
userData: null,
};
};
const upsertPaymentDocument = async (docId, data = {}) => {
if (!docId) {
return null;
}
const docRef = paymentsCollection.doc(docId);
try {
await docRef.set(
{
...data,
updatedAt: getServerTimestamp(),
createdAt: getServerTimestamp(),
},
{ merge: true },
);
} catch (error) {
console.error(
"[subscription-upsertPaymentDocument] Failed to persist payment",
docId,
error,
);
}
return docRef;
};
module.exports = {
admin,
refsList,
paymentsCollection,
toFiniteNumber,
parseCoinsPerMonth,
parseCoinAmount,
toDateSafe,
addMonths,
computeNextGrantTimestamp,
getServerTimestamp,
resolveStripeWebhookSecret,
toFirestoreTimestamp,
extractFirebaseUid,
formatCoinPack,
getSubscriptionMetaFromPrice,
buildEventSnapshot,
formatSubscriptionForClient,
buildSubscriptionPayload,
resolveUserContext,
upsertPaymentDocument,
COIN_PACK_PRODUCT_MAP,
};
+547
View File
@@ -0,0 +1,547 @@
const { onRequest } = require("firebase-functions/v2/https");
const { HttpsError } = require("firebase-functions/https");
const { ORDER_TYPES, createOrderDocument } = require("../helpers/orders");
const { getStripeClient } = require("../../helpers/stripe");
const { REGION } = require("./config");
const {
paymentsCollection,
resolveStripeWebhookSecret,
getServerTimestamp,
toFirestoreTimestamp,
extractFirebaseUid,
buildEventSnapshot,
computeNextGrantTimestamp,
parseCoinsPerMonth,
getSubscriptionMetaFromPrice,
formatSubscriptionForClient,
buildSubscriptionPayload,
resolveUserContext,
upsertPaymentDocument,
} = require("./shared");
const {
SUBSCRIPTION_LEVEL_ALLOWANCES,
PREMIUM_SUBSCRIPTION_STATUSES,
} = require("./constants");
const handleCheckoutSessionCompleted = async (
session,
event,
{ stripe } = {},
) => {
if (!session || typeof session !== "object") {
return;
}
const firebaseUid = extractFirebaseUid(session.metadata);
const paymentDocRef = await upsertPaymentDocument(session.id, {
userId: firebaseUid || null,
customerId: session.customer || null,
subscriptionId: session.subscription || null,
invoiceId: session.invoice || null,
status: session.status || "completed",
paymentStatus: session.payment_status || null,
mode: session.mode || null,
amountSubtotal: session.amount_subtotal ?? null,
amountTotal: session.amount_total ?? null,
currency: session.currency || null,
metadata: session.metadata || {},
completedAt: toFirestoreTimestamp(session.created),
expiresAt: toFirestoreTimestamp(session.expires_at),
paymentIntentId:
typeof session.payment_intent === "string"
? session.payment_intent
: null,
lastEventType: event?.type || null,
lastEventId: event?.id || null,
lastEventAt: getServerTimestamp(),
});
const { uid, userRef } = await resolveUserContext({
metadata: session.metadata,
customerId: session.customer,
});
if (userRef) {
const lastEvent = buildEventSnapshot(event?.type, session.id);
if (firebaseUid) {
lastEvent.uid = firebaseUid;
}
const userUpdate = {
lastStripeWebhookEvent: lastEvent,
};
if (session.customer) {
userUpdate.stripeCustomerId = session.customer;
}
if (session.metadata?.subscriptionLevel) {
userUpdate.premiumLevel = session.metadata.subscriptionLevel;
}
if (session.metadata?.subscriptionBillingPeriod) {
userUpdate.premiumBillingPeriod =
session.metadata.subscriptionBillingPeriod;
}
await userRef.set(userUpdate, { merge: true });
}
if (
stripe &&
session.mode === "subscription" &&
typeof session.subscription === "string" &&
session.subscription
) {
try {
const subscription = await stripe.subscriptions.retrieve(
session.subscription,
{ expand: ["items.data.price.product"] },
);
if (subscription) {
await handleCustomerSubscriptionEvent(subscription, event, { stripe });
}
} catch (error) {
console.error(
"[subscription-handleCheckoutSessionCompleted] Unable to sync subscription",
session.subscription,
error,
);
}
}
if (
session.mode === "payment" &&
(session.payment_status === "paid" ||
session.payment_status === "no_payment_required") &&
session.metadata?.purchaseType === "COIN_PACK" &&
userRef
) {
const coinAmountRaw = Number(session.metadata?.coinAmount || 0);
const coinAmount = Number.isFinite(coinAmountRaw) ? coinAmountRaw : 0;
if (coinAmount > 0 && paymentDocRef) {
let paymentSnapshot = null;
try {
paymentSnapshot = await paymentDocRef.get();
} catch (error) {
console.warn(
"[subscription-handleCheckoutSessionCompleted] Unable to read payment doc",
session.id,
error?.message || error,
);
}
const alreadyGranted = Boolean(
paymentSnapshot?.exists && paymentSnapshot.data()?.coinPackGrantedAt,
);
if (!alreadyGranted) {
const targetUserId = uid || firebaseUid || userRef.id;
await createOrderDocument({
userId: targetUserId,
type: ORDER_TYPES.COINS,
amount: coinAmount,
metadata: {
source: "STRIPE_CHECKOUT",
paymentId: session.id || null,
coinPackKey: session.metadata?.coinPackKey || null,
},
orderId: `stripe_${session.id}`,
});
await paymentDocRef.set(
{
coinPackGrantedAt: getServerTimestamp(),
coinPackGrantedAmount: coinAmount,
coinPackGrantedKey: session.metadata?.coinPackKey || null,
},
{ merge: true },
);
}
}
}
};
const handleCustomerSubscriptionEvent = async (
subscription,
event,
{ stripe } = {},
) => {
if (!subscription || typeof subscription !== "object") {
return;
}
const subscriptionPayload = buildSubscriptionPayload(subscription);
if (!subscriptionPayload) {
return;
}
const {
uid,
userRef,
userData: resolvedUserData,
} = await resolveUserContext({
metadata: subscription.metadata,
customerId: subscription.customer,
});
let userData = resolvedUserData || null;
if (!userData && userRef) {
try {
const snapshot = await userRef.get();
userData = snapshot.exists ? snapshot.data() || null : null;
} catch (error) {
console.warn(
"[subscription-handleCustomerSubscriptionEvent] Unable to read user",
subscription.customer,
error?.message || error,
);
}
}
let resolvedCustomerId = subscriptionPayload?.customerId || null;
if (!resolvedCustomerId && subscription.customer) {
resolvedCustomerId = subscription.customer;
}
const fallbackUid = extractFirebaseUid(subscription.metadata);
const resolvedUid = uid || fallbackUid || null;
await upsertPaymentDocument(subscription.id, {
userId: resolvedUid,
customerId: resolvedCustomerId,
subscriptionId: subscription.id || null,
status: subscription.status || null,
mode: "subscription",
priceId: subscriptionPayload?.priceId || null,
productId: subscriptionPayload?.productId || null,
cancelAtPeriodEnd: subscriptionPayload?.cancelAtPeriodEnd ?? null,
currentPeriodStart: subscriptionPayload?.currentPeriodStart || null,
currentPeriodEnd: subscriptionPayload?.currentPeriodEnd || null,
metadata: {
...subscription.metadata,
stripeEventType: event?.type || null,
},
lastEventType: event?.type || null,
lastEventId: event?.id || null,
lastEventAt: getServerTimestamp(),
});
if (!userRef) {
console.warn(
"[subscription-handleCustomerSubscriptionEvent] User not resolved",
{
subscriptionId: subscription?.id || null,
customerId: subscription?.customer || null,
metadataKeys: Object.keys(subscription?.metadata || {}),
eventType: event?.type || null,
},
);
return;
}
const lastEvent = buildEventSnapshot(event?.type, subscription.id);
if (resolvedUid) {
lastEvent.uid = resolvedUid;
}
const priceMeta = getSubscriptionMetaFromPrice(subscriptionPayload?.priceId);
const metadataLevel = subscription.metadata?.subscriptionLevel || null;
const metadataPeriod = subscription.metadata?.subscriptionBillingPeriod || null;
const resolvedLevel = metadataLevel || priceMeta?.level || null;
const resolvedPeriod = metadataPeriod || priceMeta?.billingPeriod || null;
const isPremium = subscription.status
? PREMIUM_SUBSCRIPTION_STATUSES.has(subscription.status)
: false;
const primaryItem = Array.isArray(subscriptionPayload?.items)
? subscriptionPayload.items[0]
: null;
let coinsPerMonth = null;
const productMetadata =
primaryItem?.price &&
typeof primaryItem.price === "object" &&
primaryItem.price.product &&
typeof primaryItem.price.product === "object"
? primaryItem.price.product.metadata
: null;
coinsPerMonth = parseCoinsPerMonth(productMetadata || {});
if (coinsPerMonth === null && stripe && primaryItem?.price?.id) {
try {
const priceWithProduct = await stripe.prices.retrieve(
primaryItem.price.id,
{ expand: ["product"] },
);
coinsPerMonth = parseCoinsPerMonth(
priceWithProduct?.product?.metadata || {},
);
} catch (error) {
console.warn(
"[subscription-handleCustomerSubscriptionEvent] Unable to retrieve price product metadata",
primaryItem.price.id,
error?.message || error,
);
}
}
let subscriptionNextGrantAt = null;
if (coinsPerMonth !== null && subscriptionPayload?.currentPeriodEnd) {
subscriptionNextGrantAt = computeNextGrantTimestamp(
subscriptionPayload.currentPeriodEnd,
1,
);
}
const userUpdate = {
lastStripeWebhookEvent: lastEvent,
stripeCustomerId: resolvedCustomerId,
stripeSubscription: {
id: subscription.id,
priceId: subscriptionPayload?.priceId || null,
productId: subscriptionPayload?.productId || null,
status: subscription.status || null,
level: resolvedLevel,
billingPeriod: resolvedPeriod,
cancelAtPeriodEnd: subscriptionPayload?.cancelAtPeriodEnd ?? null,
currentPeriodStart: subscriptionPayload?.currentPeriodStart || null,
currentPeriodEnd: subscriptionPayload?.currentPeriodEnd || null,
},
premiumLevel: isPremium ? resolvedLevel : null,
premiumBillingPeriod: isPremium ? resolvedPeriod : null,
subscriptionNextGrantAt,
};
if (!isPremium) {
userUpdate.subscriptionNextGrantAt = null;
}
await userRef.set(userUpdate, { merge: true });
};
const handleInvoiceEvent = async (invoice, event, { stripe } = {}) => {
if (!invoice || typeof invoice !== "object") {
return;
}
const firebaseUid = extractFirebaseUid(invoice.metadata);
const eventType = event?.type || null;
const paymentDocRef = paymentsCollection.doc(invoice.id);
let resolvedSubscriptionId =
typeof invoice.subscription === "string" && invoice.subscription
? invoice.subscription
: null;
if (!resolvedSubscriptionId) {
const lineSubscriptionId = Array.isArray(invoice?.lines?.data)
? invoice.lines.data
.map((line) =>
typeof line?.subscription === "string" && line.subscription
? line.subscription
: null,
)
.find((value) => value)
: null;
if (lineSubscriptionId) {
resolvedSubscriptionId = lineSubscriptionId;
console.log(
"[subscription-handleInvoiceEvent] Subscription resolved from invoice line",
{
invoiceId: invoice?.id || null,
subscriptionId: resolvedSubscriptionId,
},
);
}
}
console.log("[subscription-handleInvoiceEvent] Received invoice webhook", {
eventType,
invoiceId: invoice?.id || null,
subscriptionId: invoice?.subscription || null,
resolvedSubscriptionId: resolvedSubscriptionId || null,
customerId: invoice?.customer || null,
status: invoice?.status || null,
billingReason: invoice?.billing_reason || null,
attemptCount: invoice?.attempt_count ?? null,
});
await upsertPaymentDocument(invoice.id, {
userId: firebaseUid || null,
customerId: invoice.customer || null,
subscriptionId: resolvedSubscriptionId,
status: invoice.status || null,
paymentStatus:
eventType === "invoice.payment_failed"
? "failed"
: invoice.status || null,
amountDue: invoice.amount_due ?? null,
amountPaid: invoice.amount_paid ?? null,
amountRemaining: invoice.amount_remaining ?? null,
currency: invoice.currency || null,
invoiceNumber: invoice.number || null,
hostedInvoiceUrl: invoice.hosted_invoice_url || null,
invoicePdf: invoice.invoice_pdf || null,
billingReason: invoice.billing_reason || null,
metadata: invoice.metadata || {},
periodStart: toFirestoreTimestamp(invoice.period_start),
periodEnd: toFirestoreTimestamp(invoice.period_end),
paidAt: toFirestoreTimestamp(invoice.status_transitions?.paid_at),
lastEventType: eventType,
lastEventId: event?.id || null,
lastEventAt: getServerTimestamp(),
mode: "invoice",
});
const {
uid,
userRef,
userData: resolvedUserData,
} = await resolveUserContext({
metadata: invoice.metadata,
customerId: invoice.customer,
});
if (!userRef) {
console.warn(
"[subscription-handleInvoiceEvent] User context not resolved",
{
invoiceId: invoice?.id || null,
customerId: invoice?.customer || null,
firebaseUid: firebaseUid || null,
metadataKeys: Object.keys(invoice?.metadata || {}),
},
);
return;
}
let userData = resolvedUserData || null;
if (!userData) {
try {
const snapshot = await userRef.get();
userData = snapshot.exists ? snapshot.data() || null : null;
} catch (error) {
console.warn(
"[subscription-handleInvoiceEvent] Unable to read user",
invoice.customer,
error?.message || error,
);
}
}
const lastEvent = buildEventSnapshot(eventType, invoice.id);
if (firebaseUid) {
lastEvent.uid = firebaseUid;
}
const userUpdate = {
lastStripeWebhookEvent: lastEvent,
};
const billingReason = invoice.billing_reason || null;
const isInvoicePaid = invoice.status === "paid";
if (
billingReason === "subscription_create" ||
billingReason === "subscription_cycle"
) {
if (isInvoicePaid && invoice.subscription) {
userUpdate.subscriptionLastInvoiceAt = getServerTimestamp();
}
}
await userRef.set(userUpdate, { merge: true });
};
const handleStripeWebhookEvent = async ({ event, stripe }) => {
if (!event || typeof event !== "object") {
return;
}
const eventType = event.type;
switch (eventType) {
case "checkout.session.completed":
await handleCheckoutSessionCompleted(event.data?.object, event, {
stripe,
});
break;
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted":
await handleCustomerSubscriptionEvent(event.data?.object, event, {
stripe,
});
break;
case "invoice.payment_succeeded":
case "invoice.payment_failed":
case "invoice.finalized":
await handleInvoiceEvent(event.data?.object, event, { stripe });
break;
default:
console.log(
"[subscription-handleStripeWebhookEvent] Unhandled event type",
eventType,
);
}
};
const handleStripeWebhook = onRequest({ region: REGION }, async (req, res) => {
if (req.method !== "POST") {
res.status(405).send("Method Not Allowed");
return;
}
const signature = req.headers["stripe-signature"];
if (!signature) {
res.status(400).send("Missing Stripe signature");
return;
}
let rawBody = req.rawBody;
if (!rawBody && req.body) {
rawBody = Buffer.from(JSON.stringify(req.body));
}
if (!rawBody) {
res.status(400).send("Missing request body");
return;
}
let stripe = null;
try {
stripe = getStripeClient();
} catch (error) {
console.error("[subscription-handleStripeWebhook] Stripe client error", error);
res.status(500).send("Client Stripe indisponible");
return;
}
try {
const event = stripe.webhooks.constructEvent(
rawBody,
signature,
resolveStripeWebhookSecret(),
);
await handleStripeWebhookEvent({ event, stripe });
res.status(200).send({ received: true });
} catch (error) {
console.error("[subscription-handleStripeWebhook] error", error);
if (error instanceof HttpsError) {
res.status(400).send(error.message);
return;
}
res.status(500).send("Erreur lors du traitement du webhook");
}
});
module.exports = {
handleStripeWebhook,
};
+19 -1
View File
@@ -27,6 +27,21 @@ const WebAlertModal = ({ title, description, options }) => {
cancelOption?.onPress(); cancelOption?.onPress();
}; };
const renderDescription = () => {
if (
typeof description === "string" ||
typeof description === "number"
) {
return <Text style={styles.description}>{description}</Text>;
}
if (!description) {
return null;
}
return <View style={styles.customDescription}>{description}</View>;
};
return ( return (
<PortalProvider> <PortalProvider>
<Overlay <Overlay
@@ -42,7 +57,7 @@ const WebAlertModal = ({ title, description, options }) => {
> >
<BlurView intensity={100} tint="dark" style={styles.modalContainer}> <BlurView intensity={100} tint="dark" style={styles.modalContainer}>
<Text style={styles.title}>{title}</Text> <Text style={styles.title}>{title}</Text>
<Text style={styles.description}>{description}</Text> {renderDescription()}
<View style={styles.divider} /> <View style={styles.divider} />
<View <View
style={hasSecondaryAction ? styles.actionsRow : styles.actions} style={hasSecondaryAction ? styles.actionsRow : styles.actions}
@@ -152,6 +167,9 @@ const styles = StyleSheet.create({
backgroundColor: "rgba(255,255,255,0.08)", backgroundColor: "rgba(255,255,255,0.08)",
marginVertical: 0, marginVertical: 0,
}, },
customDescription: {
width: "100%",
},
actionsRow: { actionsRow: {
flexDirection: "row", flexDirection: "row",
gap: gutters, gap: gutters,
+100 -51
View File
@@ -25,10 +25,23 @@ const STRIPE_SUCCESS_URL =
"https://dashboard.stripe.com/test/billing/starter-guide/checkout-success"; "https://dashboard.stripe.com/test/billing/starter-guide/checkout-success";
const STRIPE_CANCEL_URL = STRIPE_SUCCESS_URL; const STRIPE_CANCEL_URL = STRIPE_SUCCESS_URL;
const isStripeTesting = false; const isStripeTesting = true;
const stripePromise = loadStripe( const rawStripePublishableKey = isStripeTesting
isStripeTesting ? STRIPE_PUBLISHABLE_KEY_TEST : STRIPE_PUBLISHABLE_KEY_LIVE, ? STRIPE_PUBLISHABLE_KEY_TEST
); : STRIPE_PUBLISHABLE_KEY_LIVE;
const stripePublishableKey =
typeof rawStripePublishableKey === "string"
? rawStripePublishableKey.trim()
: "";
const stripePromise = stripePublishableKey
? loadStripe(stripePublishableKey)
: null;
if (!stripePublishableKey) {
console.warn(
"[StripeProvider] Aucune clé publique Stripe fournie, le checkout intégré sera désactivé.",
);
}
const isSafariBrowser = () => { const isSafariBrowser = () => {
if ( if (
@@ -86,6 +99,7 @@ export const useStripe = () => {
const StripeProvider = ({ children }) => { const StripeProvider = ({ children }) => {
const { isMobile } = useLayoutType(); const { isMobile } = useLayoutType();
const { currentUserData } = useUserData() || {}; const { currentUserData } = useUserData() || {};
const canUseEmbeddedCheckout = isWeb && Boolean(stripePromise);
const [clientSecret, setClientSecret] = React.useState(null); const [clientSecret, setClientSecret] = React.useState(null);
const [subscriptions, setSubscriptions] = React.useState({ const [subscriptions, setSubscriptions] = React.useState({
@@ -118,33 +132,12 @@ const StripeProvider = ({ children }) => {
} }
if (isWeb) { if (isWeb) {
if (safariWindow && !safariWindow.closed) { throw new Error(
try { "Impossible d'ouvrir le paiement Stripe sans checkout intégré.",
safariWindow.location.replace(checkoutUrl);
} catch (navigationError) {
safariWindow.location.href = checkoutUrl;
}
safariWindow.focus?.();
return;
}
if (typeof window !== "undefined") {
const openedTab = window.open(
checkoutUrl,
"_blank",
"noopener,noreferrer",
); );
if (openedTab) {
return;
}
} }
await Linking.openURL(checkoutUrl); const isMobileApp = Platform.OS === "ios" || Platform.OS === "android";
return;
}
const isMobileApp =
Platform.OS === "ios" || Platform.OS === "android";
if (isMobileApp) { if (isMobileApp) {
try { try {
@@ -173,17 +166,37 @@ const StripeProvider = ({ children }) => {
); );
const runCheckoutSession = React.useCallback( const runCheckoutSession = React.useCallback(
async ({ callableName, payload, logTag }) => { async ({ callableName, payload, logTag, useEmbeddedFlow = false }) => {
const safariWindow = openSafariCheckoutWindow(); if (useEmbeddedFlow && !canUseEmbeddedCheckout) {
try { throw new Error(
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable( "Le paiement intégré Stripe est indisponible sur cette plateforme.",
callableName,
); );
}
const wantsEmbeddedCheckout = useEmbeddedFlow && canUseEmbeddedCheckout;
const safariWindow = wantsEmbeddedCheckout
? null
: openSafariCheckoutWindow();
try {
const callable =
getFunctionsClient(FUNCTIONS_REGION).httpsCallable(callableName);
const { data } = await callable(payload); const { data } = await callable(payload);
const checkoutUrl = data?.url; const checkoutUrl = data?.url;
const clientSecret = data?.client_secret || data?.clientSecret;
if (wantsEmbeddedCheckout) {
if (!clientSecret) {
throw new Error("Session Stripe introuvable.");
}
setClientSecret(clientSecret);
return;
}
if (!checkoutUrl) { if (!checkoutUrl) {
throw new Error("Session Stripe introuvable."); throw new Error("Session Stripe introuvable.");
} }
console.log("[StripeProvider] Stripe checkout URL", checkoutUrl);
await redirectToCheckout(checkoutUrl, { safariWindow }); await redirectToCheckout(checkoutUrl, { safariWindow });
} catch (error) { } catch (error) {
if (safariWindow && !safariWindow.closed) { if (safariWindow && !safariWindow.closed) {
@@ -196,7 +209,7 @@ const StripeProvider = ({ children }) => {
); );
} }
}, },
[redirectToCheckout], [canUseEmbeddedCheckout, redirectToCheckout, setClientSecret],
); );
const createSubscriptionCheckout = React.useCallback( const createSubscriptionCheckout = React.useCallback(
@@ -204,6 +217,19 @@ const StripeProvider = ({ children }) => {
if (!priceId) { if (!priceId) {
throw new Error("Aucun abonnement sélectionné."); throw new Error("Aucun abonnement sélectionné.");
} }
if (isWeb && !canUseEmbeddedCheckout) {
console.error(
"[StripeProvider] checkout blocked (missing embedded support)",
{
hasStripeKey: Boolean(stripePublishableKey),
isClientSecretReady: false,
},
);
throw new Error(
"Le paiement intégré Stripe est indisponible pour le moment (clé Stripe ou client secret absent).",
);
}
const shouldUseEmbeddedCheckout = canUseEmbeddedCheckout;
await runCheckoutSession({ await runCheckoutSession({
callableName: "subscription-createSubscriptionCheckoutSession", callableName: "subscription-createSubscriptionCheckoutSession",
payload: { payload: {
@@ -212,11 +238,13 @@ const StripeProvider = ({ children }) => {
successUrl: STRIPE_SUCCESS_URL, successUrl: STRIPE_SUCCESS_URL,
cancelUrl: STRIPE_CANCEL_URL, cancelUrl: STRIPE_CANCEL_URL,
}, },
uiMode: shouldUseEmbeddedCheckout ? "embedded" : "hosted",
}, },
logTag: "subscription checkout error", logTag: "subscription checkout error",
useEmbeddedFlow: shouldUseEmbeddedCheckout,
}); });
}, },
[runCheckoutSession], [canUseEmbeddedCheckout, runCheckoutSession],
); );
const createCoinPackCheckout = React.useCallback( const createCoinPackCheckout = React.useCallback(
@@ -224,6 +252,19 @@ const StripeProvider = ({ children }) => {
if (!productId) { if (!productId) {
throw new Error("Aucun pack sélectionné."); throw new Error("Aucun pack sélectionné.");
} }
if (isWeb && !canUseEmbeddedCheckout) {
console.error(
"[StripeProvider] checkout blocked (missing embedded support)",
{
hasStripeKey: Boolean(stripePublishableKey),
isClientSecretReady: false,
},
);
throw new Error(
"Le paiement intégré Stripe est indisponible pour le moment (clé Stripe ou client secret absent).",
);
}
const shouldUseEmbeddedCheckout = canUseEmbeddedCheckout;
await runCheckoutSession({ await runCheckoutSession({
callableName: "subscription-createCoinPackCheckoutSession", callableName: "subscription-createCoinPackCheckoutSession",
payload: { payload: {
@@ -232,11 +273,13 @@ const StripeProvider = ({ children }) => {
successUrl: STRIPE_SUCCESS_URL, successUrl: STRIPE_SUCCESS_URL,
cancelUrl: STRIPE_CANCEL_URL, cancelUrl: STRIPE_CANCEL_URL,
}, },
uiMode: shouldUseEmbeddedCheckout ? "embedded" : "hosted",
}, },
logTag: "coin pack checkout error", logTag: "coin pack checkout error",
useEmbeddedFlow: shouldUseEmbeddedCheckout,
}); });
}, },
[runCheckoutSession], [canUseEmbeddedCheckout, runCheckoutSession],
); );
const fetchActiveSubscription = React.useCallback(async () => { const fetchActiveSubscription = React.useCallback(async () => {
@@ -252,9 +295,9 @@ const StripeProvider = ({ children }) => {
setIsActiveSubscriptionLoading(true); setIsActiveSubscriptionLoading(true);
setActiveSubscriptionError(null); setActiveSubscriptionError(null);
try { try {
const callable = getFunctionsClient( const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
FUNCTIONS_REGION, "subscription-getActiveSubscription",
).httpsCallable("subscription-getActiveSubscription"); );
const { data } = await callable(); const { data } = await callable();
setActiveSubscription(data?.subscription || null); setActiveSubscription(data?.subscription || null);
} catch (error) { } catch (error) {
@@ -276,8 +319,9 @@ const StripeProvider = ({ children }) => {
let lastError = null; let lastError = null;
try { try {
const callable = const callable = functionsClient.httpsCallable(
functionsClient.httpsCallable("subscription-listSubscriptionPlans"); "subscription-listSubscriptionPlans",
);
const { data } = await callable(); const { data } = await callable();
const nextPlans = data?.plans || {}; const nextPlans = data?.plans || {};
setSubscriptions({ setSubscriptions({
@@ -290,8 +334,9 @@ const StripeProvider = ({ children }) => {
} }
try { try {
const callable = const callable = functionsClient.httpsCallable(
functionsClient.httpsCallable("subscription-listCoinPacks"); "subscription-listCoinPacks",
);
const { data } = await callable(); const { data } = await callable();
setCoinPacks(Array.isArray(data?.packs) ? data.packs : []); setCoinPacks(Array.isArray(data?.packs) ? data.packs : []);
} catch (error) { } catch (error) {
@@ -382,23 +427,28 @@ const StripeProvider = ({ children }) => {
isVisible={clientSecret !== null} isVisible={clientSecret !== null}
setIsVisible={closeEmbeddedCheckout} setIsVisible={closeEmbeddedCheckout}
> >
<View <View style={[StyleSheet.absoluteFillObject, styles.overlayContent]}>
style={[StyleSheet.absoluteFillObject, styles.overlayContent]}
>
<View <View
style={[ style={[
styles.embeddedWrapper, styles.embeddedWrapper,
{ {
width: isMobile ? "90%" : "80%", width: isMobile ? "99%" : "95%",
height: isMobile ? "92vh" : "96vh",
maxWidth: isMobile ? "100%" : "1400px",
}, },
]} ]}
> >
{clientSecret ? ( {clientSecret && stripePromise ? (
<EmbeddedCheckoutProvider <EmbeddedCheckoutProvider
stripe={stripePromise} stripe={stripePromise}
options={{ clientSecret }} options={{ clientSecret }}
> >
<EmbeddedCheckout /> <EmbeddedCheckout
onComplete={() => {
closeEmbeddedCheckout();
fetchActiveSubscription();
}}
/>
</EmbeddedCheckoutProvider> </EmbeddedCheckoutProvider>
) : null} ) : null}
</View> </View>
@@ -422,9 +472,8 @@ const styles = StyleSheet.create({
}, },
embeddedWrapper: { embeddedWrapper: {
alignSelf: "center", alignSelf: "center",
height: "70vh",
borderRadius: mainBorderRadius, borderRadius: mainBorderRadius,
overflow: "scroll", overflow: "hidden",
}, },
}); });
+25 -11
View File
@@ -16,6 +16,7 @@ import {
StyleSheet, StyleSheet,
Text, Text,
View, View,
useWindowDimensions,
} from "react-native"; } from "react-native";
import { SheetManager } from "react-native-actions-sheet"; import { SheetManager } from "react-native-actions-sheet";
import { import {
@@ -74,6 +75,8 @@ const MusicDetails = ({ route }) => {
const hasAutoPlayedRef = React.useRef(false); const hasAutoPlayedRef = React.useRef(false);
const { isLooping, setLooping } = usePlayer() || {}; const { isLooping, setLooping } = usePlayer() || {};
const isWeb = Platform.OS === "web"; const isWeb = Platform.OS === "web";
const { width: windowWidth = 0 } = useWindowDimensions();
const isCompactLayout = (windowWidth || 0) < 1200;
const handleBackPress = useCallback(() => { const handleBackPress = useCallback(() => {
goBack(); goBack();
}, []); }, []);
@@ -964,6 +967,7 @@ const MusicDetails = ({ route }) => {
headerType="NAVIGATION" headerType="NAVIGATION"
hideBackButton={isWeb} hideBackButton={isWeb}
topStickyContent={renderWebBackButton} topStickyContent={renderWebBackButton}
width={isCompactLayout ? "100%" : undefined}
title={action === "userProfile" ? "Mon profil" : "Détail musique"} title={action === "userProfile" ? "Mon profil" : "Détail musique"}
backgroundImg={ backgroundImg={
action === "userProfile" action === "userProfile"
@@ -993,11 +997,12 @@ const MusicDetails = ({ route }) => {
style={{ style={{
flex: 1, flex: 1,
paddingBottom: gutters * 2, paddingBottom: gutters * 2,
flexDirection: "row", flexDirection: isCompactLayout ? "column" : "row",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: isCompactLayout ? "stretch" : "center",
height: "auto", height: "auto",
gap: 24, gap: isCompactLayout ? 16 : 24,
width: "100%",
}} }}
> >
{/* Left column: player */} {/* Left column: player */}
@@ -1005,13 +1010,14 @@ const MusicDetails = ({ route }) => {
intensity={40} intensity={40}
style={{ style={{
flex: 1, flex: 1,
paddingRight: 8, paddingRight: isCompactLayout ? 0 : 8,
height: 400, height: isCompactLayout ? undefined : 400,
borderRadius: 12, borderRadius: 12,
padding: 10, padding: 10,
borderWidth: 1, borderWidth: 1,
borderColor: Palette.transparentWhite, borderColor: Palette.transparentWhite,
maxWidth: "50%", maxWidth: isCompactLayout ? "100%" : "50%",
width: "100%",
}} }}
> >
<View> <View>
@@ -1159,20 +1165,25 @@ const MusicDetails = ({ route }) => {
intensity={40} intensity={40}
style={{ style={{
flex: 1, flex: 1,
paddingLeft: 8, paddingLeft: isCompactLayout ? 0 : 8,
padding: 10, padding: 10,
borderWidth: 1, borderWidth: 1,
borderColor: Palette.transparentWhite, borderColor: Palette.transparentWhite,
borderRadius: 12, borderRadius: 12,
height: 400, height: isCompactLayout ? undefined : 400,
maxWidth: "50%", maxWidth: isCompactLayout ? "100%" : "50%",
width: "100%",
marginTop: isCompactLayout ? 12 : 0,
}} }}
> >
{sections.length ? ( {sections.length ? (
<ScrollView <ScrollView
ref={lyricsRef} ref={lyricsRef}
style={{ flex: 1 }} style={{ flex: 1 }}
contentContainerStyle={{ paddingBottom: 40, height: 400 }} contentContainerStyle={{
paddingBottom: 40,
...(isCompactLayout ? {} : { height: 400 }),
}}
> >
{sections.map((section, sectionIdx) => ( {sections.map((section, sectionIdx) => (
<View <View
@@ -1231,7 +1242,10 @@ const MusicDetails = ({ route }) => {
) : description?.length > 0 ? ( ) : description?.length > 0 ? (
<ScrollView <ScrollView
style={{ flex: 1 }} style={{ flex: 1 }}
contentContainerStyle={{ paddingBottom: 40 }} contentContainerStyle={{
paddingBottom: 40,
...(isCompactLayout ? {} : { height: 400 }),
}}
> >
<Text <Text
style={{ style={{
+192 -31
View File
@@ -8,7 +8,7 @@ import React, {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { Text, TouchableOpacity, View } from "react-native"; import { BackHandler, Text, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import Svg, { Circle } from "react-native-svg"; import Svg, { Circle } from "react-native-svg";
import alert from "../../components/Alert"; import alert from "../../components/Alert";
@@ -54,6 +54,10 @@ const RecordPlayback = ({ route }) => {
const checkSongEndRef = useRef(null); const checkSongEndRef = useRef(null);
const stopRequestedRef = useRef(false); const stopRequestedRef = useRef(false);
const restartRequestedRef = useRef(false); const restartRequestedRef = useRef(false);
const manualRestartInFlightRef = useRef(false);
const stopRequestedAtRef = useRef(0);
const activeRecordingPromiseRef = useRef(null);
const exitRequestedRef = useRef(false);
const startedRef = useRef(false); // empêche les doubles démarrages const startedRef = useRef(false); // empêche les doubles démarrages
const countdownActiveRef = useRef(false); // évite le déclenchement avant 1er tick const countdownActiveRef = useRef(false); // évite le déclenchement avant 1er tick
const playbackStartedRef = useRef(false); // devient vrai lorsque l'audio progresse réellement const playbackStartedRef = useRef(false); // devient vrai lorsque l'audio progresse réellement
@@ -259,7 +263,11 @@ const RecordPlayback = ({ route }) => {
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
// Reset complet // Reset complet
const resetSession = useCallback(async () => { const resetSession = useCallback(
async ({
preserveSongEndWatcher = false,
preserveStopRequest = false,
} = {}) => {
try { try {
if (countdownTimerRef.current) { if (countdownTimerRef.current) {
clearInterval(countdownTimerRef.current); clearInterval(countdownTimerRef.current);
@@ -271,13 +279,20 @@ const RecordPlayback = ({ route }) => {
log("listenTimerRef cleared"); log("listenTimerRef cleared");
} }
if (checkSongEndRef.current) { if (checkSongEndRef.current) {
if (preserveSongEndWatcher) {
log("checkSongEndRef preserved");
} else {
clearInterval(checkSongEndRef.current); clearInterval(checkSongEndRef.current);
checkSongEndRef.current = null; checkSongEndRef.current = null;
log("checkSongEndRef cleared"); log("checkSongEndRef cleared");
} }
}
startedRef.current = false; startedRef.current = false;
if (!preserveStopRequest) {
stopRequestedRef.current = false; stopRequestedRef.current = false;
stopRequestedAtRef.current = 0;
}
countdownActiveRef.current = false; countdownActiveRef.current = false;
listenedMsRef.current = 0; listenedMsRef.current = 0;
incrementDoneRef.current = false; incrementDoneRef.current = false;
@@ -297,7 +312,9 @@ const RecordPlayback = ({ route }) => {
} catch (_) {} } catch (_) {}
} }
} catch (_) {} } catch (_) {}
}, [player]); },
[player]
);
const resetSessionRef = useRef(resetSession); const resetSessionRef = useRef(resetSession);
useEffect(() => { useEffect(() => {
@@ -307,6 +324,7 @@ const RecordPlayback = ({ route }) => {
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
log("Screen focused, resetting session"); log("Screen focused, resetting session");
exitRequestedRef.current = false;
void resetSessionRef.current?.(); void resetSessionRef.current?.();
return () => { return () => {
log("Screen blurred, stopping playback"); log("Screen blurred, stopping playback");
@@ -337,15 +355,47 @@ const RecordPlayback = ({ route }) => {
}, [setLooping]) }, [setLooping])
); );
const discardRecordingFile = useCallback(async (uri, reason = "") => {
if (!uri) return;
try {
const info = await FileSystem.getInfoAsync(uri);
if (info?.exists) {
await FileSystem.deleteAsync(uri, { idempotent: true });
log("Discarded recording file", { reason: reason || "cleanup" });
}
} catch (error) {
log("Failed to discard recording", {
reason: reason || "cleanup",
message: error?.message || String(error || ""),
});
}
}, []);
// Lancer le compte à rebours (le tick décrémente uniquement) // Lancer le compte à rebours (le tick décrémente uniquement)
const startCountdownThenRecord = async () => { const startCountdownThenRecord = async ({
preserveRestartFlag = false,
preserveSongEndWatcher = false,
preserveStopRequest = false,
} = {}) => {
if (!songUrl) { if (!songUrl) {
log("startCountdownThenRecord aborted: missing song URL"); log("startCountdownThenRecord aborted: missing song URL");
return; return;
} }
log("startCountdownThenRecord invoked", { projectId, songUrl }); log("startCountdownThenRecord invoked", {
await resetSession(); projectId,
songUrl,
preserveRestartFlag,
preserveSongEndWatcher,
preserveStopRequest,
});
await resetSession({
preserveSongEndWatcher,
preserveStopRequest,
});
if (!preserveRestartFlag) {
restartRequestedRef.current = false; restartRequestedRef.current = false;
manualRestartInFlightRef.current = false;
}
setIsPreparing(true); setIsPreparing(true);
setShowProgress(false); setShowProgress(false);
setCountdown(5); setCountdown(5);
@@ -387,6 +437,7 @@ const RecordPlayback = ({ route }) => {
const startRecordingWithMusic = async () => { const startRecordingWithMusic = async () => {
try { try {
stopRequestedRef.current = false; stopRequestedRef.current = false;
stopRequestedAtRef.current = 0;
listenedMsRef.current = 0; listenedMsRef.current = 0;
incrementDoneRef.current = false; incrementDoneRef.current = false;
@@ -399,6 +450,18 @@ const RecordPlayback = ({ route }) => {
songUrl, songUrl,
hasCamera: !!cameraRef.current, hasCamera: !!cameraRef.current,
}); });
if (activeRecordingPromiseRef.current) {
log("Waiting for previous recording to finish before starting a new one");
try {
await activeRecordingPromiseRef.current;
} catch (error) {
log("Previous recording promise rejected", {
message: error?.message || String(error || ""),
});
}
}
const recordPromise = (() => { const recordPromise = (() => {
const camera = cameraRef.current; const camera = cameraRef.current;
if (!camera) throw new Error("Caméra indisponible"); if (!camera) throw new Error("Caméra indisponible");
@@ -451,6 +514,7 @@ const RecordPlayback = ({ route }) => {
"L'enregistrement vidéo n'est pas supporté sur cet appareil." "L'enregistrement vidéo n'est pas supporté sur cet appareil."
); );
})(); })();
activeRecordingPromiseRef.current = recordPromise;
if (player && songUrl) { if (player && songUrl) {
try { try {
@@ -501,7 +565,18 @@ const RecordPlayback = ({ route }) => {
log("Song end watcher armed"); log("Song end watcher armed");
checkSongEndRef.current = setInterval(() => { checkSongEndRef.current = setInterval(() => {
try { try {
if (!player || stopRequestedRef.current) return; if (!player) return;
if (stopRequestedRef.current) {
const sinceLastRequest = Date.now() - (stopRequestedAtRef.current || 0);
if (sinceLastRequest >= 1200) {
stopRequestedAtRef.current = Date.now();
try {
cameraRef.current?.stopRecording?.();
log("stopRecording retried while awaiting stop");
} catch (_) {}
}
return;
}
const duration = (player?.duration || 0) * 1000; const duration = (player?.duration || 0) * 1000;
const currentTime = (player?.currentTime || 0) * 1000; const currentTime = (player?.currentTime || 0) * 1000;
if ( if (
@@ -524,10 +599,7 @@ const RecordPlayback = ({ route }) => {
playing: player?.playing, playing: player?.playing,
}); });
stopRequestedRef.current = true; stopRequestedRef.current = true;
if (checkSongEndRef.current) { stopRequestedAtRef.current = Date.now();
clearInterval(checkSongEndRef.current);
checkSongEndRef.current = null;
}
try { try {
cameraRef.current?.stopRecording?.(); cameraRef.current?.stopRecording?.();
log("stopRecording triggered"); log("stopRecording triggered");
@@ -539,6 +611,9 @@ const RecordPlayback = ({ route }) => {
const video = await recordPromise; const video = await recordPromise;
log("Recording promise resolved", { hasVideo: !!video?.uri }); log("Recording promise resolved", { hasVideo: !!video?.uri });
activeRecordingPromiseRef.current = null;
stopRequestedRef.current = false;
stopRequestedAtRef.current = 0;
if (checkSongEndRef.current) { if (checkSongEndRef.current) {
clearInterval(checkSongEndRef.current); clearInterval(checkSongEndRef.current);
@@ -559,30 +634,31 @@ const RecordPlayback = ({ route }) => {
log("Recording flow completed", { hasVideo: !!video?.uri }); log("Recording flow completed", { hasVideo: !!video?.uri });
playbackStartedRef.current = false; playbackStartedRef.current = false;
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 }; progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 };
const exitRequested = exitRequestedRef.current;
const shouldRestart = restartRequestedRef.current; const shouldRestart = restartRequestedRef.current;
if (exitRequested) {
exitRequestedRef.current = false;
restartRequestedRef.current = false;
manualRestartInFlightRef.current = false;
await discardRecordingFile(video?.uri, "exit");
log("Recording aborted before completion, skipping navigation");
return;
}
if (shouldRestart) { if (shouldRestart) {
restartRequestedRef.current = false; restartRequestedRef.current = false;
} await discardRecordingFile(video?.uri, "restart");
const manualRestartPending = manualRestartInFlightRef.current;
if (video?.uri && shouldRestart) { manualRestartInFlightRef.current = false;
try { if (manualRestartPending) {
const info = await FileSystem.getInfoAsync(video.uri); log("Manual restart already scheduled, waiting for countdown");
if (info?.exists) { } else {
await FileSystem.deleteAsync(video.uri, { idempotent: true });
log("Discarded interim recording file");
}
} catch (error) {
log("Failed to discard interim recording", {
message: error?.message || String(error || ""),
});
}
}
if (shouldRestart) {
log("Restart requested, relaunching countdown"); log("Restart requested, relaunching countdown");
requestAnimationFrame(() => { requestAnimationFrame(() => {
void startCountdownThenRecord(); void startCountdownThenRecord();
}); });
}
return; return;
} }
@@ -602,6 +678,9 @@ const RecordPlayback = ({ route }) => {
message: e?.message || String(e || ""), message: e?.message || String(e || ""),
}); });
console.log("RecordPlayback error:", e); console.log("RecordPlayback error:", e);
activeRecordingPromiseRef.current = null;
stopRequestedRef.current = false;
stopRequestedAtRef.current = 0;
setIsRecording(false); setIsRecording(false);
setIsPreparing(false); setIsPreparing(false);
setShowProgress(false); setShowProgress(false);
@@ -617,13 +696,27 @@ const RecordPlayback = ({ route }) => {
} }
playbackStartedRef.current = false; playbackStartedRef.current = false;
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 }; progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 };
const exitRequested = exitRequestedRef.current;
const shouldRestart = restartRequestedRef.current; const shouldRestart = restartRequestedRef.current;
if (exitRequested) {
exitRequestedRef.current = false;
restartRequestedRef.current = false;
manualRestartInFlightRef.current = false;
log("Recording aborted, skipping error handling");
return;
}
if (shouldRestart) { if (shouldRestart) {
restartRequestedRef.current = false; restartRequestedRef.current = false;
const manualRestartPending = manualRestartInFlightRef.current;
manualRestartInFlightRef.current = false;
if (manualRestartPending) {
log("Manual restart already scheduled after error");
} else {
log("Restart requested despite error, restarting flow"); log("Restart requested despite error, restarting flow");
requestAnimationFrame(() => { requestAnimationFrame(() => {
void startCountdownThenRecord(); void startCountdownThenRecord();
}); });
}
return; return;
} }
// Inform the user when using a simulator where recording isn't supported // Inform the user when using a simulator where recording isn't supported
@@ -648,25 +741,94 @@ const RecordPlayback = ({ route }) => {
} }
}; };
const handleBackPress = useCallback(() => {
const busy = isPreparing || isRecording;
log("Back button pressed", { isPreparing, isRecording, busy });
if (busy) {
exitRequestedRef.current = true;
restartRequestedRef.current = false;
manualRestartInFlightRef.current = false;
stopRequestedRef.current = true;
stopRequestedAtRef.current = Date.now();
if (isPreparing && countdownTimerRef.current) {
clearInterval(countdownTimerRef.current);
countdownTimerRef.current = null;
}
try {
if (player?.playing) {
const maybePromise = player.pause?.();
if (maybePromise && typeof maybePromise.catch === "function") {
maybePromise.catch(() => {});
}
}
} catch (_) {}
try {
if (isRecording) {
cameraRef.current?.stopRecording?.();
}
} catch (_) {}
} else {
exitRequestedRef.current = false;
}
try {
goBack();
} catch (_) {}
return true;
}, [goBack, isPreparing, isRecording, player]);
useFocusEffect(
useCallback(() => {
const subscription = BackHandler.addEventListener(
"hardwareBackPress",
() => handleBackPress()
);
return () => subscription.remove();
}, [handleBackPress])
);
const handleRestartRecording = async () => { const handleRestartRecording = async () => {
try { try {
const hasRecordingPending = !!activeRecordingPromiseRef.current;
log("Restart button pressed", { log("Restart button pressed", {
isPreparing, isPreparing,
isRecording, isRecording,
hasRecordingPending,
}); });
if (isPreparing || !isRecording) { if (isPreparing) {
if (hasRecordingPending) {
await startCountdownThenRecord({
preserveRestartFlag: true,
preserveSongEndWatcher: true,
preserveStopRequest: true,
});
return;
}
restartRequestedRef.current = false; restartRequestedRef.current = false;
manualRestartInFlightRef.current = false;
await startCountdownThenRecord();
return;
}
if (!isRecording) {
restartRequestedRef.current = false;
manualRestartInFlightRef.current = false;
await startCountdownThenRecord(); await startCountdownThenRecord();
return; return;
} }
restartRequestedRef.current = true; restartRequestedRef.current = true;
manualRestartInFlightRef.current = true;
stopRequestedRef.current = true; stopRequestedRef.current = true;
stopRequestedAtRef.current = Date.now();
try { try {
if (player?.playing) await player.pause?.(); if (player?.playing) await player.pause?.();
} catch (_) {} } catch (_) {}
try { try {
cameraRef.current?.stopRecording?.(); cameraRef.current?.stopRecording?.();
} catch (_) {} } catch (_) {}
await startCountdownThenRecord({
preserveRestartFlag: true,
preserveSongEndWatcher: true,
preserveStopRequest: true,
});
} catch (_) {} } catch (_) {}
}; };
@@ -693,7 +855,7 @@ const RecordPlayback = ({ route }) => {
paddingBottom: gutters * 2, paddingBottom: gutters * 2,
}} }}
> >
<MusicLandHeader progress={9} onPressBack={goBack} /> <MusicLandHeader progress={9} onPressBack={handleBackPress} />
<View style={{ marginTop: 12, alignItems: "flex-end" }}> <View style={{ marginTop: 12, alignItems: "flex-end" }}>
<CameraFacingSelector <CameraFacingSelector
@@ -869,7 +1031,6 @@ const ProgressRing = ({ size = 50, strokeWidth = 12, progress = 0 }) => {
height={size} height={size}
style={{ style={{
transform: [{ rotate: "-90deg" }], transform: [{ rotate: "-90deg" }],
backgroundColor: "#ffffff3d",
borderRadius: size / 2, borderRadius: size / 2,
}} }}
> >
+43 -1
View File
@@ -202,9 +202,51 @@ const SongReady = () => {
const handleRegeneratePress = () => { const handleRegeneratePress = () => {
if (isWeb) { if (isWeb) {
const descriptionTextStyle = {
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
textAlign: "center",
};
const amountTextStyle = {
...descriptionTextStyle,
fontFamily: FONT_FAMILY.InterSemiBold,
};
alert( alert(
"Re-générer le morceau", "Re-générer le morceau",
`Tu vas pouvoir modifier tes choix pour re-générer ${SONG_OPTIONS_PER_GENERATION} nouveaux morceaux pour ${MUSIC_GENERATION_COIN_COST} crédits.\n\nLes crédits seront utilisés lors de l'étape de génération.`, (
<View
style={{
width: "100%",
alignItems: "center",
gap: 12,
}}
>
<Text style={descriptionTextStyle}>
{`Tu vas pouvoir modifier tes choix pour re-générer ${SONG_OPTIONS_PER_GENERATION} nouveaux morceaux.`}
</Text>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
flexWrap: "wrap",
gap: 6,
}}
>
<Text style={descriptionTextStyle}>Cette action coûte</Text>
<CreditAmount
value={MUSIC_GENERATION_COIN_COST}
iconSize={18}
textStyle={amountTextStyle}
/>
</View>
<Text style={descriptionTextStyle}>
Les crédits seront utilisés lors de l'étape de génération.
</Text>
</View>
),
[ [
{ {
text: "Annuler", text: "Annuler",
+5
View File
@@ -8,6 +8,11 @@
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1.00001, user-scalable=no, viewport-fit=cover" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1.00001, user-scalable=no, viewport-fit=cover"
/> />
<meta
http-equiv="Permissions-Policy"
content="payment=(self \"https://checkout.stripe.com\")"
/>
<meta name="description" content="MusicLand" /> <meta name="description" content="MusicLand" />
<meta <meta
name="keywords" name="keywords"