fix(billing): link RevenueCat identity to Firebase auth + purchase UX

Critical fix: Purchases was never told the Firebase uid, so RevenueCat assigned its
own anonymous app_user_id. The revenueCatWebhook Cloud Function writes premium status
to users/{app_user_id}/entitlements/premium using that id — meaning a real purchase
would silently never unlock premium for the signed-in account, and CouplePremiumChecker's
partner-side read (same path, different uid) was broken by the same root cause.

RevenueCatBillingRepository now collects AuthRepository.authState and calls
Purchases.awaitLogIn(uid) on sign-in / awaitLogOut() on sign-out, guarded against
redundant calls and wrapped best-effort so a failed sync retries on the next auth event
instead of crashing the singleton.

Also:
- Bump com.revenuecat.purchases 8.20.0 -> 10.12.0 (verified: real published version,
  stable API surface across 8->10 per RevenueCat's own migration notes for the calls
  this app uses; confirmed resolved + full Hilt/KSP graph compiles clean).
- Purchase cancellation (user backs out of the Play billing sheet) is now distinguished
  from a real failure via PurchasesTransactionException.userCancelled, using a shared
  PURCHASE_CANCELLED_SENTINEL (same marker-constant idiom PaywallViewModel already uses
  for offering-load failures) so PaywallViewModel resets silently instead of surfacing
  the SDK's internal error text.
- PaywallScreen: genuine purchase errors (billing unavailable, network, etc.) now show
  a snackbar. Previously there was zero user-facing feedback on a real purchase failure
  beyond the loading spinner disappearing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-08 14:19:58 -05:00
parent 661f391582
commit b99a83388e
5 changed files with 99 additions and 4 deletions

View File

@ -2,9 +2,13 @@ package app.closer.data.repository
import android.app.Activity import android.app.Activity
import android.app.Application import android.app.Application
import android.util.Log
import app.closer.BuildConfig import app.closer.BuildConfig
import app.closer.core.billing.EntitlementChecker import app.closer.core.billing.EntitlementChecker
import app.closer.domain.model.AuthState
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.BillingRepository import app.closer.domain.repository.BillingRepository
import app.closer.domain.repository.BillingRepository.Companion.PURCHASE_CANCELLED_SENTINEL
import app.closer.domain.repository.BillingState import app.closer.domain.repository.BillingState
import com.revenuecat.purchases.CustomerInfo import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.LogLevel import com.revenuecat.purchases.LogLevel
@ -14,19 +18,35 @@ import com.revenuecat.purchases.Purchases
import com.revenuecat.purchases.PurchasesConfiguration import com.revenuecat.purchases.PurchasesConfiguration
import com.revenuecat.purchases.PurchaseResult import com.revenuecat.purchases.PurchaseResult
import com.revenuecat.purchases.PurchaseParams import com.revenuecat.purchases.PurchaseParams
import com.revenuecat.purchases.PurchasesTransactionException
import com.revenuecat.purchases.awaitLogIn
import com.revenuecat.purchases.awaitLogOut
import com.revenuecat.purchases.awaitOfferings import com.revenuecat.purchases.awaitOfferings
import com.revenuecat.purchases.awaitPurchase import com.revenuecat.purchases.awaitPurchase
import com.revenuecat.purchases.awaitRestore import com.revenuecat.purchases.awaitRestore
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
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.distinctUntilChanged
import kotlinx.coroutines.launch
/** /**
* RevenueCat-backed implementation of [BillingRepository]. * RevenueCat-backed implementation of [BillingRepository].
* *
* - Initializes Purchases once per process with the API key from BuildConfig. * - Initializes Purchases once per process with the API key from BuildConfig.
* - Identifies the RevenueCat user with the Firebase Auth uid on every sign-in (and logs out to a
* fresh anonymous identity on sign-out) REQUIRED for the server-authoritative entitlement
* architecture: the `revenueCatWebhook` Cloud Function writes premium status to
* `users/{app_user_id}/entitlements/premium` using whatever app_user_id RevenueCat reports on the
* purchase event. Without this link, purchases attach to RevenueCat's auto-generated anonymous ID
* instead of the Firebase uid, so [FirestoreEntitlementChecker] (which reads by Firebase uid) and
* the couple-shared premium check ([CouplePremiumChecker], which reads the PARTNER's doc the same
* way) would never see the entitlement flip.
* - Uses RevenueCat Kotlin coroutine extensions where available. * - Uses RevenueCat Kotlin coroutine extensions where available.
* - Maps RevenueCat results to [BillingState] for stable UI contracts. * - Maps RevenueCat results to [BillingState] for stable UI contracts.
* *
@ -36,9 +56,15 @@ import kotlinx.coroutines.flow.callbackFlow
@Singleton @Singleton
class RevenueCatBillingRepository @Inject constructor( class RevenueCatBillingRepository @Inject constructor(
private val application: Application, private val application: Application,
private val entitlementChecker: EntitlementChecker private val entitlementChecker: EntitlementChecker,
private val authRepository: AuthRepository,
) : BillingRepository { ) : BillingRepository {
// Mirrors FirestoreEntitlementChecker's own process-lifetime scope for the same reason: this
// singleton has no natural coroutine scope of its own, and the identity link must survive for
// the life of the process, not any single caller's collection.
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init { init {
if (!Purchases.isConfigured) { if (!Purchases.isConfigured) {
Purchases.logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.INFO Purchases.logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.INFO
@ -46,6 +72,30 @@ class RevenueCatBillingRepository @Inject constructor(
PurchasesConfiguration.Builder(application, BuildConfig.RC_API_KEY).build() PurchasesConfiguration.Builder(application, BuildConfig.RC_API_KEY).build()
) )
} }
scope.launch {
authRepository.authState.distinctUntilChanged().collect { state ->
syncRevenueCatIdentity(state)
}
}
}
/** Best-effort — a failed identity sync must never crash the app; it just retries on the next auth event. */
private suspend fun syncRevenueCatIdentity(state: AuthState) {
runCatching {
when (state) {
is AuthState.Authenticated -> {
if (Purchases.sharedInstance.appUserID != state.userId) {
Purchases.sharedInstance.awaitLogIn(state.userId)
}
}
AuthState.Unauthenticated -> {
if (!Purchases.sharedInstance.isAnonymous) {
Purchases.sharedInstance.awaitLogOut()
}
}
AuthState.Loading -> Unit
}
}.onFailure { Log.w(TAG, "RevenueCat identity sync failed (will retry on next auth event)", it) }
} }
override suspend fun getOfferings(): BillingState<Offerings> = runCatching { override suspend fun getOfferings(): BillingState<Offerings> = runCatching {
@ -58,7 +108,15 @@ class RevenueCatBillingRepository @Inject constructor(
PurchaseParams.Builder(getActivity(), pkg).build() PurchaseParams.Builder(getActivity(), pkg).build()
) )
) )
}.getOrElse { BillingState.Error(it.localizedMessage ?: "Purchase failed") } }.getOrElse { error ->
// The user backing out of the Play billing sheet is not a failure — it's a distinct, expected
// path callers must not render as an error (see PaywallViewModel.purchase()).
if (error is PurchasesTransactionException && error.userCancelled) {
BillingState.Error(PURCHASE_CANCELLED_SENTINEL)
} else {
BillingState.Error(error.localizedMessage ?: "Purchase failed")
}
}
override suspend fun restorePurchases(): BillingState<CustomerInfo> = runCatching { override suspend fun restorePurchases(): BillingState<CustomerInfo> = runCatching {
BillingState.Success(Purchases.sharedInstance.awaitRestore()) BillingState.Success(Purchases.sharedInstance.awaitRestore())
@ -88,4 +146,8 @@ class RevenueCatBillingRepository @Inject constructor(
return ActivityProvider.currentActivity return ActivityProvider.currentActivity
?: throw IllegalStateException("No active Activity available for purchase") ?: throw IllegalStateException("No active Activity available for purchase")
} }
private companion object {
const val TAG = "RevenueCatBilling"
}
} }

View File

@ -31,6 +31,16 @@ interface BillingRepository {
/** Continuous stream of customer info updates. */ /** Continuous stream of customer info updates. */
fun getCustomerInfo(): Flow<BillingState<CustomerInfo>> fun getCustomerInfo(): Flow<BillingState<CustomerInfo>>
companion object {
/**
* Marker-only [BillingState.Error] message for a purchase the user cancelled (backed out of
* the Play billing sheet). Callers (see PaywallViewModel.purchase()) must treat this as a
* silent reset, not a user-facing error mirrors PaywallViewModel's own LOAD_FAILED marker
* pattern for offering-load failures.
*/
const val PURCHASE_CANCELLED_SENTINEL = "purchase_cancelled"
}
} }
/** /**

View File

@ -35,6 +35,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -86,12 +88,19 @@ fun PaywallScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
var showThankYou by remember { mutableStateOf(false) } var showThankYou by remember { mutableStateOf(false) }
val snackbar = remember { SnackbarHostState() }
LaunchedEffect(uiState.purchaseState) { LaunchedEffect(uiState.purchaseState) {
val state = uiState.purchaseState val state = uiState.purchaseState
if (state is BillingState.Success<*>) { if (state is BillingState.Success<*>) {
showThankYou = true showThankYou = true
} }
// Cancellation is reset to null upstream (PaywallViewModel), so any Error reaching here is a
// genuine failure (billing unavailable, network, etc.) — previously this had NO user-facing
// surface at all; the spinner just vanished with no explanation.
if (state is BillingState.Error) {
snackbar.showSnackbar("We couldn't complete that purchase. Please try again.")
}
} }
Box( Box(
@ -167,6 +176,14 @@ fun PaywallScreen(
onNavigate("back") onNavigate("back")
}) })
} }
SnackbarHost(
hostState = snackbar,
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(16.dp)
)
} }
} }

View File

@ -110,7 +110,13 @@ class PaywallViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(purchaseState = BillingState.Loading) } _uiState.update { it.copy(purchaseState = BillingState.Loading) }
val result = billingRepository.purchasePackage(pkg) val result = billingRepository.purchasePackage(pkg)
_uiState.update { it.copy(purchaseState = result) } // The user backing out of the Play billing sheet is expected, not an error — reset to
// null so the screen just returns to plan selection with no error toast (BillingRepository
// tags this path with PURCHASE_CANCELLED_SENTINEL; see purchasePackage's cancellation
// handling). A real failure keeps its BillingState.Error and PaywallScreen surfaces it.
val isCancelled = result is BillingState.Error &&
result.message == BillingRepository.PURCHASE_CANCELLED_SENTINEL
_uiState.update { it.copy(purchaseState = if (isCancelled) null else result) }
} }
} }

View File

@ -29,7 +29,7 @@ playIntegrity = "1.4.0"
googleid = "1.1.1" googleid = "1.1.1"
# Third-party # Third-party
revenuecat = "8.20.0" revenuecat = "10.12.0"
coil = "2.7.0" coil = "2.7.0"
coroutines = "1.9.0" coroutines = "1.9.0"
tink = "1.13.0" tink = "1.13.0"