Taking money is the one feature where a bug costs real money, so it is worth being precise about who decides what. This lesson walks the app's checkout from "Place order" to a receipt that says PAID, using Stripe's PaymentSheet — and shows the one place the Android design has to differ from the iOS one, because of the lifecycle rules from lesson 5.
Rule one: the device never decides the price
The app shows totals everywhere: the builder, the cart, checkout. Every one of them is a preview. The order request the app sends contains no money at all:
data class OrderCreateRequest(
val orderType: OrderType,
val customerName: String,
/** Ignored by the server when a token is present — the account's email wins. */
val guestEmail: String?,
val phone: String?,
val addressLine1: String?,
val addressLine2: String?,
val city: String?,
val state: String?,
val postalCode: String?,
val items: List<LineItemRequest>,
)
Identifiers and quantities. The server looks up every price in its own database, computes the
total, and creates a Stripe PaymentIntent for exactly that amount. A patched APK that sends
"total": 0.01 changes nothing, because there is no total field to send. That is why the
app can use Double for money (lesson 2): it is never the authority.
Rule two: order first, then pay
Checkout is two steps, and the second exists only once the server says so:
sealed interface CheckoutStep {
data object CollectingDetails : CheckoutStep
/** The order exists server-side and is reserved; only the payment remains. */
data class AwaitingPayment(val created: OrderCreateResponse) : CheckoutStep
}
"Place order" validates the form and creates the order. The response carries the order — priced by the server — and a client secret: a token that lets this device confirm that one PaymentIntent and nothing else. From then on, the screen shows the server's figures, not its own preview, because they are the ones being charged.
Setting Stripe up
The Stripe SDK needs the account's publishable key, once, at startup:
apiConfig.stripePublishableKey?.let { PaymentConfiguration.init(this, it) }
A publishable key is public by design: it identifies the account and can only create and confirm
intents, never charge on its own. It lives in local.properties and reaches the app through
BuildConfig. The secret key never comes near the app — it is on the server. If a
key starting sk_ ever appears in an Android project, something has gone badly wrong.
Why the ViewModel cannot hold the sheet
The iOS version of this app hides Stripe behind a protocol its view model calls directly:
protocol PaymentGateway {
/// Opens the payment sheet and returns once the customer is done with it.
func pay(_ request: PaymentRequest) async -> PaymentOutcome
/// Collects a card against a SetupIntent WITHOUT charging it, for the profile screen.
func saveCard(setupIntentClientSecret: String) async -> CardSetupOutcome
/// False when no publishable key is configured — the UI then explains, rather than failing at
/// the moment of tapping "Pay".
var isReady: Bool { get }
}
The view model awaits pay() and gets an outcome. It is a clean design, and on Android
it is a memory leak. Stripe's PaymentSheet opens its own Activity and registers for that
Activity's result with the current Activity. A ViewModel outlives that Activity — that is its
whole purpose. After the first rotation, a ViewModel holding a PaymentSheet holds a
destroyed Activity, which can never be garbage-collected, and a result that would be delivered to it
goes nowhere.
So the responsibilities split along lifetimes. The ViewModel, which survives rotation, decides that a payment should start and handles the outcome. The screen, which lives exactly as long as the Activity, owns the sheet.
The screen's half
fun rememberPaymentLauncher(onOutcome: (PaymentOutcome) -> Unit): PaymentLauncher {
// The sheet is registered once; this keeps it calling the LATEST lambda, not the first one.
val currentOnOutcome by rememberUpdatedState(onOutcome)
/*
* `build()` here is the COMPOSABLE overload: it registers the sheet's activity-result
* launcher with the current Activity and remembers it across recompositions — and, because
* it is part of composition, registers again on the new Activity after a rotation.
*/
val sheet = PaymentSheet.Builder { result ->
currentOnOutcome(
when (result) {
is PaymentSheetResult.Completed -> PaymentOutcome.Succeeded
is PaymentSheetResult.Canceled -> PaymentOutcome.Cancelled
is PaymentSheetResult.Failed ->
PaymentOutcome.Failed(result.error.localizedMessage ?: "The payment did not go through.")
},
)
}.build()
return remember(sheet) { PaymentLauncher(sheet) }
}
PaymentSheet.Builder(…).build() is a composable overload: it registers the
sheet's result launcher with the current Activity and remembers it, and because it is part of
composition, it registers again with the new Activity after a rotation. (The older
rememberPaymentSheet does the same and is deprecated in Stripe 23.) Stripe's three results
become the app's own PaymentOutcome, so nothing above this file imports Stripe.
rememberUpdatedState handles a subtle problem: the sheet is registered once, but the
onOutcome lambda might change between recompositions. Reading it through
rememberUpdatedState means the result always goes to the latest lambda rather than
the one captured at registration.
fun present(request: PaymentSheetRequest) {
val configuration = PaymentSheet.Configuration.Builder(merchantDisplayName = "StayHub Pizza")
.defaultBillingDetails(
PaymentSheet.BillingDetails(name = request.customerName, email = request.customerEmail),
)
.allowsDelayedPaymentMethods(false)
.build()
if (request.isSetup) {
sheet.presentWithSetupIntent(request.clientSecret, configuration)
} else {
sheet.presentWithPaymentIntent(request.clientSecret, configuration)
}
}
Card only — allowsDelayedPaymentMethods(false) — because a bank debit that settles in
three days would leave a pizza order "paid" long after it went cold.
The ViewModel's half: events as state
The ViewModel asks for the sheet the only way a ViewModel should talk to a screen — by changing its state:
fun pay() {
val step = _state.value.step as? CheckoutStep.AwaitingPayment ?: return
val clientSecret = step.created.clientSecret ?: return
_state.update {
it.copy(
isPaying = true,
errorMessage = null,
paymentRequest = PaymentSheetRequest(
clientSecret = clientSecret,
customerName = form.value.customerName.trim(),
customerEmail = form.value.email.trim(),
),
)
}
}
The screen reacts, and reports back that it did:
LaunchedEffect(state.paymentRequest) {
val request = state.paymentRequest ?: return@LaunchedEffect
payment.present(request)
viewModel.onPaymentSheetPresented()
}
fun onPaymentSheetPresented() {
_state.update { it.copy(paymentRequest = null) }
}
The timing of that last call is the detail that makes it correct. The request is consumed when the sheet is presented, not when the result arrives. If it waited for the result, a rotation while the sheet was up would recompose the screen, find a request still pending, and open a second sheet on top of the first. Clearing it immediately means the request is handled exactly once, however many times the screen is recreated.
This "events as state" pattern — the ViewModel sets a value, the screen acts and clears it — is
Google's recommended alternative to firing one-off events through a Channel, which can
drop or duplicate an event that arrives while the screen is between Activities.
The outcome
fun onPaymentOutcome(outcome: PaymentOutcome) {
val step = _state.value.step as? CheckoutStep.AwaitingPayment
_state.update { it.copy(isPaying = false) }
when (outcome) {
PaymentOutcome.Succeeded -> {
// The server — not this result — decides the order is paid; the receipt screen
// polls for that. Emptying the cart here is the device's half of the bargain.
cartStore.clear()
_state.update { it.copy(completedOrderId = step?.created?.order?.id) }
}
// Not an error. The order is still reserved and they can try again.
PaymentOutcome.Cancelled -> Unit
is PaymentOutcome.Failed -> _state.update { it.copy(errorMessage = outcome.message) }
}
}
Three outcomes, three different responses. Cancelled is not an error — the customer closed the sheet; the order is still reserved and the Pay button is still there. Failed shows Stripe's message ("Your card was declined"), on the same order, so a second card can be tried without creating a second order. Succeeded empties the cart and asks the screen to show the receipt — through state again, then the receipt replaces checkout on the back stack so Back cannot return to a paid order.
Rule three: ask the server whether it is paid
The sheet reporting "completed" means Stripe accepted the card. It does not mean the order is paid. The order becomes PAID when Stripe's webhook reaches the backend — a separate, signed server-to-server call that can lag by seconds. The device is never the authority on money, so it never marks anything paid. The receipt screen asks:
repeat(maxAttempts) { attempt ->
try {
val order = repository.paymentStatus(orderId)
_state.value = UiState.Loaded(order)
if (!order.status.isSettling) return
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Keep showing the last good receipt if there is one; a blip mid-poll is not news.
if (_state.value.valueOrNull == null) _state.value = UiState.failure(e, "Could not load the order.")
return
}
// No sleep after the final attempt — it would delay the "still processing" message.
if (attempt < maxAttempts - 1) delay(pollInterval)
}
Every two seconds, up to ten times, until the server says the order has settled. If it still has not, the screen says the payment is being confirmed and an email will follow — honest, and true.
Saving a card
Signed-in customers can save cards on their profile. The flow is the same sheet with a different intent: a SetupIntent, which collects and verifies a card without charging it.
The wrinkle is that the sheet reports only "completed". The backend needs the id of the payment
method Stripe created — a pm_… token — which lives on the SetupIntent. So after the sheet
completes, the app reads it back from Stripe with the publishable key:
class StripeSetupIntentReader @Inject constructor(
@ApplicationContext private val context: Context,
private val config: ApiConfig,
) : SetupIntentReader {
override suspend fun paymentMethodId(clientSecret: String): String? {
val key = config.stripePublishableKey ?: return null
return Stripe(context, key).retrieveSetupIntent(clientSecret).paymentMethodId
}
}
…and sends only that token to its own backend, which attaches it to the customer at Stripe. The card number went from the customer's keyboard straight into Stripe's sheet and never touched this app's code, its memory, its logs or its server. That is the whole security model: the app handles nothing that would make it a target.
When it is not configured
A developer cloning the app without keys should not see a Pay button that does nothing. The ViewModel explains, specifically, what is missing:
fun paymentUnavailableMessage(created: OrderCreateResponse): String? = when {
created.clientSecret == null ->
"The server created this order but returned no Stripe client secret, which means no " +
"Stripe key is configured on the backend. Run it with the local profile."
!config.isStripeConfigured ->
"Card payment is unavailable because this build has no Stripe publishable key. Set " +
"pizza.stripePublishableKey in local.properties and rebuild."
else -> null
}
Testing payments
The ViewModel never sees Stripe, so its tests do not either. They create an order, call
pay(), assert a request was published, call onPaymentSheetPresented(), then
feed onPaymentOutcome each of the three outcomes directly — including the one that matters
most: a rotation must not open a second sheet.
The real sheet was exercised by hand against Stripe's test mode: test card
4242 4242 4242 4242, any future expiry, any CVC. That order reached PAID, which was
confirmed in the database rather than on the screen — and the cart row on the server was empty
afterwards, as it should be.
Next
The payment sheet is fully accessible because Stripe made it so. The rest of the app is accessible only if we did. The next lesson is accessibility in Compose: the semantics tree that TalkBack reads, and that the UI tests read too.