diff --git a/functions/src/subscription/checkout.js b/functions/src/subscription/checkout.js index 46395e8..64160c9 100644 --- a/functions/src/subscription/checkout.js +++ b/functions/src/subscription/checkout.js @@ -152,6 +152,85 @@ const createSubscriptionCheckoutSession = onCall({ region: REGION }, async (requ } }) +const createSongDownloadCheckoutSession = onCall({ region: REGION }, async (request) => { + try { + const uid = request?.auth?.uid + if (!uid) { + throw new HttpsError('unauthenticated', 'Connecte-toi pour télécharger ton morceau.') + } + + 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 Musicland', + 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: 'SONG_DOWNLOAD', + projectId, + }, + }, + { + uiMode, + successUrl, + cancelUrl, + } + ) + ) + + return formatCheckoutSessionResponse(session) + } catch (error) { + console.error('[subscription-createSongDownloadCheckoutSession] 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 @@ -272,6 +351,7 @@ const createCoinPackCheckoutSession = onCall({ region: REGION }, async (request) module.exports = { createSubscriptionCheckoutSession, + createSongDownloadCheckoutSession, createCoinPackCheckoutSession, resolveCheckoutUiMode, } diff --git a/functions/src/subscription/index.js b/functions/src/subscription/index.js index 7af745c..94d987c 100644 --- a/functions/src/subscription/index.js +++ b/functions/src/subscription/index.js @@ -1,5 +1,9 @@ const { listSubscriptionPlans, listCoinPacks } = require('./catalog') -const { createSubscriptionCheckoutSession, createCoinPackCheckoutSession } = require('./checkout') +const { + createSubscriptionCheckoutSession, + createSongDownloadCheckoutSession, + createCoinPackCheckoutSession, +} = require('./checkout') const { cancelActiveSubscription, getActiveSubscription } = require('./management') const { handleStripeWebhook } = require('./webhooks') const { processAnnualSubscriptionAllowances } = require('./schedule') @@ -7,6 +11,7 @@ const { processAnnualSubscriptionAllowances } = require('./schedule') module.exports = { listSubscriptionPlans, createSubscriptionCheckoutSession, + createSongDownloadCheckoutSession, cancelActiveSubscription, getActiveSubscription, listCoinPacks, diff --git a/functions/src/subscription/webhooks.js b/functions/src/subscription/webhooks.js index 48cca8b..21eb0ed 100644 --- a/functions/src/subscription/webhooks.js +++ b/functions/src/subscription/webhooks.js @@ -6,6 +6,7 @@ const { getStripeClient } = require('../../helpers/stripe') const { REGION } = require('./config') const { paymentsCollection, + refsList, resolveStripeWebhookSecret, getServerTimestamp, toFirestoreTimestamp, @@ -149,6 +150,91 @@ const handleCheckoutSessionCompleted = async (session, event, { stripe } = {}) = } } } + + if ( + session.mode === 'payment' && + (session.payment_status === 'paid' || session.payment_status === 'no_payment_required') && + session.metadata?.purchaseType === 'SONG_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()?.downloadGrantedAt + ) + + 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( + { + downloadPurchase: { + 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( + { + downloadGrantedAt: getServerTimestamp(), + downloadProjectId: projectId, + }, + { merge: true } + ) + } + } + } + } } const handleCustomerSubscriptionEvent = async (subscription, event, { stripe } = {}) => { diff --git a/src/providers/StripeProvider.js b/src/providers/StripeProvider.js index c60b18c..0552a34 100644 --- a/src/providers/StripeProvider.js +++ b/src/providers/StripeProvider.js @@ -210,6 +210,38 @@ const StripeProvider = ({ children }) => { [canUseEmbeddedCheckout, runCheckoutSession] ) + const createSongDownloadCheckout = 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-createSongDownloadCheckoutSession', + payload: { + projectId, + returnUrls: { + successUrl: STRIPE_SUCCESS_URL, + cancelUrl: STRIPE_CANCEL_URL, + }, + uiMode: shouldUseEmbeddedCheckout ? 'embedded' : 'hosted', + }, + logTag: 'song download checkout error', + useEmbeddedFlow: shouldUseEmbeddedCheckout, + }) + }, + [canUseEmbeddedCheckout, runCheckoutSession] + ) + const createCoinPackCheckout = React.useCallback( async (productId) => { if (!productId) { @@ -343,6 +375,7 @@ const StripeProvider = ({ children }) => { catalogError, refreshCatalog: fetchStripeCatalog, createSubscriptionCheckout, + createSongDownloadCheckout, createCoinPackCheckout, openEmbeddedCheckout: setClientSecret, closeEmbeddedCheckout, @@ -359,6 +392,7 @@ const StripeProvider = ({ children }) => { catalogError, fetchStripeCatalog, createSubscriptionCheckout, + createSongDownloadCheckout, createCoinPackCheckout, setClientSecret, closeEmbeddedCheckout, diff --git a/src/screens/cover/SongDownload.js b/src/screens/cover/SongDownload.js index 21f9c7c..5d4bb17 100644 --- a/src/screens/cover/SongDownload.js +++ b/src/screens/cover/SongDownload.js @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Image as ExpoImage } from 'expo-image' import { Linking, @@ -13,13 +13,12 @@ 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 AppCheckbox from '../../components/AppCheckbox' -import BorderGradientButton from '../../components/BorderGradientButton' import MusicLandHeader from '../../components/MusicLandHeader' import Page from '../../layouts/Page' import { Routes } from '../../navigation/Routes' import { goBack, navigate } from '../../navigation/NavigationService' import { useUser } from '../../providers/UserDataProvider' +import { useStripe } from '../../providers/StripeProvider' import { gutters, Palette, Style } from '../../styles' import { FONT_FAMILY } from '../../styles/Fonts' import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader' @@ -31,7 +30,6 @@ import { MaterialCommunityIcons } from '@expo/vector-icons' import { isWeb } from '../../hooks/useLayoutType' import { getArtistDisplayName } from '../../utils/artistName' import { toDate } from '../../utils/dateFormatting' -import SubscriptionConfirmModal from '../../components/SubscriptionConfirmModal' import FullscreenIntroVideo from '../../components/FullscreenIntroVideo' import AdventureChoiceModal from './components/AdventureChoiceModal' import ShareAndCreditsModal from './components/ShareAndCreditsModal' @@ -43,21 +41,26 @@ const SongDownload = ({ route }) => { coverOptions: routeCoverOptions, } = route?.params || {} const { selectedProject, updateProjectData, hasActiveSubscription } = useUser() + const { createSongDownloadCheckout } = useStripe() const { setTooltip } = useMinuit() const { setLoading } = useGlobalLoading() const [isDownloading, setIsDownloading] = useState(false) - const [showConfirmModal, setShowConfirmModal] = useState(false) - const [hasAcceptedPublication, setHasAcceptedPublication] = useState(true) + const [isCheckoutLaunching, setIsCheckoutLaunching] = useState(false) + const [downloadPaymentPending, setDownloadPaymentPending] = useState(false) const { videos } = useUser() const part1Url = isWeb ? videos?.benaiPart1 : videos?.benaiPart1 const [showIntro, setShowIntro] = useState(null) const [showAdventureModal, setShowAdventureModal] = useState(false) const [showShareModal, setShowShareModal] = useState(false) + const [adventureChoice, setAdventureChoice] = useState('pending') + const adventureGateTriggeredRef = useRef(false) - const projectForStage = useMemo( - () => routeProject || selectedProject || null, - [routeProject, selectedProject] - ) + const projectForStage = useMemo(() => { + if (routeProject?.id && selectedProject?.id && routeProject.id === selectedProject.id) { + return selectedProject + } + return routeProject || selectedProject || null + }, [routeProject, selectedProject]) const coverOptions = useMemo(() => { if (Array.isArray(routeCoverOptions) && routeCoverOptions.length) { @@ -93,6 +96,47 @@ const SongDownload = ({ route }) => { typeof projectForStage?.title === 'string' && projectForStage.title.trim() ? projectForStage.title.trim() : 'Musicland Track' + const projectId = projectForStage?.id || null + const downloadPurchaseStatus = projectForStage?.downloadPurchase?.status || null + const hasPaidDownload = downloadPurchaseStatus === 'paid' + const canDownloadDirectly = hasActiveSubscription || hasPaidDownload + const downloadLabel = canDownloadDirectly + ? 'Télécharger mon morceau' + : 'Acheter ce morceau pour 1,99€' + const shouldShowDownloadPage = adventureChoice === 'stop' + + useEffect(() => { + if (adventureGateTriggeredRef.current) { + return + } + if (adventureChoice !== 'pending') { + return + } + if (typeof videos === 'undefined') { + return + } + adventureGateTriggeredRef.current = true + if (part1Url) { + setShowIntro(part1Url) + } else { + setShowAdventureModal(true) + } + }, [adventureChoice, part1Url, videos]) + + useEffect(() => { + if (adventureChoice !== 'pending') { + return + } + if (!adventureGateTriggeredRef.current) { + return + } + if (showIntro) { + return + } + if (!showAdventureModal) { + setShowAdventureModal(true) + } + }, [adventureChoice, showAdventureModal, showIntro]) const continueFlow = useCallback(async () => { if (!selectedOption) { @@ -137,32 +181,6 @@ const SongDownload = ({ route }) => { updateProjectData, ]) - const handleContinue = useCallback(() => { - if (!hasAcceptedPublication) { - if (setTooltip) { - setTooltip({ - type: 'error', - text: 'Confirme la diffusion sur Musicland et YouTube avant de continuer', - }) - } else { - Alert.alert( - 'Confirmation requise', - 'Confirme la diffusion sur Musicland et YouTube avant de continuer' - ) - } - return - } - if (hasActiveSubscription) { - if (part1Url) { - setShowIntro(part1Url) - } else { - continueFlow() - } - return - } - setShowConfirmModal(true) - }, [continueFlow, hasAcceptedPublication, hasActiveSubscription, part1Url, setTooltip]) - const handleCloseIntro = useCallback(() => { setShowIntro(null) setShowAdventureModal(true) @@ -170,14 +188,10 @@ const SongDownload = ({ route }) => { const handleContinueAdventure = useCallback(() => { setShowAdventureModal(false) + setAdventureChoice('continue') continueFlow() }, [continueFlow]) - const handleStopAdventure = useCallback(() => { - setShowAdventureModal(false) - setShowShareModal(true) - }, []) - const handleDownload = useCallback(async () => { const downloadUrl = projectForStage?.songUrl || @@ -387,64 +401,140 @@ const SongDownload = ({ route }) => { } }, [coverUrl, isDownloading, projectForStage, selectedOption, trackTitle]) + const startSongDownloadCheckout = useCallback( + async ({ force = false } = {}) => { + if (!projectId) { + if (setTooltip) { + setTooltip({ + type: 'error', + text: "Impossible d'identifier le projet pour le paiement.", + }) + } else { + Alert.alert('Paiement', "Impossible d'identifier le projet pour le paiement.") + } + return + } + + if (!force && (isCheckoutLaunching || downloadPaymentPending)) { + return + } + + setIsCheckoutLaunching(true) + setDownloadPaymentPending(true) + try { + await createSongDownloadCheckout(projectId) + } catch (error) { + setDownloadPaymentPending(false) + if (setTooltip) { + setTooltip({ + type: 'error', + text: error?.message || "Une erreur est survenue lors du paiement.", + }) + } else { + Alert.alert('Paiement', error?.message || "Une erreur est survenue lors du paiement.") + } + } finally { + setIsCheckoutLaunching(false) + } + }, + [ + createSongDownloadCheckout, + downloadPaymentPending, + isCheckoutLaunching, + projectId, + setTooltip, + ] + ) + + const handleDownloadPress = useCallback(() => { + if (isDownloading || isCheckoutLaunching) { + return + } + if (canDownloadDirectly) { + handleDownload() + 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: () => startSongDownloadCheckout({ force: true }) }, + ] + ) + return + } + startSongDownloadCheckout() + }, [ + canDownloadDirectly, + downloadPaymentPending, + handleDownload, + isCheckoutLaunching, + isDownloading, + startSongDownloadCheckout, + ]) + + useEffect(() => { + if (!downloadPaymentPending || !hasPaidDownload || isDownloading) { + return + } + setDownloadPaymentPending(false) + handleDownload() + }, [downloadPaymentPending, handleDownload, hasPaidDownload, isDownloading]) + + const handleStopAdventure = useCallback(() => { + setShowAdventureModal(false) + setAdventureChoice('stop') + }, []) + return ( - - - - {coverUrl ? ( - - ) : ( - - Aucune pochette + {shouldShowDownloadPage ? ( + + + + {coverUrl ? ( + + ) : ( + + Aucune pochette + + )} + + + + + {downloadLabel} + + {!canDownloadDirectly && downloadPaymentPending ? ( + + Paiement en attente de confirmation... + + ) : null} - )} + - - - Acheter ce morceau pour 1,99€ - - - - - - - setHasAcceptedPublication((prev) => !prev)} - label="J'accepte la diffusion de mon contenu sur Musicland et YouTube." - /> - - Cette confirmation est requise pour continuer. - - - - - - navigate(Routes.Payments)} - onContinue={() => { - if (part1Url) { - setShowIntro(part1Url) - } else { - continueFlow() - } - }} - /> + + + ) : ( + + )} { const { selectedProject, updateProjectData } = useUserData() - const { videos } = useUser() - const benhaiUrl = isWeb ? videos?.benhaiWeb || null : videos?.benhai - const [showIntro, setShowIntro] = useState(null) - const { setIsLoading } = useMinuit() const coverOptions = useMemo(() => { @@ -91,17 +85,12 @@ const ValidateCover = () => { if (!selectedOption) { return } - setShowIntro(benhaiUrl) - } - - const handleCloseIntro = useCallback(() => { - setShowIntro(null) navigate(Routes.SongDownload, { project: selectedProject, selectedOption, coverOptions, }) - }, [coverOptions, selectedOption, selectedProject]) + } return ( @@ -231,7 +220,6 @@ const ValidateCover = () => { disabled={!selectedOption || isSelecting} /> - ) }