Skip to content

Commit 7be138b

Browse files
cttsai-stripecodex
andcommitted
Guard checkout session card update capabilities
Committed-By-Agent: codex Co-authored-by: codex <noreply@openai.com> Committed-By-Agent: codex Co-authored-by: codex <noreply@openai.com>
1 parent 6bda4e1 commit 7be138b

3 files changed

Lines changed: 147 additions & 9 deletions

File tree

paymentsheet/src/main/java/com/stripe/android/paymentsheet/repositories/CheckoutSessionRepository.kt

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,21 +92,46 @@ internal class CheckoutSessionRepository @Inject constructor(
9292
sessionId: String,
9393
paymentMethodId: String,
9494
params: PaymentMethodUpdateParams,
95-
): Result<CheckoutSessionResponse> = executePost(
96-
url = updateUrl(sessionId),
97-
params = buildMap {
95+
): Result<CheckoutSessionResponse> {
96+
val updateParams = params.toParamMap()
97+
if (updateParams.hasUnsupportedCheckoutSessionUpdateParams()) {
98+
return Result.failure(
99+
IllegalArgumentException(
100+
"Checkout sessions support updating card expiry and billing details only."
101+
)
102+
)
103+
}
104+
105+
val checkoutSessionUpdateParams = buildMap {
98106
put("payment_method_to_update[payment_method_id]", paymentMethodId)
99107

100-
val updateParams = params.toParamMap()
101108
putNestedParams(
102109
prefix = "payment_method_to_update[billing_details]",
103110
params = updateParams["billing_details"] as? Map<*, *>,
104111
)
105112
putExpiryDetails(updateParams["card"] as? Map<*, *>)
106113

107114
put("elements_session_client[is_aggregation_expected]", "true")
108-
},
109-
)
115+
}
116+
117+
val hasSupportedUpdateParams = checkoutSessionUpdateParams.keys.any {
118+
it.startsWith("payment_method_to_update[billing_details]") ||
119+
it.startsWith("payment_method_to_update[expiry_details]")
120+
}
121+
122+
return if (hasSupportedUpdateParams) {
123+
executePost(
124+
url = updateUrl(sessionId),
125+
params = checkoutSessionUpdateParams,
126+
)
127+
} else {
128+
Result.failure(
129+
IllegalArgumentException(
130+
"Checkout sessions support updating card expiry and billing details only."
131+
)
132+
)
133+
}
134+
}
110135

111136
suspend fun applyPromotionCode(
112137
sessionId: String,
@@ -226,3 +251,11 @@ private fun MutableMap<String, Any>.putExpiryDetails(cardParams: Map<*, *>?) {
226251
put("payment_method_to_update[expiry_details][exp_year]", expiryYear)
227252
}
228253
}
254+
255+
private fun Map<String, Any>.hasUnsupportedCheckoutSessionUpdateParams(): Boolean {
256+
val unsupportedTopLevelParams = keys - setOf("billing_details", "card")
257+
val cardParams = this["card"] as? Map<*, *>
258+
val unsupportedCardParams = cardParams?.keys.orEmpty() - setOf("exp_month", "exp_year")
259+
260+
return unsupportedTopLevelParams.isNotEmpty() || unsupportedCardParams.isNotEmpty()
261+
}

paymentsheet/src/main/java/com/stripe/android/paymentsheet/repositories/SavedPaymentMethodRepository.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,9 @@ internal class DefaultSavedPaymentMethodRepository @Inject constructor(
7878
sessionId = customerMetadata.sessionId,
7979
paymentMethodId = paymentMethodId,
8080
params = params,
81-
).map { response ->
81+
).mapCatching { response ->
8282
response.customer?.paymentMethods?.firstOrNull { it.id == paymentMethodId }
83-
?: PaymentMethod.Builder().setId(paymentMethodId).build()
83+
?: error("Checkout session update response did not include updated payment method.")
8484
}
8585
}
8686
is CustomerMetadata.CustomerSession -> {

paymentsheet/src/test/java/com/stripe/android/paymentsheet/repositories/DefaultSavedPaymentMethodRepositoryTest.kt

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ class DefaultSavedPaymentMethodRepositoryTest {
109109
bodyPart("payment_method_to_update[billing_details][name]", "Jane Doe"),
110110
bodyPart("payment_method_to_update[billing_details][address][postal_code]", "94111"),
111111
) { response ->
112-
response.testBodyFromFile("checkout-session-init.json")
112+
response.setBody(checkoutSessionUpdateResponse())
113113
}
114114

115115
val result = repository.updatePaymentMethod(
@@ -127,6 +127,70 @@ class DefaultSavedPaymentMethodRepositoryTest {
127127

128128
assertThat(result.isSuccess).isTrue()
129129
assertThat(result.getOrThrow().id).isEqualTo("pm_123")
130+
assertThat(result.getOrThrow().billingDetails?.name).isEqualTo("Jane Doe")
131+
assertThat(result.getOrThrow().card?.expiryYear).isEqualTo(2030)
132+
}
133+
134+
@Test
135+
fun `update fails for checkout session when response omits updated payment method`() = runScenario(
136+
customerMetadata = CHECKOUT_SESSION_METADATA,
137+
) {
138+
networkRule.enqueue(
139+
method("POST"),
140+
path("/v1/payment_pages/cs_123"),
141+
bodyPart("payment_method_to_update[payment_method_id]", "pm_123"),
142+
) { response ->
143+
response.testBodyFromFile("checkout-session-init.json")
144+
}
145+
146+
val result = repository.updatePaymentMethod(
147+
customerMetadata = customerMetadata,
148+
paymentMethodId = "pm_123",
149+
params = PaymentMethodUpdateParams.createCard(
150+
expiryMonth = 12,
151+
expiryYear = 2030,
152+
),
153+
)
154+
155+
assertThat(result.isFailure).isTrue()
156+
assertThat(result.exceptionOrNull()).hasMessageThat()
157+
.contains("did not include updated payment method")
158+
}
159+
160+
@Test
161+
fun `update fails for checkout session when params only update card brand`() = runScenario(
162+
customerMetadata = CHECKOUT_SESSION_METADATA,
163+
) {
164+
val result = repository.updatePaymentMethod(
165+
customerMetadata = customerMetadata,
166+
paymentMethodId = "pm_123",
167+
params = PaymentMethodUpdateParams.createCard(
168+
networks = PaymentMethodUpdateParams.Card.Networks(preferred = "visa"),
169+
),
170+
)
171+
172+
assertThat(result.isFailure).isTrue()
173+
assertThat(result.exceptionOrNull()).hasMessageThat()
174+
.contains("card expiry and billing details only")
175+
}
176+
177+
@Test
178+
fun `update fails for checkout session when params include card brand`() = runScenario(
179+
customerMetadata = CHECKOUT_SESSION_METADATA,
180+
) {
181+
val result = repository.updatePaymentMethod(
182+
customerMetadata = customerMetadata,
183+
paymentMethodId = "pm_123",
184+
params = PaymentMethodUpdateParams.createCard(
185+
expiryMonth = 12,
186+
expiryYear = 2030,
187+
networks = PaymentMethodUpdateParams.Card.Networks(preferred = "visa"),
188+
),
189+
)
190+
191+
assertThat(result.isFailure).isTrue()
192+
assertThat(result.exceptionOrNull()).hasMessageThat()
193+
.contains("card expiry and billing details only")
130194
}
131195

132196
@Test
@@ -328,5 +392,46 @@ class DefaultSavedPaymentMethodRepositoryTest {
328392
canUpdateCardPaymentMethodDetails = false,
329393
canUpdateCardBrandChoice = true,
330394
)
395+
396+
private fun checkoutSessionUpdateResponse(): String {
397+
return """
398+
{
399+
"session_id": "cs_123",
400+
"ui_mode": "custom",
401+
"currency": "usd",
402+
"total_summary": {
403+
"due": 5099
404+
},
405+
"customer": {
406+
"id": "cus_123",
407+
"can_detach_payment_method": true,
408+
"payment_methods": [
409+
{
410+
"id": "pm_123",
411+
"object": "payment_method",
412+
"billing_details": {
413+
"address": {
414+
"postal_code": "94111"
415+
},
416+
"email": null,
417+
"name": "Jane Doe",
418+
"phone": null
419+
},
420+
"card": {
421+
"brand": "visa",
422+
"exp_month": 12,
423+
"exp_year": 2030,
424+
"last4": "4242"
425+
},
426+
"created": 1712554485,
427+
"customer": "cus_123",
428+
"livemode": false,
429+
"type": "card"
430+
}
431+
]
432+
}
433+
}
434+
""".trimIndent()
435+
}
331436
}
332437
}

0 commit comments

Comments
 (0)