Skip to content

Commit

Permalink
Add detachPaymentMethodAndDuplicates extension function. (#8467)
Browse files Browse the repository at this point in the history
* Add `detachPaymentMethodAndDuplicates` extension function.

* Update with PR comments.
  • Loading branch information
samer-stripe committed May 15, 2024
1 parent 20254c3 commit 6d5c460
Show file tree
Hide file tree
Showing 3 changed files with 346 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.stripe.android.paymentsheet.repositories

import com.stripe.android.model.PaymentMethod

/**
* Removes the provided saved payment method alongside any duplicate stored payment methods. This function should be
* deleted once an endpoint is stood up to handle detaching and removing duplicates.
*
* @param customerInfo authentication information that can perform detaching operations.
* @param paymentMethodId the id of the payment method to remove and to compare with for stored duplicates
*
* @return a list of removal results that identify the payment methods that were successfully removed and the payment
* methods that failed to be removed.
*/
internal suspend fun CustomerRepository.detachCardPaymentMethodAndDuplicates(
customerInfo: CustomerRepository.CustomerInfo,
paymentMethodId: String,
): List<PaymentMethodRemovalResult> {
val paymentMethods = getPaymentMethods(
customerInfo = customerInfo,
// We only support removing duplicate cards.
types = listOf(PaymentMethod.Type.Card),
silentlyFail = false,
).getOrElse {
return listOf(
PaymentMethodRemovalResult(
paymentMethodId = paymentMethodId,
result = Result.failure(it)
)
)
}

val requestedPaymentMethodToRemove = paymentMethods.find { paymentMethod ->
paymentMethod.id == paymentMethodId
} ?: throw IllegalArgumentException("Payment method with id '$paymentMethodId' does not exist!")

val paymentMethodRemovalResults = mutableListOf<PaymentMethodRemovalResult>()

val paymentMethodsToRemove = paymentMethods.filter { paymentMethod ->
paymentMethod.type == PaymentMethod.Type.Card &&
paymentMethod.card?.fingerprint == requestedPaymentMethodToRemove.card?.fingerprint
}

paymentMethodsToRemove.forEachIndexed { index, paymentMethod ->
val paymentMethodIdToRemove = paymentMethod.id

val result = paymentMethodIdToRemove?.let { id ->
detachPaymentMethod(
customerInfo = customerInfo,
paymentMethodId = id
)
} ?: Result.failure(
NoPaymentMethodIdOnRemovalException(
index = index,
paymentMethod = paymentMethod
)
)

paymentMethodRemovalResults.add(
PaymentMethodRemovalResult(
paymentMethodId = paymentMethodIdToRemove,
result = result,
)
)
}

return paymentMethodRemovalResults
}

internal class NoPaymentMethodIdOnRemovalException(
val index: Int,
val paymentMethod: PaymentMethod,
) : Exception() {
override val message: String =
"A payment method at index '$index' with type '${paymentMethod.type}' does not have an ID!"
}

internal data class PaymentMethodRemovalResult(
val paymentMethodId: String?,
val result: Result<PaymentMethod>,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
package com.stripe.android.paymentsheet.repositories

import com.google.common.truth.Truth.assertThat
import com.stripe.android.testing.PaymentMethodFactory
import com.stripe.android.utils.FakeCustomerRepository
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.assertFails

class CustomerRepositoryKtxTest {
@Test
fun `'detachPaymentMethodAndDuplicates' attempts to remove all duplicate cards with matching fingerprints`() =
runTest {
val fingerprint = "4343434343"
val differentFingerprint = "5454545454"

val cards = PaymentMethodFactory.cards(size = 5)
val cardsWithFingerprints = cards.mapIndexed { index, paymentMethod ->
if (index < 3) {
paymentMethod.copy(
card = paymentMethod.card?.copy(
fingerprint = fingerprint
)
)
} else {
paymentMethod.copy(
card = paymentMethod.card?.copy(
fingerprint = differentFingerprint
)
)
}
}

val repository = FakeCustomerRepository(
paymentMethods = cardsWithFingerprints,
onDetachPaymentMethod = {
Result.success(cards.first())
}
)

repository.detachCardPaymentMethodAndDuplicates(
customerInfo = CustomerRepository.CustomerInfo(
id = "cus_1",
ephemeralKeySecret = "ephemeral_key_secret",
),
paymentMethodId = cards[0].id!!
)

assertThat(repository.detachRequests[0].paymentMethodId)
.isEqualTo(cards[0].id!!)

assertThat(repository.detachRequests[1].paymentMethodId)
.isEqualTo(cards[1].id!!)

assertThat(repository.detachRequests[2].paymentMethodId)
.isEqualTo(cards[2].id!!)
}

@Test
fun `'detachPaymentMethodAndDuplicates' returns both successful and failed removals`() =
runTest {
val cards = PaymentMethodFactory.cards(size = 5).map { paymentMethod ->
paymentMethod.copy(
card = paymentMethod.card?.copy(
fingerprint = "4343434343"
)
)
}

val repository = FakeCustomerRepository(
paymentMethods = cards,
onDetachPaymentMethod = { paymentMethodId ->
if (paymentMethodId == cards[3].id || paymentMethodId == cards[4].id) {
Result.failure(TestException("Failed!"))
} else {
val card = cards.find { paymentMethod ->
paymentMethodId == paymentMethod.id
}!!

Result.success(card)
}
}
)

val results = repository.detachCardPaymentMethodAndDuplicates(
customerInfo = CustomerRepository.CustomerInfo(
id = "cus_1",
ephemeralKeySecret = "ephemeral_key_secret",
),
paymentMethodId = cards.first().id!!
)

assertThat(results).containsExactly(
PaymentMethodRemovalResult(
paymentMethodId = cards[0].id!!,
result = Result.success(cards[0])
),
PaymentMethodRemovalResult(
paymentMethodId = cards[1].id!!,
result = Result.success(cards[1])
),
PaymentMethodRemovalResult(
paymentMethodId = cards[2].id!!,
result = Result.success(cards[2])
),
PaymentMethodRemovalResult(
paymentMethodId = cards[3].id!!,
result = Result.failure(TestException("Failed!"))
),
PaymentMethodRemovalResult(
paymentMethodId = cards[4].id!!,
result = Result.failure(TestException("Failed!"))
)
)
}

@Test
fun `'detachCardPaymentMethodAndDuplicates' returns payment method object received from removal operation`() =
runTest {
val cards = PaymentMethodFactory.cards(size = 2).map { paymentMethod ->
paymentMethod.copy(
customerId = "cus_111",
card = paymentMethod.card?.copy(fingerprint = "4343434343"),
)
}

val repository = FakeCustomerRepository(
paymentMethods = cards,
onDetachPaymentMethod = { paymentMethodId ->
Result.success(
cards.find { paymentMethod ->
paymentMethod.id == paymentMethodId
}!!.copy(
customerId = null
)
)
}
)

val results = repository.detachCardPaymentMethodAndDuplicates(
customerInfo = CustomerRepository.CustomerInfo(
id = "cus_1",
ephemeralKeySecret = "ephemeral_key_secret",
),
paymentMethodId = cards.first().id!!
)

assertThat(results).containsExactly(
PaymentMethodRemovalResult(
paymentMethodId = cards.first().id!!,
result = Result.success(cards.first().copy(customerId = null))
),
PaymentMethodRemovalResult(
paymentMethodId = cards.last().id!!,
result = Result.success(cards.last().copy(customerId = null))
)
)
}

@Test
fun `'detachPaymentMethodAndDuplicates' returns single removal failure if fails to fetch payment methods`() =
runTest {
val cards = PaymentMethodFactory.cards(size = 2)
val repository = FakeCustomerRepository(
paymentMethods = cards,
onGetPaymentMethods = {
Result.failure(TestException("Failed!"))
},
)

val results = repository.detachCardPaymentMethodAndDuplicates(
customerInfo = CustomerRepository.CustomerInfo(
id = "cus_1",
ephemeralKeySecret = "ephemeral_key_secret",
),
paymentMethodId = cards.first().id!!
)

assertThat(results).containsExactly(
PaymentMethodRemovalResult(
paymentMethodId = cards.first().id!!,
result = Result.failure(TestException("Failed!"))
),
)
}

@Test
fun `'detachPaymentMethodAndDuplicates' returns failure if payment method does not exist`() =
runTest {
val repository = FakeCustomerRepository(
paymentMethods = listOf(),
)

val exception = assertFails {
repository.detachCardPaymentMethodAndDuplicates(
customerInfo = CustomerRepository.CustomerInfo(
id = "cus_1",
ephemeralKeySecret = "ephemeral_key_secret",
),
paymentMethodId = "pm_1"
)
}

assertThat(exception).isInstanceOf(IllegalArgumentException::class.java)
assertThat(exception.message).isEqualTo("Payment method with id 'pm_1' does not exist!")
}

@Test
fun `'detachPaymentMethodAndDuplicates' returns exception in result if a payment method has no id`() =
runTest {
val cards = PaymentMethodFactory.cards(size = 2).mapIndexed { index, paymentMethod ->
if (index == 1) {
paymentMethod.copy(id = null)
} else {
paymentMethod
}
}

val repository = FakeCustomerRepository(
paymentMethods = cards,
onDetachPaymentMethod = {
Result.success(cards.first())
}
)

val results = repository.detachCardPaymentMethodAndDuplicates(
customerInfo = CustomerRepository.CustomerInfo(
id = "cus_1",
ephemeralKeySecret = "ephemeral_key_secret",
),
paymentMethodId = cards.first().id!!
)

assertThat(results[0]).isEqualTo(
PaymentMethodRemovalResult(
paymentMethodId = cards[0].id!!,
result = Result.success(cards[0])
)
)

assertThat(results[1].result.exceptionOrNull())
.isInstanceOf(NoPaymentMethodIdOnRemovalException::class.java)
}

private data class TestException(override val message: String?) : Exception()
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ internal open class FakeCustomerRepository(
private val onGetPaymentMethods: () -> Result<List<PaymentMethod>> = {
Result.success(paymentMethods)
},
private val onDetachPaymentMethod: () -> Result<PaymentMethod> = {
private val onDetachPaymentMethod: (paymentMethodId: String) -> Result<PaymentMethod> = {
Result.failure(NotImplementedError())
},
private val onAttachPaymentMethod: () -> Result<PaymentMethod> = {
Expand All @@ -24,6 +24,9 @@ internal open class FakeCustomerRepository(
Result.failure(NotImplementedError())
}
) : CustomerRepository {
private val _detachRequests = mutableListOf<DetachRequest>()
val detachRequests: List<DetachRequest> = _detachRequests

var error: Throwable? = null

override suspend fun retrieveCustomer(
Expand All @@ -39,7 +42,16 @@ internal open class FakeCustomerRepository(
override suspend fun detachPaymentMethod(
customerInfo: CustomerRepository.CustomerInfo,
paymentMethodId: String
): Result<PaymentMethod> = onDetachPaymentMethod()
): Result<PaymentMethod> {
_detachRequests.add(
DetachRequest(
paymentMethodId = paymentMethodId,
customerInfo = customerInfo
)
)

return onDetachPaymentMethod(paymentMethodId)
}

override suspend fun attachPaymentMethod(
customerInfo: CustomerRepository.CustomerInfo,
Expand All @@ -51,4 +63,9 @@ internal open class FakeCustomerRepository(
paymentMethodId: String,
params: PaymentMethodUpdateParams
): Result<PaymentMethod> = onUpdatePaymentMethod()

data class DetachRequest(
val paymentMethodId: String,
val customerInfo: CustomerRepository.CustomerInfo,
)
}

0 comments on commit 6d5c460

Please sign in to comment.