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:
parent
661f391582
commit
b99a83388e
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue