diff --git a/functions/helpers/stripe.js b/functions/helpers/stripe.js
index 68a76b1..8cf4deb 100644
--- a/functions/helpers/stripe.js
+++ b/functions/helpers/stripe.js
@@ -390,6 +390,7 @@ const formatCheckoutSessionResponse = (session) => ({
amount_total: session.amount_total,
created: session.created,
expires_at: session.expires_at,
+ client_secret: session.client_secret || null,
});
const getPortalConfigurationId = async (stripe) => {
diff --git a/functions/src/subscription.js b/functions/src/subscription.js
deleted file mode 100644
index 297bf53..0000000
--- a/functions/src/subscription.js
+++ /dev/null
@@ -1,2367 +0,0 @@
-const admin = require("firebase-admin");
-const { HttpsError, onCall } = require("firebase-functions/https");
-const { onRequest } = require("firebase-functions/v2/https");
-const { onSchedule } = require("firebase-functions/v2/scheduler");
-const { FieldValue } = require("firebase-admin/firestore");
-const { refList } = require("../index");
-const refsList = refList;
-const { ORDER_TYPES, createOrderDocument } = require("./helpers/orders");
-const { batchFirestore } = require("../helpers/firebase");
-const { BATCH_TYPE } = require("../config/types");
-const {
- getStripeClient,
- buildCheckoutLineItems,
- ensureStripeCustomer,
- getReturnUrls,
- formatCheckoutSessionResponse,
- mapStripeErrorToHttps,
-} = require("../helpers/stripe");
-const { STRIPE_WEBHOOK_SECRET } = require("../config/keys");
-
-const REGION = process.env.FIREBASE_REGION || "europe-west1";
-
-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 paymentsCollection = admin.firestore().collection("payments");
-let cachedStripeWebhookSecret = null;
-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",
-]);
-
-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 HttpsError(
- "failed-precondition",
- "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 findUserByStripeCustomerId = async (customerId) => {
- if (!customerId || !refList?.users) {
- return { uid: null, userRef: null, userData: null };
- }
-
- try {
- const snapshot = await refList.users
- .where("stripeCustomerId", "==", customerId)
- .limit(1)
- .get();
-
- const doc = snapshot?.docs?.[0];
- if (doc?.id) {
- return {
- uid: doc.id,
- userRef: refList.users.doc(doc.id),
- userData: doc.data() || null,
- };
- }
- } catch (error) {
- console.error(
- "[subscription-findUserByStripeCustomerId] Query failed",
- customerId,
- error,
- );
- }
-
- return { uid: null, userRef: null, userData: null };
-};
-
-const resolveUserContext = async ({ metadata, customerId }) => {
- const metadataUid = extractFirebaseUid(metadata);
- if (metadataUid && refList?.users) {
- return {
- uid: metadataUid,
- userRef: refList.users.doc(metadataUid),
- userData: null,
- };
- }
-
- return findUserByStripeCustomerId(customerId);
-};
-
-const upsertPaymentDocument = async (docId, data = {}) => {
- if (!docId) {
- return null;
- }
-
- try {
- const docRef = paymentsCollection.doc(docId);
- const snapshot = await docRef.get();
-
- const payload = {
- ...data,
- updatedAt: getServerTimestamp(),
- };
-
- if (!snapshot.exists) {
- payload.createdAt = getServerTimestamp();
- }
-
- await docRef.set(payload, { merge: true });
- return docRef;
- } catch (error) {
- console.error(
- "[subscription-upsertPaymentDocument] Failed to persist payment",
- docId,
- error,
- );
- return null;
- }
-};
-
-const buildEventSnapshot = (type, referenceId) => ({
- type: type || null,
- referenceId: referenceId || null,
- receivedAt: getServerTimestamp(),
-});
-
-const buildSubscriptionPayload = (subscription) => {
- if (!subscription || typeof subscription !== "object") {
- return null;
- }
-
- const itemList = Array.isArray(subscription?.items?.data)
- ? subscription.items.data
- : [];
-
- const items = itemList.map((item) => {
- const stripePrice =
- item?.price && typeof item.price === "object" ? item.price : null;
- return {
- id: item?.id || null,
- priceId:
- stripePrice?.id ||
- (typeof item?.price === "string" ? item.price : null) ||
- null,
- productId: stripePrice?.product || null,
- currency: stripePrice?.currency || null,
- unitAmount: stripePrice?.unit_amount ?? null,
- recurring: stripePrice?.recurring || null,
- metadata:
- stripePrice && typeof stripePrice.metadata === "object"
- ? stripePrice.metadata
- : {},
- quantity: item?.quantity ?? null,
- };
- });
-
- const primaryItem = items[0] || null;
-
- return {
- id: subscription.id || null,
- status: subscription.status || null,
- customerId: subscription.customer || null,
- priceId: primaryItem?.priceId || null,
- productId: primaryItem?.productId || null,
- cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
- cancelAt: toFirestoreTimestamp(subscription.cancel_at),
- canceledAt: toFirestoreTimestamp(subscription.canceled_at),
- createdAt: toFirestoreTimestamp(subscription.created),
- currentPeriodStart: toFirestoreTimestamp(subscription.current_period_start),
- currentPeriodEnd: toFirestoreTimestamp(subscription.current_period_end),
- endedAt: toFirestoreTimestamp(subscription.ended_at),
- trialStart: toFirestoreTimestamp(subscription.trial_start),
- trialEnd: toFirestoreTimestamp(subscription.trial_end),
- latestInvoiceId: subscription.latest_invoice || null,
- items,
- metadata: subscription.metadata || {},
- };
-};
-
-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,
- );
- }
- }
-
- if (coinsPerMonth === null && priceMeta?.coinsPerMonth) {
- coinsPerMonth = priceMeta.coinsPerMonth;
- console.log(
- "[subscription-handleCustomerSubscriptionEvent] Using static allowance from price metadata",
- {
- subscriptionId: subscription.id,
- priceId: subscriptionPayload?.priceId || null,
- coinsPerMonth,
- },
- );
- }
-
- if (
- coinsPerMonth === null &&
- typeof subscription.metadata?.coinsPerMonth !== "undefined"
- ) {
- const metaCoins = toFiniteNumber(subscription.metadata.coinsPerMonth);
- if (metaCoins && metaCoins > 0) {
- coinsPerMonth = Math.round(metaCoins);
- console.log(
- "[subscription-handleCustomerSubscriptionEvent] Using allowance from subscription metadata",
- {
- subscriptionId: subscription.id,
- priceId: subscriptionPayload?.priceId || null,
- coinsPerMonth,
- },
- );
- }
- }
-
- if (coinsPerMonth === null && resolvedLevel) {
- const allowance = SUBSCRIPTION_LEVEL_ALLOWANCES[resolvedLevel];
- if (allowance && allowance > 0) {
- coinsPerMonth = allowance;
- console.log(
- "[subscription-handleCustomerSubscriptionEvent] Using allowance fallback from level",
- {
- subscriptionId: subscription.id,
- priceId: subscriptionPayload?.priceId || null,
- resolvedLevel,
- coinsPerMonth,
- },
- );
- }
- }
-
- const currentNextGrantAt =
- userData?.subscriptionNextGrantAt ?? userData?.subscriptionGrantNextAt;
- const hasExistingNextGrant =
- currentNextGrantAt && typeof currentNextGrantAt?.toDate === "function";
-
- const userUpdate = {
- stripeSubscription: subscriptionPayload,
- stripeSubscriptionStatus: subscription.status || null,
- stripeSubscriptionUpdatedAt: getServerTimestamp(),
- isPremium,
- lastStripeWebhookEvent: lastEvent,
- };
-
- if (subscriptionPayload?.customerId) {
- userUpdate.stripeCustomerId = subscriptionPayload.customerId;
- } else if (resolvedCustomerId) {
- userUpdate.stripeCustomerId = resolvedCustomerId;
- }
-
- if (resolvedLevel && isPremium) {
- userUpdate.premiumLevel = resolvedLevel;
- } else if (!isPremium) {
- userUpdate.premiumLevel = null;
- }
-
- if (isPremium && resolvedPeriod) {
- userUpdate.premiumBillingPeriod = resolvedPeriod;
- } else if (!isPremium) {
- userUpdate.premiumBillingPeriod = null;
- }
-
- if (coinsPerMonth !== null) {
- userUpdate.subscriptionCoinsPerMonth = coinsPerMonth;
- } else if (!isPremium) {
- userUpdate.subscriptionCoinsPerMonth = null;
- }
-
- if (!isPremium) {
- userUpdate.subscriptionGrantInterval = null;
- userUpdate.subscriptionNextGrantAt = null;
- } else if (
- resolvedPeriod === "annual" &&
- coinsPerMonth !== null &&
- coinsPerMonth > 0
- ) {
- userUpdate.subscriptionGrantInterval = "monthly";
- if (!hasExistingNextGrant) {
- const scheduleBase =
- subscriptionPayload?.currentPeriodStart ||
- subscriptionPayload?.createdAt ||
- admin.firestore.Timestamp.now();
- const nextGrantTimestamp = computeNextGrantTimestamp(scheduleBase, 1);
- if (nextGrantTimestamp) {
- userUpdate.subscriptionNextGrantAt = nextGrantTimestamp;
- }
- }
- } else {
- userUpdate.subscriptionGrantInterval = null;
- 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";
- const subscriptionInvoiceReasons = ["subscription_cycle", "subscription_create"];
- const isSubscriptionBillingReason =
- subscriptionInvoiceReasons.includes(billingReason);
-
- if (
- isSubscriptionBillingReason &&
- !resolvedSubscriptionId &&
- stripe &&
- typeof invoice.id === "string"
- ) {
- try {
- const fetchedInvoice = await stripe.invoices.retrieve(invoice.id);
- const fetchedSubscriptionId =
- typeof fetchedInvoice?.subscription === "string" &&
- fetchedInvoice.subscription
- ? fetchedInvoice.subscription
- : typeof fetchedInvoice?.subscription?.id === "string" &&
- fetchedInvoice.subscription.id
- ? fetchedInvoice.subscription.id
- : null;
- if (fetchedSubscriptionId) {
- resolvedSubscriptionId = fetchedSubscriptionId;
- console.log(
- "[subscription-handleInvoiceEvent] Subscription resolved from fetched invoice",
- {
- invoiceId: invoice.id || null,
- subscriptionId: resolvedSubscriptionId,
- },
- );
- }
- } catch (error) {
- console.warn(
- "[subscription-handleInvoiceEvent] Unable to fetch invoice to resolve subscription",
- invoice.id,
- error?.message || error,
- );
- }
- }
-
- const isSubscriptionInvoice =
- isSubscriptionBillingReason && Boolean(resolvedSubscriptionId);
-
- const shouldProcessAllowance =
- eventType === "invoice.paid" && isInvoicePaid && isSubscriptionInvoice;
-
- if (shouldProcessAllowance) {
- const orderTargetUid = uid || firebaseUid || userRef.id || null;
-
- console.log("[subscription-handleInvoiceEvent] Processing subscription allowance", {
- invoiceId: invoice.id || null,
- subscriptionId: resolvedSubscriptionId || null,
- customer: invoice.customer || null,
- billingReason,
- orderTargetUid,
- });
-
- const readPaymentSnapshot = async () => {
- try {
- const snapshot = await paymentDocRef.get();
- return snapshot.exists ? snapshot.data() || {} : {};
- } catch (error) {
- console.warn(
- "[subscription-handleInvoiceEvent] Unable to read payment doc",
- invoice.id,
- error?.message || error,
- );
- return {};
- }
- };
-
- const loadInvoicePrice = async () => {
- const lines = Array.isArray(invoice?.lines?.data)
- ? invoice.lines.data
- : [];
- let stripePrice = lines[0]?.price || null;
-
- if (
- (!stripePrice || typeof stripePrice !== "object") &&
- stripe &&
- typeof invoice.id === "string"
- ) {
- try {
- const expandedInvoice = await stripe.invoices.retrieve(invoice.id, {
- expand: ["lines.data.price.product"],
- });
- const expandedLines = Array.isArray(expandedInvoice?.lines?.data)
- ? expandedInvoice.lines.data
- : [];
- const expandedPrice = expandedLines[0]?.price;
- if (expandedPrice && typeof expandedPrice === "object") {
- stripePrice = expandedPrice;
- }
-
- if (
- !resolvedSubscriptionId &&
- typeof expandedInvoice?.subscription === "string" &&
- expandedInvoice.subscription
- ) {
- resolvedSubscriptionId = expandedInvoice.subscription;
- console.log(
- "[subscription-handleInvoiceEvent] Subscription resolved from expanded invoice",
- {
- invoiceId: invoice.id || null,
- subscriptionId: resolvedSubscriptionId,
- },
- );
- }
- } catch (error) {
- console.warn(
- "[subscription-handleInvoiceEvent] Unable to expand invoice price",
- invoice.id,
- error?.message || error,
- );
- }
- }
-
- return stripePrice && typeof stripePrice === "object"
- ? stripePrice
- : null;
- };
-
- const stripePrice = await loadInvoicePrice();
- console.log("[subscription-handleInvoiceEvent] Loaded invoice price", {
- invoiceId: invoice.id || null,
- subscriptionId: resolvedSubscriptionId || null,
- priceId: stripePrice?.id || null,
- productId:
- (typeof stripePrice?.product === "string"
- ? stripePrice.product
- : stripePrice?.product?.id) || null,
- hasProductMetadata:
- !!(
- stripePrice?.product &&
- typeof stripePrice.product === "object" &&
- Object.keys(stripePrice.product.metadata || {}).length > 0
- ),
- });
- let coinsPerMonth = parseCoinsPerMonth(
- (stripePrice?.product && typeof stripePrice.product === "object"
- ? stripePrice.product.metadata
- : {}) || {},
- );
-
- if (coinsPerMonth === null && stripe && stripePrice?.id) {
- try {
- const priceWithProduct = await stripe.prices.retrieve(stripePrice.id, {
- expand: ["product"],
- });
- coinsPerMonth = parseCoinsPerMonth(
- priceWithProduct?.product?.metadata || {},
- );
- } catch (error) {
- console.warn(
- "[subscription-handleInvoiceEvent] Unable to retrieve price product metadata",
- stripePrice.id,
- error?.message || error,
- );
- }
- }
-
- const invoicePriceId = sanitizePriceId(stripePrice?.id);
- const priceMeta =
- invoicePriceId && SUBSCRIPTION_PRICE_METADATA[invoicePriceId]
- ? SUBSCRIPTION_PRICE_METADATA[invoicePriceId]
- : null;
-
- if (coinsPerMonth === null && priceMeta?.coinsPerMonth) {
- coinsPerMonth = priceMeta.coinsPerMonth;
- console.log(
- "[subscription-handleInvoiceEvent] Using static allowance from price metadata",
- {
- invoiceId: invoice.id || null,
- subscriptionId: resolvedSubscriptionId || null,
- priceId: invoicePriceId,
- coinsPerMonth,
- },
- );
- }
-
- if (
- coinsPerMonth === null &&
- typeof invoice.metadata?.coinsPerMonth !== "undefined"
- ) {
- const metaCoins = toFiniteNumber(invoice.metadata.coinsPerMonth);
- if (metaCoins && metaCoins > 0) {
- coinsPerMonth = Math.round(metaCoins);
- console.log(
- "[subscription-handleInvoiceEvent] Using allowance from invoice metadata",
- {
- invoiceId: invoice.id || null,
- subscriptionId: resolvedSubscriptionId || null,
- priceId: invoicePriceId,
- coinsPerMonth,
- },
- );
- }
- }
-
- if (coinsPerMonth === null && userData?.subscriptionCoinsPerMonth) {
- const storedCoins = Number(userData.subscriptionCoinsPerMonth);
- if (Number.isFinite(storedCoins) && storedCoins > 0) {
- coinsPerMonth = Math.round(storedCoins);
- console.log(
- "[subscription-handleInvoiceEvent] Using allowance from user profile cache",
- {
- invoiceId: invoice.id || null,
- subscriptionId: resolvedSubscriptionId || null,
- priceId: invoicePriceId,
- coinsPerMonth,
- },
- );
- }
- }
-
- if (coinsPerMonth === null) {
- console.warn("[subscription-handleInvoiceEvent] Missing coinsPerMonth allowance", {
- invoiceId: invoice.id || null,
- subscriptionId: resolvedSubscriptionId || null,
- priceId: invoicePriceId || null,
- userId: userRef.id,
- hasStoredAllowance: Boolean(userData?.subscriptionCoinsPerMonth),
- metadataCoins: invoice.metadata?.coinsPerMonth ?? null,
- });
- }
-
- const recurringInterval = stripePrice?.recurring?.interval || null;
- let billingPeriod = userData?.premiumBillingPeriod || null;
- if (recurringInterval === "month") {
- billingPeriod = "monthly";
- } else if (recurringInterval === "year") {
- billingPeriod = "annual";
- }
-
- const isAnnual = billingPeriod === "annual";
- const isMonthly = billingPeriod === "monthly";
-
- console.log("[subscription-handleInvoiceEvent] Allowance context", {
- invoiceId: invoice.id || null,
- subscriptionId: resolvedSubscriptionId || null,
- coinsPerMonth,
- billingPeriod,
- recurringInterval,
- isAnnual,
- isMonthly,
- });
-
- if (coinsPerMonth && coinsPerMonth > 0 && orderTargetUid) {
- console.log("[subscription-handleInvoiceEvent] Preparing coin grant", {
- invoiceId: invoice.id || null,
- subscriptionId: resolvedSubscriptionId || null,
- userId: orderTargetUid,
- coinsPerMonth,
- billingPeriod,
- });
-
- const paymentData = await readPaymentSnapshot();
- const alreadyGranted = Boolean(paymentData?.subscriptionCoinsGrantedAt);
-
- if (!alreadyGranted) {
- const orderIdBase = `subscription_${invoice.id}`;
- const orderId = isAnnual ? `${orderIdBase}_initial` : orderIdBase;
-
- try {
- console.log("[subscription-handleInvoiceEvent] Creating subscription order", {
- orderId,
- userId: orderTargetUid,
- amount: coinsPerMonth,
- billingPeriod,
- billingReason,
- });
-
- const { orderId: processedOrderId } = await createOrderDocument({
- userId: orderTargetUid,
- type: ORDER_TYPES.SUBSCRIPTION,
- amount: coinsPerMonth,
- metadata: {
- source: "STRIPE_SUBSCRIPTION",
- schedule: isAnnual ? "annual_invoice" : "monthly_invoice",
- invoiceId: invoice.id,
- subscriptionId: resolvedSubscriptionId || null,
- billingPeriod,
- billingReason,
- },
- orderId,
- });
-
- await paymentDocRef.set(
- {
- subscriptionCoinsGrantedAt: getServerTimestamp(),
- subscriptionCoinsGrantAmount: coinsPerMonth,
- subscriptionCoinsGrantOrderId: processedOrderId,
- subscriptionCoinsGrantSource: isAnnual
- ? "annual_invoice"
- : "monthly_invoice",
- },
- { merge: true },
- );
-
- console.log("[subscription-handleInvoiceEvent] Subscription coins granted", {
- orderId: processedOrderId,
- invoiceId: invoice.id,
- subscriptionId: resolvedSubscriptionId || null,
- userId: orderTargetUid,
- amount: coinsPerMonth,
- billingPeriod,
- });
-
- userUpdate.subscriptionCoinsPerMonth = coinsPerMonth;
- userUpdate.subscriptionLastGrantAt = getServerTimestamp();
- userUpdate.subscriptionLastGrantAmount = coinsPerMonth;
- userUpdate.subscriptionLastGrantInvoiceId = invoice.id;
-
- if (isAnnual) {
- userUpdate.subscriptionGrantInterval = "monthly";
- const existingNextGrant =
- userData?.subscriptionNextGrantAt &&
- typeof userData.subscriptionNextGrantAt.toDate === "function"
- ? userData.subscriptionNextGrantAt
- : null;
- if (!existingNextGrant) {
- const periodStartTimestamp =
- toFirestoreTimestamp(invoice.period_start) ||
- userData?.stripeSubscription?.currentPeriodStart ||
- userData?.stripeSubscription?.createdAt ||
- admin.firestore.Timestamp.now();
- const nextGrantTimestamp = computeNextGrantTimestamp(
- periodStartTimestamp,
- 1,
- );
- if (nextGrantTimestamp) {
- userUpdate.subscriptionNextGrantAt = nextGrantTimestamp;
- }
- }
- } else if (isMonthly) {
- userUpdate.subscriptionGrantInterval = null;
- userUpdate.subscriptionNextGrantAt = null;
- }
- } catch (error) {
- console.error(
- "[subscription-handleInvoiceEvent] Unable to create subscription order",
- invoice.id,
- error,
- );
- }
- } else {
- console.log("[subscription-handleInvoiceEvent] Coins already granted for invoice", {
- invoiceId: invoice.id,
- subscriptionId: resolvedSubscriptionId || null,
- userId: orderTargetUid,
- });
- }
- } else {
- console.log("[subscription-handleInvoiceEvent] Skipping allowance grant", {
- invoiceId: invoice.id || null,
- subscriptionId: resolvedSubscriptionId || null,
- coinsPerMonth,
- orderTargetUid,
- billingPeriod,
- });
- }
- }
-
- if (!shouldProcessAllowance) {
- console.log("[subscription-handleInvoiceEvent] Allowance conditions not met", {
- eventType,
- invoiceStatus: invoice.status || null,
- billingReason,
- hasSubscription: Boolean(resolvedSubscriptionId),
- isSubscriptionInvoice,
- isInvoicePaid,
- });
- }
-
- await userRef.set(userUpdate, { merge: true });
-};
-
-const handlePaymentIntentEvent = async (paymentIntent, event) => {
- if (!paymentIntent || typeof paymentIntent !== "object") {
- return;
- }
-
- const firebaseUid = extractFirebaseUid(paymentIntent.metadata);
-
- await upsertPaymentDocument(paymentIntent.id, {
- userId: firebaseUid || null,
- customerId: paymentIntent.customer || null,
- status: paymentIntent.status || null,
- paymentStatus: paymentIntent.status || null,
- amountTotal: paymentIntent.amount ?? null,
- amountReceived: paymentIntent.amount_received ?? null,
- currency: paymentIntent.currency || null,
- description: paymentIntent.description || null,
- metadata: paymentIntent.metadata || {},
- latestChargeId: paymentIntent.latest_charge || null,
- paymentMethodId:
- typeof paymentIntent.payment_method === "string"
- ? paymentIntent.payment_method
- : null,
- createdAtFromStripe: toFirestoreTimestamp(paymentIntent.created),
- succeededAt: toFirestoreTimestamp(
- paymentIntent.status_transitions?.succeeded_at,
- ),
- canceledAt: toFirestoreTimestamp(
- paymentIntent.status_transitions?.canceled_at,
- ),
- lastEventType: event?.type || null,
- lastEventId: event?.id || null,
- lastEventAt: getServerTimestamp(),
- mode: "payment",
- });
-
- const { userRef } = await resolveUserContext({
- metadata: paymentIntent.metadata,
- customerId: paymentIntent.customer,
- });
-
- if (userRef) {
- const lastEvent = buildEventSnapshot(event?.type, paymentIntent.id);
- if (firebaseUid) {
- lastEvent.uid = firebaseUid;
- }
- await userRef.set(
- {
- lastStripeWebhookEvent: lastEvent,
- },
- { merge: true },
- );
- }
-};
-
-const handleStripeWebhookEvent = async ({ event, stripe }) => {
- if (!event || typeof event !== "object") {
- return;
- }
-
- console.log("[subscription-handleStripeWebhookEvent] Received event", {
- id: event?.id || null,
- type: event?.type || null,
- apiVersion: event?.api_version || null,
- created: event?.created || null,
- });
-
- switch (event.type) {
- 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.paid":
- case "invoice.payment_failed":
- await handleInvoiceEvent(event.data?.object, event, { stripe });
- break;
- case "payment_intent.succeeded":
- case "payment_intent.payment_failed":
- await handlePaymentIntentEvent(event.data?.object, event);
- break;
- default:
- console.log(
- "[subscription-handleStripeWebhookEvent] Unhandled event type",
- event.type,
- );
- }
-};
-
-const handleStripeWebhook = onRequest({ region: REGION }, async (req, res) => {
- if (req.method !== "POST") {
- res.set("Allow", "POST");
- res.status(405).send("Méthode non autorisée");
- 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;
- }
-
- const signature = req.headers["stripe-signature"];
- if (!signature) {
- res.status(400).send("En-tête stripe-signature manquant");
- return;
- }
-
- let rawBody = null;
- if (Buffer.isBuffer(req.rawBody)) {
- rawBody = req.rawBody;
- } else {
- console.warn(
- "[subscription-handleStripeWebhook] rawBody indisponible, utilisation d'un fallback JSON stringify",
- );
- rawBody = Buffer.from(JSON.stringify(req.body || {}));
- }
-
- let event = null;
- try {
- const webhookSecret = resolveStripeWebhookSecret();
- event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
- } catch (error) {
- console.error(
- "[subscription-handleStripeWebhook] Signature invalide",
- error?.message || error,
- );
- res.status(400).send(`Signature invalide: ${error?.message || "Webhook"}`);
- return;
- }
-
- try {
- await handleStripeWebhookEvent({ event, stripe });
- res.status(200).json({ received: true });
- } catch (error) {
- console.error(
- "[subscription-handleStripeWebhook] Traitement échoué",
- event?.id,
- error,
- );
- res.status(500).send("Erreur lors du traitement du webhook");
- }
-});
-
-const sanitizePriceId = (value) => {
- if (typeof value !== "string") {
- return "";
- }
- return value.trim();
-};
-
-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 && typeof product === "object" ? 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 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 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.",
- );
- }
-});
-
-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 { successUrl, cancelUrl } = getReturnUrls(
- request?.data?.returnUrls,
- );
-
- const { customerId } = await ensureStripeCustomer({
- uid,
- stripe,
- refsList: refList,
- createIfMissing: true,
- });
-
- if (!customerId) {
- throw new HttpsError(
- "failed-precondition",
- "Impossible de retrouver le client Stripe associé.",
- );
- }
-
- const session = await stripe.checkout.sessions.create({
- mode: "subscription",
- customer: customerId,
- line_items: lineItems,
- success_url: successUrl,
- cancel_url: cancelUrl,
- allow_promotion_codes: true,
- metadata: {
- firebaseUID: uid,
- priceId,
- purchaseType: "SUBSCRIPTION",
- subscriptionLevel: priceMeta.level || null,
- subscriptionBillingPeriod: priceMeta.billingPeriod || null,
- },
- });
-
- await paymentsCollection.doc(session.id).set(
- {
- userId: uid,
- customerId,
- status: session.status || "created",
- mode: "subscription",
- createdAt: getServerTimestamp(),
- updatedAt: getServerTimestamp(),
- sessionId: session.id,
- sessionUrl: session.url,
- lineItems: summary,
- amountSubtotal: session.amount_subtotal,
- amountTotal: session.amount_total,
- currency: session.currency,
- paymentStatus: session.payment_status,
- metadata: session.metadata || {},
- },
- { merge: true },
- );
-
- 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 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 = refList?.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,
- },
- );
-
- const syntheticEvent = {
- id: `manual_${subscriptionId}_${Date.now()}`,
- type: "subscription.cancel.requested",
- };
-
- const subscriptionForHandler = {
- ...updatedSubscription,
- metadata: {
- ...(updatedSubscription.metadata || {}),
- firebaseUID: uid,
- },
- };
-
- await handleCustomerSubscriptionEvent(
- subscriptionForHandler,
- syntheticEvent,
- { stripe },
- );
-
- 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 formatSubscriptionForClient = (subscription) => {
- if (!subscription || typeof subscription !== "object") {
- return null;
- }
-
- const itemList = Array.isArray(subscription?.items?.data)
- ? subscription.items.data
- : [];
-
- const primaryItem = itemList[0] || null;
- const stripePrice =
- primaryItem?.price && typeof primaryItem.price === "object"
- ? primaryItem.price
- : null;
-
- const priceId =
- stripePrice?.id ||
- (typeof primaryItem?.price === "string" ? primaryItem.price : null) ||
- null;
-
- const productId = stripePrice?.product || null;
-
- const priceMeta = getSubscriptionMetaFromPrice(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 coinsPerMonth = parseCoinsPerMonth(
- (stripePrice?.product && typeof stripePrice.product === "object"
- ? stripePrice.product.metadata
- : {}) || {},
- );
-
- const toPositiveInteger = (value) => {
- if (typeof value === "number" && Number.isFinite(value)) {
- return Math.max(0, Math.trunc(value));
- }
- return null;
- };
-
- return {
- id: subscription.id || null,
- status: subscription.status || null,
- customerId: subscription.customer || null,
- cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
- cancelAt: toPositiveInteger(subscription.cancel_at),
- canceledAt: toPositiveInteger(subscription.canceled_at),
- created: toPositiveInteger(subscription.created),
- currentPeriodStart: toPositiveInteger(subscription.current_period_start),
- currentPeriodEnd: toPositiveInteger(subscription.current_period_end),
- endedAt: toPositiveInteger(subscription.ended_at),
- trialStart: toPositiveInteger(subscription.trial_start),
- trialEnd: toPositiveInteger(subscription.trial_end),
- latestInvoiceId: subscription.latest_invoice || null,
- priceId,
- productId,
- level: resolvedLevel,
- billingPeriod: resolvedPeriod,
- coinsPerMonth:
- coinsPerMonth !== null && coinsPerMonth > 0 ? coinsPerMonth : null,
- metadata: subscription.metadata || {},
- };
-};
-
-const getActiveSubscription = 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 = refList?.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-getActiveSubscription] Unable to list subscriptions",
- customerId,
- error?.message || error,
- );
- }
- }
-
- if (!subscriptionId) {
- return {
- subscription: null,
- customerId,
- };
- }
-
- const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
- expand: ["items.data.price.product"],
- });
-
- if (!subscription) {
- return {
- subscription: null,
- customerId,
- };
- }
-
- const syntheticEvent = {
- id: `manual_${subscriptionId}_${Date.now()}`,
- type: "subscription.sync.requested",
- };
-
- const subscriptionForHandler = {
- ...subscription,
- metadata: {
- ...(subscription.metadata || {}),
- firebaseUID: uid,
- },
- };
-
- await handleCustomerSubscriptionEvent(
- subscriptionForHandler,
- syntheticEvent,
- { stripe },
- );
-
- return {
- subscription: formatSubscriptionForClient(subscription),
- 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.",
- );
- }
-});
-
-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 { successUrl, cancelUrl } = getReturnUrls(
- request?.data?.returnUrls,
- );
-
- 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({
- mode: "payment",
- customer: customerId,
- line_items: [
- {
- price: coinPack.priceId,
- quantity: 1,
- },
- ],
- success_url: successUrl,
- cancel_url: cancelUrl,
- 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,
- },
- });
-
- await paymentsCollection.doc(session.id).set(
- {
- userId: uid,
- customerId,
- status: session.status || "created",
- mode: "payment",
- createdAt: getServerTimestamp(),
- updatedAt: getServerTimestamp(),
- sessionId: session.id,
- sessionUrl: session.url,
- lineItems: [
- {
- productId: coinPack.productId,
- priceId: coinPack.priceId,
- quantity: 1,
- coinAmount: coinPack.coinAmount,
- },
- ],
- amountSubtotal: session.amount_subtotal,
- amountTotal: session.amount_total,
- currency: session.currency,
- paymentStatus: session.payment_status,
- metadata: session.metadata || {},
- },
- { merge: true },
- );
-
- 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.",
- );
- }
- },
-);
-
-exports.listSubscriptionPlans = listSubscriptionPlans;
-exports.createSubscriptionCheckoutSession = createSubscriptionCheckoutSession;
-exports.cancelActiveSubscription = cancelActiveSubscription;
-exports.getActiveSubscription = getActiveSubscription;
-exports.listCoinPacks = listCoinPacks;
-exports.createCoinPackCheckoutSession = createCoinPackCheckoutSession;
-exports.handleStripeWebhook = handleStripeWebhook;
-
-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 = refList.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;
- }
- },
-);
-
-exports.processAnnualSubscriptionAllowances =
- processAnnualSubscriptionAllowances;
diff --git a/functions/src/subscription/catalog.js b/functions/src/subscription/catalog.js
new file mode 100644
index 0000000..76573a7
--- /dev/null
+++ b/functions/src/subscription/catalog.js
@@ -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,
+};
diff --git a/functions/src/subscription/checkout.js b/functions/src/subscription/checkout.js
new file mode 100644
index 0000000..20472a3
--- /dev/null
+++ b/functions/src/subscription/checkout.js
@@ -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,
+};
diff --git a/functions/src/subscription/config.js b/functions/src/subscription/config.js
new file mode 100644
index 0000000..553e148
--- /dev/null
+++ b/functions/src/subscription/config.js
@@ -0,0 +1,5 @@
+const REGION = process.env.FIREBASE_REGION || "europe-west1";
+
+module.exports = {
+ REGION,
+};
diff --git a/functions/src/subscription/constants.js b/functions/src/subscription/constants.js
new file mode 100644
index 0000000..8217309
--- /dev/null
+++ b/functions/src/subscription/constants.js
@@ -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,
+};
diff --git a/functions/src/subscription/index.js b/functions/src/subscription/index.js
new file mode 100644
index 0000000..b009356
--- /dev/null
+++ b/functions/src/subscription/index.js
@@ -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,
+};
diff --git a/functions/src/subscription/management.js b/functions/src/subscription/management.js
new file mode 100644
index 0000000..bdf79d8
--- /dev/null
+++ b/functions/src/subscription/management.js
@@ -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,
+};
diff --git a/functions/src/subscription/schedule.js b/functions/src/subscription/schedule.js
new file mode 100644
index 0000000..af001c2
--- /dev/null
+++ b/functions/src/subscription/schedule.js
@@ -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,
+};
diff --git a/functions/src/subscription/shared.js b/functions/src/subscription/shared.js
new file mode 100644
index 0000000..f9459fc
--- /dev/null
+++ b/functions/src/subscription/shared.js
@@ -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,
+};
diff --git a/functions/src/subscription/webhooks.js b/functions/src/subscription/webhooks.js
new file mode 100644
index 0000000..770d6b3
--- /dev/null
+++ b/functions/src/subscription/webhooks.js
@@ -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,
+};
diff --git a/src/components/Alert.js b/src/components/Alert.js
index ecb2790..237eb2b 100644
--- a/src/components/Alert.js
+++ b/src/components/Alert.js
@@ -27,6 +27,21 @@ const WebAlertModal = ({ title, description, options }) => {
cancelOption?.onPress();
};
+ const renderDescription = () => {
+ if (
+ typeof description === "string" ||
+ typeof description === "number"
+ ) {
+ return {description};
+ }
+
+ if (!description) {
+ return null;
+ }
+
+ return {description};
+ };
+
return (
{
>
{title}
- {description}
+ {renderDescription()}
{
if (
@@ -86,6 +99,7 @@ export const useStripe = () => {
const StripeProvider = ({ children }) => {
const { isMobile } = useLayoutType();
const { currentUserData } = useUserData() || {};
+ const canUseEmbeddedCheckout = isWeb && Boolean(stripePromise);
const [clientSecret, setClientSecret] = React.useState(null);
const [subscriptions, setSubscriptions] = React.useState({
@@ -118,33 +132,12 @@ const StripeProvider = ({ children }) => {
}
if (isWeb) {
- if (safariWindow && !safariWindow.closed) {
- try {
- safariWindow.location.replace(checkoutUrl);
- } catch (navigationError) {
- safariWindow.location.href = checkoutUrl;
- }
- safariWindow.focus?.();
- return;
- }
-
- if (typeof window !== "undefined") {
- const openedTab = window.open(
- checkoutUrl,
- "_blank",
- "noopener,noreferrer",
- );
- if (openedTab) {
- return;
- }
- }
-
- await Linking.openURL(checkoutUrl);
- return;
+ throw new Error(
+ "Impossible d'ouvrir le paiement Stripe sans checkout intégré.",
+ );
}
- const isMobileApp =
- Platform.OS === "ios" || Platform.OS === "android";
+ const isMobileApp = Platform.OS === "ios" || Platform.OS === "android";
if (isMobileApp) {
try {
@@ -173,17 +166,37 @@ const StripeProvider = ({ children }) => {
);
const runCheckoutSession = React.useCallback(
- async ({ callableName, payload, logTag }) => {
- const safariWindow = openSafariCheckoutWindow();
- try {
- const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
- callableName,
+ async ({ callableName, payload, logTag, useEmbeddedFlow = false }) => {
+ if (useEmbeddedFlow && !canUseEmbeddedCheckout) {
+ throw new Error(
+ "Le paiement intégré Stripe est indisponible sur cette plateforme.",
);
+ }
+
+ const wantsEmbeddedCheckout = useEmbeddedFlow && canUseEmbeddedCheckout;
+ const safariWindow = wantsEmbeddedCheckout
+ ? null
+ : openSafariCheckoutWindow();
+ try {
+ const callable =
+ getFunctionsClient(FUNCTIONS_REGION).httpsCallable(callableName);
const { data } = await callable(payload);
const checkoutUrl = data?.url;
+ const clientSecret = data?.client_secret || data?.clientSecret;
+
+ if (wantsEmbeddedCheckout) {
+ if (!clientSecret) {
+ throw new Error("Session Stripe introuvable.");
+ }
+ setClientSecret(clientSecret);
+ return;
+ }
+
if (!checkoutUrl) {
throw new Error("Session Stripe introuvable.");
}
+
+ console.log("[StripeProvider] Stripe checkout URL", checkoutUrl);
await redirectToCheckout(checkoutUrl, { safariWindow });
} catch (error) {
if (safariWindow && !safariWindow.closed) {
@@ -196,7 +209,7 @@ const StripeProvider = ({ children }) => {
);
}
},
- [redirectToCheckout],
+ [canUseEmbeddedCheckout, redirectToCheckout, setClientSecret],
);
const createSubscriptionCheckout = React.useCallback(
@@ -204,6 +217,19 @@ const StripeProvider = ({ children }) => {
if (!priceId) {
throw new Error("Aucun abonnement sélectionné.");
}
+ if (isWeb && !canUseEmbeddedCheckout) {
+ console.error(
+ "[StripeProvider] checkout blocked (missing embedded support)",
+ {
+ hasStripeKey: Boolean(stripePublishableKey),
+ isClientSecretReady: false,
+ },
+ );
+ throw new Error(
+ "Le paiement intégré Stripe est indisponible pour le moment (clé Stripe ou client secret absent).",
+ );
+ }
+ const shouldUseEmbeddedCheckout = canUseEmbeddedCheckout;
await runCheckoutSession({
callableName: "subscription-createSubscriptionCheckoutSession",
payload: {
@@ -212,11 +238,13 @@ const StripeProvider = ({ children }) => {
successUrl: STRIPE_SUCCESS_URL,
cancelUrl: STRIPE_CANCEL_URL,
},
+ uiMode: shouldUseEmbeddedCheckout ? "embedded" : "hosted",
},
logTag: "subscription checkout error",
+ useEmbeddedFlow: shouldUseEmbeddedCheckout,
});
},
- [runCheckoutSession],
+ [canUseEmbeddedCheckout, runCheckoutSession],
);
const createCoinPackCheckout = React.useCallback(
@@ -224,6 +252,19 @@ const StripeProvider = ({ children }) => {
if (!productId) {
throw new Error("Aucun pack sélectionné.");
}
+ if (isWeb && !canUseEmbeddedCheckout) {
+ console.error(
+ "[StripeProvider] checkout blocked (missing embedded support)",
+ {
+ hasStripeKey: Boolean(stripePublishableKey),
+ isClientSecretReady: false,
+ },
+ );
+ throw new Error(
+ "Le paiement intégré Stripe est indisponible pour le moment (clé Stripe ou client secret absent).",
+ );
+ }
+ const shouldUseEmbeddedCheckout = canUseEmbeddedCheckout;
await runCheckoutSession({
callableName: "subscription-createCoinPackCheckoutSession",
payload: {
@@ -232,11 +273,13 @@ const StripeProvider = ({ children }) => {
successUrl: STRIPE_SUCCESS_URL,
cancelUrl: STRIPE_CANCEL_URL,
},
+ uiMode: shouldUseEmbeddedCheckout ? "embedded" : "hosted",
},
logTag: "coin pack checkout error",
+ useEmbeddedFlow: shouldUseEmbeddedCheckout,
});
},
- [runCheckoutSession],
+ [canUseEmbeddedCheckout, runCheckoutSession],
);
const fetchActiveSubscription = React.useCallback(async () => {
@@ -252,9 +295,9 @@ const StripeProvider = ({ children }) => {
setIsActiveSubscriptionLoading(true);
setActiveSubscriptionError(null);
try {
- const callable = getFunctionsClient(
- FUNCTIONS_REGION,
- ).httpsCallable("subscription-getActiveSubscription");
+ const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
+ "subscription-getActiveSubscription",
+ );
const { data } = await callable();
setActiveSubscription(data?.subscription || null);
} catch (error) {
@@ -276,8 +319,9 @@ const StripeProvider = ({ children }) => {
let lastError = null;
try {
- const callable =
- functionsClient.httpsCallable("subscription-listSubscriptionPlans");
+ const callable = functionsClient.httpsCallable(
+ "subscription-listSubscriptionPlans",
+ );
const { data } = await callable();
const nextPlans = data?.plans || {};
setSubscriptions({
@@ -290,8 +334,9 @@ const StripeProvider = ({ children }) => {
}
try {
- const callable =
- functionsClient.httpsCallable("subscription-listCoinPacks");
+ const callable = functionsClient.httpsCallable(
+ "subscription-listCoinPacks",
+ );
const { data } = await callable();
setCoinPacks(Array.isArray(data?.packs) ? data.packs : []);
} catch (error) {
@@ -382,23 +427,28 @@ const StripeProvider = ({ children }) => {
isVisible={clientSecret !== null}
setIsVisible={closeEmbeddedCheckout}
>
-
+
- {clientSecret ? (
+ {clientSecret && stripePromise ? (
-
+ {
+ closeEmbeddedCheckout();
+ fetchActiveSubscription();
+ }}
+ />
) : null}
@@ -422,9 +472,8 @@ const styles = StyleSheet.create({
},
embeddedWrapper: {
alignSelf: "center",
- height: "70vh",
borderRadius: mainBorderRadius,
- overflow: "scroll",
+ overflow: "hidden",
},
});
diff --git a/src/screens/Library/MusicDetails.web.js b/src/screens/Library/MusicDetails.web.js
index dc8d284..5410008 100644
--- a/src/screens/Library/MusicDetails.web.js
+++ b/src/screens/Library/MusicDetails.web.js
@@ -16,6 +16,7 @@ import {
StyleSheet,
Text,
View,
+ useWindowDimensions,
} from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import {
@@ -74,6 +75,8 @@ const MusicDetails = ({ route }) => {
const hasAutoPlayedRef = React.useRef(false);
const { isLooping, setLooping } = usePlayer() || {};
const isWeb = Platform.OS === "web";
+ const { width: windowWidth = 0 } = useWindowDimensions();
+ const isCompactLayout = (windowWidth || 0) < 1200;
const handleBackPress = useCallback(() => {
goBack();
}, []);
@@ -964,6 +967,7 @@ const MusicDetails = ({ route }) => {
headerType="NAVIGATION"
hideBackButton={isWeb}
topStickyContent={renderWebBackButton}
+ width={isCompactLayout ? "100%" : undefined}
title={action === "userProfile" ? "Mon profil" : "Détail musique"}
backgroundImg={
action === "userProfile"
@@ -993,11 +997,12 @@ const MusicDetails = ({ route }) => {
style={{
flex: 1,
paddingBottom: gutters * 2,
- flexDirection: "row",
+ flexDirection: isCompactLayout ? "column" : "row",
justifyContent: "center",
- alignItems: "center",
+ alignItems: isCompactLayout ? "stretch" : "center",
height: "auto",
- gap: 24,
+ gap: isCompactLayout ? 16 : 24,
+ width: "100%",
}}
>
{/* Left column: player */}
@@ -1005,13 +1010,14 @@ const MusicDetails = ({ route }) => {
intensity={40}
style={{
flex: 1,
- paddingRight: 8,
- height: 400,
+ paddingRight: isCompactLayout ? 0 : 8,
+ height: isCompactLayout ? undefined : 400,
borderRadius: 12,
padding: 10,
borderWidth: 1,
borderColor: Palette.transparentWhite,
- maxWidth: "50%",
+ maxWidth: isCompactLayout ? "100%" : "50%",
+ width: "100%",
}}
>
@@ -1159,20 +1165,25 @@ const MusicDetails = ({ route }) => {
intensity={40}
style={{
flex: 1,
- paddingLeft: 8,
+ paddingLeft: isCompactLayout ? 0 : 8,
padding: 10,
borderWidth: 1,
borderColor: Palette.transparentWhite,
borderRadius: 12,
- height: 400,
- maxWidth: "50%",
+ height: isCompactLayout ? undefined : 400,
+ maxWidth: isCompactLayout ? "100%" : "50%",
+ width: "100%",
+ marginTop: isCompactLayout ? 12 : 0,
}}
>
{sections.length ? (
{sections.map((section, sectionIdx) => (
{
) : description?.length > 0 ? (
{
const checkSongEndRef = useRef(null);
const stopRequestedRef = useRef(false);
const restartRequestedRef = useRef(false);
+ const manualRestartInFlightRef = useRef(false);
+ const stopRequestedAtRef = useRef(0);
+ const activeRecordingPromiseRef = useRef(null);
+ const exitRequestedRef = useRef(false);
const startedRef = useRef(false); // empêche les doubles démarrages
const countdownActiveRef = useRef(false); // évite le déclenchement avant 1er tick
const playbackStartedRef = useRef(false); // devient vrai lorsque l'audio progresse réellement
@@ -259,45 +263,58 @@ const RecordPlayback = ({ route }) => {
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Reset complet
- const resetSession = useCallback(async () => {
- try {
- if (countdownTimerRef.current) {
- clearInterval(countdownTimerRef.current);
- countdownTimerRef.current = null;
- }
- if (listenTimerRef.current) {
- clearInterval(listenTimerRef.current);
- listenTimerRef.current = null;
- log("listenTimerRef cleared");
- }
- if (checkSongEndRef.current) {
- clearInterval(checkSongEndRef.current);
- checkSongEndRef.current = null;
- log("checkSongEndRef cleared");
- }
+ const resetSession = useCallback(
+ async ({
+ preserveSongEndWatcher = false,
+ preserveStopRequest = false,
+ } = {}) => {
+ try {
+ if (countdownTimerRef.current) {
+ clearInterval(countdownTimerRef.current);
+ countdownTimerRef.current = null;
+ }
+ if (listenTimerRef.current) {
+ clearInterval(listenTimerRef.current);
+ listenTimerRef.current = null;
+ log("listenTimerRef cleared");
+ }
+ if (checkSongEndRef.current) {
+ if (preserveSongEndWatcher) {
+ log("checkSongEndRef preserved");
+ } else {
+ clearInterval(checkSongEndRef.current);
+ checkSongEndRef.current = null;
+ log("checkSongEndRef cleared");
+ }
+ }
- startedRef.current = false;
- stopRequestedRef.current = false;
- countdownActiveRef.current = false;
- listenedMsRef.current = 0;
- incrementDoneRef.current = false;
- playbackStartedRef.current = false;
- progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 };
- log("Session reset");
+ startedRef.current = false;
+ if (!preserveStopRequest) {
+ stopRequestedRef.current = false;
+ stopRequestedAtRef.current = 0;
+ }
+ countdownActiveRef.current = false;
+ listenedMsRef.current = 0;
+ incrementDoneRef.current = false;
+ playbackStartedRef.current = false;
+ progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 };
+ log("Session reset");
- setIsPreparing(false);
- setIsRecording(false);
- setShowProgress(false);
- setCountdown(0);
+ setIsPreparing(false);
+ setIsRecording(false);
+ setShowProgress(false);
+ setCountdown(0);
- if (player) {
- try {
- if (player.playing) await player.pause?.();
- await player.seekTo?.(0);
- } catch (_) {}
- }
- } catch (_) {}
- }, [player]);
+ if (player) {
+ try {
+ if (player.playing) await player.pause?.();
+ await player.seekTo?.(0);
+ } catch (_) {}
+ }
+ } catch (_) {}
+ },
+ [player]
+ );
const resetSessionRef = useRef(resetSession);
useEffect(() => {
@@ -307,6 +324,7 @@ const RecordPlayback = ({ route }) => {
useFocusEffect(
useCallback(() => {
log("Screen focused, resetting session");
+ exitRequestedRef.current = false;
void resetSessionRef.current?.();
return () => {
log("Screen blurred, stopping playback");
@@ -337,15 +355,47 @@ const RecordPlayback = ({ route }) => {
}, [setLooping])
);
+ const discardRecordingFile = useCallback(async (uri, reason = "") => {
+ if (!uri) return;
+ try {
+ const info = await FileSystem.getInfoAsync(uri);
+ if (info?.exists) {
+ await FileSystem.deleteAsync(uri, { idempotent: true });
+ log("Discarded recording file", { reason: reason || "cleanup" });
+ }
+ } catch (error) {
+ log("Failed to discard recording", {
+ reason: reason || "cleanup",
+ message: error?.message || String(error || ""),
+ });
+ }
+ }, []);
+
// Lancer le compte à rebours (le tick décrémente uniquement)
- const startCountdownThenRecord = async () => {
+ const startCountdownThenRecord = async ({
+ preserveRestartFlag = false,
+ preserveSongEndWatcher = false,
+ preserveStopRequest = false,
+ } = {}) => {
if (!songUrl) {
log("startCountdownThenRecord aborted: missing song URL");
return;
}
- log("startCountdownThenRecord invoked", { projectId, songUrl });
- await resetSession();
- restartRequestedRef.current = false;
+ log("startCountdownThenRecord invoked", {
+ projectId,
+ songUrl,
+ preserveRestartFlag,
+ preserveSongEndWatcher,
+ preserveStopRequest,
+ });
+ await resetSession({
+ preserveSongEndWatcher,
+ preserveStopRequest,
+ });
+ if (!preserveRestartFlag) {
+ restartRequestedRef.current = false;
+ manualRestartInFlightRef.current = false;
+ }
setIsPreparing(true);
setShowProgress(false);
setCountdown(5);
@@ -387,6 +437,7 @@ const RecordPlayback = ({ route }) => {
const startRecordingWithMusic = async () => {
try {
stopRequestedRef.current = false;
+ stopRequestedAtRef.current = 0;
listenedMsRef.current = 0;
incrementDoneRef.current = false;
@@ -399,6 +450,18 @@ const RecordPlayback = ({ route }) => {
songUrl,
hasCamera: !!cameraRef.current,
});
+
+ if (activeRecordingPromiseRef.current) {
+ log("Waiting for previous recording to finish before starting a new one");
+ try {
+ await activeRecordingPromiseRef.current;
+ } catch (error) {
+ log("Previous recording promise rejected", {
+ message: error?.message || String(error || ""),
+ });
+ }
+ }
+
const recordPromise = (() => {
const camera = cameraRef.current;
if (!camera) throw new Error("Caméra indisponible");
@@ -451,6 +514,7 @@ const RecordPlayback = ({ route }) => {
"L'enregistrement vidéo n'est pas supporté sur cet appareil."
);
})();
+ activeRecordingPromiseRef.current = recordPromise;
if (player && songUrl) {
try {
@@ -501,7 +565,18 @@ const RecordPlayback = ({ route }) => {
log("Song end watcher armed");
checkSongEndRef.current = setInterval(() => {
try {
- if (!player || stopRequestedRef.current) return;
+ if (!player) return;
+ if (stopRequestedRef.current) {
+ const sinceLastRequest = Date.now() - (stopRequestedAtRef.current || 0);
+ if (sinceLastRequest >= 1200) {
+ stopRequestedAtRef.current = Date.now();
+ try {
+ cameraRef.current?.stopRecording?.();
+ log("stopRecording retried while awaiting stop");
+ } catch (_) {}
+ }
+ return;
+ }
const duration = (player?.duration || 0) * 1000;
const currentTime = (player?.currentTime || 0) * 1000;
if (
@@ -524,10 +599,7 @@ const RecordPlayback = ({ route }) => {
playing: player?.playing,
});
stopRequestedRef.current = true;
- if (checkSongEndRef.current) {
- clearInterval(checkSongEndRef.current);
- checkSongEndRef.current = null;
- }
+ stopRequestedAtRef.current = Date.now();
try {
cameraRef.current?.stopRecording?.();
log("stopRecording triggered");
@@ -539,6 +611,9 @@ const RecordPlayback = ({ route }) => {
const video = await recordPromise;
log("Recording promise resolved", { hasVideo: !!video?.uri });
+ activeRecordingPromiseRef.current = null;
+ stopRequestedRef.current = false;
+ stopRequestedAtRef.current = 0;
if (checkSongEndRef.current) {
clearInterval(checkSongEndRef.current);
@@ -559,30 +634,31 @@ const RecordPlayback = ({ route }) => {
log("Recording flow completed", { hasVideo: !!video?.uri });
playbackStartedRef.current = false;
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 };
+ const exitRequested = exitRequestedRef.current;
const shouldRestart = restartRequestedRef.current;
+
+ if (exitRequested) {
+ exitRequestedRef.current = false;
+ restartRequestedRef.current = false;
+ manualRestartInFlightRef.current = false;
+ await discardRecordingFile(video?.uri, "exit");
+ log("Recording aborted before completion, skipping navigation");
+ return;
+ }
+
if (shouldRestart) {
restartRequestedRef.current = false;
- }
-
- if (video?.uri && shouldRestart) {
- try {
- const info = await FileSystem.getInfoAsync(video.uri);
- if (info?.exists) {
- await FileSystem.deleteAsync(video.uri, { idempotent: true });
- log("Discarded interim recording file");
- }
- } catch (error) {
- log("Failed to discard interim recording", {
- message: error?.message || String(error || ""),
+ await discardRecordingFile(video?.uri, "restart");
+ const manualRestartPending = manualRestartInFlightRef.current;
+ manualRestartInFlightRef.current = false;
+ if (manualRestartPending) {
+ log("Manual restart already scheduled, waiting for countdown");
+ } else {
+ log("Restart requested, relaunching countdown");
+ requestAnimationFrame(() => {
+ void startCountdownThenRecord();
});
}
- }
-
- if (shouldRestart) {
- log("Restart requested, relaunching countdown");
- requestAnimationFrame(() => {
- void startCountdownThenRecord();
- });
return;
}
@@ -602,6 +678,9 @@ const RecordPlayback = ({ route }) => {
message: e?.message || String(e || ""),
});
console.log("RecordPlayback error:", e);
+ activeRecordingPromiseRef.current = null;
+ stopRequestedRef.current = false;
+ stopRequestedAtRef.current = 0;
setIsRecording(false);
setIsPreparing(false);
setShowProgress(false);
@@ -617,13 +696,27 @@ const RecordPlayback = ({ route }) => {
}
playbackStartedRef.current = false;
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 };
+ const exitRequested = exitRequestedRef.current;
const shouldRestart = restartRequestedRef.current;
+ if (exitRequested) {
+ exitRequestedRef.current = false;
+ restartRequestedRef.current = false;
+ manualRestartInFlightRef.current = false;
+ log("Recording aborted, skipping error handling");
+ return;
+ }
if (shouldRestart) {
restartRequestedRef.current = false;
- log("Restart requested despite error, restarting flow");
- requestAnimationFrame(() => {
- void startCountdownThenRecord();
- });
+ const manualRestartPending = manualRestartInFlightRef.current;
+ manualRestartInFlightRef.current = false;
+ if (manualRestartPending) {
+ log("Manual restart already scheduled after error");
+ } else {
+ log("Restart requested despite error, restarting flow");
+ requestAnimationFrame(() => {
+ void startCountdownThenRecord();
+ });
+ }
return;
}
// Inform the user when using a simulator where recording isn't supported
@@ -648,25 +741,94 @@ const RecordPlayback = ({ route }) => {
}
};
+ const handleBackPress = useCallback(() => {
+ const busy = isPreparing || isRecording;
+ log("Back button pressed", { isPreparing, isRecording, busy });
+ if (busy) {
+ exitRequestedRef.current = true;
+ restartRequestedRef.current = false;
+ manualRestartInFlightRef.current = false;
+ stopRequestedRef.current = true;
+ stopRequestedAtRef.current = Date.now();
+ if (isPreparing && countdownTimerRef.current) {
+ clearInterval(countdownTimerRef.current);
+ countdownTimerRef.current = null;
+ }
+ try {
+ if (player?.playing) {
+ const maybePromise = player.pause?.();
+ if (maybePromise && typeof maybePromise.catch === "function") {
+ maybePromise.catch(() => {});
+ }
+ }
+ } catch (_) {}
+ try {
+ if (isRecording) {
+ cameraRef.current?.stopRecording?.();
+ }
+ } catch (_) {}
+ } else {
+ exitRequestedRef.current = false;
+ }
+ try {
+ goBack();
+ } catch (_) {}
+ return true;
+ }, [goBack, isPreparing, isRecording, player]);
+
+ useFocusEffect(
+ useCallback(() => {
+ const subscription = BackHandler.addEventListener(
+ "hardwareBackPress",
+ () => handleBackPress()
+ );
+ return () => subscription.remove();
+ }, [handleBackPress])
+ );
+
const handleRestartRecording = async () => {
try {
+ const hasRecordingPending = !!activeRecordingPromiseRef.current;
log("Restart button pressed", {
isPreparing,
isRecording,
+ hasRecordingPending,
});
- if (isPreparing || !isRecording) {
+ if (isPreparing) {
+ if (hasRecordingPending) {
+ await startCountdownThenRecord({
+ preserveRestartFlag: true,
+ preserveSongEndWatcher: true,
+ preserveStopRequest: true,
+ });
+ return;
+ }
restartRequestedRef.current = false;
+ manualRestartInFlightRef.current = false;
+ await startCountdownThenRecord();
+ return;
+ }
+ if (!isRecording) {
+ restartRequestedRef.current = false;
+ manualRestartInFlightRef.current = false;
await startCountdownThenRecord();
return;
}
restartRequestedRef.current = true;
+ manualRestartInFlightRef.current = true;
stopRequestedRef.current = true;
+ stopRequestedAtRef.current = Date.now();
try {
if (player?.playing) await player.pause?.();
} catch (_) {}
try {
cameraRef.current?.stopRecording?.();
} catch (_) {}
+ await startCountdownThenRecord({
+ preserveRestartFlag: true,
+ preserveSongEndWatcher: true,
+ preserveStopRequest: true,
+ });
} catch (_) {}
};
@@ -693,7 +855,7 @@ const RecordPlayback = ({ route }) => {
paddingBottom: gutters * 2,
}}
>
-
+
{
height={size}
style={{
transform: [{ rotate: "-90deg" }],
- backgroundColor: "#ffffff3d",
borderRadius: size / 2,
}}
>
diff --git a/src/screens/Studio/SongReady.js b/src/screens/Studio/SongReady.js
index 7437b39..b0e5f48 100644
--- a/src/screens/Studio/SongReady.js
+++ b/src/screens/Studio/SongReady.js
@@ -202,9 +202,51 @@ const SongReady = () => {
const handleRegeneratePress = () => {
if (isWeb) {
+ const descriptionTextStyle = {
+ fontSize: 16,
+ color: Palette.white,
+ fontFamily: FONT_FAMILY.InterRegular,
+ textAlign: "center",
+ };
+ const amountTextStyle = {
+ ...descriptionTextStyle,
+ fontFamily: FONT_FAMILY.InterSemiBold,
+ };
+
alert(
"Re-générer le morceau",
- `Tu vas pouvoir modifier tes choix pour re-générer ${SONG_OPTIONS_PER_GENERATION} nouveaux morceaux pour ${MUSIC_GENERATION_COIN_COST} crédits.\n\nLes crédits seront utilisés lors de l'étape de génération.`,
+ (
+
+
+ {`Tu vas pouvoir modifier tes choix pour re-générer ${SONG_OPTIONS_PER_GENERATION} nouveaux morceaux.`}
+
+
+ Cette action coûte
+
+
+
+ Les crédits seront utilisés lors de l'étape de génération.
+
+
+ ),
[
{
text: "Annuler",
diff --git a/web/index.html b/web/index.html
index cc7522d..8790a03 100644
--- a/web/index.html
+++ b/web/index.html
@@ -8,6 +8,11 @@
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1.00001, user-scalable=no, viewport-fit=cover"
/>
+
+