fix(premium): couple-shared premium everywhere (A-001)
Route all feature gates (Play hub, Desire Sync, Memory Lane, Connection Challenges, Question Packs, wheel category/spin/history) through CouplePremiumChecker instead of per-user EntitlementChecker. CouplePremiumChecker now exposes isPremium()/hasPremium() that resolve the partner internally (self OR partner premium). Verified live: Sam premium → QA enters Desire Sync; both free → QA → paywall. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c54ceb16c3
commit
e8892a9669
|
|
@ -1,26 +1,48 @@
|
||||||
package app.closer.core.billing
|
package app.closer.core.billing
|
||||||
|
|
||||||
import app.closer.data.remote.FirestoreCollections
|
import app.closer.data.remote.FirestoreCollections
|
||||||
|
import app.closer.domain.repository.CoupleRepository
|
||||||
|
import com.google.firebase.auth.FirebaseAuth
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.emitAll
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Couple-shared premium: media/reactions unlock if EITHER partner has an active subscription, so a
|
* Couple-shared premium: a premium feature unlocks if EITHER partner has an active subscription, so a
|
||||||
* single subscription covers the couple. Combines the current user's [EntitlementChecker.isPremium]
|
* single subscription covers the couple. Combines the current user's [EntitlementChecker.isPremium]
|
||||||
* with a live read of the partner's `users/{partnerId}/entitlements/premium` doc (same active/expiry
|
* with a live read of the partner's `users/{partnerId}/entitlements/premium` doc (same active/expiry
|
||||||
* rule as [FirestoreEntitlementChecker]). Reactive: flips the moment either side subscribes/expires.
|
* rule as [FirestoreEntitlementChecker]). Reactive: flips the moment either side subscribes/expires.
|
||||||
|
*
|
||||||
|
* Exposes [isPremium]/[hasPremium] with the SAME shape as [EntitlementChecker] so it's a drop-in for
|
||||||
|
* any feature gate (resolves the partner internally), plus [coupleHasPremium] when the caller already
|
||||||
|
* knows the partner id.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class CouplePremiumChecker @Inject constructor(
|
class CouplePremiumChecker @Inject constructor(
|
||||||
private val entitlementChecker: EntitlementChecker,
|
private val entitlementChecker: EntitlementChecker,
|
||||||
private val firestore: FirebaseFirestore
|
private val firestore: FirebaseFirestore,
|
||||||
|
private val coupleRepository: CoupleRepository
|
||||||
) {
|
) {
|
||||||
|
/** Couple-shared premium for the current user (resolves the partner internally). Drop-in for EntitlementChecker. */
|
||||||
|
fun isPremium(): Flow<Boolean> = flow {
|
||||||
|
val uid = FirebaseAuth.getInstance().currentUser?.uid
|
||||||
|
val partnerId = uid?.let {
|
||||||
|
runCatching { coupleRepository.getCoupleForUser(it)?.userIds?.firstOrNull { id -> id != it } }.getOrNull()
|
||||||
|
}
|
||||||
|
emitAll(coupleHasPremium(partnerId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-shot couple-shared premium check. Drop-in for EntitlementChecker.hasPremium. */
|
||||||
|
suspend fun hasPremium(): Boolean = isPremium().first()
|
||||||
|
|
||||||
fun coupleHasPremium(partnerId: String?): Flow<Boolean> =
|
fun coupleHasPremium(partnerId: String?): Flow<Boolean> =
|
||||||
if (partnerId.isNullOrBlank()) entitlementChecker.isPremium()
|
if (partnerId.isNullOrBlank()) entitlementChecker.isPremium()
|
||||||
else combine(entitlementChecker.isPremium(), observePartnerPremium(partnerId)) { mine, theirs ->
|
else combine(entitlementChecker.isPremium(), observePartnerPremium(partnerId)) { mine, theirs ->
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.core.billing.EntitlementChecker
|
import app.closer.core.billing.CouplePremiumChecker
|
||||||
import app.closer.core.navigation.AppRoute
|
import app.closer.core.navigation.AppRoute
|
||||||
import app.closer.data.challenges.ChallengesCatalog
|
import app.closer.data.challenges.ChallengesCatalog
|
||||||
import app.closer.data.remote.FirestoreChallengeDataSource
|
import app.closer.data.remote.FirestoreChallengeDataSource
|
||||||
|
|
@ -105,7 +105,7 @@ class ConnectionChallengesViewModel @Inject constructor(
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val coupleRepository: CoupleRepository,
|
private val coupleRepository: CoupleRepository,
|
||||||
private val challengeDataSource: FirestoreChallengeDataSource,
|
private val challengeDataSource: FirestoreChallengeDataSource,
|
||||||
private val entitlementChecker: EntitlementChecker
|
private val premiumChecker: CouplePremiumChecker
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ChallengesUiState())
|
private val _uiState = MutableStateFlow(ChallengesUiState())
|
||||||
|
|
@ -114,7 +114,7 @@ class ConnectionChallengesViewModel @Inject constructor(
|
||||||
private var progressJob: Job? = null
|
private var progressJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
entitlementChecker.isPremium()
|
premiumChecker.isPremium()
|
||||||
.onEach { premium -> _uiState.update { it.copy(hasPremium = premium) } }
|
.onEach { premium -> _uiState.update { it.copy(hasPremium = premium) } }
|
||||||
.launchIn(viewModelScope)
|
.launchIn(viewModelScope)
|
||||||
load()
|
load()
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.core.billing.EntitlementChecker
|
import app.closer.core.billing.CouplePremiumChecker
|
||||||
import app.closer.core.navigation.AppRoute
|
import app.closer.core.navigation.AppRoute
|
||||||
import app.closer.data.remote.FirestoreDesireSyncDataSource
|
import app.closer.data.remote.FirestoreDesireSyncDataSource
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
|
@ -123,7 +123,7 @@ class DesireSyncViewModel @Inject constructor(
|
||||||
private val repository: QuestionRepository,
|
private val repository: QuestionRepository,
|
||||||
private val gameSessionManager: GameSessionManager,
|
private val gameSessionManager: GameSessionManager,
|
||||||
private val dataSource: FirestoreDesireSyncDataSource,
|
private val dataSource: FirestoreDesireSyncDataSource,
|
||||||
private val entitlementChecker: EntitlementChecker
|
private val premiumChecker: CouplePremiumChecker
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(DesireSyncUiState())
|
private val _uiState = MutableStateFlow(DesireSyncUiState())
|
||||||
|
|
@ -144,7 +144,7 @@ class DesireSyncViewModel @Inject constructor(
|
||||||
|
|
||||||
private fun load() {
|
private fun load() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (!entitlementChecker.hasPremium()) {
|
if (!premiumChecker.hasPremium()) {
|
||||||
_uiState.update { it.copy(navigateTo = AppRoute.PAYWALL) }
|
_uiState.update { it.copy(navigateTo = AppRoute.PAYWALL) }
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.core.billing.EntitlementChecker
|
import app.closer.core.billing.CouplePremiumChecker
|
||||||
import app.closer.core.navigation.AppRoute
|
import app.closer.core.navigation.AppRoute
|
||||||
import app.closer.data.remote.FirestoreCapsuleDataSource
|
import app.closer.data.remote.FirestoreCapsuleDataSource
|
||||||
import app.closer.domain.model.TimeCapsule
|
import app.closer.domain.model.TimeCapsule
|
||||||
|
|
@ -140,7 +140,7 @@ class MemoryLaneViewModel @Inject constructor(
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val coupleRepository: CoupleRepository,
|
private val coupleRepository: CoupleRepository,
|
||||||
private val capsuleDataSource: FirestoreCapsuleDataSource,
|
private val capsuleDataSource: FirestoreCapsuleDataSource,
|
||||||
private val entitlementChecker: EntitlementChecker
|
private val premiumChecker: CouplePremiumChecker
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(MemoryLaneUiState())
|
private val _uiState = MutableStateFlow(MemoryLaneUiState())
|
||||||
|
|
@ -152,7 +152,7 @@ class MemoryLaneViewModel @Inject constructor(
|
||||||
|
|
||||||
private fun load() {
|
private fun load() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val hasPremium = entitlementChecker.hasPremium()
|
val hasPremium = premiumChecker.hasPremium()
|
||||||
if (!hasPremium) {
|
if (!hasPremium) {
|
||||||
_uiState.update { it.copy(phase = MemoryLanePhase.LIST, hasPremium = false) }
|
_uiState.update { it.copy(phase = MemoryLanePhase.LIST, hasPremium = false) }
|
||||||
return@launch
|
return@launch
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package app.closer.ui.play
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.core.billing.EntitlementChecker
|
import app.closer.core.billing.CouplePremiumChecker
|
||||||
import app.closer.domain.usecase.GameSessionManager
|
import app.closer.domain.usecase.GameSessionManager
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -15,10 +15,10 @@ import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class PlayHubViewModel @Inject constructor(
|
class PlayHubViewModel @Inject constructor(
|
||||||
entitlementChecker: EntitlementChecker,
|
premiumChecker: CouplePremiumChecker,
|
||||||
private val gameSessionManager: GameSessionManager
|
private val gameSessionManager: GameSessionManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val hasPremium: StateFlow<Boolean> = entitlementChecker.isPremium()
|
val hasPremium: StateFlow<Boolean> = premiumChecker.isPremium()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
// Default true so paired users never see an invite redirect flash while this loads.
|
// Default true so paired users never see an invite redirect flash while this loads.
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package app.closer.ui.questions
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.core.billing.EntitlementChecker
|
import app.closer.core.billing.CouplePremiumChecker
|
||||||
import app.closer.domain.model.QuestionCategory
|
import app.closer.domain.model.QuestionCategory
|
||||||
import app.closer.domain.repository.QuestionRepository
|
import app.closer.domain.repository.QuestionRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
|
@ -29,7 +29,7 @@ data class QuestionPackLibraryUiState(
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class QuestionPackLibraryViewModel @Inject constructor(
|
class QuestionPackLibraryViewModel @Inject constructor(
|
||||||
private val repository: QuestionRepository,
|
private val repository: QuestionRepository,
|
||||||
private val entitlementChecker: EntitlementChecker
|
private val premiumChecker: CouplePremiumChecker
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(QuestionPackLibraryUiState())
|
private val _uiState = MutableStateFlow(QuestionPackLibraryUiState())
|
||||||
|
|
@ -43,7 +43,7 @@ class QuestionPackLibraryViewModel @Inject constructor(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.value = QuestionPackLibraryUiState(isLoading = true)
|
_uiState.value = QuestionPackLibraryUiState(isLoading = true)
|
||||||
try {
|
try {
|
||||||
val hasPremium = entitlementChecker.isPremium().first()
|
val hasPremium = premiumChecker.isPremium().first()
|
||||||
val packs = repository.getCategories().map { category ->
|
val packs = repository.getCategories().map { category ->
|
||||||
val isPremium = category.access == "premium"
|
val isPremium = category.access == "premium"
|
||||||
QuestionPackItem(
|
QuestionPackItem(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package app.closer.ui.wheel
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.core.billing.EntitlementChecker
|
import app.closer.core.billing.CouplePremiumChecker
|
||||||
import app.closer.core.navigation.AppRoute
|
import app.closer.core.navigation.AppRoute
|
||||||
import app.closer.domain.model.GameType
|
import app.closer.domain.model.GameType
|
||||||
import app.closer.domain.model.QuestionCategory
|
import app.closer.domain.model.QuestionCategory
|
||||||
|
|
@ -33,7 +33,7 @@ data class CategoryPickerUiState(
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class CategoryPickerViewModel @Inject constructor(
|
class CategoryPickerViewModel @Inject constructor(
|
||||||
private val repository: QuestionRepository,
|
private val repository: QuestionRepository,
|
||||||
private val entitlementChecker: EntitlementChecker,
|
private val premiumChecker: CouplePremiumChecker,
|
||||||
private val gameSessionManager: GameSessionManager
|
private val gameSessionManager: GameSessionManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
|
@ -78,7 +78,7 @@ class CategoryPickerViewModel @Inject constructor(
|
||||||
// checkActiveSession isn't clobbered by the category load finishing.
|
// checkActiveSession isn't clobbered by the category load finishing.
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
try {
|
try {
|
||||||
val hasPremium = entitlementChecker.isPremium().first()
|
val hasPremium = premiumChecker.isPremium().first()
|
||||||
val items = repository.getCategories().map { category ->
|
val items = repository.getCategories().map { category ->
|
||||||
CategoryPickerItem(
|
CategoryPickerItem(
|
||||||
category = category,
|
category = category,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import android.util.Log
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.core.billing.EntitlementChecker
|
import app.closer.core.billing.CouplePremiumChecker
|
||||||
import app.closer.core.navigation.AppRoute
|
import app.closer.core.navigation.AppRoute
|
||||||
import app.closer.domain.model.GameType
|
import app.closer.domain.model.GameType
|
||||||
import app.closer.domain.model.QuestionCategory
|
import app.closer.domain.model.QuestionCategory
|
||||||
|
|
@ -38,7 +38,7 @@ class SpinWheelViewModel @Inject constructor(
|
||||||
private val repository: QuestionRepository,
|
private val repository: QuestionRepository,
|
||||||
private val sessionStore: LocalWheelSessionStore,
|
private val sessionStore: LocalWheelSessionStore,
|
||||||
private val gameSessionManager: GameSessionManager,
|
private val gameSessionManager: GameSessionManager,
|
||||||
private val entitlementChecker: EntitlementChecker
|
private val premiumChecker: CouplePremiumChecker
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val categoryId: String = savedStateHandle["categoryId"] ?: ""
|
private val categoryId: String = savedStateHandle["categoryId"] ?: ""
|
||||||
|
|
@ -56,7 +56,7 @@ class SpinWheelViewModel @Inject constructor(
|
||||||
|
|
||||||
private fun loadPremiumStatus() {
|
private fun loadPremiumStatus() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val hasPremium = runCatching { entitlementChecker.isPremium().first() }.getOrDefault(false)
|
val hasPremium = runCatching { premiumChecker.isPremium().first() }.getOrDefault(false)
|
||||||
_uiState.update { it.copy(hasPremium = hasPremium) }
|
_uiState.update { it.copy(hasPremium = hasPremium) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package app.closer.ui.wheel
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.core.billing.EntitlementChecker
|
import app.closer.core.billing.CouplePremiumChecker
|
||||||
import app.closer.data.challenges.ChallengesCatalog
|
import app.closer.data.challenges.ChallengesCatalog
|
||||||
import app.closer.data.remote.FirestoreCapsuleDataSource
|
import app.closer.data.remote.FirestoreCapsuleDataSource
|
||||||
import app.closer.data.remote.FirestoreChallengeDataSource
|
import app.closer.data.remote.FirestoreChallengeDataSource
|
||||||
|
|
@ -45,14 +45,14 @@ class GameHistoryViewModel @Inject constructor(
|
||||||
private val coupleRepository: CoupleRepository,
|
private val coupleRepository: CoupleRepository,
|
||||||
private val challengeDataSource: FirestoreChallengeDataSource,
|
private val challengeDataSource: FirestoreChallengeDataSource,
|
||||||
private val capsuleDataSource: FirestoreCapsuleDataSource,
|
private val capsuleDataSource: FirestoreCapsuleDataSource,
|
||||||
entitlementChecker: EntitlementChecker
|
premiumChecker: CouplePremiumChecker
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(GameHistoryUiState())
|
private val _uiState = MutableStateFlow(GameHistoryUiState())
|
||||||
val uiState: StateFlow<GameHistoryUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<GameHistoryUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
entitlementChecker.isPremium()
|
premiumChecker.isPremium()
|
||||||
.onEach { premium ->
|
.onEach { premium ->
|
||||||
_uiState.update { it.copy(hasPremium = premium) }
|
_uiState.update { it.copy(hasPremium = premium) }
|
||||||
if (premium) load()
|
if (premium) load()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue