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:
null 2026-06-24 20:52:22 -05:00
parent c54ceb16c3
commit e8892a9669
9 changed files with 48 additions and 26 deletions

View File

@ -1,26 +1,48 @@
package app.closer.core.billing
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 kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
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.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]
* 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.
*
* 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
class CouplePremiumChecker @Inject constructor(
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> =
if (partnerId.isNullOrBlank()) entitlementChecker.isPremium()
else combine(entitlementChecker.isPremium(), observePartnerPremium(partnerId)) { mine, theirs ->

View File

@ -56,7 +56,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
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.data.challenges.ChallengesCatalog
import app.closer.data.remote.FirestoreChallengeDataSource
@ -105,7 +105,7 @@ class ConnectionChallengesViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val challengeDataSource: FirestoreChallengeDataSource,
private val entitlementChecker: EntitlementChecker
private val premiumChecker: CouplePremiumChecker
) : ViewModel() {
private val _uiState = MutableStateFlow(ChallengesUiState())
@ -114,7 +114,7 @@ class ConnectionChallengesViewModel @Inject constructor(
private var progressJob: Job? = null
init {
entitlementChecker.isPremium()
premiumChecker.isPremium()
.onEach { premium -> _uiState.update { it.copy(hasPremium = premium) } }
.launchIn(viewModelScope)
load()

View File

@ -58,7 +58,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
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.data.remote.FirestoreDesireSyncDataSource
import android.content.Context
@ -123,7 +123,7 @@ class DesireSyncViewModel @Inject constructor(
private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreDesireSyncDataSource,
private val entitlementChecker: EntitlementChecker
private val premiumChecker: CouplePremiumChecker
) : ViewModel() {
private val _uiState = MutableStateFlow(DesireSyncUiState())
@ -144,7 +144,7 @@ class DesireSyncViewModel @Inject constructor(
private fun load() {
viewModelScope.launch {
if (!entitlementChecker.hasPremium()) {
if (!premiumChecker.hasPremium()) {
_uiState.update { it.copy(navigateTo = AppRoute.PAYWALL) }
return@launch
}

View File

@ -70,7 +70,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
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.data.remote.FirestoreCapsuleDataSource
import app.closer.domain.model.TimeCapsule
@ -140,7 +140,7 @@ class MemoryLaneViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val capsuleDataSource: FirestoreCapsuleDataSource,
private val entitlementChecker: EntitlementChecker
private val premiumChecker: CouplePremiumChecker
) : ViewModel() {
private val _uiState = MutableStateFlow(MemoryLaneUiState())
@ -152,7 +152,7 @@ class MemoryLaneViewModel @Inject constructor(
private fun load() {
viewModelScope.launch {
val hasPremium = entitlementChecker.hasPremium()
val hasPremium = premiumChecker.hasPremium()
if (!hasPremium) {
_uiState.update { it.copy(phase = MemoryLanePhase.LIST, hasPremium = false) }
return@launch

View File

@ -2,7 +2,7 @@ package app.closer.ui.play
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.billing.EntitlementChecker
import app.closer.core.billing.CouplePremiumChecker
import app.closer.domain.usecase.GameSessionManager
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@ -15,10 +15,10 @@ import kotlinx.coroutines.launch
@HiltViewModel
class PlayHubViewModel @Inject constructor(
entitlementChecker: EntitlementChecker,
premiumChecker: CouplePremiumChecker,
private val gameSessionManager: GameSessionManager
) : ViewModel() {
val hasPremium: StateFlow<Boolean> = entitlementChecker.isPremium()
val hasPremium: StateFlow<Boolean> = premiumChecker.isPremium()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
// Default true so paired users never see an invite redirect flash while this loads.

View File

@ -2,7 +2,7 @@ package app.closer.ui.questions
import androidx.lifecycle.ViewModel
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.repository.QuestionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
@ -29,7 +29,7 @@ data class QuestionPackLibraryUiState(
@HiltViewModel
class QuestionPackLibraryViewModel @Inject constructor(
private val repository: QuestionRepository,
private val entitlementChecker: EntitlementChecker
private val premiumChecker: CouplePremiumChecker
) : ViewModel() {
private val _uiState = MutableStateFlow(QuestionPackLibraryUiState())
@ -43,7 +43,7 @@ class QuestionPackLibraryViewModel @Inject constructor(
viewModelScope.launch {
_uiState.value = QuestionPackLibraryUiState(isLoading = true)
try {
val hasPremium = entitlementChecker.isPremium().first()
val hasPremium = premiumChecker.isPremium().first()
val packs = repository.getCategories().map { category ->
val isPremium = category.access == "premium"
QuestionPackItem(

View File

@ -2,7 +2,7 @@ package app.closer.ui.wheel
import androidx.lifecycle.ViewModel
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.domain.model.GameType
import app.closer.domain.model.QuestionCategory
@ -33,7 +33,7 @@ data class CategoryPickerUiState(
@HiltViewModel
class CategoryPickerViewModel @Inject constructor(
private val repository: QuestionRepository,
private val entitlementChecker: EntitlementChecker,
private val premiumChecker: CouplePremiumChecker,
private val gameSessionManager: GameSessionManager
) : ViewModel() {
@ -78,7 +78,7 @@ class CategoryPickerViewModel @Inject constructor(
// checkActiveSession isn't clobbered by the category load finishing.
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val hasPremium = entitlementChecker.isPremium().first()
val hasPremium = premiumChecker.isPremium().first()
val items = repository.getCategories().map { category ->
CategoryPickerItem(
category = category,

View File

@ -4,7 +4,7 @@ import android.util.Log
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
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.domain.model.GameType
import app.closer.domain.model.QuestionCategory
@ -38,7 +38,7 @@ class SpinWheelViewModel @Inject constructor(
private val repository: QuestionRepository,
private val sessionStore: LocalWheelSessionStore,
private val gameSessionManager: GameSessionManager,
private val entitlementChecker: EntitlementChecker
private val premiumChecker: CouplePremiumChecker
) : ViewModel() {
private val categoryId: String = savedStateHandle["categoryId"] ?: ""
@ -56,7 +56,7 @@ class SpinWheelViewModel @Inject constructor(
private fun loadPremiumStatus() {
viewModelScope.launch {
val hasPremium = runCatching { entitlementChecker.isPremium().first() }.getOrDefault(false)
val hasPremium = runCatching { premiumChecker.isPremium().first() }.getOrDefault(false)
_uiState.update { it.copy(hasPremium = hasPremium) }
}
}

View File

@ -2,7 +2,7 @@ package app.closer.ui.wheel
import androidx.lifecycle.ViewModel
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.remote.FirestoreCapsuleDataSource
import app.closer.data.remote.FirestoreChallengeDataSource
@ -45,14 +45,14 @@ class GameHistoryViewModel @Inject constructor(
private val coupleRepository: CoupleRepository,
private val challengeDataSource: FirestoreChallengeDataSource,
private val capsuleDataSource: FirestoreCapsuleDataSource,
entitlementChecker: EntitlementChecker
premiumChecker: CouplePremiumChecker
) : ViewModel() {
private val _uiState = MutableStateFlow(GameHistoryUiState())
val uiState: StateFlow<GameHistoryUiState> = _uiState.asStateFlow()
init {
entitlementChecker.isPremium()
premiumChecker.isPremium()
.onEach { premium ->
_uiState.update { it.copy(hasPremium = premium) }
if (premium) load()