From 7ab868518062355911b67ce025ad6612851a18e8 Mon Sep 17 00:00:00 2001 From: Thomas Demirdjian Date: Tue, 18 Nov 2025 11:40:39 +0100 Subject: [PATCH] stripe embedded and other fixes --- functions/helpers/stripe.js | 1 + functions/src/subscription.js | 2367 ---------------------- functions/src/subscription/catalog.js | 140 ++ functions/src/subscription/checkout.js | 317 +++ functions/src/subscription/config.js | 5 + functions/src/subscription/constants.js | 96 + functions/src/subscription/index.js | 22 + functions/src/subscription/management.js | 213 ++ functions/src/subscription/schedule.js | 178 ++ functions/src/subscription/shared.js | 401 ++++ functions/src/subscription/webhooks.js | 547 +++++ src/components/Alert.js | 20 +- src/providers/StripeProvider.js | 153 +- src/screens/Library/MusicDetails.web.js | 36 +- src/screens/Playback/RecordPlayback.js | 305 ++- src/screens/Studio/SongReady.js | 44 +- web/index.html | 5 + 17 files changed, 2346 insertions(+), 2504 deletions(-) delete mode 100644 functions/src/subscription.js create mode 100644 functions/src/subscription/catalog.js create mode 100644 functions/src/subscription/checkout.js create mode 100644 functions/src/subscription/config.js create mode 100644 functions/src/subscription/constants.js create mode 100644 functions/src/subscription/index.js create mode 100644 functions/src/subscription/management.js create mode 100644 functions/src/subscription/schedule.js create mode 100644 functions/src/subscription/shared.js create mode 100644 functions/src/subscription/webhooks.js 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" /> + +