Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,28 @@ import com.stripe.android.paymentelement.CheckoutSessionPreview
import com.stripe.android.paymentelement.EmbeddedPaymentElement
import com.stripe.android.paymentsheet.PaymentSheet
import com.stripe.android.paymentsheet.addresselement.AddressDetails
import com.stripe.android.paymentsheet.repositories.CheckoutSessionResponse

@OptIn(CheckoutSessionPreview::class)
internal interface CheckoutSessionData {
val checkoutSessionResponse: CheckoutSessionResponse
val shippingName: String?
val billingName: String?
val shippingPhoneNumber: String?
val billingPhoneNumber: String?
val shippingAddress: Address.State?
val billingAddress: Address.State?
}

@OptIn(CheckoutSessionPreview::class)
internal sealed class CheckoutConfigurationMerger<T> {

abstract fun forCheckoutSession(state: InternalState): T
abstract fun forCheckoutSession(state: CheckoutSessionData): T

class PaymentSheetConfiguration(
private val config: PaymentSheet.Configuration,
) : CheckoutConfigurationMerger<PaymentSheet.Configuration>() {
override fun forCheckoutSession(state: InternalState): PaymentSheet.Configuration {
override fun forCheckoutSession(state: CheckoutSessionData): PaymentSheet.Configuration {
val merged = mergeCheckoutSessionData(
existingBillingDetails = config.defaultBillingDetails,
existingShippingDetails = config.shippingDetails,
Expand All @@ -32,7 +44,7 @@ internal sealed class CheckoutConfigurationMerger<T> {
class EmbeddedConfiguration(
private val config: EmbeddedPaymentElement.Configuration,
) : CheckoutConfigurationMerger<EmbeddedPaymentElement.Configuration>() {
override fun forCheckoutSession(state: InternalState): EmbeddedPaymentElement.Configuration {
override fun forCheckoutSession(state: CheckoutSessionData): EmbeddedPaymentElement.Configuration {
val merged = mergeCheckoutSessionData(
existingBillingDetails = config.defaultBillingDetails,
existingShippingDetails = config.shippingDetails,
Expand All @@ -58,7 +70,7 @@ private data class MergedDetails(
private fun mergeCheckoutSessionData(
existingBillingDetails: PaymentSheet.BillingDetails?,
existingShippingDetails: AddressDetails?,
state: InternalState,
state: CheckoutSessionData,
): MergedDetails {
val response = state.checkoutSessionResponse
return MergedDetails(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.stripe.android.checkout

import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import com.stripe.android.core.injection.ViewModelScope
import com.stripe.android.lpmfoundations.paymentmethod.PaymentMethodMetadata
import com.stripe.android.paymentelement.EmbeddedPaymentElement
import com.stripe.android.paymentelement.embedded.EmbeddedSelectionHolder
import com.stripe.android.paymentsheet.model.PaymentSelection
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
import javax.inject.Singleton

/**
* Owns the confirmation [State] for [CheckoutController], persisting it in [SavedStateHandle] so it
* survives process death. Kept separate from the controller so the confirmation flow can depend on
* this holder directly rather than reaching back into the controller.
*/
@Singleton
internal class CheckoutConfirmationStateHolder @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val selectionHolder: EmbeddedSelectionHolder,
@ViewModelScope private val coroutineScope: CoroutineScope,
) {
var state: State?
get() = savedStateHandle[CONFIRMATION_STATE_KEY]
set(value) {
savedStateHandle[CONFIRMATION_STATE_KEY] = value
}

val stateFlow: StateFlow<State?>
get() = savedStateHandle.getStateFlow(CONFIRMATION_STATE_KEY, null)

init {
coroutineScope.launch {
selectionHolder.selection.collect { selection ->
state = state?.copy(selection = selection)
}
}
}

@Parcelize
data class State(
val paymentMethodMetadata: PaymentMethodMetadata,
val selection: PaymentSelection?,
val configuration: EmbeddedPaymentElement.Configuration,
) : Parcelable

companion object {
const val CONFIRMATION_STATE_KEY = "CheckoutController_ConfirmationState"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,11 @@ import androidx.lifecycle.SavedStateHandle
import com.stripe.android.checkout.injection.DaggerCheckoutControllerComponent
import com.stripe.android.core.injection.ViewModelScope
import com.stripe.android.paymentelement.CheckoutSessionPreview
import com.stripe.android.paymentsheet.repositories.CheckoutSessionRepository
import dev.drewhamilton.poko.Poko
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
import javax.inject.Singleton
Expand All @@ -25,15 +24,32 @@ import javax.inject.Singleton
class CheckoutController @Inject internal constructor(
resultCallback: ResultCallback,
@ViewModelScope private val viewModelScope: CoroutineScope,
private val checkoutSessionRepository: CheckoutSessionRepository,
private val checkoutStateLoader: CheckoutStateLoader,
private val stateHolder: CheckoutControllerStateHolder,
internal val confirmationStateHolder: CheckoutConfirmationStateHolder,
) {
private val _checkoutSession = MutableStateFlow<CheckoutSession?>(null)
val checkoutSession: StateFlow<CheckoutSession?> = _checkoutSession.asStateFlow()
val checkoutSession: StateFlow<CheckoutSession?>
get() = stateHolder.checkoutSession

suspend fun configure(
checkoutSessionClientSecret: String,
configuration: Configuration = Configuration(),
): kotlin.Result<Unit> {
TODO("Not yet implemented")
val configurationState = configuration.build()
val sessionId = checkoutSessionClientSecret.substringBefore("_secret_")

return checkoutSessionRepository.init(
sessionId = sessionId,
adaptivePricingAllowed = configurationState.adaptivePricingAllowed,
).mapCatching { response ->
checkoutStateLoader.load(
CheckoutControllerState.defaultState(
configuration = configurationState,
checkoutSessionResponse = response,
)
)
}
}

suspend fun applyPromotionCode(promotionCode: String): kotlin.Result<Unit> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.stripe.android.checkout

import android.graphics.Bitmap
import android.os.Parcelable
import com.stripe.android.paymentelement.CheckoutSessionPreview
import com.stripe.android.paymentsheet.repositories.CheckoutSessionResponse
import com.stripe.android.paymentsheet.state.PaymentElementLoader
import kotlinx.parcelize.Parcelize
import java.util.UUID

/**
* Internal state for [CheckoutController]. Unlike [InternalState] (used by [Checkout]), this holds
* the controller's own [CheckoutController.Configuration.State] directly, so the configuration
* doesn't have to be reconstructed and can be read back via [configuration].
*/
@OptIn(CheckoutSessionPreview::class)
@Parcelize
internal data class CheckoutControllerState(
val key: String,
val configuration: CheckoutController.Configuration.State,
override val checkoutSessionResponse: CheckoutSessionResponse,
val flagImages: Map<String, Bitmap>?,
override val shippingName: String?,
override val billingName: String?,
override val shippingPhoneNumber: String?,
override val billingPhoneNumber: String?,
override val shippingAddress: Address.State?,
override val billingAddress: Address.State?,
val integrationLaunched: Boolean,
) : Parcelable, CheckoutSessionData {
val initializationMode: PaymentElementLoader.InitializationMode.CheckoutSession
get() = PaymentElementLoader.InitializationMode.CheckoutSession(
instancesKey = key,
checkoutSessionResponse = checkoutSessionResponse,
)

fun asCheckoutSession(): CheckoutSession {
return checkoutSessionResponse.asCheckoutSession(flagImages)
}

companion object {
/**
* Builds the initial state for a freshly configured checkout session: a new [key], the
* given [configuration] and [checkoutSessionResponse], and no resolved flag images or
* collected shipping/billing details yet.
*/
fun defaultState(
configuration: CheckoutController.Configuration.State,
checkoutSessionResponse: CheckoutSessionResponse,
): CheckoutControllerState = CheckoutControllerState(
key = UUID.randomUUID().toString(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OOC why do we not use the checkout session response ID for this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't think of a great reason not to use it.

configuration = configuration,
checkoutSessionResponse = checkoutSessionResponse,
flagImages = null,
shippingName = null,
billingName = null,
shippingPhoneNumber = null,
billingPhoneNumber = null,
shippingAddress = null,
billingAddress = null,
integrationLaunched = false,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.stripe.android.checkout

import androidx.lifecycle.SavedStateHandle
import com.stripe.android.paymentelement.CheckoutSessionPreview
import com.stripe.android.uicore.utils.mapAsStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
import javax.inject.Singleton

/**
* Owns [CheckoutController]'s [CheckoutControllerState], persisting it in [SavedStateHandle] so it
* survives process death, and derives the observable [checkoutSession] from it. Kept separate from
* the controller so [CheckoutStateLoader] can commit loaded state directly rather than reaching back
* into the controller.
*/
@OptIn(CheckoutSessionPreview::class)
@Singleton
internal class CheckoutControllerStateHolder @Inject constructor(
private val savedStateHandle: SavedStateHandle,
) {
var state: CheckoutControllerState?
get() = savedStateHandle[STATE_KEY]
set(value) {
savedStateHandle[STATE_KEY] = value
}

val checkoutSession: StateFlow<CheckoutSession?> =
savedStateHandle.getStateFlow<CheckoutControllerState?>(STATE_KEY, null)
.mapAsStateFlow { it?.asCheckoutSession() }

companion object {
const val STATE_KEY = "CheckoutController_InternalState"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.stripe.android.checkout

import com.stripe.android.checkout.injection.MerchantDisplayName
import com.stripe.android.common.model.asCommonConfiguration
import com.stripe.android.paymentelement.CheckoutSessionPreview
import com.stripe.android.paymentelement.EmbeddedPaymentElement
import com.stripe.android.paymentelement.embedded.EmbeddedSelectionHolder
import com.stripe.android.paymentelement.embedded.content.EmbeddedSelectionChooser
import com.stripe.android.paymentsheet.state.PaymentElementLoader
import javax.inject.Inject

/**
* Loads the payment element for a [CheckoutControllerState] and, on success, atomically commits it
* to the [stateHolder], [confirmationStateHolder], and [selectionHolder]. Extracted from
* [CheckoutController] so the load-and-commit flow can be unit tested in isolation from the
* controller.
*/
@OptIn(CheckoutSessionPreview::class)
internal class CheckoutStateLoader @Inject constructor(
@MerchantDisplayName private val merchantDisplayName: String,
private val flagImageResolver: FlagImageResolver,
private val paymentElementLoader: PaymentElementLoader,
private val selectionChooser: EmbeddedSelectionChooser,
private val selectionHolder: EmbeddedSelectionHolder,
private val confirmationStateHolder: CheckoutConfirmationStateHolder,
private val stateHolder: CheckoutControllerStateHolder,
) {
/**
* Loads the payment element for [state] and, on success, atomically commits the resolved state.
* Throws if the load fails, leaving the previously committed state untouched (the commits are
* the final step, so a failure short-circuits before any holder is mutated).
*/
suspend fun load(state: CheckoutControllerState) {
val response = state.checkoutSessionResponse

val resolvedState = state.copy(
// [state] carries the previously resolved flag images forward (a mutation copies them
// from the committed state), so they're reused when the currencies haven't changed.
flagImages = flagImageResolver.resolve(response, cached = state.flagImages),
)
Comment on lines +36 to +40

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it feels surprising that we have to make a copy of state here. Does this mean that the state in CheckoutController is out of sync with what we use in load?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For configure this is a little silly. But I built it to be prepared for mutations, which is where this is actually useful. During a mutation, we'll have the previously configured response + flags, etc -- and we want all the same state, except for the new checkout sessions response + the updated flags (if they're needed).


val baseConfig = EmbeddedPaymentElement.Configuration.Builder(merchantDisplayName)
.embeddedViewDisplaysMandateText(
resolvedState.configuration.paymentElementConfiguration.embeddedViewDisplaysMandateText
)
.build()

val embeddedConfig = CheckoutConfigurationMerger.EmbeddedConfiguration(baseConfig)
.forCheckoutSession(resolvedState)

val loaderState = paymentElementLoader.load(
initializationMode = resolvedState.initializationMode,
integrationConfiguration = PaymentElementLoader.Configuration.Embedded(
isRowSelectionImmediateAction = false,
configuration = embeddedConfig,
),
metadata = PaymentElementLoader.Metadata(
isReloadingAfterProcessDeath = false,
initializedViaCompose = false,
),
).getOrThrow()

// Preserve the customer's existing selection across reloads when it's still valid, rather
// than blindly adopting the loader's recomputed selection (reuses the embedded logic).
val selection = selectionChooser.choose(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this logic live in PaymentElementLoader?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could probably inject it. But it does seem logical that it would go there 🤔 I'll attempt this in a follow up!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good! Right now I think PaymentElementLoader is doing some work and then we throw out the results of that work

paymentMethodMetadata = loaderState.paymentMethodMetadata,
paymentMethods = loaderState.customer?.paymentMethods,
previousSelection = selectionHolder.selection.value,
newSelection = loaderState.paymentSelection,
newConfiguration = embeddedConfig.asCommonConfiguration(),
formSheetAction = embeddedConfig.formSheetAction,
)

stateHolder.state = resolvedState
confirmationStateHolder.state = CheckoutConfirmationStateHolder.State(
paymentMethodMetadata = loaderState.paymentMethodMetadata,
selection = selection,
configuration = embeddedConfig,
)
selectionHolder.set(selection)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,16 @@ import kotlinx.parcelize.Parcelize
internal data class InternalState(
val key: String,
val configuration: Checkout.Configuration.State,
val checkoutSessionResponse: CheckoutSessionResponse,
override val checkoutSessionResponse: CheckoutSessionResponse,
val flagImages: Map<String, Bitmap>?,
val shippingName: String? = null,
val billingName: String? = null,
val shippingPhoneNumber: String? = null,
val billingPhoneNumber: String? = null,
val shippingAddress: Address.State? = null,
val billingAddress: Address.State? = null,
override val shippingName: String? = null,
override val billingName: String? = null,
override val shippingPhoneNumber: String? = null,
override val billingPhoneNumber: String? = null,
override val shippingAddress: Address.State? = null,
override val billingAddress: Address.State? = null,
val integrationLaunched: Boolean = false,
) : Parcelable {
) : Parcelable, CheckoutSessionData {
val initializationMode: PaymentElementLoader.InitializationMode.CheckoutSession
get() = PaymentElementLoader.InitializationMode.CheckoutSession(
instancesKey = key,
Expand All @@ -35,7 +35,7 @@ internal data class InternalState(
}

@OptIn(CheckoutSessionPreview::class)
private fun CheckoutSessionResponse.asCheckoutSession(
internal fun CheckoutSessionResponse.asCheckoutSession(
flagImages: Map<String, Bitmap>?,
): CheckoutSession {
return CheckoutSession(
Expand Down
Loading
Loading