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 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 ->

View File

@ -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()

View File

@ -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
} }

View File

@ -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

View File

@ -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.

View File

@ -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(

View File

@ -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,

View File

@ -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) }
} }
} }

View File

@ -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()