From 5f777f3b6dc0d0750f1c24cdc314ff5856e30a63 Mon Sep 17 00:00:00 2001 From: Thomas Demirdjian Date: Wed, 5 Nov 2025 15:39:15 +0100 Subject: [PATCH] continue subscriptions and coins --- functions/config/keys.js | 2 +- functions/src/cover.js | 13 +- functions/src/helpers/orders.js | 4 +- functions/src/lyrics.js | 3 +- functions/src/music.js | 11 +- functions/src/notifications.js | 7 +- functions/src/orders.js | 34 +- functions/src/payouts.js | 5 +- functions/src/project.js | 5 +- functions/src/rankings.js | 5 +- functions/src/stripe.js | 5 +- functions/src/subscription.js | 1172 ++++++++++++++++++++- functions/src/thumbnail.js | 3 +- functions/src/users.js | 3 +- functions/src/youtube.js | 9 +- src/navigation/MainStack.js | 5 + src/navigation/Routes.js | 1 + src/screens/AccountSettings.js | 2 +- src/screens/Payments.js | 14 + src/screens/Profile/ManageSubscription.js | 699 ++++++++++++ src/screens/Profile/OrderHistory.js | 184 ++-- src/screens/Profile/Profile.js | 14 - src/screens/Profile/Settings.js | 10 +- src/screens/Studio/ComposeSong.js | 3 +- src/screens/Studio/ComposeSong.web.js | 3 +- 25 files changed, 1994 insertions(+), 222 deletions(-) create mode 100644 src/screens/Profile/ManageSubscription.js diff --git a/functions/config/keys.js b/functions/config/keys.js index 2165a2a..687aef2 100644 --- a/functions/config/keys.js +++ b/functions/config/keys.js @@ -1,6 +1,6 @@ exports.GEMINI_API_KEY = "AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y"; -exports.SUNO_API_KEY = "5a689606d3c59f58268b4963d5107361"; // api key +exports.SUNO_API_KEY = "c1636e04f606811511e19ec6e1545aa6"; // api key exports.RESEND_API_KEY = "re_"; diff --git a/functions/src/cover.js b/functions/src/cover.js index 9b8d71e..1643505 100644 --- a/functions/src/cover.js +++ b/functions/src/cover.js @@ -1,5 +1,6 @@ const { onDocumentCreated } = require("firebase-functions/v2/firestore"); const admin = require("firebase-admin"); +const { FieldValue } = require("firebase-admin/firestore"); const logger = require("firebase-functions/logger"); const { generateImageV2 } = require("../helpers/gemini"); const { generatePicturePrompt } = require("../helpers/prompts"); @@ -129,7 +130,7 @@ async function performCoverGeneration(project) { options, }, coverStatus: "GENERATED", - updatedAt: admin.firestore.FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), }, { merge: true } ); @@ -178,14 +179,14 @@ exports.onTaskCreateGenerateCover = onDocumentCreated( status: "CANCELLED", error: "La personnalisation de la pochette avec une photo n'est plus disponible.", - updatedAt: admin.firestore.FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), }); return; } await refList.projects.doc(projectId).update({ coverStatus: "GENERATING", - updatedAt: admin.firestore.FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), }); project = (await refList.projects.doc(projectId).get())?.data() || null; @@ -212,12 +213,12 @@ exports.onTaskCreateGenerateCover = onDocumentCreated( }); await refList.projects.doc(projectId).update({ coverStatus: "GENERATED", - updatedAt: admin.firestore.FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), }); await event.data.ref.update({ status: "CANCELLED", error: "Cover already generated", - updatedAt: admin.firestore.FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), }); return; } @@ -231,7 +232,7 @@ exports.onTaskCreateGenerateCover = onDocumentCreated( await event.data.ref.update({ status: "DONE", coverUrl, - updatedAt: admin.firestore.FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), }); logger.info("📦 [Task] Marked DONE", { taskId: event?.params?.taskId, diff --git a/functions/src/helpers/orders.js b/functions/src/helpers/orders.js index 0f8b3b5..edca6f4 100644 --- a/functions/src/helpers/orders.js +++ b/functions/src/helpers/orders.js @@ -1,9 +1,11 @@ const admin = require("firebase-admin"); +const { FieldValue } = require("firebase-admin/firestore"); const ORDER_TYPES = { GIFT: "GIFT", SONG: "SONG", COINS: "COINS", + SUBSCRIPTION: "SUBSCRIPTION", }; const ORDER_STATUS = { @@ -60,7 +62,7 @@ const createOrderDocument = async ({ type, amount: normalizedAmount, songId: type === ORDER_TYPES.SONG ? songId || null : null, - createdAt: admin.firestore.FieldValue.serverTimestamp(), + createdAt: FieldValue.serverTimestamp(), createdBy: createdBy || "system", status: ORDER_STATUS.PENDING, metadata: metadata || {}, diff --git a/functions/src/lyrics.js b/functions/src/lyrics.js index b0288e6..f1f0f65 100644 --- a/functions/src/lyrics.js +++ b/functions/src/lyrics.js @@ -8,6 +8,7 @@ const { const { SUNO_API_KEY } = require("../config/keys"); const axios = require("axios"); const admin = require("firebase-admin"); +const { FieldValue } = require("firebase-admin/firestore"); const { refList } = require("../index"); const STRUCTURE_PROMPT_LABELS = { @@ -453,7 +454,7 @@ async function getSunoTimestamps(projectId) { musicTimestamps: { [songIndex]: dataToReturn, }, - updatedAt: admin.firestore.FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), }, { merge: true }, ); diff --git a/functions/src/music.js b/functions/src/music.js index 3c648fc..9750b98 100644 --- a/functions/src/music.js +++ b/functions/src/music.js @@ -5,6 +5,7 @@ const { } = require("firebase-functions/v2/https"); const axios = require("axios"); const admin = require("firebase-admin"); +const { FieldValue } = require("firebase-admin/firestore"); const { logger } = require("firebase-functions/logger"); const { ALERT_TYPE, refList } = require("../index"); const { sendNotification } = require("./notifications"); @@ -44,10 +45,10 @@ async function markProjectMusicFailure(projectId, error) { await docRef.set( { musicStatus: "FAILED", - sunoTaskId: admin.firestore.FieldValue.delete(), - generationStartAt: admin.firestore.FieldValue.delete(), + sunoTaskId: FieldValue.delete(), + generationStartAt: FieldValue.delete(), musicError: errorPayload, - updatedAt: admin.firestore.FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), }, { merge: true }, ); @@ -677,8 +678,8 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => { { musicStatus: "GENERATED", musicUrls, - musicError: admin.firestore.FieldValue.delete(), - updatedAt: admin.firestore.FieldValue.serverTimestamp(), + musicError: FieldValue.delete(), + updatedAt: FieldValue.serverTimestamp(), }, { merge: true }, ); diff --git a/functions/src/notifications.js b/functions/src/notifications.js index 428f649..a01483a 100644 --- a/functions/src/notifications.js +++ b/functions/src/notifications.js @@ -3,6 +3,7 @@ const { onDocumentWritten, } = require("firebase-functions/v2/firestore"); const admin = require("firebase-admin"); +const { FieldValue } = require("firebase-admin/firestore"); const { refList, ALERT_TYPE } = require("../index"); const { Expo } = require("expo-server-sdk"); const { Resend } = require("resend"); @@ -209,11 +210,11 @@ async function removeInvalidTokens({ const userData = userSnap?.data() || {}; const updates = { - pushTokens: admin.firestore.FieldValue.arrayRemove(...uniqueTokens), + pushTokens: FieldValue.arrayRemove(...uniqueTokens), }; if (uniqueTokens.includes(userData?.pushToken)) { - updates.pushToken = admin.firestore.FieldValue.delete(); + updates.pushToken = FieldValue.delete(); } await docRef.set(updates, { merge: true }); @@ -246,7 +247,7 @@ const sendNotification = async ({ receiverCollection, title, message, - time: admin.firestore.FieldValue.serverTimestamp(), + time: FieldValue.serverTimestamp(), read: false, readAt: null, mailOnly, diff --git a/functions/src/orders.js b/functions/src/orders.js index 068f4dd..138bd9d 100644 --- a/functions/src/orders.js +++ b/functions/src/orders.js @@ -1,4 +1,5 @@ const admin = require("firebase-admin"); +const { FieldValue } = require("firebase-admin/firestore"); const { onDocumentCreated } = require("firebase-functions/firestore"); const { HttpsError, onCall } = require("firebase-functions/https"); @@ -35,7 +36,7 @@ const onOrderCreated = onDocumentCreated( await orderRef.set( { status: ORDER_STATUS.REJECTED, - processedAt: admin.firestore.FieldValue.serverTimestamp(), + processedAt: FieldValue.serverTimestamp(), failureReason: "USER_NOT_FOUND", }, { merge: true }, @@ -47,7 +48,7 @@ const onOrderCreated = onDocumentCreated( await orderRef.set( { status: ORDER_STATUS.REJECTED, - processedAt: admin.firestore.FieldValue.serverTimestamp(), + processedAt: FieldValue.serverTimestamp(), failureReason: "INVALID_AMOUNT", }, { merge: true }, @@ -76,7 +77,7 @@ const onOrderCreated = onDocumentCreated( orderRef, { status: ORDER_STATUS.REJECTED, - processedAt: admin.firestore.FieldValue.serverTimestamp(), + processedAt: FieldValue.serverTimestamp(), failureReason: "INSUFFICIENT_FUNDS", balanceBefore: currentBalance, balanceAfter: currentBalance, @@ -89,15 +90,15 @@ const onOrderCreated = onDocumentCreated( if (userSnapshot?.exists) { transaction.update(userRef, { coins: nextBalance, - updatedAt: admin.firestore.FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), }); } else { transaction.set( userRef, { coins: nextBalance, - createdAt: admin.firestore.FieldValue.serverTimestamp(), - updatedAt: admin.firestore.FieldValue.serverTimestamp(), + createdAt: FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), }, { merge: true }, ); @@ -107,7 +108,7 @@ const onOrderCreated = onDocumentCreated( orderRef, { status: ORDER_STATUS.APPLIED, - processedAt: admin.firestore.FieldValue.serverTimestamp(), + processedAt: FieldValue.serverTimestamp(), balanceBefore: currentBalance, balanceAfter: nextBalance, }, @@ -124,7 +125,7 @@ const onOrderCreated = onDocumentCreated( await orderRef.set( { status: ORDER_STATUS.REJECTED, - processedAt: admin.firestore.FieldValue.serverTimestamp(), + processedAt: FieldValue.serverTimestamp(), failureReason: "PROCESSING_ERROR", errorMessage: error?.message || String(error), }, @@ -168,17 +169,24 @@ const createSongOrder = onCall({ region: REGION }, async (request) => { ); } + const metadata = { + source: + typeof data?.source === "string" && data.source.trim() + ? data.source.trim() + : "music_generation", + }; + + if (typeof data?.requestId === "string" && data.requestId.trim()) { + metadata.requestId = data.requestId.trim(); + } + const { orderId } = await createOrderDocument({ userId: auth.uid, type: ORDER_TYPES.SONG, amount, songId, createdBy: auth.uid, - metadata: { - source: data?.source || "music_generation", - requestId: - typeof data?.requestId === "string" ? data.requestId : undefined, - }, + metadata, }); return { orderId }; diff --git a/functions/src/payouts.js b/functions/src/payouts.js index 54ae822..91f2f16 100644 --- a/functions/src/payouts.js +++ b/functions/src/payouts.js @@ -1,4 +1,5 @@ const admin = require("firebase-admin"); +const { FieldValue } = require("firebase-admin/firestore"); const { onSchedule } = require("firebase-functions/v2/scheduler"); const _ = require("lodash"); const { refList, db, ALERT_TYPE } = require("../index"); @@ -123,7 +124,7 @@ exports.distributeMonthlyPayouts = onSchedule( totalStreams: userData.totalStreams, projects: userData.projects, computedAt: now, - updatedAt: admin.firestore.FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), }, }; }); @@ -195,7 +196,7 @@ exports.distributeMonthlyPayouts = onSchedule( totalAllocated: _.round(_.sumBy(payouts, "amount"), 2), payouts, status: payouts.length ? "computed" : "no-data", - updatedAt: admin.firestore.FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), computedAt: now, }; diff --git a/functions/src/project.js b/functions/src/project.js index 5ea40a2..b164ea4 100644 --- a/functions/src/project.js +++ b/functions/src/project.js @@ -1,4 +1,5 @@ const admin = require("firebase-admin"); +const { FieldValue } = require("firebase-admin/firestore"); const { onDocumentWritten } = require("firebase-functions/firestore"); const _ = require("lodash"); const { refList, db } = require("../index"); @@ -76,7 +77,7 @@ exports.onProjectWritten = onDocumentWritten( updatedAt: now, lastStreamAt: now, lastDelta: delta, - streams: admin.firestore.FieldValue.increment(delta), + streams: FieldValue.increment(delta), }; const hasFirstStream = @@ -92,7 +93,7 @@ exports.onProjectWritten = onDocumentWritten( updatedAt: now, lastStreamAt: now, lastDelta: delta, - totalStreams: admin.firestore.FieldValue.increment(delta), + totalStreams: FieldValue.increment(delta), }; const totalsHasFirstStream = totalsSnapshot.exists && totalsSnapshot.data()?.firstStreamAt; diff --git a/functions/src/rankings.js b/functions/src/rankings.js index b44d605..1d5cb33 100644 --- a/functions/src/rankings.js +++ b/functions/src/rankings.js @@ -1,4 +1,5 @@ const admin = require("firebase-admin"); +const { FieldValue } = require("firebase-admin/firestore"); const { onSchedule } = require("firebase-functions/v2/scheduler"); const { refList } = require("../index"); const firestore = refList.projects.firestore; @@ -71,11 +72,11 @@ exports.snapshotMonthlyTopSongs = onSchedule( }, topProjects, totalProjects: topProjects.length, - updatedAt: admin.firestore.FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), }; if (!existingSnapshot.exists) { - payload.createdAt = admin.firestore.FieldValue.serverTimestamp(); + payload.createdAt = FieldValue.serverTimestamp(); } await docRef.set(payload, { merge: true }); diff --git a/functions/src/stripe.js b/functions/src/stripe.js index 6c97ce6..f2b54ab 100644 --- a/functions/src/stripe.js +++ b/functions/src/stripe.js @@ -27,8 +27,9 @@ const getServerTimestamp = () => { if (FieldValue?.serverTimestamp) { return FieldValue.serverTimestamp(); } - if (admin.firestore?.FieldValue?.serverTimestamp) { - return admin.firestore.FieldValue.serverTimestamp(); + const fallback = admin.firestore?.FieldValue; + if (fallback?.serverTimestamp) { + return fallback.serverTimestamp(); } throw new HttpsError( "failed-precondition", diff --git a/functions/src/subscription.js b/functions/src/subscription.js index fe7c4b1..9988d6f 100644 --- a/functions/src/subscription.js +++ b/functions/src/subscription.js @@ -1,10 +1,13 @@ 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, @@ -32,30 +35,42 @@ const SUBSCRIPTION_PRICE_IDS = { 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, }, }; @@ -63,17 +78,14 @@ const COIN_PACK_PRODUCTS = [ { productId: "prod_TMPPEXZ1wGk2cS", key: "starter", - fallbackCoinAmount: 10, }, { productId: "prod_TMPQ5SNdS47gY6", key: "pro", - fallbackCoinAmount: 40, }, { productId: "prod_TMPRpA0qQSHXj5", key: "premium", - fallbackCoinAmount: 60, }, ]; @@ -90,13 +102,117 @@ const COIN_PACK_PRODUCT_MAP = COIN_PACK_PRODUCTS.reduce( 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(); } - if (admin.firestore?.FieldValue?.serverTimestamp) { - return admin.firestore.FieldValue.serverTimestamp(); + const fallback = admin.firestore?.FieldValue; + if (typeof fallback?.serverTimestamp === "function") { + return fallback.serverTimestamp(); } throw new HttpsError( "failed-precondition", @@ -266,6 +382,10 @@ const buildSubscriptionPayload = (subscription) => { 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, }; }); @@ -293,7 +413,11 @@ const buildSubscriptionPayload = (subscription) => { }; }; -const handleCheckoutSessionCompleted = async (session, event) => { +const handleCheckoutSessionCompleted = async ( + session, + event, + { stripe } = {}, +) => { if (!session || typeof session !== "object") { return; } @@ -354,6 +478,29 @@ const handleCheckoutSessionCompleted = async (session, event) => { 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" || @@ -408,7 +555,11 @@ const handleCheckoutSessionCompleted = async (session, event) => { } }; -const handleCustomerSubscriptionEvent = async (subscription, event) => { +const handleCustomerSubscriptionEvent = async ( + subscription, + event, + { stripe } = {}, +) => { if (!subscription || typeof subscription !== "object") { return; } @@ -418,11 +569,29 @@ const handleCustomerSubscriptionEvent = async (subscription, event) => { return; } - const { uid, userRef } = await resolveUserContext({ + 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; @@ -476,6 +645,90 @@ const handleCustomerSubscriptionEvent = async (subscription, event) => { ? 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, @@ -502,16 +755,47 @@ const handleCustomerSubscriptionEvent = async (subscription, event) => { 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) => { +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); await upsertPaymentDocument(invoice.id, { userId: firebaseUid || null, @@ -540,23 +824,329 @@ const handleInvoiceEvent = async (invoice, event) => { mode: "invoice", }); - const { userRef } = await resolveUserContext({ + const { + uid, + userRef, + userData: resolvedUserData, + } = await resolveUserContext({ metadata: invoice.metadata, customerId: invoice.customer, }); - if (userRef) { - const lastEvent = buildEventSnapshot(eventType, invoice.id); - if (firebaseUid) { - lastEvent.uid = firebaseUid; - } - await userRef.set( - { - lastStripeWebhookEvent: lastEvent, - }, - { merge: true }, - ); + if (!userRef) { + 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 isSubscriptionInvoice = + ["subscription_cycle", "subscription_create"].includes(billingReason) && + typeof invoice.subscription === "string" && + invoice.subscription; + + 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: invoice.subscription || 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; + } + } 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(); + 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: invoice.subscription || 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: invoice.subscription || 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: invoice.subscription || null, + priceId: invoicePriceId, + coinsPerMonth, + }, + ); + } + } + + 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: invoice.subscription || null, + coinsPerMonth, + billingPeriod, + recurringInterval, + isAnnual, + isMonthly, + }); + + if (coinsPerMonth && coinsPerMonth > 0 && orderTargetUid) { + console.log("[subscription-handleInvoiceEvent] Preparing coin grant", { + invoiceId: invoice.id || null, + subscriptionId: invoice.subscription || 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: invoice.subscription || 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: invoice.subscription || 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: invoice.subscription || null, + userId: orderTargetUid, + }); + } + } else { + console.log("[subscription-handleInvoiceEvent] Skipping allowance grant", { + invoiceId: invoice.id || null, + subscriptionId: invoice.subscription || null, + coinsPerMonth, + orderTargetUid, + billingPeriod, + }); + } + } + + await userRef.set(userUpdate, { merge: true }); }; const handlePaymentIntentEvent = async (paymentIntent, event) => { @@ -613,23 +1203,27 @@ const handlePaymentIntentEvent = async (paymentIntent, event) => { } }; -const handleStripeWebhookEvent = async ({ event }) => { +const handleStripeWebhookEvent = async ({ event, stripe }) => { if (!event || typeof event !== "object") { return; } switch (event.type) { case "checkout.session.completed": - await handleCheckoutSessionCompleted(event.data?.object, event); + 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); + await handleCustomerSubscriptionEvent(event.data?.object, event, { + stripe, + }); break; case "invoice.paid": case "invoice.payment_failed": - await handleInvoiceEvent(event.data?.object, event); + await handleInvoiceEvent(event.data?.object, event, { stripe }); break; case "payment_intent.succeeded": case "payment_intent.payment_failed": @@ -732,6 +1326,12 @@ const formatPlan = (price, priceId) => { 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 || "", @@ -741,7 +1341,7 @@ const formatPlan = (price, priceId) => { }; }; -const formatCoinPack = ({ product, price, fallback = {} }) => { +const formatCoinPack = ({ product, price }) => { if (!product || typeof product !== "object") { return null; } @@ -755,23 +1355,19 @@ const formatCoinPack = ({ product, price, fallback = {} }) => { (resolvedPrice && resolvedPrice.id) || (typeof product.default_price === "string" ? product.default_price : null); - const rawCoinAmount = - product?.metadata?.coins ?? - product?.metadata?.coinAmount ?? - fallback.fallbackCoinAmount ?? - 0; - - const coinAmount = - typeof rawCoinAmount === "string" - ? parseInt(rawCoinAmount, 10) - : Number(rawCoinAmount); + 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: Number.isFinite(coinAmount) && coinAmount > 0 ? coinAmount : 0, + coinAmount, currency: resolvedPrice?.currency || (typeof resolvedPrice?.currency === "string" @@ -849,11 +1445,14 @@ const listCoinPacks = onCall({ region: REGION }, async () => { resolvedPrice = product.default_price; } - return formatCoinPack({ + const formatted = formatCoinPack({ product, price: resolvedPrice, - fallback: pack, }); + return { + ...formatted, + coinPackKey: pack.key || null, + }; } catch (error) { console.error( "[subscription-listCoinPacks] Unable to retrieve product", @@ -991,6 +1590,310 @@ const createSubscriptionCheckoutSession = onCall( }, ); +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) => { @@ -1037,11 +1940,23 @@ const createCoinPackCheckoutSession = onCall( resolvedPrice = product.default_price; } - const coinPack = formatCoinPack({ - product, - price: resolvedPrice, - fallback: COIN_PACK_PRODUCT_MAP[productId], - }); + 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( @@ -1136,6 +2051,175 @@ const createCoinPackCheckoutSession = onCall( 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/thumbnail.js b/functions/src/thumbnail.js index 02ac8e7..b9399df 100644 --- a/functions/src/thumbnail.js +++ b/functions/src/thumbnail.js @@ -1,6 +1,7 @@ const { onObjectFinalized } = require("firebase-functions/v2/storage"); const logger = require("firebase-functions/logger"); const admin = require("firebase-admin"); +const { FieldValue } = require("firebase-admin/firestore"); const ffmpeg = require("fluent-ffmpeg"); const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg"); const fs = require("node:fs/promises"); @@ -150,7 +151,7 @@ exports.generateVideoThumbnail = onObjectFinalized( await refList.projects.doc(projectId).set( { thumbnailUrl, - updatedAt: admin.firestore.FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), }, { merge: true } ); diff --git a/functions/src/users.js b/functions/src/users.js index 8b09ff8..9a8784b 100644 --- a/functions/src/users.js +++ b/functions/src/users.js @@ -1,4 +1,5 @@ const admin = require("firebase-admin"); +const { FieldValue } = require("firebase-admin/firestore"); const { onDocumentDeleted, onDocumentCreated, @@ -126,7 +127,7 @@ async function clearAllUserData(userID) { const snapshot = await ref.where(arrayName, "array-contains", userID).get(); snapshot.forEach((item) => item.ref.update({ - [arrayName]: admin.firestore.FieldValue.arrayRemove(userID), + [arrayName]: FieldValue.arrayRemove(userID), }), ); }; diff --git a/functions/src/youtube.js b/functions/src/youtube.js index 19519e2..2b3a3c7 100644 --- a/functions/src/youtube.js +++ b/functions/src/youtube.js @@ -3,6 +3,7 @@ const { defineSecret } = require("firebase-functions/params"); const logger = require("firebase-functions/logger"); const functions = require("firebase-functions"); const admin = require("firebase-admin"); +const { FieldValue } = require("firebase-admin/firestore"); const axios = require("axios"); const fs = require("node:fs"); const fsp = require("node:fs/promises"); @@ -240,7 +241,7 @@ exports.publishPlaybackToYoutube = onCall( youtubeStatus: "PUBLISHING", youtubePublished: false, youtubeError: null, - updatedAt: admin.firestore.FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), }, { merge: true } ); @@ -270,9 +271,9 @@ exports.publishPlaybackToYoutube = onCall( youtubePublished: true, youtubeUrl: youtubeLink, youtubeVideoId: videoId, - youtubePublishedAt: admin.firestore.FieldValue.serverTimestamp(), + youtubePublishedAt: FieldValue.serverTimestamp(), youtubeError: null, - updatedAt: admin.firestore.FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), }, { merge: true } ); @@ -302,7 +303,7 @@ exports.publishPlaybackToYoutube = onCall( youtubeStatus: "FAILED", youtubePublished: false, youtubeError: errorMessage, - updatedAt: admin.firestore.FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), }, { merge: true } ); diff --git a/src/navigation/MainStack.js b/src/navigation/MainStack.js index faae07c..a1a07a5 100644 --- a/src/navigation/MainStack.js +++ b/src/navigation/MainStack.js @@ -38,6 +38,7 @@ import Follows from "../screens/Profile/Follows"; import Language from "../screens/Profile/Language"; import Notifications from "../screens/Profile/Notifications"; import OrderHistory from "../screens/Profile/OrderHistory"; +import ManageSubscription from "../screens/Profile/ManageSubscription"; import Profile from "../screens/Profile/Profile"; import Reels from "../screens/Profile/Reels"; import Settings from "../screens/Profile/Settings"; @@ -272,6 +273,10 @@ const baseScreens = [ name: Routes.Settings, component: Settings, }, + { + name: Routes.ManageSubscription, + component: ManageSubscription, + }, { name: Routes.OrderHistory, component: OrderHistory, diff --git a/src/navigation/Routes.js b/src/navigation/Routes.js index ac13756..eb876e7 100644 --- a/src/navigation/Routes.js +++ b/src/navigation/Routes.js @@ -77,6 +77,7 @@ export const Routes = { EditProfile: "EditProfile", Settings: "Settings", OrderHistory: "OrderHistory", + ManageSubscription: "ManageSubscription", ChangeEmailAddress: "ChangeEmailAddress", ChangePassword: "ChangePassword", Notifications: "Notifications", diff --git a/src/screens/AccountSettings.js b/src/screens/AccountSettings.js index e890137..08ea5df 100644 --- a/src/screens/AccountSettings.js +++ b/src/screens/AccountSettings.js @@ -293,7 +293,7 @@ export default ({ navigation }) => { {}} + action={() => navigation.navigate(Routes.ManageSubscription)} containerStyle={{}} /> diff --git a/src/screens/Payments.js b/src/screens/Payments.js index e949939..594d1e1 100644 --- a/src/screens/Payments.js +++ b/src/screens/Payments.js @@ -78,6 +78,10 @@ function SubscriptionCard({ plan, selected, onSelect }) { const planDescription = plan?.product?.description || ""; const formattedPrice = formatCurrency(plan?.unitAmount, plan?.currency); const intervalLabel = getIntervalLabel(plan?.recurring); + const coinsPerMonth = + typeof plan?.coinsPerMonth === "number" && Number.isFinite(plan.coinsPerMonth) + ? Math.round(plan.coinsPerMonth) + : null; const handleSelect = React.useCallback(() => { if (typeof onSelect === "function" && plan?.priceId) { onSelect(plan.priceId); @@ -120,6 +124,11 @@ function SubscriptionCard({ plan, selected, onSelect }) { {intervalLabel} ) : null} + {coinsPerMonth !== null ? ( + + {`+${coinsPerMonth} crédits / mois`} + + ) : null} ) : null} @@ -707,6 +716,11 @@ const styles = StyleSheet.create({ alignItems: "baseline", gap: 8, }, + coinsPerMonth: { + fontFamily: FONT_FAMILY.InterMedium, + fontSize: 13, + color: Palette.primary, + }, priceValue: { fontFamily: FONT_FAMILY.InterBold, fontSize: 22, diff --git a/src/screens/Profile/ManageSubscription.js b/src/screens/Profile/ManageSubscription.js new file mode 100644 index 0000000..d122d41 --- /dev/null +++ b/src/screens/Profile/ManageSubscription.js @@ -0,0 +1,699 @@ +import { useFocusEffect } from "@react-navigation/native"; +import React, { useCallback, useMemo, useState } from "react"; +import { Alert, Platform, StyleSheet, Text, View } from "react-native"; + +import BorderGradientButton from "../../components/BorderGradientButton"; +import { getFunctionsClient } from "../../config/firebase"; +import Page from "../../layouts/Page"; +import { Routes } from "../../navigation/Routes"; +import { useUserData } from "../../providers/UserDataProvider"; +import { gutters, Palette } from "../../styles"; +import { FONT_FAMILY } from "../../styles/Fonts"; + +const FUNCTIONS_REGION = "europe-west1"; + +const ACTIVE_SUBSCRIPTION_STATUSES = new Set([ + "trialing", + "active", + "past_due", + "unpaid", +]); + +const STATUS_LABELS = { + trialing: "Période d'essai", + active: "Actif", + past_due: "Paiement en attente", + unpaid: "Impaye", + canceled: "Annulé", + incomplete: "Incomplet", + incomplete_expired: "Expiré", + paused: "En pause", +}; + +const PLAN_LABELS = { + starter: "Starter", + pro: "Pro", + premium: "Premium", +}; + +const PERIOD_LABELS = { + monthly: "Mensuel", + annual: "Annuel", +}; + +const formatCoinsAmount = (value) => { + if (typeof value !== "number" || !Number.isFinite(value)) { + return null; + } + try { + return new Intl.NumberFormat("fr-FR", { + maximumFractionDigits: 0, + }).format(value); + } catch (_error) { + return `${Math.round(value)}`; + } +}; + +const getStatusColors = (status) => { + switch (status) { + case "trialing": + case "active": + return { + text: Palette.green, + background: Palette.transparentGreen, + }; + case "past_due": + case "unpaid": + return { + text: Palette.orange, + background: Palette.transparentOrange, + }; + case "canceled": + case "incomplete_expired": + return { + text: Palette.red, + background: Palette.transparentRed, + }; + default: + return { + text: Palette.grayMid, + background: Palette.ultraLightWhite, + }; + } +}; + +const toDate = (value) => { + if (!value) { + return null; + } + if (typeof value.toDate === "function") { + try { + return value.toDate(); + } catch (_error) { + return null; + } + } + if (value instanceof Date) { + return value; + } + if (typeof value === "number" && Number.isFinite(value)) { + if (value > 1e12) { + return new Date(value); + } + return new Date(value * 1000); + } + if (typeof value === "object" && Number.isFinite(value.seconds)) { + return new Date(value.seconds * 1000); + } + return null; +}; + +const formatDate = (date) => { + if (!date) { + return "À déterminer"; + } + try { + return new Intl.DateTimeFormat("fr-FR", { + day: "2-digit", + month: "long", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date); + } catch (_error) { + return date.toString(); + } +}; + +const capitalize = (value) => { + if (typeof value !== "string" || !value) { + return null; + } + return value.charAt(0).toUpperCase() + value.slice(1); +}; + +const ManageSubscription = ({ navigation }) => { + const { currentUserData } = useUserData() || {}; + const [isCancelling, setIsCancelling] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [remoteSubscription, setRemoteSubscription] = useState(null); + const [remoteError, setRemoteError] = useState(null); + const [isFetchingRemote, setIsFetchingRemote] = useState(false); + const [refreshToken, setRefreshToken] = useState(0); + + const stripeCustomerId = currentUserData?.stripeCustomerId || null; + const localSubscriptionId = + currentUserData?.stripeSubscription?.id || + currentUserData?.stripeSubscription?.subscriptionId || + null; + + const triggerRefresh = useCallback(() => { + setRefreshToken((value) => value + 1); + setIsFetchingRemote(true); + }, []); + + useFocusEffect( + useCallback(() => { + triggerRefresh(); + }, [triggerRefresh]), + ); + + React.useEffect(() => { + let isMounted = true; + + const run = async () => { + const hasLookupContext = + Boolean(stripeCustomerId) || Boolean(localSubscriptionId); + if (!hasLookupContext) { + if (isMounted) { + setRemoteSubscription(null); + setRemoteError(null); + setIsFetchingRemote(false); + } + return; + } + + setIsFetchingRemote(true); + setRemoteError(null); + + try { + const callable = getFunctionsClient( + FUNCTIONS_REGION, + ).httpsCallable("subscription-getActiveSubscription"); + const { data } = await callable(); + if (!isMounted) { + return; + } + setRemoteSubscription(data?.subscription || null); + } catch (error) { + console.warn( + "[ManageSubscription] fetch subscription error", + error?.message || error, + ); + if (isMounted) { + const message = + error?.message || + "Impossible de mettre à jour les informations d'abonnement."; + setRemoteError(message); + } + } finally { + if (isMounted) { + setIsFetchingRemote(false); + } + } + }; + + run(); + + return () => { + isMounted = false; + }; + }, [stripeCustomerId, localSubscriptionId, refreshToken]); + + const subscriptionInfo = useMemo(() => { + const rawSubscription = + remoteSubscription || currentUserData?.stripeSubscription || null; + + const pickString = (value) => { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed ? trimmed : null; + }; + + const statusSource = + pickString(currentUserData?.stripeSubscriptionStatus) || + pickString(remoteSubscription?.status) || + pickString(currentUserData?.stripeSubscription?.status) || + pickString(currentUserData?.stripeSubscription?.stripeSubscriptionStatus) || + pickString(rawSubscription?.status) || + null; + + const status = statusSource ? statusSource.toLowerCase() : null; + + const cancelAtPeriodEnd = + remoteSubscription?.cancelAtPeriodEnd === true || + rawSubscription?.cancelAtPeriodEnd === true || + rawSubscription?.cancel_at_period_end === true || + false; + + const resolveLevelSource = () => { + const candidates = [ + pickString(remoteSubscription?.level), + pickString(currentUserData?.premiumLevel), + pickString(rawSubscription?.metadata?.level), + pickString(rawSubscription?.metadata?.subscriptionLevel), + ]; + return candidates.find(Boolean) || null; + }; + + const resolvePeriodSource = () => { + const candidates = [ + pickString(remoteSubscription?.billingPeriod), + pickString(currentUserData?.premiumBillingPeriod), + pickString(rawSubscription?.metadata?.billingPeriod), + pickString(rawSubscription?.metadata?.subscriptionBillingPeriod), + ]; + return candidates.find(Boolean) || null; + }; + + const levelSource = resolveLevelSource(); + const periodSource = resolvePeriodSource(); + + const level = levelSource ? levelSource.toLowerCase() : null; + const billingPeriod = periodSource ? periodSource.toLowerCase() : null; + + const isAnnual = billingPeriod === "annual"; + + const planLabelParts = []; + if (PLAN_LABELS[level]) { + planLabelParts.push(PLAN_LABELS[level]); + } else if (level) { + planLabelParts.push(capitalize(level)); + } + if (PERIOD_LABELS[billingPeriod]) { + planLabelParts.push(PERIOD_LABELS[billingPeriod]); + } else if (billingPeriod) { + planLabelParts.push(capitalize(billingPeriod)); + } + const planLabel = + planLabelParts.length > 0 + ? planLabelParts.join(" · ") + : "Abonnement Musicland"; + + const currentPeriodEndDate = + toDate(remoteSubscription?.currentPeriodEnd) || + toDate(rawSubscription?.currentPeriodEnd) || + toDate(rawSubscription?.current_period_end); + const createdAtDate = + toDate(remoteSubscription?.created) || + toDate(rawSubscription?.createdAt) || + toDate(rawSubscription?.created_at); + + const statusLabelBase = + STATUS_LABELS[status] || + (status ? status.replace(/_/g, " ").toLowerCase() : null); + const statusLabel = statusLabelBase ? capitalize(statusLabelBase) : null; + const statusColors = getStatusColors(status); + + const hasAnySubscription = Boolean(rawSubscription?.id); + const hasActiveSubscription = + hasAnySubscription && ACTIVE_SUBSCRIPTION_STATUSES.has(status); + const canCancel = hasActiveSubscription && !cancelAtPeriodEnd; + + const periodEndLabel = currentPeriodEndDate + ? formatDate(currentPeriodEndDate) + : "—"; + const createdAtLabel = createdAtDate ? formatDate(createdAtDate) : null; + + let coinsPerMonth = + typeof remoteSubscription?.coinsPerMonth === "number" + ? remoteSubscription.coinsPerMonth + : null; + + if ( + coinsPerMonth === null && + typeof currentUserData?.subscriptionCoinsPerMonth === "number" + ) { + const userCoins = currentUserData.subscriptionCoinsPerMonth; + coinsPerMonth = Number.isFinite(userCoins) ? userCoins : null; + } + + const normalizedCoins = + typeof coinsPerMonth === "number" && Number.isFinite(coinsPerMonth) + ? Math.round(coinsPerMonth) + : null; + const coinsPerMonthLabel = + normalizedCoins !== null + ? `${formatCoinsAmount(normalizedCoins)} coins` + : null; + + const nextGrantTimestamp = + currentUserData?.subscriptionNextGrantAt || + currentUserData?.subscriptionGrantNextAt || + null; + const nextGrantDate = toDate(nextGrantTimestamp); + const nextGrantLabel = + nextGrantDate && isAnnual ? formatDate(nextGrantDate) : null; + + const helperMessages = []; + if (!hasActiveSubscription && hasAnySubscription) { + helperMessages.push( + "Ton abonnement n'est plus actif. Tu peux souscrire à nouveau à tout moment.", + ); + } + if (isAnnual && coinsPerMonthLabel) { + helperMessages.push( + `Tes crédits sont versés chaque mois (${coinsPerMonthLabel}).`, + ); + } + const helperMessage = helperMessages.join("\n"); + + return { + hasAnySubscription, + hasActiveSubscription, + canCancel, + cancelAtPeriodEnd, + status, + statusLabel, + statusColors, + planLabel, + billingPeriodLabel: + PERIOD_LABELS[billingPeriod] || capitalize(billingPeriod), + periodEndLabel, + createdAtLabel, + helperMessage: helperMessage || null, + level: level || null, + subscriptionId: + remoteSubscription?.id || + rawSubscription?.id || + currentUserData?.stripeSubscription?.subscriptionId || + null, + coinsPerMonth: normalizedCoins, + coinsPerMonthLabel, + isAnnual, + nextGrantDate, + nextGrantLabel, + }; + }, [currentUserData, remoteSubscription]); + + const handleOpenPlans = useCallback(() => { + const params = + subscriptionInfo.level && typeof subscriptionInfo.level === "string" + ? { pack: subscriptionInfo.level } + : undefined; + navigation.navigate(Routes.Payments, params); + }, [navigation, subscriptionInfo.level]); + + const performCancellation = useCallback(async () => { + setIsCancelling(true); + setErrorMessage(null); + setSuccessMessage(null); + + try { + const callable = getFunctionsClient( + FUNCTIONS_REGION, + ).httpsCallable("subscription-cancelActiveSubscription"); + const payload = subscriptionInfo.subscriptionId + ? { subscriptionId: subscriptionInfo.subscriptionId } + : {}; + const { data } = await callable(payload); + + if (data?.alreadyCanceled) { + setSuccessMessage( + "Ton abonnement est déjà en cours d'annulation. L'accès premium restera actif jusqu'à la fin de la période en cours.", + ); + } else if (data?.cancelAtPeriodEnd) { + setSuccessMessage( + "Ton abonnement sera résilié à la fin de la période en cours.", + ); + } else { + setSuccessMessage( + "La demande d'annulation a été prise en compte. Vérifie ton abonnement dans quelques instants.", + ); + } + triggerRefresh(); + } catch (error) { + console.warn( + "[ManageSubscription] cancel subscription error", + error?.message || error, + ); + const message = + error?.message || + error?.codeMessage || + "Impossible d'annuler l'abonnement pour le moment."; + setErrorMessage(message); + } finally { + setIsCancelling(false); + } + }, [subscriptionInfo.subscriptionId, triggerRefresh]); + + const handleCancel = useCallback(() => { + if (!subscriptionInfo.canCancel || isCancelling) { + return; + } + + const confirm = () => { + performCancellation(); + }; + + if (Platform.OS === "web" && typeof window !== "undefined") { + const confirmed = window.confirm( + "Confirmer l'annulation ? Ton accès premium restera actif jusqu'à la fin de la période en cours.", + ); + if (confirmed) { + confirm(); + } + return; + } + + Alert.alert( + "Confirmer l'annulation", + "Ton accès premium restera actif jusqu'à la fin de la période en cours.", + [ + { text: "Conserver mon abonnement", style: "cancel" }, + { + text: "Annuler l'abonnement", + style: "destructive", + onPress: confirm, + }, + ], + ); + }, [isCancelling, performCancellation, subscriptionInfo.canCancel]); + + const cancelButtonTitle = subscriptionInfo.cancelAtPeriodEnd + ? "Annulation programmée" + : isCancelling + ? "Annulation..." + : "Annuler l'abonnement"; + + const cancelTitleColor = subscriptionInfo.cancelAtPeriodEnd + ? Palette.grayMid + : Palette.red; + + return ( + + + {subscriptionInfo.hasAnySubscription ? ( + <> + + {subscriptionInfo.planLabel} + + + Statut + {subscriptionInfo.statusLabel ? ( + + + {subscriptionInfo.statusLabel} + + + ) : ( + — + )} + + + + Cycle de facturation + + {subscriptionInfo.billingPeriodLabel || "—"} + + + + + Crédits mensuels + + {subscriptionInfo.coinsPerMonthLabel || "—"} + + + + {subscriptionInfo.isAnnual && subscriptionInfo.nextGrantLabel ? ( + + Prochain versement + + {subscriptionInfo.nextGrantLabel} + + + ) : null} + + + + {subscriptionInfo.cancelAtPeriodEnd + ? "Fin d'accès" + : "Prochain renouvellement"} + + + {subscriptionInfo.periodEndLabel} + + + + {subscriptionInfo.createdAtLabel ? ( + + Abonné depuis + + {subscriptionInfo.createdAtLabel} + + + ) : null} + + {subscriptionInfo.cancelAtPeriodEnd ? ( + + Ton abonnement restera actif jusqu'à cette date. + + ) : null} + + {subscriptionInfo.helperMessage ? ( + + {subscriptionInfo.helperMessage} + + ) : null} + + + {successMessage ? ( + {successMessage} + ) : null} + {errorMessage ? ( + {errorMessage} + ) : null} + {remoteError ? ( + {remoteError} + ) : null} + + + + + + + ) : ( + + Aucun abonnement actif + + Souscris à l’une de nos offres pour profiter des fonctionnalités + premium de Musicland. + + + + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: gutters * 1.5, + paddingBottom: gutters * 1.5, + paddingTop: gutters * 1.2, + gap: gutters, + }, + card: { + padding: gutters * 1.5, + borderRadius: 24, + backgroundColor: "rgba(255, 255, 255, 0.04)", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.08)", + gap: gutters * 0.75, + }, + cardTitle: { + fontFamily: FONT_FAMILY.InterSemiBold, + fontSize: 20, + color: Palette.white, + }, + detailRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: gutters, + }, + detailLabel: { + fontFamily: FONT_FAMILY.InterRegular, + fontSize: 14, + color: Palette.grayMid, + flexShrink: 1, + }, + detailValue: { + fontFamily: FONT_FAMILY.InterMedium, + fontSize: 15, + color: Palette.white, + flexShrink: 0, + textAlign: "right", + }, + statusBadge: { + borderRadius: 999, + paddingHorizontal: 12, + paddingVertical: 4, + }, + statusText: { + fontFamily: FONT_FAMILY.InterSemiBold, + fontSize: 12, + letterSpacing: 0.3, + }, + helperText: { + fontFamily: FONT_FAMILY.InterRegular, + fontSize: 13, + lineHeight: 18, + color: Palette.grayMid, + }, + successMessage: { + fontFamily: FONT_FAMILY.InterMedium, + fontSize: 14, + color: Palette.green, + }, + errorMessage: { + fontFamily: FONT_FAMILY.InterMedium, + fontSize: 14, + color: Palette.red, + }, + infoText: { + fontFamily: FONT_FAMILY.InterRegular, + fontSize: 15, + lineHeight: 22, + color: Palette.white, + }, + buttonSpacing: { + marginTop: gutters * 0.5, + }, + cancelButton: { + marginTop: gutters * 0.5, + }, + emptyButton: { + marginTop: gutters * 1.5, + }, +}); + +export default ManageSubscription; diff --git a/src/screens/Profile/OrderHistory.js b/src/screens/Profile/OrderHistory.js index 2e957f2..711bf64 100644 --- a/src/screens/Profile/OrderHistory.js +++ b/src/screens/Profile/OrderHistory.js @@ -18,12 +18,7 @@ const ORDER_TYPE_LABELS = { GIFT: "Crédit offert", COINS: "Achat de coins", SONG: "Génération de musique", -}; - -const STATUS_LABELS = { - PENDING: "En cours", - APPLIED: "Confirmée", - REJECTED: "Refusée", + SUBSCRIPTION: "Abonnement", }; const formatCoins = (amount) => { @@ -39,26 +34,39 @@ const formatCoins = (amount) => { } }; -const formatDate = (date) => { +const formatDateParts = (date) => { if (!date) { - return "En attente de confirmation"; + return { + dateLabel: "En attente de confirmation", + timeLabel: "", + }; } try { - return date.toLocaleString("fr-FR", { + const dateLabel = date.toLocaleDateString("fr-FR", { day: "2-digit", month: "short", year: "numeric", + }); + const timeLabel = date.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit", }); + return { dateLabel, timeLabel }; } catch (_error) { - return date.toString(); + return { + dateLabel: date.toString(), + timeLabel: "", + }; } }; const mapOrderType = (type) => ORDER_TYPE_LABELS[type] || "Opération"; -const mapStatus = (status) => STATUS_LABELS[status] || "Inconnue"; +const shortenIdentifier = (value, visible = 6) => { + if (typeof value !== "string") return null; + if (value.length <= visible + 2) return value; + return `${value.slice(0, visible)}…`; +}; const OrderHistory = () => { const { currentUID } = useUser() || {}; @@ -126,64 +134,64 @@ const OrderHistory = () => { : 0; const isPositive = amountValue > 0; const amountLabel = `${isPositive ? "+" : ""}${formatCoins(amountValue)} ${ - amountValue === 1 ? "coin" : "coins" + Math.abs(amountValue) === 1 ? "coin" : "coins" }`; - const status = item.status || "PENDING"; + const { dateLabel } = formatDateParts(item.createdAt); - const details = (() => { - if (item.type === "GIFT" && item.metadata?.reason) { - switch (item.metadata.reason) { - case "WELCOME_BONUS": - return "Crédit de bienvenue"; - default: - return item.metadata.reason; - } + let reasonLabel = mapOrderType(item.type); + + if (item.type === "GIFT") { + const reason = item.metadata?.reason; + if (reason === "WELCOME_BONUS") { + reasonLabel = "Crédit de bienvenue"; + } else if (typeof reason === "string" && reason.trim()) { + reasonLabel = reason.trim(); } - if (item.type === "SONG" && item.songId) { - return `Musique ${item.songId}`; + } else if (item.type === "SONG") { + reasonLabel = "Génération de musique"; + } else if (item.type === "COINS") { + if (item.metadata?.coinPackKey) { + const shortenedPack = shortenIdentifier( + item.metadata.coinPackKey, + 10, + ); + reasonLabel = `Pack ${shortenedPack || item.metadata.coinPackKey}`; + } else if ( + typeof item.metadata?.source === "string" && + item.metadata.source.trim() + ) { + const source = item.metadata.source.trim(); + reasonLabel = + source === "STRIPE_CHECKOUT" ? "Recharge Stripe" : source; + } else { + reasonLabel = "Rechargement de coins"; } - if (item.type === "COINS" && item.metadata?.paymentId) { - return `Paiement ${item.metadata.paymentId}`; - } - return null; - })(); + } else if (item.type === "SUBSCRIPTION") { + const rawPeriod = + typeof item.metadata?.billingPeriod === "string" + ? item.metadata.billingPeriod.toLowerCase() + : null; + reasonLabel = + rawPeriod === "annual" + ? "Abonnement annuel" + : rawPeriod === "monthly" + ? "Abonnement mensuel" + : "Abonnement"; + } return ( - - {mapOrderType(item.type)} - - {amountLabel} - - - - {formatDate(item.createdAt)} - - {mapStatus(status)} - - - {details ? {details} : null} - {typeof item.balanceAfter === "number" && - Number.isFinite(item.balanceAfter) ? ( - - Solde après opération: {formatCoins(item.balanceAfter)} coins - - ) : null} + + {amountLabel} + + {reasonLabel} + {dateLabel} ); }, []); @@ -268,19 +276,7 @@ const styles = StyleSheet.create({ backgroundColor: Palette.ultraLightWhite, borderWidth: 1, borderColor: "rgba(255, 255, 255, 0.08)", - }, - orderHeader: { - flexDirection: "row", - alignItems: "flex-start", - justifyContent: "space-between", - marginBottom: 8, - gap: 12, - }, - orderTitle: { - flex: 1, - fontSize: 16, - fontFamily: FONT_FAMILY.InterSemiBold, - color: Palette.white, + gap: 6, }, orderAmount: { fontSize: 16, @@ -292,47 +288,15 @@ const styles = StyleSheet.create({ amountNegative: { color: Palette.red, }, - orderMetaRow: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - marginBottom: 6, - gap: 12, - }, - orderDate: { - fontSize: 13, - color: Palette.grayMid, - fontFamily: FONT_FAMILY.InterRegular, - }, - statusBadge: { - paddingHorizontal: 10, - paddingVertical: 4, - borderRadius: 999, - }, - statusApplied: { - backgroundColor: Palette.transparentGreen, - }, - statusPending: { - backgroundColor: Palette.transparentOrange, - }, - statusRejected: { - backgroundColor: Palette.transparentRed, - }, - statusText: { - fontSize: 12, - fontFamily: FONT_FAMILY.InterMedium, - color: Palette.white, - }, - orderDetails: { - marginBottom: 6, + orderReason: { fontSize: 14, fontFamily: FONT_FAMILY.InterRegular, color: Palette.white, }, - orderBalance: { - fontSize: 13, - fontFamily: FONT_FAMILY.InterRegular, + orderTimestamp: { + fontSize: 12, color: Palette.grayMid, + fontFamily: FONT_FAMILY.InterMedium, }, emptyText: { textAlign: "center", diff --git a/src/screens/Profile/Profile.js b/src/screens/Profile/Profile.js index 349e551..2a269a5 100644 --- a/src/screens/Profile/Profile.js +++ b/src/screens/Profile/Profile.js @@ -18,7 +18,6 @@ import { responsiveHeight } from "react-native-responsive-dimensions"; import { useGlobal } from "reactn"; import { background, icons } from "../../assets"; import BorderGradient from "../../components/BorderGradient/BorderGradient"; -import GradientButton from "../../components/GradientButton"; import MoreMenu from "../../components/MoreMenu"; import PressableScale from "../../components/PressableScale"; import ProfilePicture from "../../components/ProfilePicture"; @@ -473,19 +472,6 @@ const Profile = () => { abonnements - {isSelf ? ( - push(Routes.Payments)} - containerStyle={{ - marginTop: 18, - width: "80%", - alignSelf: "center", - }} - gradientStyle={{ width: "100%" }} - /> - ) : null} {!isSelf && ( { - navigate(Routes.Payments, { pack: "premium" }); - }, - }, { title: "Gérer mon abonnement", - action: () => {}, + action: () => { + navigate(Routes.ManageSubscription); + }, }, ]; diff --git a/src/screens/Studio/ComposeSong.js b/src/screens/Studio/ComposeSong.js index 8920305..e31cd1a 100644 --- a/src/screens/Studio/ComposeSong.js +++ b/src/screens/Studio/ComposeSong.js @@ -24,6 +24,7 @@ import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; const { width } = Dimensions.get("window"); const MUSIC_GENERATION_COIN_COST = 8; const CONFIRM_MODAL_MAX_WIDTH = 540; +const FUNCTIONS_REGION = "europe-west1"; const ComposeSong = () => { const scrollRef = useRef(null); @@ -144,7 +145,7 @@ const ComposeSong = () => { } try { - const functionsClient = getFunctionsClient(); + const functionsClient = getFunctionsClient(FUNCTIONS_REGION); const createSongOrder = functionsClient.httpsCallable("orders-createSongOrder"); diff --git a/src/screens/Studio/ComposeSong.web.js b/src/screens/Studio/ComposeSong.web.js index 9c15d2d..1a13fcc 100644 --- a/src/screens/Studio/ComposeSong.web.js +++ b/src/screens/Studio/ComposeSong.web.js @@ -29,6 +29,7 @@ import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; const { width: windowWidth } = Dimensions.get("window"); const MUSIC_GENERATION_COIN_COST = 8; const CONFIRM_MODAL_MAX_WIDTH = 540; +const FUNCTIONS_REGION = "europe-west1"; const ComposeSong = () => { const scrollRef = useRef(null); @@ -199,7 +200,7 @@ const ComposeSong = () => { } try { - const functionsClient = getFunctionsClient(); + const functionsClient = getFunctionsClient(FUNCTIONS_REGION); const createSongOrder = functionsClient.httpsCallable("orders-createSongOrder");