stripe embedded and other fixes
This commit is contained in:
@@ -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
@@ -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();
|
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
@@ -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",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user