From b99a83388ed83a9d64d396a85ed6a096a0d0487d Mon Sep 17 00:00:00 2001 From: null Date: Wed, 8 Jul 2026 14:19:58 -0500 Subject: [PATCH] fix(billing): link RevenueCat identity to Firebase auth + purchase UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../repository/RevenueCatBillingRepository.kt | 66 ++++++++++++++++++- .../domain/repository/BillingRepository.kt | 10 +++ .../app/closer/ui/paywall/PaywallScreen.kt | 17 +++++ .../app/closer/ui/paywall/PaywallViewModel.kt | 8 ++- gradle/libs.versions.toml | 2 +- 5 files changed, 99 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/closer/data/repository/RevenueCatBillingRepository.kt b/app/src/main/java/app/closer/data/repository/RevenueCatBillingRepository.kt index 2bfa4a49..9fe558f1 100644 --- a/app/src/main/java/app/closer/data/repository/RevenueCatBillingRepository.kt +++ b/app/src/main/java/app/closer/data/repository/RevenueCatBillingRepository.kt @@ -2,9 +2,13 @@ package app.closer.data.repository import android.app.Activity import android.app.Application +import android.util.Log import app.closer.BuildConfig 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.Companion.PURCHASE_CANCELLED_SENTINEL import app.closer.domain.repository.BillingState import com.revenuecat.purchases.CustomerInfo import com.revenuecat.purchases.LogLevel @@ -14,19 +18,35 @@ import com.revenuecat.purchases.Purchases import com.revenuecat.purchases.PurchasesConfiguration import com.revenuecat.purchases.PurchaseResult 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.awaitPurchase import com.revenuecat.purchases.awaitRestore import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch /** * RevenueCat-backed implementation of [BillingRepository]. * * - 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. * - Maps RevenueCat results to [BillingState] for stable UI contracts. * @@ -36,9 +56,15 @@ import kotlinx.coroutines.flow.callbackFlow @Singleton class RevenueCatBillingRepository @Inject constructor( private val application: Application, - private val entitlementChecker: EntitlementChecker + private val entitlementChecker: EntitlementChecker, + private val authRepository: AuthRepository, ) : 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 { if (!Purchases.isConfigured) { 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() ) } + 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 = runCatching { @@ -58,7 +108,15 @@ class RevenueCatBillingRepository @Inject constructor( 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 = runCatching { BillingState.Success(Purchases.sharedInstance.awaitRestore()) @@ -88,4 +146,8 @@ class RevenueCatBillingRepository @Inject constructor( return ActivityProvider.currentActivity ?: throw IllegalStateException("No active Activity available for purchase") } + + private companion object { + const val TAG = "RevenueCatBilling" + } } diff --git a/app/src/main/java/app/closer/domain/repository/BillingRepository.kt b/app/src/main/java/app/closer/domain/repository/BillingRepository.kt index f3986432..515e8a14 100644 --- a/app/src/main/java/app/closer/domain/repository/BillingRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/BillingRepository.kt @@ -31,6 +31,16 @@ interface BillingRepository { /** Continuous stream of customer info updates. */ fun getCustomerInfo(): Flow> + + 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" + } } /** diff --git a/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt b/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt index 1d21b9d5..18edfcba 100644 --- a/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt +++ b/app/src/main/java/app/closer/ui/paywall/PaywallScreen.kt @@ -35,6 +35,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -86,12 +88,19 @@ fun PaywallScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current var showThankYou by remember { mutableStateOf(false) } + val snackbar = remember { SnackbarHostState() } LaunchedEffect(uiState.purchaseState) { val state = uiState.purchaseState if (state is BillingState.Success<*>) { 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( @@ -167,6 +176,14 @@ fun PaywallScreen( onNavigate("back") }) } + + SnackbarHost( + hostState = snackbar, + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(16.dp) + ) } } diff --git a/app/src/main/java/app/closer/ui/paywall/PaywallViewModel.kt b/app/src/main/java/app/closer/ui/paywall/PaywallViewModel.kt index 8fe88186..68d2e4da 100644 --- a/app/src/main/java/app/closer/ui/paywall/PaywallViewModel.kt +++ b/app/src/main/java/app/closer/ui/paywall/PaywallViewModel.kt @@ -110,7 +110,13 @@ class PaywallViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(purchaseState = BillingState.Loading) } 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) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c57256b8..6ed2be2d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,7 +29,7 @@ playIntegrity = "1.4.0" googleid = "1.1.1" # Third-party -revenuecat = "8.20.0" +revenuecat = "10.12.0" coil = "2.7.0" coroutines = "1.9.0" tink = "1.13.0"