pay for music

This commit is contained in:
2026-02-23 16:22:03 +01:00
parent 8648793d64
commit 947d0e9641
6 changed files with 406 additions and 122 deletions
+80
View File
@@ -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,
}
+6 -1
View File
@@ -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,
+86
View File
@@ -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 } = {}) => {
+34
View File
@@ -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,
+198 -107
View File
@@ -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 (
<Page backgroundImg={background.studioBG2} headerType="NONE">
<MusicLandHeader onPressBack={goBack} progress={84} />
<ScrollView
contentContainerStyle={[
styles.container,
!isWeb && styles.containerMobile,
styles.scrollContent,
]}
showsVerticalScrollIndicator={false}
>
<CreateLyricsHeader title="Ta pochette est validée" />
<View style={styles.coverRow}>
{coverUrl ? (
<ExpoImage source={{ uri: coverUrl }} style={styles.coverImage} contentFit="cover" />
) : (
<View style={[styles.coverImage, styles.coverPlaceholder]}>
<Text style={styles.placeholderText}>Aucune pochette</Text>
{shouldShowDownloadPage ? (
<ScrollView
contentContainerStyle={[
styles.container,
!isWeb && styles.containerMobile,
styles.scrollContent,
]}
showsVerticalScrollIndicator={false}
>
<CreateLyricsHeader title="Ta pochette est validée" />
<View style={styles.coverRow}>
{coverUrl ? (
<ExpoImage source={{ uri: coverUrl }} style={styles.coverImage} contentFit="cover" />
) : (
<View style={[styles.coverImage, styles.coverPlaceholder]}>
<Text style={styles.placeholderText}>Aucune pochette</Text>
</View>
)}
<View style={styles.downloadColumn}>
<Pressable
style={[
styles.downloadTile,
(isDownloading || isCheckoutLaunching) && { opacity: 0.6 },
]}
onPress={handleDownloadPress}
disabled={isDownloading || isCheckoutLaunching}
>
<MaterialCommunityIcons name="download" size={22} color={Palette.white} />
<Text style={styles.downloadText}>{downloadLabel}</Text>
</Pressable>
{!canDownloadDirectly && downloadPaymentPending ? (
<Text style={styles.downloadPendingText}>
Paiement en attente de confirmation...
</Text>
) : null}
</View>
)}
</View>
<Pressable style={styles.downloadTile} onPress={handleDownload}>
<MaterialCommunityIcons name="download" size={22} color={Palette.white} />
<Text style={styles.downloadText}>Acheter ce morceau pour 1,99</Text>
</Pressable>
</View>
<ClubAdvantagesCard style={styles.clubCardSpacing} />
<View style={styles.consentContainer}>
<AppCheckbox
selected={hasAcceptedPublication}
onPress={() => setHasAcceptedPublication((prev) => !prev)}
label="J'accepte la diffusion de mon contenu sur Musicland et YouTube."
/>
<Text style={styles.consentDescription}>
Cette confirmation est requise pour continuer.
</Text>
</View>
<BorderGradientButton
title={hasActiveSubscription ? 'Continuer' : 'Continuer sans générer de revenus'}
onPress={handleContinue}
containerStyle={styles.continueButton}
/>
</ScrollView>
<SubscriptionConfirmModal
isVisible={showConfirmModal}
setIsVisible={setShowConfirmModal}
onJoinClub={() => navigate(Routes.Payments)}
onContinue={() => {
if (part1Url) {
setShowIntro(part1Url)
} else {
continueFlow()
}
}}
/>
<ClubAdvantagesCard style={styles.clubCardSpacing} />
</ScrollView>
) : (
<View style={styles.adventureGatePlaceholder} />
)}
<FullscreenIntroVideo url={showIntro} visible={!!showIntro} onClose={handleCloseIntro} />
<AdventureChoiceModal
isVisible={showAdventureModal}
@@ -483,16 +573,6 @@ const styles = StyleSheet.create({
scrollContent: {
paddingBottom: gutters * 2.6,
},
consentContainer: {
gap: 6,
marginTop: 10,
},
consentDescription: {
fontSize: 13,
fontFamily: FONT_FAMILY.InterRegular,
color: Palette.white,
opacity: 0.8,
},
coverRow: {
flexDirection: isWeb ? 'row' : 'column',
alignItems: 'center',
@@ -524,15 +604,26 @@ const styles = StyleSheet.create({
backgroundColor: '#8C4BFF',
borderWidth: 0,
},
downloadColumn: {
alignItems: 'center',
gap: 4,
},
downloadText: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 15,
color: Palette.white,
},
clubCardSpacing: {
marginTop: gutters * 0.5,
downloadPendingText: {
marginTop: 6,
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 12,
color: Palette.white,
opacity: 0.8,
},
continueButton: {
adventureGatePlaceholder: {
flex: 1,
},
clubCardSpacing: {
marginTop: gutters * 0.5,
},
})
+2 -14
View File
@@ -1,4 +1,3 @@
import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
import { Image as ExpoImage } from 'expo-image'
import React, { useCallback, useMemo, useState } from 'react'
import { Pressable, Text, View } from 'react-native'
@@ -6,19 +5,14 @@ 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'
import { useUser, useUserData } from '../../providers/UserDataProvider'
import { useUserData } from '../../providers/UserDataProvider'
import { gutters, Palette, Style } from '../../styles'
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
const ValidateCover = () => {
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 (
<Page backgroundImg={background.studioBG2} headerType="NONE">
@@ -231,7 +220,6 @@ const ValidateCover = () => {
disabled={!selectedOption || isSelecting}
/>
</View>
<FullscreenIntroVideo url={showIntro} visible={!!showIntro} onClose={handleCloseIntro} />
</Page>
)
}