stripe embedded and other fixes
This commit is contained in:
@@ -390,6 +390,7 @@ const formatCheckoutSessionResponse = (session) => ({
|
||||
amount_total: session.amount_total,
|
||||
created: session.created,
|
||||
expires_at: session.expires_at,
|
||||
client_secret: session.client_secret || null,
|
||||
});
|
||||
|
||||
const getPortalConfigurationId = async (stripe) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
const REGION = process.env.FIREBASE_REGION || "europe-west1";
|
||||
|
||||
module.exports = {
|
||||
REGION,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -27,6 +27,21 @@ const WebAlertModal = ({ title, description, options }) => {
|
||||
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 (
|
||||
<PortalProvider>
|
||||
<Overlay
|
||||
@@ -42,7 +57,7 @@ const WebAlertModal = ({ title, description, options }) => {
|
||||
>
|
||||
<BlurView intensity={100} tint="dark" style={styles.modalContainer}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.description}>{description}</Text>
|
||||
{renderDescription()}
|
||||
<View style={styles.divider} />
|
||||
<View
|
||||
style={hasSecondaryAction ? styles.actionsRow : styles.actions}
|
||||
@@ -152,6 +167,9 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: "rgba(255,255,255,0.08)",
|
||||
marginVertical: 0,
|
||||
},
|
||||
customDescription: {
|
||||
width: "100%",
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: "row",
|
||||
gap: gutters,
|
||||
|
||||
+100
-51
@@ -25,10 +25,23 @@ const STRIPE_SUCCESS_URL =
|
||||
"https://dashboard.stripe.com/test/billing/starter-guide/checkout-success";
|
||||
const STRIPE_CANCEL_URL = STRIPE_SUCCESS_URL;
|
||||
|
||||
const isStripeTesting = false;
|
||||
const stripePromise = loadStripe(
|
||||
isStripeTesting ? STRIPE_PUBLISHABLE_KEY_TEST : STRIPE_PUBLISHABLE_KEY_LIVE,
|
||||
);
|
||||
const isStripeTesting = true;
|
||||
const rawStripePublishableKey = isStripeTesting
|
||||
? 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 = () => {
|
||||
if (
|
||||
@@ -86,6 +99,7 @@ export const useStripe = () => {
|
||||
const StripeProvider = ({ children }) => {
|
||||
const { isMobile } = useLayoutType();
|
||||
const { currentUserData } = useUserData() || {};
|
||||
const canUseEmbeddedCheckout = isWeb && Boolean(stripePromise);
|
||||
|
||||
const [clientSecret, setClientSecret] = React.useState(null);
|
||||
const [subscriptions, setSubscriptions] = React.useState({
|
||||
@@ -118,33 +132,12 @@ const StripeProvider = ({ children }) => {
|
||||
}
|
||||
|
||||
if (isWeb) {
|
||||
if (safariWindow && !safariWindow.closed) {
|
||||
try {
|
||||
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",
|
||||
throw new Error(
|
||||
"Impossible d'ouvrir le paiement Stripe sans checkout intégré.",
|
||||
);
|
||||
if (openedTab) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await Linking.openURL(checkoutUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const isMobileApp =
|
||||
Platform.OS === "ios" || Platform.OS === "android";
|
||||
const isMobileApp = Platform.OS === "ios" || Platform.OS === "android";
|
||||
|
||||
if (isMobileApp) {
|
||||
try {
|
||||
@@ -173,17 +166,37 @@ const StripeProvider = ({ children }) => {
|
||||
);
|
||||
|
||||
const runCheckoutSession = React.useCallback(
|
||||
async ({ callableName, payload, logTag }) => {
|
||||
const safariWindow = openSafariCheckoutWindow();
|
||||
try {
|
||||
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
|
||||
callableName,
|
||||
async ({ callableName, payload, logTag, useEmbeddedFlow = false }) => {
|
||||
if (useEmbeddedFlow && !canUseEmbeddedCheckout) {
|
||||
throw new Error(
|
||||
"Le paiement intégré Stripe est indisponible sur cette plateforme.",
|
||||
);
|
||||
}
|
||||
|
||||
const wantsEmbeddedCheckout = useEmbeddedFlow && canUseEmbeddedCheckout;
|
||||
const safariWindow = wantsEmbeddedCheckout
|
||||
? null
|
||||
: openSafariCheckoutWindow();
|
||||
try {
|
||||
const callable =
|
||||
getFunctionsClient(FUNCTIONS_REGION).httpsCallable(callableName);
|
||||
const { data } = await callable(payload);
|
||||
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) {
|
||||
throw new Error("Session Stripe introuvable.");
|
||||
}
|
||||
|
||||
console.log("[StripeProvider] Stripe checkout URL", checkoutUrl);
|
||||
await redirectToCheckout(checkoutUrl, { safariWindow });
|
||||
} catch (error) {
|
||||
if (safariWindow && !safariWindow.closed) {
|
||||
@@ -196,7 +209,7 @@ const StripeProvider = ({ children }) => {
|
||||
);
|
||||
}
|
||||
},
|
||||
[redirectToCheckout],
|
||||
[canUseEmbeddedCheckout, redirectToCheckout, setClientSecret],
|
||||
);
|
||||
|
||||
const createSubscriptionCheckout = React.useCallback(
|
||||
@@ -204,6 +217,19 @@ const StripeProvider = ({ children }) => {
|
||||
if (!priceId) {
|
||||
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({
|
||||
callableName: "subscription-createSubscriptionCheckoutSession",
|
||||
payload: {
|
||||
@@ -212,11 +238,13 @@ const StripeProvider = ({ children }) => {
|
||||
successUrl: STRIPE_SUCCESS_URL,
|
||||
cancelUrl: STRIPE_CANCEL_URL,
|
||||
},
|
||||
uiMode: shouldUseEmbeddedCheckout ? "embedded" : "hosted",
|
||||
},
|
||||
logTag: "subscription checkout error",
|
||||
useEmbeddedFlow: shouldUseEmbeddedCheckout,
|
||||
});
|
||||
},
|
||||
[runCheckoutSession],
|
||||
[canUseEmbeddedCheckout, runCheckoutSession],
|
||||
);
|
||||
|
||||
const createCoinPackCheckout = React.useCallback(
|
||||
@@ -224,6 +252,19 @@ const StripeProvider = ({ children }) => {
|
||||
if (!productId) {
|
||||
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({
|
||||
callableName: "subscription-createCoinPackCheckoutSession",
|
||||
payload: {
|
||||
@@ -232,11 +273,13 @@ const StripeProvider = ({ children }) => {
|
||||
successUrl: STRIPE_SUCCESS_URL,
|
||||
cancelUrl: STRIPE_CANCEL_URL,
|
||||
},
|
||||
uiMode: shouldUseEmbeddedCheckout ? "embedded" : "hosted",
|
||||
},
|
||||
logTag: "coin pack checkout error",
|
||||
useEmbeddedFlow: shouldUseEmbeddedCheckout,
|
||||
});
|
||||
},
|
||||
[runCheckoutSession],
|
||||
[canUseEmbeddedCheckout, runCheckoutSession],
|
||||
);
|
||||
|
||||
const fetchActiveSubscription = React.useCallback(async () => {
|
||||
@@ -252,9 +295,9 @@ const StripeProvider = ({ children }) => {
|
||||
setIsActiveSubscriptionLoading(true);
|
||||
setActiveSubscriptionError(null);
|
||||
try {
|
||||
const callable = getFunctionsClient(
|
||||
FUNCTIONS_REGION,
|
||||
).httpsCallable("subscription-getActiveSubscription");
|
||||
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
|
||||
"subscription-getActiveSubscription",
|
||||
);
|
||||
const { data } = await callable();
|
||||
setActiveSubscription(data?.subscription || null);
|
||||
} catch (error) {
|
||||
@@ -276,8 +319,9 @@ const StripeProvider = ({ children }) => {
|
||||
let lastError = null;
|
||||
|
||||
try {
|
||||
const callable =
|
||||
functionsClient.httpsCallable("subscription-listSubscriptionPlans");
|
||||
const callable = functionsClient.httpsCallable(
|
||||
"subscription-listSubscriptionPlans",
|
||||
);
|
||||
const { data } = await callable();
|
||||
const nextPlans = data?.plans || {};
|
||||
setSubscriptions({
|
||||
@@ -290,8 +334,9 @@ const StripeProvider = ({ children }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const callable =
|
||||
functionsClient.httpsCallable("subscription-listCoinPacks");
|
||||
const callable = functionsClient.httpsCallable(
|
||||
"subscription-listCoinPacks",
|
||||
);
|
||||
const { data } = await callable();
|
||||
setCoinPacks(Array.isArray(data?.packs) ? data.packs : []);
|
||||
} catch (error) {
|
||||
@@ -382,23 +427,28 @@ const StripeProvider = ({ children }) => {
|
||||
isVisible={clientSecret !== null}
|
||||
setIsVisible={closeEmbeddedCheckout}
|
||||
>
|
||||
<View
|
||||
style={[StyleSheet.absoluteFillObject, styles.overlayContent]}
|
||||
>
|
||||
<View style={[StyleSheet.absoluteFillObject, styles.overlayContent]}>
|
||||
<View
|
||||
style={[
|
||||
styles.embeddedWrapper,
|
||||
{
|
||||
width: isMobile ? "90%" : "80%",
|
||||
width: isMobile ? "99%" : "95%",
|
||||
height: isMobile ? "92vh" : "96vh",
|
||||
maxWidth: isMobile ? "100%" : "1400px",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{clientSecret ? (
|
||||
{clientSecret && stripePromise ? (
|
||||
<EmbeddedCheckoutProvider
|
||||
stripe={stripePromise}
|
||||
options={{ clientSecret }}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
<EmbeddedCheckout
|
||||
onComplete={() => {
|
||||
closeEmbeddedCheckout();
|
||||
fetchActiveSubscription();
|
||||
}}
|
||||
/>
|
||||
</EmbeddedCheckoutProvider>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -422,9 +472,8 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
embeddedWrapper: {
|
||||
alignSelf: "center",
|
||||
height: "70vh",
|
||||
borderRadius: mainBorderRadius,
|
||||
overflow: "scroll",
|
||||
overflow: "hidden",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import {
|
||||
@@ -74,6 +75,8 @@ const MusicDetails = ({ route }) => {
|
||||
const hasAutoPlayedRef = React.useRef(false);
|
||||
const { isLooping, setLooping } = usePlayer() || {};
|
||||
const isWeb = Platform.OS === "web";
|
||||
const { width: windowWidth = 0 } = useWindowDimensions();
|
||||
const isCompactLayout = (windowWidth || 0) < 1200;
|
||||
const handleBackPress = useCallback(() => {
|
||||
goBack();
|
||||
}, []);
|
||||
@@ -964,6 +967,7 @@ const MusicDetails = ({ route }) => {
|
||||
headerType="NAVIGATION"
|
||||
hideBackButton={isWeb}
|
||||
topStickyContent={renderWebBackButton}
|
||||
width={isCompactLayout ? "100%" : undefined}
|
||||
title={action === "userProfile" ? "Mon profil" : "Détail musique"}
|
||||
backgroundImg={
|
||||
action === "userProfile"
|
||||
@@ -993,11 +997,12 @@ const MusicDetails = ({ route }) => {
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingBottom: gutters * 2,
|
||||
flexDirection: "row",
|
||||
flexDirection: isCompactLayout ? "column" : "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
alignItems: isCompactLayout ? "stretch" : "center",
|
||||
height: "auto",
|
||||
gap: 24,
|
||||
gap: isCompactLayout ? 16 : 24,
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{/* Left column: player */}
|
||||
@@ -1005,13 +1010,14 @@ const MusicDetails = ({ route }) => {
|
||||
intensity={40}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingRight: 8,
|
||||
height: 400,
|
||||
paddingRight: isCompactLayout ? 0 : 8,
|
||||
height: isCompactLayout ? undefined : 400,
|
||||
borderRadius: 12,
|
||||
padding: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: Palette.transparentWhite,
|
||||
maxWidth: "50%",
|
||||
maxWidth: isCompactLayout ? "100%" : "50%",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<View>
|
||||
@@ -1159,20 +1165,25 @@ const MusicDetails = ({ route }) => {
|
||||
intensity={40}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingLeft: 8,
|
||||
paddingLeft: isCompactLayout ? 0 : 8,
|
||||
padding: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: Palette.transparentWhite,
|
||||
borderRadius: 12,
|
||||
height: 400,
|
||||
maxWidth: "50%",
|
||||
height: isCompactLayout ? undefined : 400,
|
||||
maxWidth: isCompactLayout ? "100%" : "50%",
|
||||
width: "100%",
|
||||
marginTop: isCompactLayout ? 12 : 0,
|
||||
}}
|
||||
>
|
||||
{sections.length ? (
|
||||
<ScrollView
|
||||
ref={lyricsRef}
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{ paddingBottom: 40, height: 400 }}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 40,
|
||||
...(isCompactLayout ? {} : { height: 400 }),
|
||||
}}
|
||||
>
|
||||
{sections.map((section, sectionIdx) => (
|
||||
<View
|
||||
@@ -1231,7 +1242,10 @@ const MusicDetails = ({ route }) => {
|
||||
) : description?.length > 0 ? (
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{ paddingBottom: 40 }}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 40,
|
||||
...(isCompactLayout ? {} : { height: 400 }),
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
|
||||
@@ -8,7 +8,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} 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 Svg, { Circle } from "react-native-svg";
|
||||
import alert from "../../components/Alert";
|
||||
@@ -54,6 +54,10 @@ const RecordPlayback = ({ route }) => {
|
||||
const checkSongEndRef = useRef(null);
|
||||
const stopRequestedRef = 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 countdownActiveRef = useRef(false); // évite le déclenchement avant 1er tick
|
||||
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
|
||||
|
||||
// Reset complet
|
||||
const resetSession = useCallback(async () => {
|
||||
const resetSession = useCallback(
|
||||
async ({
|
||||
preserveSongEndWatcher = false,
|
||||
preserveStopRequest = false,
|
||||
} = {}) => {
|
||||
try {
|
||||
if (countdownTimerRef.current) {
|
||||
clearInterval(countdownTimerRef.current);
|
||||
@@ -271,13 +279,20 @@ const RecordPlayback = ({ route }) => {
|
||||
log("listenTimerRef cleared");
|
||||
}
|
||||
if (checkSongEndRef.current) {
|
||||
if (preserveSongEndWatcher) {
|
||||
log("checkSongEndRef preserved");
|
||||
} else {
|
||||
clearInterval(checkSongEndRef.current);
|
||||
checkSongEndRef.current = null;
|
||||
log("checkSongEndRef cleared");
|
||||
}
|
||||
}
|
||||
|
||||
startedRef.current = false;
|
||||
if (!preserveStopRequest) {
|
||||
stopRequestedRef.current = false;
|
||||
stopRequestedAtRef.current = 0;
|
||||
}
|
||||
countdownActiveRef.current = false;
|
||||
listenedMsRef.current = 0;
|
||||
incrementDoneRef.current = false;
|
||||
@@ -297,7 +312,9 @@ const RecordPlayback = ({ route }) => {
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
}, [player]);
|
||||
},
|
||||
[player]
|
||||
);
|
||||
|
||||
const resetSessionRef = useRef(resetSession);
|
||||
useEffect(() => {
|
||||
@@ -307,6 +324,7 @@ const RecordPlayback = ({ route }) => {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
log("Screen focused, resetting session");
|
||||
exitRequestedRef.current = false;
|
||||
void resetSessionRef.current?.();
|
||||
return () => {
|
||||
log("Screen blurred, stopping playback");
|
||||
@@ -337,15 +355,47 @@ const RecordPlayback = ({ route }) => {
|
||||
}, [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)
|
||||
const startCountdownThenRecord = async () => {
|
||||
const startCountdownThenRecord = async ({
|
||||
preserveRestartFlag = false,
|
||||
preserveSongEndWatcher = false,
|
||||
preserveStopRequest = false,
|
||||
} = {}) => {
|
||||
if (!songUrl) {
|
||||
log("startCountdownThenRecord aborted: missing song URL");
|
||||
return;
|
||||
}
|
||||
log("startCountdownThenRecord invoked", { projectId, songUrl });
|
||||
await resetSession();
|
||||
log("startCountdownThenRecord invoked", {
|
||||
projectId,
|
||||
songUrl,
|
||||
preserveRestartFlag,
|
||||
preserveSongEndWatcher,
|
||||
preserveStopRequest,
|
||||
});
|
||||
await resetSession({
|
||||
preserveSongEndWatcher,
|
||||
preserveStopRequest,
|
||||
});
|
||||
if (!preserveRestartFlag) {
|
||||
restartRequestedRef.current = false;
|
||||
manualRestartInFlightRef.current = false;
|
||||
}
|
||||
setIsPreparing(true);
|
||||
setShowProgress(false);
|
||||
setCountdown(5);
|
||||
@@ -387,6 +437,7 @@ const RecordPlayback = ({ route }) => {
|
||||
const startRecordingWithMusic = async () => {
|
||||
try {
|
||||
stopRequestedRef.current = false;
|
||||
stopRequestedAtRef.current = 0;
|
||||
listenedMsRef.current = 0;
|
||||
incrementDoneRef.current = false;
|
||||
|
||||
@@ -399,6 +450,18 @@ const RecordPlayback = ({ route }) => {
|
||||
songUrl,
|
||||
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 camera = cameraRef.current;
|
||||
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."
|
||||
);
|
||||
})();
|
||||
activeRecordingPromiseRef.current = recordPromise;
|
||||
|
||||
if (player && songUrl) {
|
||||
try {
|
||||
@@ -501,7 +565,18 @@ const RecordPlayback = ({ route }) => {
|
||||
log("Song end watcher armed");
|
||||
checkSongEndRef.current = setInterval(() => {
|
||||
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 currentTime = (player?.currentTime || 0) * 1000;
|
||||
if (
|
||||
@@ -524,10 +599,7 @@ const RecordPlayback = ({ route }) => {
|
||||
playing: player?.playing,
|
||||
});
|
||||
stopRequestedRef.current = true;
|
||||
if (checkSongEndRef.current) {
|
||||
clearInterval(checkSongEndRef.current);
|
||||
checkSongEndRef.current = null;
|
||||
}
|
||||
stopRequestedAtRef.current = Date.now();
|
||||
try {
|
||||
cameraRef.current?.stopRecording?.();
|
||||
log("stopRecording triggered");
|
||||
@@ -539,6 +611,9 @@ const RecordPlayback = ({ route }) => {
|
||||
|
||||
const video = await recordPromise;
|
||||
log("Recording promise resolved", { hasVideo: !!video?.uri });
|
||||
activeRecordingPromiseRef.current = null;
|
||||
stopRequestedRef.current = false;
|
||||
stopRequestedAtRef.current = 0;
|
||||
|
||||
if (checkSongEndRef.current) {
|
||||
clearInterval(checkSongEndRef.current);
|
||||
@@ -559,30 +634,31 @@ const RecordPlayback = ({ route }) => {
|
||||
log("Recording flow completed", { hasVideo: !!video?.uri });
|
||||
playbackStartedRef.current = false;
|
||||
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 };
|
||||
const exitRequested = exitRequestedRef.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) {
|
||||
restartRequestedRef.current = false;
|
||||
}
|
||||
|
||||
if (video?.uri && shouldRestart) {
|
||||
try {
|
||||
const info = await FileSystem.getInfoAsync(video.uri);
|
||||
if (info?.exists) {
|
||||
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) {
|
||||
await discardRecordingFile(video?.uri, "restart");
|
||||
const manualRestartPending = manualRestartInFlightRef.current;
|
||||
manualRestartInFlightRef.current = false;
|
||||
if (manualRestartPending) {
|
||||
log("Manual restart already scheduled, waiting for countdown");
|
||||
} else {
|
||||
log("Restart requested, relaunching countdown");
|
||||
requestAnimationFrame(() => {
|
||||
void startCountdownThenRecord();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -602,6 +678,9 @@ const RecordPlayback = ({ route }) => {
|
||||
message: e?.message || String(e || ""),
|
||||
});
|
||||
console.log("RecordPlayback error:", e);
|
||||
activeRecordingPromiseRef.current = null;
|
||||
stopRequestedRef.current = false;
|
||||
stopRequestedAtRef.current = 0;
|
||||
setIsRecording(false);
|
||||
setIsPreparing(false);
|
||||
setShowProgress(false);
|
||||
@@ -617,13 +696,27 @@ const RecordPlayback = ({ route }) => {
|
||||
}
|
||||
playbackStartedRef.current = false;
|
||||
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 };
|
||||
const exitRequested = exitRequestedRef.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) {
|
||||
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");
|
||||
requestAnimationFrame(() => {
|
||||
void startCountdownThenRecord();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 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 () => {
|
||||
try {
|
||||
const hasRecordingPending = !!activeRecordingPromiseRef.current;
|
||||
log("Restart button pressed", {
|
||||
isPreparing,
|
||||
isRecording,
|
||||
hasRecordingPending,
|
||||
});
|
||||
if (isPreparing || !isRecording) {
|
||||
if (isPreparing) {
|
||||
if (hasRecordingPending) {
|
||||
await startCountdownThenRecord({
|
||||
preserveRestartFlag: true,
|
||||
preserveSongEndWatcher: true,
|
||||
preserveStopRequest: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
restartRequestedRef.current = false;
|
||||
manualRestartInFlightRef.current = false;
|
||||
await startCountdownThenRecord();
|
||||
return;
|
||||
}
|
||||
if (!isRecording) {
|
||||
restartRequestedRef.current = false;
|
||||
manualRestartInFlightRef.current = false;
|
||||
await startCountdownThenRecord();
|
||||
return;
|
||||
}
|
||||
restartRequestedRef.current = true;
|
||||
manualRestartInFlightRef.current = true;
|
||||
stopRequestedRef.current = true;
|
||||
stopRequestedAtRef.current = Date.now();
|
||||
try {
|
||||
if (player?.playing) await player.pause?.();
|
||||
} catch (_) {}
|
||||
try {
|
||||
cameraRef.current?.stopRecording?.();
|
||||
} catch (_) {}
|
||||
await startCountdownThenRecord({
|
||||
preserveRestartFlag: true,
|
||||
preserveSongEndWatcher: true,
|
||||
preserveStopRequest: true,
|
||||
});
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
@@ -693,7 +855,7 @@ const RecordPlayback = ({ route }) => {
|
||||
paddingBottom: gutters * 2,
|
||||
}}
|
||||
>
|
||||
<MusicLandHeader progress={9} onPressBack={goBack} />
|
||||
<MusicLandHeader progress={9} onPressBack={handleBackPress} />
|
||||
|
||||
<View style={{ marginTop: 12, alignItems: "flex-end" }}>
|
||||
<CameraFacingSelector
|
||||
@@ -869,7 +1031,6 @@ const ProgressRing = ({ size = 50, strokeWidth = 12, progress = 0 }) => {
|
||||
height={size}
|
||||
style={{
|
||||
transform: [{ rotate: "-90deg" }],
|
||||
backgroundColor: "#ffffff3d",
|
||||
borderRadius: size / 2,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -202,9 +202,51 @@ const SongReady = () => {
|
||||
|
||||
const handleRegeneratePress = () => {
|
||||
if (isWeb) {
|
||||
const descriptionTextStyle = {
|
||||
fontSize: 16,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
textAlign: "center",
|
||||
};
|
||||
const amountTextStyle = {
|
||||
...descriptionTextStyle,
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
};
|
||||
|
||||
alert(
|
||||
"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",
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
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="keywords"
|
||||
|
||||
Reference in New Issue
Block a user