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.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<Offerings> = 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<CustomerInfo> = 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"
}
}

View File

@ -31,6 +31,16 @@ interface BillingRepository {
/** Continuous stream of customer info updates. */
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.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)
)
}
}

View File

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

View File

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