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 CreditAmount from "../../components/CreditAmount"; import { background } from "../../assets"; 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"; import { formatDate, toDate } from "../../utils/dateFormatting"; import { Image as ExpoImage } from "expo-image"; import { isWeb } from "../../hooks/useLayoutType"; 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 PAGE_BACKGROUND_COLOR = "#303438"; 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 capitalize = (value) => { if (typeof value !== "string" || !value) { return null; } return value.charAt(0).toUpperCase() + value.slice(1); }; const SCHEDULE_COMPARISON_HOUR = 2; const SCHEDULE_COMPARISON_MINUTE = 30; const SCHEDULE_DISPLAY_HOUR = 3; const SCHEDULE_DISPLAY_MINUTE = 30; const addMonthsSafe = (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 addDaysSafe = (date, days = 1) => { if (!(date instanceof Date) || !Number.isFinite(days)) { return null; } const result = new Date(date.getTime()); result.setDate(result.getDate() + days); return result; }; const alignToScheduleTime = (date) => { if (!(date instanceof Date)) { return null; } const aligned = new Date(date.getTime()); aligned.setHours(SCHEDULE_DISPLAY_HOUR, SCHEDULE_DISPLAY_MINUTE, 0, 0); return aligned; }; const computeFirstAnnualGrantFromCreation = (creationDate) => { if (!(creationDate instanceof Date)) { return null; } const cutoff = new Date(creationDate.getTime()); cutoff.setHours(SCHEDULE_COMPARISON_HOUR, SCHEDULE_COMPARISON_MINUTE, 0, 0); let base = null; if (creationDate <= cutoff) { base = addMonthsSafe(creationDate, 1); } else { base = addDaysSafe(creationDate, 1); } if (!base) { return null; } return alignToScheduleTime(base); }; 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 nextGrantTimestamp = currentUserData?.subscriptionNextGrantAt || currentUserData?.subscriptionGrantNextAt || null; const nextGrantRawDate = toDate(nextGrantTimestamp); const now = new Date(); const alignedStoredNextGrant = alignToScheduleTime(nextGrantRawDate); const futureStoredNextGrant = alignedStoredNextGrant && alignedStoredNextGrant.getTime() >= now.getTime() ? alignedStoredNextGrant : null; const fallbackInitialGrant = isAnnual && createdAtDate ? computeFirstAnnualGrantFromCreation(createdAtDate) : null; const futureFallbackGrant = fallbackInitialGrant && fallbackInitialGrant.getTime() >= now.getTime() ? fallbackInitialGrant : null; let resolvedNextGrantDate = futureStoredNextGrant || futureFallbackGrant || null; if (futureStoredNextGrant && futureFallbackGrant) { resolvedNextGrantDate = futureFallbackGrant.getTime() < futureStoredNextGrant.getTime() ? futureFallbackGrant : futureStoredNextGrant; } const nextGrantLabel = resolvedNextGrantDate && isAnnual ? formatDate(resolvedNextGrantDate) : null; const helperMessages = []; if (!hasActiveSubscription && hasAnySubscription) { helperMessages.push( "Ton abonnement n'est plus actif. Tu peux souscrire à nouveau à tout moment.", ); } 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, isAnnual, nextGrantDate: resolvedNextGrantDate, 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 || "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; const HOME_BACKGROUND_WIDTH = isWeb ? 1280 : 520; const HOME_BACKGROUND_HEIGHT = isWeb ? 760 : 360; const HOME_BACKGROUND_STYLE_WIDTH = HOME_BACKGROUND_WIDTH + 120; const HOME_BACKGROUND_STYLE_HEIGHT = HOME_BACKGROUND_HEIGHT + 80; return ( {subscriptionInfo.hasAnySubscription ? ( <> {subscriptionInfo.planLabel} Statut {subscriptionInfo.statusLabel ? ( {subscriptionInfo.statusLabel} ) : ( )} Cycle de facturation {subscriptionInfo.billingPeriodLabel || "—"} Crédits mensuels {typeof subscriptionInfo.coinsPerMonth === "number" ? ( ) : ( )} {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} {subscriptionInfo.isAnnual && typeof subscriptionInfo.coinsPerMonth === "number" ? ( Tes pièces sont versées chaque mois ( ) ) : 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, }, helperInlineRow: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 4, }, 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;