diff --git a/functions/src/subscription/checkout.js b/functions/src/subscription/checkout.js index 64160c9..b13e0f6 100644 --- a/functions/src/subscription/checkout.js +++ b/functions/src/subscription/checkout.js @@ -231,6 +231,85 @@ const createSongDownloadCheckoutSession = onCall({ region: REGION }, async (requ } }) +const createPlaybackDownloadCheckoutSession = onCall({ region: REGION }, async (request) => { + try { + const uid = request?.auth?.uid + if (!uid) { + throw new HttpsError('unauthenticated', 'Connecte-toi pour télécharger ton playback.') + } + + const rawProjectId = request?.data?.projectId + const projectId = typeof rawProjectId === 'string' ? rawProjectId.trim() : '' + if (!projectId) { + throw new HttpsError('invalid-argument', 'Un identifiant de projet est requis.') + } + + const stripe = getStripeClient() + + const { lineItems } = await buildCheckoutLineItems( + [ + { + label: 'Téléchargement playback', + unitAmount: 199, + currency: 'eur', + quantity: 1, + isRenewable: false, + }, + ], + { stripe } + ) + + const uiMode = resolveCheckoutUiMode(request) + const shouldProvideReturnUrls = uiMode !== 'embedded' + const { successUrl, cancelUrl } = shouldProvideReturnUrls + ? getReturnUrls(request?.data?.returnUrls) + : { successUrl: null, cancelUrl: null } + + const { customerId } = await ensureStripeCustomer({ + uid, + stripe, + refsList, + createIfMissing: true, + }) + + if (!customerId) { + throw new HttpsError( + 'failed-precondition', + 'Impossible de retrouver le client Stripe associé.' + ) + } + + const session = await stripe.checkout.sessions.create( + withCheckoutNavigationParams( + { + mode: 'payment', + customer: customerId, + line_items: lineItems, + allow_promotion_codes: false, + metadata: { + firebaseUID: uid, + purchaseType: 'PLAYBACK_DOWNLOAD', + projectId, + }, + }, + { + uiMode, + successUrl, + cancelUrl, + } + ) + ) + + return formatCheckoutSessionResponse(session) + } catch (error) { + console.error('[subscription-createPlaybackDownloadCheckoutSession] error', error) + if (error instanceof HttpsError) { + throw error + } + throw mapStripeErrorToHttps(error, "Impossible de créer la session d'achat.") + } +}) + const createCoinPackCheckoutSession = onCall({ region: REGION }, async (request) => { try { const uid = request?.auth?.uid @@ -352,6 +431,7 @@ const createCoinPackCheckoutSession = onCall({ region: REGION }, async (request) module.exports = { createSubscriptionCheckoutSession, createSongDownloadCheckoutSession, + createPlaybackDownloadCheckoutSession, createCoinPackCheckoutSession, resolveCheckoutUiMode, } diff --git a/functions/src/subscription/index.js b/functions/src/subscription/index.js index 94d987c..caeea93 100644 --- a/functions/src/subscription/index.js +++ b/functions/src/subscription/index.js @@ -2,6 +2,7 @@ const { listSubscriptionPlans, listCoinPacks } = require('./catalog') const { createSubscriptionCheckoutSession, createSongDownloadCheckoutSession, + createPlaybackDownloadCheckoutSession, createCoinPackCheckoutSession, } = require('./checkout') const { cancelActiveSubscription, getActiveSubscription } = require('./management') @@ -12,6 +13,7 @@ module.exports = { listSubscriptionPlans, createSubscriptionCheckoutSession, createSongDownloadCheckoutSession, + createPlaybackDownloadCheckoutSession, cancelActiveSubscription, getActiveSubscription, listCoinPacks, diff --git a/functions/src/subscription/management.js b/functions/src/subscription/management.js index aa0bcf5..b51f749 100644 --- a/functions/src/subscription/management.js +++ b/functions/src/subscription/management.js @@ -137,7 +137,7 @@ const getActiveSubscription = onCall({ region: REGION }, async (request) => { if (subscriptionId) { const subscription = await stripe.subscriptions.retrieve(subscriptionId, { - expand: ['items.data.price.product'], + expand: ['items.data.price'], }) if (subscription) { return { @@ -152,7 +152,7 @@ const getActiveSubscription = onCall({ region: REGION }, async (request) => { customer: customerId, status: 'all', limit: 5, - expand: ['data.items.data.price.product'], + expand: ['data.items.data.price'], }) const [subscription] = response?.data || [] if (subscription && ACTIVE_SUBSCRIPTION_STATUSES.has(subscription.status)) { diff --git a/functions/src/subscription/webhooks.js b/functions/src/subscription/webhooks.js index 21eb0ed..588a39d 100644 --- a/functions/src/subscription/webhooks.js +++ b/functions/src/subscription/webhooks.js @@ -235,6 +235,91 @@ const handleCheckoutSessionCompleted = async (session, event, { stripe } = {}) = } } } + + if ( + session.mode === 'payment' && + (session.payment_status === 'paid' || session.payment_status === 'no_payment_required') && + session.metadata?.purchaseType === 'PLAYBACK_DOWNLOAD' + ) { + const rawProjectId = session.metadata?.projectId + const projectId = typeof rawProjectId === 'string' ? rawProjectId.trim() : '' + + if (projectId && paymentDocRef) { + let paymentSnapshot = null + try { + paymentSnapshot = await paymentDocRef.get() + } catch (error) { + console.warn( + '[subscription-handleCheckoutSessionCompleted] Unable to read payment doc', + session.id, + error?.message || error + ) + } + + const alreadyGranted = Boolean( + paymentSnapshot?.exists && paymentSnapshot.data()?.playbackDownloadGrantedAt + ) + + if (!alreadyGranted) { + const projectRef = refsList?.projects?.doc(projectId) || null + let shouldGrant = true + + if (projectRef) { + try { + const projectSnapshot = await projectRef.get() + if (!projectSnapshot.exists) { + shouldGrant = false + } else { + const projectData = projectSnapshot.data() || {} + const ownerId = + typeof projectData?.userId === 'string' ? projectData.userId.trim() : '' + const targetUserId = uid || firebaseUid || userRef?.id || null + if (ownerId && targetUserId && ownerId !== targetUserId) { + console.warn( + '[subscription-handleCheckoutSessionCompleted] Project owner mismatch', + projectId + ) + shouldGrant = false + } + } + } catch (error) { + console.warn( + '[subscription-handleCheckoutSessionCompleted] Unable to read project', + projectId, + error?.message || error + ) + } + } else { + shouldGrant = false + } + + if (shouldGrant && projectRef) { + await projectRef.set( + { + playbackDownloadPurchase: { + status: 'paid', + paymentId: session.id || null, + paymentIntentId: + typeof session.payment_intent === 'string' ? session.payment_intent : null, + amount: session.amount_total ?? null, + currency: session.currency || null, + paidAt: getServerTimestamp(), + }, + }, + { merge: true } + ) + + await paymentDocRef.set( + { + playbackDownloadGrantedAt: getServerTimestamp(), + playbackDownloadProjectId: projectId, + }, + { merge: true } + ) + } + } + } + } } const handleCustomerSubscriptionEvent = async (subscription, event, { stripe } = {}) => { diff --git a/src/providers/StripeProvider.js b/src/providers/StripeProvider.js index 0552a34..d6410be 100644 --- a/src/providers/StripeProvider.js +++ b/src/providers/StripeProvider.js @@ -242,6 +242,38 @@ const StripeProvider = ({ children }) => { [canUseEmbeddedCheckout, runCheckoutSession] ) + const createPlaybackDownloadCheckout = React.useCallback( + async (projectId) => { + if (!projectId) { + throw new Error('Aucun projet sélectionné.') + } + if (isWeb && !canUseEmbeddedCheckout) { + console.error('[StripeProvider] checkout blocked (missing embedded support)', { + hasStripeKey: Boolean(stripePublishableKey), + isClientSecretReady: false, + }) + throw new Error( + 'Le paiement intégré Stripe est indisponible pour le moment (clé Stripe ou client secret absent).' + ) + } + const shouldUseEmbeddedCheckout = canUseEmbeddedCheckout + await runCheckoutSession({ + callableName: 'subscription-createPlaybackDownloadCheckoutSession', + payload: { + projectId, + returnUrls: { + successUrl: STRIPE_SUCCESS_URL, + cancelUrl: STRIPE_CANCEL_URL, + }, + uiMode: shouldUseEmbeddedCheckout ? 'embedded' : 'hosted', + }, + logTag: 'playback download checkout error', + useEmbeddedFlow: shouldUseEmbeddedCheckout, + }) + }, + [canUseEmbeddedCheckout, runCheckoutSession] + ) + const createCoinPackCheckout = React.useCallback( async (productId) => { if (!productId) { @@ -376,6 +408,7 @@ const StripeProvider = ({ children }) => { refreshCatalog: fetchStripeCatalog, createSubscriptionCheckout, createSongDownloadCheckout, + createPlaybackDownloadCheckout, createCoinPackCheckout, openEmbeddedCheckout: setClientSecret, closeEmbeddedCheckout, @@ -393,6 +426,7 @@ const StripeProvider = ({ children }) => { fetchStripeCatalog, createSubscriptionCheckout, createSongDownloadCheckout, + createPlaybackDownloadCheckout, createCoinPackCheckout, setClientSecret, closeEmbeddedCheckout, diff --git a/src/screens/Production/PlaybackDownload.js b/src/screens/Production/PlaybackDownload.js index 3cf39bc..fdc0913 100644 --- a/src/screens/Production/PlaybackDownload.js +++ b/src/screens/Production/PlaybackDownload.js @@ -1,11 +1,12 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' +import { Alert, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' import { MaterialCommunityIcons } from '@expo/vector-icons' import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js' import * as FileSystem from 'expo-file-system' import { shareAsync } from 'expo-sharing' import FullscreenIntroVideo from '../../components/FullscreenIntroVideo' import BorderGradientButton from '../../components/BorderGradientButton' +import GradientButton from '../../components/GradientButton' import MusicLandHeader from '../../components/MusicLandHeader' import AppCheckbox from '../../components/AppCheckbox' import firebase, { projectsRef, serverTimestamp } from '../../config/firebase' @@ -14,6 +15,7 @@ import Page from '../../layouts/Page' import { Routes } from '../../navigation' import { goBack, navigate } from '../../navigation/NavigationService' import { useUserData, useUser } from '../../providers/UserDataProvider' +import { useStripe } from '../../providers/StripeProvider' import { Palette } from '../../styles' import { FONT_FAMILY } from '../../styles/Fonts' import { size } from '../../styles/Style' @@ -134,26 +136,44 @@ const uploadSourceRecording = async ({ uri, uid, projectId }) => { } const PlaybackDownload = ({ route }) => { - const { currentUID } = useUserData() + const { currentUID, selectedProject } = useUserData() const { hasActiveSubscription, videos } = useUser() || {} - const { action: routeAction, uri, project } = route.params || {} + const { createPlaybackDownloadCheckout } = useStripe() + const { action: routeAction, uri, project: routeProject } = route.params || {} const action = routeAction || 'playback' console.log('[PlaybackDownload] route params', { action, - projectId: project?.id, + projectId: routeProject?.id, hasUri: Boolean(uri), currentUID, }) const { setIsLoading, setTooltip } = useMinuit() const [isAfterPlaybackVideoVisible, setIsAfterPlaybackVideoVisible] = useState(false) const [showConfirmModal, setShowConfirmModal] = useState(false) - const [pendingPlaybackUrl, setPendingPlaybackUrl] = useState(project?.playbackUrl || null) + const [pendingPlaybackUrl, setPendingPlaybackUrl] = useState(routeProject?.playbackUrl || null) const [isPublishing, setIsPublishing] = useState(false) const [isDownloading, setIsDownloading] = useState(false) + const [isCheckoutLaunching, setIsCheckoutLaunching] = useState(false) + const [downloadPaymentPending, setDownloadPaymentPending] = useState(false) const [hasAcceptedPublication, setHasAcceptedPublication] = useState(true) const hasShownAfterPlaybackRef = useRef(false) const publishSuccessMessage = action === 'playback' ? 'Playback publié !' : 'Chanson publiée !' + const projectForDownload = useMemo(() => { + if (routeProject?.id && selectedProject?.id && routeProject.id === selectedProject.id) { + return selectedProject + } + return routeProject || selectedProject || null + }, [routeProject, selectedProject]) + + const playbackDownloadStatus = projectForDownload?.playbackDownloadPurchase?.status || null + const hasPaidPlaybackDownload = playbackDownloadStatus === 'paid' + const canDownloadPlayback = hasActiveSubscription || hasPaidPlaybackDownload + const downloadLabel = canDownloadPlayback + ? 'Télécharger le playback' + : 'Acheter le playback pour 1,99€' + const showSubscriptionCta = !hasActiveSubscription + const afterPlaybackUrl = useMemo(() => { if (!videos) return null return videos?.afterPlayback || null @@ -168,28 +188,30 @@ const PlaybackDownload = ({ route }) => { }, [action, afterPlaybackUrl]) useEffect(() => { - if (project?.playbackUrl) { - setPendingPlaybackUrl(project.playbackUrl) + if (projectForDownload?.playbackUrl) { + setPendingPlaybackUrl(projectForDownload.playbackUrl) } - }, [project?.playbackUrl]) + }, [projectForDownload?.playbackUrl]) const resolveAudioUrl = useCallback(() => { - if (typeof project?.songUrl === 'string' && project.songUrl.trim()) { - return project.songUrl.trim() + if (typeof projectForDownload?.songUrl === 'string' && projectForDownload.songUrl.trim()) { + return projectForDownload.songUrl.trim() } - const urls = Array.isArray(project?.musicUrls) ? project.musicUrls : [] + const urls = Array.isArray(projectForDownload?.musicUrls) ? projectForDownload.musicUrls : [] if (!urls.length) return null - const idx = Number.isFinite(Number(project?.songIndex)) ? Number(project.songIndex) : 0 + const idx = Number.isFinite(Number(projectForDownload?.songIndex)) + ? Number(projectForDownload.songIndex) + : 0 const candidate = urls[idx] if (typeof candidate === 'string' && candidate.trim()) { return candidate.trim() } return null - }, [project]) + }, [projectForDownload]) - const handleDownloadUri = async () => { + const handleDownloadUri = useCallback(async () => { if (isPublishing || isDownloading) return if (action !== 'playback') { setTooltip({ @@ -199,18 +221,18 @@ const PlaybackDownload = ({ route }) => { return } - const playbackUrlToDownload = pendingPlaybackUrl || project?.playbackUrl || null + const playbackUrlToDownload = pendingPlaybackUrl || projectForDownload?.playbackUrl || null if (playbackUrlToDownload) { setIsDownloading(true) try { - await triggerDownload(playbackUrlToDownload, project?.title) + await triggerDownload(playbackUrlToDownload, projectForDownload?.title) } finally { setIsDownloading(false) } return } - if (!project?.id) { + if (!projectForDownload?.id) { setTooltip({ type: 'error', text: 'Projet introuvable pour ce playback', @@ -243,9 +265,8 @@ const PlaybackDownload = ({ route }) => { return } - // Publication du playback console.log('[PlaybackDownload] handleDownloadUri playback', { - projectId: project.id, + projectId: projectForDownload.id, uri, currentUID, }) @@ -257,17 +278,17 @@ const PlaybackDownload = ({ route }) => { const { sourcePath, videoUrl } = await uploadSourceRecording({ uri, uid: currentUID, - projectId: project.id, + projectId: projectForDownload.id, }) tempSourcePath = sourcePath const callable = firebase.functions().httpsCallable('upload-mergeVideoAndAudio') const payload = { - projectId: project?.id, + projectId: projectForDownload?.id, videoUrl, audioUrl, - storagePath: `users/${currentUID}/projects/${project.id}/playback.mp4`, + storagePath: `users/${currentUID}/projects/${projectForDownload.id}/playback.mp4`, } console.log('[PlaybackDownload] calling upload-mergeVideoAndAudio', payload) @@ -284,7 +305,7 @@ const PlaybackDownload = ({ route }) => { type: 'success', text: 'Playback prêt à télécharger', }) - await triggerDownload(resultURI, project?.title) + await triggerDownload(resultURI, projectForDownload?.title) if (tempSourcePath) { try { await firebase.storage().ref(tempSourcePath).delete() @@ -322,7 +343,92 @@ const PlaybackDownload = ({ route }) => { setIsDownloading(false) setIsLoading(false) } - } + }, [ + action, + currentUID, + isDownloading, + isPublishing, + pendingPlaybackUrl, + projectForDownload, + resolveAudioUrl, + setIsLoading, + setTooltip, + uri, + ]) + + const startPlaybackDownloadCheckout = useCallback( + async ({ force = false } = {}) => { + if (!projectForDownload?.id) { + setTooltip({ + type: 'error', + text: "Impossible d'identifier le projet pour le paiement.", + }) + return + } + + if (!force && (isCheckoutLaunching || downloadPaymentPending)) { + return + } + + setIsCheckoutLaunching(true) + setDownloadPaymentPending(true) + try { + await createPlaybackDownloadCheckout(projectForDownload.id) + } catch (error) { + setDownloadPaymentPending(false) + setTooltip({ + type: 'error', + text: error?.message || "Une erreur est survenue lors du paiement.", + }) + } finally { + setIsCheckoutLaunching(false) + } + }, + [ + createPlaybackDownloadCheckout, + downloadPaymentPending, + isCheckoutLaunching, + projectForDownload, + setTooltip, + ] + ) + + const handleDownloadPress = useCallback(() => { + if (isDownloading || isCheckoutLaunching) { + return + } + if (canDownloadPlayback) { + handleDownloadUri() + return + } + if (downloadPaymentPending) { + Alert.alert( + 'Paiement en attente', + "Le paiement n'est pas encore confirmé. Si tu as déjà payé, patiente quelques instants.", + [ + { text: 'Attendre', style: 'cancel' }, + { text: 'Relancer le paiement', onPress: () => startPlaybackDownloadCheckout({ force: true }) }, + ] + ) + return + } + startPlaybackDownloadCheckout() + }, [ + canDownloadPlayback, + downloadPaymentPending, + handleDownloadUri, + isCheckoutLaunching, + isDownloading, + startPlaybackDownloadCheckout, + ]) + + useEffect(() => { + if (!downloadPaymentPending || !hasPaidPlaybackDownload || isDownloading) { + return + } + setDownloadPaymentPending(false) + handleDownloadUri() + }, [downloadPaymentPending, handleDownloadUri, hasPaidPlaybackDownload, isDownloading]) const handlePublish = async () => { if (isPublishing) return @@ -333,7 +439,7 @@ const PlaybackDownload = ({ route }) => { }) return } - if (!project?.id) { + if (!projectForDownload?.id) { setTooltip({ type: 'success', text: publishSuccessMessage, @@ -342,7 +448,7 @@ const PlaybackDownload = ({ route }) => { return } let tempSourcePath = null - let playbackUrlToSave = pendingPlaybackUrl || project?.playbackUrl || null + let playbackUrlToSave = pendingPlaybackUrl || projectForDownload?.playbackUrl || null const audioUrl = resolveAudioUrl() if (!audioUrl && !playbackUrlToSave) { setTooltip({ @@ -367,17 +473,17 @@ const PlaybackDownload = ({ route }) => { const { sourcePath, videoUrl } = await uploadSourceRecording({ uri, uid: currentUID, - projectId: project.id, + projectId: projectForDownload.id, }) tempSourcePath = sourcePath const callable = firebase.functions().httpsCallable('upload-mergeVideoAndAudio') const payload = { - projectId: project?.id, + projectId: projectForDownload?.id, videoUrl, audioUrl, - storagePath: `users/${currentUID}/projects/${project.id}/playback.mp4`, + storagePath: `users/${currentUID}/projects/${projectForDownload.id}/playback.mp4`, } console.log('[PlaybackDownload] publish: calling merge', payload) @@ -391,7 +497,7 @@ const PlaybackDownload = ({ route }) => { setPendingPlaybackUrl(playbackUrlToSave) } - await projectsRef.doc(project.id).set( + await projectsRef.doc(projectForDownload.id).set( { playbackUrl: playbackUrlToSave, updatedAt: serverTimestamp(), @@ -452,9 +558,9 @@ const PlaybackDownload = ({ route }) => { - {project?.coverUrl ? ( + {projectForDownload?.coverUrl ? ( @@ -465,15 +571,40 @@ const PlaybackDownload = ({ route }) => { )} - Télécharger le playback + {downloadLabel} + {!canDownloadPlayback && downloadPaymentPending ? ( + + Paiement en attente de confirmation... + + ) : null} + {showSubscriptionCta ? ( + + + Avec un abonnement, le téléchargement est gratuit. + + navigate(Routes.Payments)} + containerStyle={styles.subscriptionButton} + /> + + ) : ( + + Avec ton abonnement, le téléchargement est gratuit. + + )} + @@ -547,6 +678,29 @@ const styles = StyleSheet.create({ fontSize: 15, color: Palette.white, }, + downloadPendingText: { + marginTop: 6, + fontFamily: FONT_FAMILY.InterRegular, + fontSize: 12, + color: Palette.white, + opacity: 0.8, + }, + subscriptionCta: { + alignItems: 'center', + gap: 10, + marginTop: 6, + }, + subscriptionNote: { + fontFamily: FONT_FAMILY.InterRegular, + fontSize: 13, + color: Palette.white, + opacity: 0.8, + textAlign: 'center', + }, + subscriptionButton: { + width: '100%', + maxWidth: 280, + }, clubCardSpacing: { marginTop: 10, }, diff --git a/src/screens/cover/SongDownload.js b/src/screens/cover/SongDownload.js index 5d4bb17..4d39f19 100644 --- a/src/screens/cover/SongDownload.js +++ b/src/screens/cover/SongDownload.js @@ -13,6 +13,7 @@ import { import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js' import * as FileSystem from 'expo-file-system' import * as Sharing from 'expo-sharing' +import GradientButton from '../../components/GradientButton' import MusicLandHeader from '../../components/MusicLandHeader' import Page from '../../layouts/Page' import { Routes } from '../../navigation/Routes' @@ -103,6 +104,7 @@ const SongDownload = ({ route }) => { const downloadLabel = canDownloadDirectly ? 'Télécharger mon morceau' : 'Acheter ce morceau pour 1,99€' + const showSubscriptionCta = !hasActiveSubscription const shouldShowDownloadPage = adventureChoice === 'stop' useEffect(() => { @@ -530,6 +532,23 @@ const SongDownload = ({ route }) => { + {showSubscriptionCta ? ( + + + Avec un abonnement, le téléchargement est gratuit. + + navigate(Routes.Payments)} + containerStyle={styles.subscriptionButton} + /> + + ) : ( + + Avec ton abonnement, le téléchargement est gratuit. + + )} + ) : ( @@ -620,6 +639,22 @@ const styles = StyleSheet.create({ color: Palette.white, opacity: 0.8, }, + subscriptionCta: { + alignItems: 'center', + gap: gutters * 0.6, + marginTop: gutters * 0.4, + }, + subscriptionNote: { + fontFamily: FONT_FAMILY.InterRegular, + fontSize: 13, + color: Palette.white, + opacity: 0.8, + textAlign: 'center', + }, + subscriptionButton: { + width: '100%', + maxWidth: 280, + }, adventureGatePlaceholder: { flex: 1, }, diff --git a/src/screens/cover/ValidateCover.js b/src/screens/cover/ValidateCover.js index f7da2ad..4f434f0 100644 --- a/src/screens/cover/ValidateCover.js +++ b/src/screens/cover/ValidateCover.js @@ -5,6 +5,7 @@ import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js' import { background } from '../../assets' import GradientButton from '../../components/GradientButton' import MusicLandHeader from '../../components/MusicLandHeader' +import { isWeb } from '../../hooks/useLayoutType' import Page from '../../layouts/Page' import { Routes } from '../../navigation' import { goBack, navigate } from '../../navigation/NavigationService'