Skip to content

Commit f6ac548

Browse files
Refresh CheckoutController state after confirmation
1 parent 5447187 commit f6ac548

6 files changed

Lines changed: 373 additions & 26 deletions

File tree

paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutConfirmationResultHandler.kt

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package com.stripe.android.checkout
33
import com.stripe.android.core.injection.ViewModelScope
44
import com.stripe.android.paymentelement.CheckoutSessionPreview
55
import com.stripe.android.paymentelement.confirmation.ConfirmationHandler
6+
import com.stripe.android.paymentelement.confirmation.intent.CheckoutSessionResponseKey
7+
import com.stripe.android.paymentsheet.repositories.CheckoutSessionResponse
68
import kotlinx.coroutines.CoroutineScope
79
import kotlinx.coroutines.flow.filterIsInstance
810
import kotlinx.coroutines.flow.launchIn
@@ -24,26 +26,40 @@ internal class CheckoutConfirmationResultHandler @Inject constructor(
2426
* per confirmation and never restores a stale completion across process death, so this single
2527
* collector delivers each result exactly once.
2628
*/
27-
fun register() {
29+
fun register(
30+
onSucceeded: suspend (CheckoutSessionResponse?) -> Unit,
31+
) {
2832
confirmationHandler.state
2933
.filterIsInstance<ConfirmationHandler.State.Complete>()
30-
.onEach { handle(it.result) }
34+
.onEach { handle(it.result, onSucceeded) }
3135
.launchIn(viewModelScope)
3236
}
3337

34-
private fun handle(result: ConfirmationHandler.Result) {
38+
private suspend fun handle(
39+
result: ConfirmationHandler.Result,
40+
onSucceeded: suspend (CheckoutSessionResponse?) -> Unit,
41+
) {
3542
val isProcessDeathResult = isAwaitingProcessDeathResult
3643
isAwaitingProcessDeathResult = false
3744

3845
when (result) {
39-
is ConfirmationHandler.Result.Succeeded ->
40-
resultCallback.onResult(CheckoutController.Result.Completed())
46+
is ConfirmationHandler.Result.Succeeded -> handleSucceeded(result, onSucceeded)
4147
is ConfirmationHandler.Result.Failed ->
4248
resultCallback.onResult(CheckoutController.Result.Failed(result.cause))
4349
is ConfirmationHandler.Result.Canceled -> handleCanceled(result, isProcessDeathResult)
4450
}
4551
}
4652

53+
private suspend fun handleSucceeded(
54+
result: ConfirmationHandler.Result.Succeeded,
55+
onSucceeded: suspend (CheckoutSessionResponse?) -> Unit,
56+
) {
57+
// Synchronous confirmation carries the latest response. Next-action confirmation does not,
58+
// so the controller retrieves the completed Checkout Session before delivering the result.
59+
onSucceeded(result.metadata[CheckoutSessionResponseKey])
60+
resultCallback.onResult(CheckoutController.Result.Completed())
61+
}
62+
4763
private fun handleCanceled(
4864
result: ConfirmationHandler.Result.Canceled,
4965
isProcessDeathResult: Boolean,
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.stripe.android.checkout
2+
3+
import com.stripe.android.paymentelement.CheckoutSessionPreview
4+
import com.stripe.android.paymentsheet.repositories.CheckoutSessionRepository
5+
import com.stripe.android.paymentsheet.repositories.CheckoutSessionResponse
6+
import javax.inject.Inject
7+
8+
@OptIn(CheckoutSessionPreview::class)
9+
internal class CheckoutConfirmationStateUpdater internal constructor(
10+
private val stateHolder: CheckoutControllerStateHolder,
11+
private val fetchResponse: suspend (sessionId: String, adaptivePricingAllowed: Boolean) ->
12+
Result<CheckoutSessionResponse>,
13+
private val reloadState: suspend (CheckoutControllerState) -> Unit,
14+
) {
15+
@Inject
16+
constructor(
17+
stateHolder: CheckoutControllerStateHolder,
18+
checkoutSessionRepository: CheckoutSessionRepository,
19+
checkoutStateLoader: CheckoutStateLoader,
20+
) : this(
21+
stateHolder = stateHolder,
22+
fetchResponse = checkoutSessionRepository::init,
23+
reloadState = checkoutStateLoader::reload,
24+
)
25+
26+
suspend fun update(checkoutSessionResponse: CheckoutSessionResponse?) {
27+
// The controller invokes this inside runSerialized, so the snapshot remains current across
28+
// the fetch and reload suspensions and cannot overwrite a concurrently committed mutation.
29+
val state = stateHolder.state ?: return
30+
val response = checkoutSessionResponse ?: runCatching {
31+
fetchResponse(
32+
state.checkoutSessionResponse.id,
33+
state.configuration.adaptivePricingAllowed,
34+
).getOrThrow()
35+
}.getOrNull() ?: return
36+
37+
// Reloading keeps every field derived from the response in sync. A refresh failure must not
38+
// turn a successfully completed payment into a failed result or prevent its callback.
39+
runCatching {
40+
reloadState(state.copy(checkoutSessionResponse = response))
41+
}
42+
}
43+
}

paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutController.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ private val SERVER_UPDATE_TIMEOUT_MS = 20.seconds.inWholeMilliseconds
5757
class CheckoutController @Inject internal constructor(
5858
@ViewModelScope private val viewModelScope: CoroutineScope,
5959
confirmationResultHandler: CheckoutConfirmationResultHandler,
60+
private val confirmationStateUpdater: CheckoutConfirmationStateUpdater,
6061
private val checkoutSessionRepository: CheckoutSessionRepository,
6162
private val checkoutStateLoader: CheckoutStateLoader,
6263
private val stateHolder: CheckoutControllerStateHolder,
@@ -70,7 +71,7 @@ class CheckoutController @Inject internal constructor(
7071
private val _isUpdating = MutableStateFlow(false)
7172

7273
init {
73-
confirmationResultHandler.register()
74+
confirmationResultHandler.register(::handleConfirmationSucceeded)
7475
}
7576

7677
/**
@@ -297,6 +298,15 @@ class CheckoutController @Inject internal constructor(
297298
IllegalStateException("Cannot mutate checkout session while a payment flow is presented.")
298299
)
299300

301+
internal suspend fun handleConfirmationSucceeded(
302+
checkoutSessionResponse: CheckoutSessionResponse?,
303+
) {
304+
runSerialized {
305+
confirmationStateUpdater.update(checkoutSessionResponse)
306+
kotlin.Result.success(Unit)
307+
}
308+
}
309+
300310
/**
301311
* Serializes [block] behind [mutex] so configuration and mutations run in sequence, and toggles
302312
* [isUpdating] while any serialized work is in flight (tracked via [pendingMutations] so

paymentsheet/src/test/java/com/stripe/android/checkout/CheckoutConfirmationResultHandlerTest.kt

Lines changed: 100 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import com.stripe.android.paymentelement.CheckoutSessionPreview
99
import com.stripe.android.paymentelement.confirmation.ConfirmationHandler
1010
import com.stripe.android.paymentelement.confirmation.FakeConfirmationHandler
1111
import com.stripe.android.paymentelement.confirmation.FakeConfirmationOption
12+
import com.stripe.android.paymentelement.confirmation.MutableConfirmationMetadata
13+
import com.stripe.android.paymentelement.confirmation.intent.CheckoutSessionResponseKey
14+
import com.stripe.android.paymentsheet.repositories.CheckoutSessionResponse
15+
import com.stripe.android.paymentsheet.repositories.CheckoutSessionResponseFactory
1216
import kotlinx.coroutines.flow.MutableStateFlow
1317
import kotlinx.coroutines.test.TestCoroutineScheduler
1418
import kotlinx.coroutines.test.runTest
@@ -18,30 +22,73 @@ import org.junit.Test
1822
internal class CheckoutConfirmationResultHandlerTest {
1923

2024
@Test
21-
fun `register handles current complete result`() {
22-
runScenario(initialResult = succeeded()) {
23-
assertThat(callbackCalls.awaitItem()).isInstanceOf<CheckoutController.Result.Completed>()
25+
fun `register handles current complete response before invoking callback`() {
26+
val response = response(status = CheckoutSessionResponse.Status.COMPLETE)
27+
28+
runScenario(initialResult = succeeded(response)) {
29+
val callbackCall = callbackCalls.awaitItem()
30+
assertThat(callbackCall.result).isInstanceOf<CheckoutController.Result.Completed>()
31+
assertThat(callbackCall.succeededCallCount).isEqualTo(1)
32+
assertThat(callbackCall.succeededResponse).isEqualTo(response)
2433
}
2534
}
2635

2736
@Test
28-
fun `succeeded result invokes Completed`() = runScenario {
29-
emit(succeeded())
37+
fun `succeeded with open response passes it to success handler and invokes Completed`() = runScenario {
38+
val response = response(status = CheckoutSessionResponse.Status.OPEN)
3039

31-
assertThat(callbackCalls.awaitItem()).isInstanceOf<CheckoutController.Result.Completed>()
40+
emit(succeeded(response))
41+
42+
val callbackCall = callbackCalls.awaitItem()
43+
assertThat(callbackCall.result).isInstanceOf<CheckoutController.Result.Completed>()
44+
assertThat(callbackCall.succeededResponse).isEqualTo(response)
45+
}
46+
47+
@Test
48+
fun `succeeded with unknown response passes it to success handler and invokes Completed`() = runScenario {
49+
val response = response(status = CheckoutSessionResponse.Status.UNKNOWN)
50+
51+
emit(succeeded(response))
52+
53+
val callbackCall = callbackCalls.awaitItem()
54+
assertThat(callbackCall.result).isInstanceOf<CheckoutController.Result.Completed>()
55+
assertThat(callbackCall.succeededResponse).isEqualTo(response)
56+
}
57+
58+
@Test
59+
fun `succeeded with expired response passes it to success handler and invokes Completed`() = runScenario {
60+
val response = response(status = CheckoutSessionResponse.Status.EXPIRED)
61+
62+
emit(succeeded(response))
63+
64+
val callbackCall = callbackCalls.awaitItem()
65+
assertThat(callbackCall.result).isInstanceOf<CheckoutController.Result.Completed>()
66+
assertThat(callbackCall.succeededResponse).isEqualTo(response)
67+
}
68+
69+
@Test
70+
fun `succeeded result without response invokes success handler before Completed`() = runScenario {
71+
emit(ConfirmationHandler.Result.Succeeded(PaymentIntentFixtures.PI_SUCCEEDED))
72+
73+
val callbackCall = callbackCalls.awaitItem()
74+
assertThat(callbackCall.result).isInstanceOf<CheckoutController.Result.Completed>()
75+
assertThat(callbackCall.succeededCallCount).isEqualTo(1)
76+
assertThat(callbackCall.succeededResponse).isNull()
3277
}
3378

3479
@Test
3580
fun `each confirmation completion delivers its own callback`() = runScenario {
36-
emit(succeeded())
37-
assertThat(callbackCalls.awaitItem()).isInstanceOf<CheckoutController.Result.Completed>()
81+
val response = response(status = CheckoutSessionResponse.Status.COMPLETE)
82+
83+
emit(succeeded(response))
84+
assertThat(callbackCalls.awaitItem().result).isInstanceOf<CheckoutController.Result.Completed>()
3885

3986
// A second confirmation returns to Confirming before completing again. The interleaved
4087
// Confirming breaks the state flow's de-duplication, so an identical result still delivers.
4188
emitConfirming()
42-
emit(succeeded())
89+
emit(succeeded(response))
4390

44-
assertThat(callbackCalls.awaitItem()).isInstanceOf<CheckoutController.Result.Completed>()
91+
assertThat(callbackCalls.awaitItem().result).isInstanceOf<CheckoutController.Result.Completed>()
4592
}
4693

4794
@Test
@@ -56,7 +103,7 @@ internal class CheckoutConfirmationResultHandlerTest {
56103
)
57104
)
58105

59-
val result = callbackCalls.awaitItem()
106+
val result = callbackCalls.awaitItem().result
60107
assertThat(result).isInstanceOf<CheckoutController.Result.Failed>()
61108
assertThat((result as CheckoutController.Result.Failed).error).isEqualTo(cause)
62109
}
@@ -69,7 +116,7 @@ internal class CheckoutConfirmationResultHandlerTest {
69116
)
70117
)
71118

72-
assertThat(callbackCalls.awaitItem()).isInstanceOf<CheckoutController.Result.Canceled>()
119+
assertThat(callbackCalls.awaitItem().result).isInstanceOf<CheckoutController.Result.Canceled>()
73120
}
74121

75122
@Test
@@ -104,15 +151,17 @@ internal class CheckoutConfirmationResultHandlerTest {
104151
)
105152
)
106153

107-
assertThat(callbackCalls.awaitItem()).isInstanceOf<CheckoutController.Result.Canceled>()
154+
assertThat(callbackCalls.awaitItem().result).isInstanceOf<CheckoutController.Result.Canceled>()
108155
}
109156

110157
private fun runScenario(
111158
initialResult: ConfirmationHandler.Result? = null,
112159
hasReloadedFromProcessDeath: Boolean = false,
113160
block: suspend Scenario.() -> Unit,
114161
) = runTest {
115-
val callbackCalls = Turbine<CheckoutController.Result>()
162+
val callbackCalls = Turbine<CallbackCall>()
163+
var succeededCallCount = 0
164+
var succeededResponse: CheckoutSessionResponse? = null
116165
val initialConfirmationState: ConfirmationHandler.State =
117166
initialResult?.let(ConfirmationHandler.State::Complete)
118167
?: ConfirmationHandler.State.Idle
@@ -122,10 +171,21 @@ internal class CheckoutConfirmationResultHandlerTest {
122171
)
123172
val handler = CheckoutConfirmationResultHandler(
124173
confirmationHandler = confirmationHandler,
125-
resultCallback = CheckoutController.ResultCallback(callbackCalls::add),
174+
resultCallback = CheckoutController.ResultCallback { result ->
175+
callbackCalls.add(
176+
CallbackCall(
177+
result = result,
178+
succeededCallCount = succeededCallCount,
179+
succeededResponse = succeededResponse,
180+
)
181+
)
182+
},
126183
viewModelScope = backgroundScope,
127184
)
128-
handler.register()
185+
handler.register { response ->
186+
succeededCallCount += 1
187+
succeededResponse = response
188+
}
129189
testScheduler.runCurrent()
130190

131191
Scenario(
@@ -138,13 +198,29 @@ internal class CheckoutConfirmationResultHandlerTest {
138198
callbackCalls.ensureAllEventsConsumed()
139199
}
140200

141-
private fun succeeded(): ConfirmationHandler.Result.Succeeded {
142-
return ConfirmationHandler.Result.Succeeded(PaymentIntentFixtures.PI_SUCCEEDED)
201+
private fun response(
202+
status: CheckoutSessionResponse.Status,
203+
): CheckoutSessionResponse {
204+
return CheckoutSessionResponseFactory.create(
205+
id = "cs_confirmed",
206+
status = status,
207+
)
208+
}
209+
210+
private fun succeeded(
211+
response: CheckoutSessionResponse,
212+
): ConfirmationHandler.Result.Succeeded {
213+
return ConfirmationHandler.Result.Succeeded(
214+
intent = PaymentIntentFixtures.PI_SUCCEEDED,
215+
metadata = MutableConfirmationMetadata().apply {
216+
set(CheckoutSessionResponseKey, response)
217+
},
218+
)
143219
}
144220

145221
private class Scenario(
146222
val confirmationHandler: FakeConfirmationHandler,
147-
val callbackCalls: Turbine<CheckoutController.Result>,
223+
val callbackCalls: Turbine<CallbackCall>,
148224
val testScheduler: TestCoroutineScheduler,
149225
) {
150226
fun emit(result: ConfirmationHandler.Result) {
@@ -159,4 +235,9 @@ internal class CheckoutConfirmationResultHandlerTest {
159235
}
160236
}
161237

238+
private data class CallbackCall(
239+
val result: CheckoutController.Result,
240+
val succeededCallCount: Int,
241+
val succeededResponse: CheckoutSessionResponse?,
242+
)
162243
}

0 commit comments

Comments
 (0)