Skip to content

Commit

Permalink
Add CustomerSessionSavedSelectionDataSource and backing `CustomerSe…
Browse files Browse the repository at this point in the history
…ssionElementsSessionManager`
  • Loading branch information
samer-stripe committed Sep 27, 2024
1 parent 9253071 commit 1c675ff
Show file tree
Hide file tree
Showing 10 changed files with 662 additions and 12 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.stripe.android.customersheet.data

import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes

internal data class CachedCustomerEphemeralKey(
val customerId: String,
val ephemeralKey: String,
private val expiresAt: Int,
) {
fun shouldRefresh(currentTimeInMillis: Long): Boolean {
val remainingTime = expiresAt - currentTimeInMillis.milliseconds.inWholeSeconds

return remainingTime <= 5.minutes.inWholeSeconds
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package com.stripe.android.customersheet.data

import com.stripe.android.core.injection.IOContext
import com.stripe.android.customersheet.CustomerSheet
import com.stripe.android.customersheet.ExperimentalCustomerSheetApi
import com.stripe.android.model.ElementsSession
import com.stripe.android.paymentsheet.ExperimentalCustomerSessionApi
import com.stripe.android.paymentsheet.PaymentSheet
import com.stripe.android.paymentsheet.PrefsRepository
import com.stripe.android.paymentsheet.model.SavedSelection
import com.stripe.android.paymentsheet.repositories.ElementsSessionRepository
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.CoroutineContext

internal interface CustomerSessionElementsSessionManager {
suspend fun fetchCustomerSessionEphemeralKey(): Result<CachedCustomerEphemeralKey>

suspend fun fetchElementsSession(): Result<ElementsSession>
}

@OptIn(ExperimentalCustomerSheetApi::class, ExperimentalCustomerSessionApi::class)
@Singleton
internal class DefaultCustomerSessionElementsSessionManager @Inject constructor(
private val elementsSessionRepository: ElementsSessionRepository,
private val prefsRepositoryFactory: @JvmSuppressWildcards (String) -> PrefsRepository,
private val customerSessionProvider: CustomerSheet.CustomerSessionProvider,
private val timeProvider: () -> Long,
@IOContext private val workContext: CoroutineContext,
) : CustomerSessionElementsSessionManager {
@Volatile
private var cachedCustomerEphemeralKey: CachedCustomerEphemeralKey? = null

private var intentConfiguration: CustomerSheet.IntentConfiguration? = null

override suspend fun fetchCustomerSessionEphemeralKey(): Result<CachedCustomerEphemeralKey> {
return withContext(workContext) {
cachedCustomerEphemeralKey.takeUnless { cachedCustomerEphemeralKey ->
cachedCustomerEphemeralKey == null || cachedCustomerEphemeralKey.shouldRefresh(
timeProvider()
)
}?.let {
Result.success(it)
} ?: run {
fetchElementsSession().mapCatching {
cachedCustomerEphemeralKey
?: throw IllegalStateException("Should have been initialized from `elements/session`!")
}
}
}
}

override suspend fun fetchElementsSession(): Result<ElementsSession> {
return withContext(workContext) {
runCatching {
val intentConfiguration = intentConfiguration
?: customerSessionProvider.intentConfiguration()
.onSuccess { intentConfiguration = it }
.getOrThrow()

val customerSessionClientSecret = customerSessionProvider
.providesCustomerSessionClientSecret()
.getOrThrow()

val prefsRepository = prefsRepositoryFactory(customerSessionClientSecret.customerId)

val savedSelection = prefsRepository.getSavedSelection(
isGooglePayAvailable = false,
isLinkAvailable = false,
) as? SavedSelection.PaymentMethod

elementsSessionRepository.get(
initializationMode = PaymentSheet.InitializationMode.DeferredIntent(
intentConfiguration = PaymentSheet.IntentConfiguration(
mode = PaymentSheet.IntentConfiguration.Mode.Setup(),
paymentMethodTypes = intentConfiguration.paymentMethodTypes,
)
),
defaultPaymentMethodId = savedSelection?.id,
customer = PaymentSheet.CustomerConfiguration.createWithCustomerSession(
id = customerSessionClientSecret.customerId,
clientSecret = customerSessionClientSecret.clientSecret,
),
externalPaymentMethods = listOf(),
).onSuccess { elementsSession ->
elementsSession.customer?.session?.run {
cachedCustomerEphemeralKey = CachedCustomerEphemeralKey(
customerId = customerId,
ephemeralKey = apiKey,
expiresAt = apiKeyExpiry,
)
}
}.getOrThrow()
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,49 @@
package com.stripe.android.customersheet.data

import com.stripe.android.core.injection.IOContext
import com.stripe.android.paymentsheet.PrefsRepository
import com.stripe.android.paymentsheet.model.SavedSelection
import kotlinx.coroutines.withContext
import java.io.IOException
import javax.inject.Inject
import kotlin.coroutines.CoroutineContext

internal class CustomerSessionSavedSelectionDataSource @Inject constructor() : CustomerSheetSavedSelectionDataSource {
internal class CustomerSessionSavedSelectionDataSource @Inject constructor(
private val elementsSessionManager: CustomerSessionElementsSessionManager,
private val prefsRepositoryFactory: @JvmSuppressWildcards (String) -> PrefsRepository,
@IOContext private val workContext: CoroutineContext,
) : CustomerSheetSavedSelectionDataSource {
override suspend fun retrieveSavedSelection(): CustomerSheetDataResult<SavedSelection?> {
throw NotImplementedError("Not implemented yet!")
return withContext(workContext) {
createPrefsRepository().mapCatching { prefsRepository ->
prefsRepository.getSavedSelection(
/*
* We don't calculate on `Google Pay` availability in this function. Instead, we check
* within `CustomerSheet` similar to how we check if a saved payment option is still exists
* within the user's payment methods from `retrievePaymentMethods`
*/
isGooglePayAvailable = true,
isLinkAvailable = false,
)
}
}
}

override suspend fun setSavedSelection(selection: SavedSelection?): CustomerSheetDataResult<Unit> {
throw NotImplementedError("Not implemented yet!")
return withContext(workContext) {
createPrefsRepository().mapCatching { prefsRepository ->
val result = prefsRepository.setSavedSelection(selection)

if (!result) {
throw IOException("Unable to persist payment option $selection")
}
}
}
}

private suspend fun createPrefsRepository(): CustomerSheetDataResult<PrefsRepository> {
return elementsSessionManager.fetchCustomerSessionEphemeralKey().mapCatching { ephemeralKey ->
prefsRepositoryFactory(ephemeralKey.customerId)
}.toCustomerSheetDataResult()
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.stripe.android.customersheet.data.injection

import android.content.Context
import com.stripe.android.core.injection.IOContext
import com.stripe.android.customersheet.data.CustomerSessionElementsSessionManager
import com.stripe.android.customersheet.data.CustomerSessionInitializationDataSource
import com.stripe.android.customersheet.data.CustomerSessionIntentDataSource
import com.stripe.android.customersheet.data.CustomerSessionPaymentMethodDataSource
Expand All @@ -8,8 +11,13 @@ import com.stripe.android.customersheet.data.CustomerSheetInitializationDataSour
import com.stripe.android.customersheet.data.CustomerSheetIntentDataSource
import com.stripe.android.customersheet.data.CustomerSheetPaymentMethodDataSource
import com.stripe.android.customersheet.data.CustomerSheetSavedSelectionDataSource
import com.stripe.android.customersheet.data.DefaultCustomerSessionElementsSessionManager
import com.stripe.android.paymentsheet.DefaultPrefsRepository
import com.stripe.android.paymentsheet.PrefsRepository
import dagger.Binds
import dagger.Module
import dagger.Provides
import kotlin.coroutines.CoroutineContext

@Module
internal interface CustomerSessionDataSourceModule {
Expand All @@ -32,4 +40,23 @@ internal interface CustomerSessionDataSourceModule {
fun bindsCustomerSheetInitializationDataSource(
impl: CustomerSessionInitializationDataSource
): CustomerSheetInitializationDataSource

@Binds
fun bindsCustomerSessionElementsSessionManager(
impl: DefaultCustomerSessionElementsSessionManager
): CustomerSessionElementsSessionManager

companion object {
@Provides
fun providePrefsRepositoryFactory(
appContext: Context,
@IOContext workContext: CoroutineContext
): (String) -> PrefsRepository = { customerId ->
DefaultPrefsRepository(
appContext,
customerId,
workContext
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import com.stripe.android.payments.core.injection.PRODUCT_USAGE
import dagger.Binds
import dagger.Module
import dagger.Provides
import java.util.Calendar
import javax.inject.Named
import javax.inject.Provider

Expand Down Expand Up @@ -68,5 +69,10 @@ internal interface CustomerSheetDataCommonModule {
publishableKeyProvider = { paymentConfiguration.get().publishableKey },
networkTypeProvider = NetworkTypeDetector(context)::invoke,
)

@Provides
fun provideTimeProvider(): () -> Long = {
Calendar.getInstance().timeInMillis
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import dagger.BindsInstance
import dagger.Component
import dagger.Module
import dagger.Provides
import java.util.Calendar
import javax.inject.Singleton
import kotlin.coroutines.CoroutineContext

Expand Down Expand Up @@ -68,11 +67,6 @@ internal interface StripeCustomerAdapterModule {
fun bindsCustomerRepository(repository: CustomerApiRepository): CustomerRepository

companion object {
@Provides
fun provideTimeProvider(): () -> Long = {
Calendar.getInstance().timeInMillis
}

@Provides
fun providePrefsRepositoryFactory(
appContext: Context,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package com.stripe.android.customersheet.data

import com.google.common.truth.Truth.assertThat
import com.stripe.android.isInstanceOf
import com.stripe.android.paymentsheet.FakePrefsRepository
import com.stripe.android.paymentsheet.PrefsRepository
import com.stripe.android.paymentsheet.model.SavedSelection
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.coroutines.coroutineContext

class CustomerSessionSavedSelectionDataSourceTest {
@Test
fun `on fetch saved selection, should get selection from prefs repository`() = runTest {
val prefsRepository = FakePrefsRepository().apply {
setSavedSelection(SavedSelection.GooglePay)
}

val dataSource = createDataSource(
prefsRepository = prefsRepository
)

val result = dataSource.retrieveSavedSelection()

assertThat(result).isInstanceOf<CustomerSheetDataResult.Success<SavedSelection?>>()

val successResult = result.asSuccess()

assertThat(successResult.value).isEqualTo(SavedSelection.GooglePay)
}

@Test
fun `on failed to fetch ephemeral key, should fail to get selection from prefs repository`() = runTest {
val exception = IllegalStateException("Failed to load!")

val elementsSessionManager = FakeCustomerSessionElementsSessionManager(
ephemeralKey = Result.failure(exception)
)
val dataSource = createDataSource(
elementsSessionManager = elementsSessionManager,
)

val result = dataSource.retrieveSavedSelection()

assertThat(result).isInstanceOf<CustomerSheetDataResult.Failure<SavedSelection?>>()

val failedResult = result.asFailure()

assertThat(failedResult.cause).isEqualTo(exception)
}

@Test
fun `on set saved selection, should set selection in prefs repository`() = runTest {
val prefsRepository = FakePrefsRepository()

val dataSource = createDataSource(
prefsRepository = prefsRepository
)

val result = dataSource.setSavedSelection(SavedSelection.PaymentMethod(id = "pm_1"))

assertThat(result).isInstanceOf<CustomerSheetDataResult.Success<Unit>>()

val savedSelection = prefsRepository.getSavedSelection(
isGooglePayAvailable = false,
isLinkAvailable = false
)

assertThat(savedSelection).isEqualTo(SavedSelection.PaymentMethod(id = "pm_1"))
}

@Test
fun `on failed to fetch ephemeral key, should fail to set selection in prefs repository`() = runTest {
val exception = IllegalStateException("Failed to load!")

val elementsSessionManager = FakeCustomerSessionElementsSessionManager(
ephemeralKey = Result.failure(exception)
)
val dataSource = createDataSource(
elementsSessionManager = elementsSessionManager,
)

val result = dataSource.setSavedSelection(SavedSelection.PaymentMethod(id = "pm_1"))

assertThat(result).isInstanceOf<CustomerSheetDataResult.Failure<Unit>>()

val failedResult = result.asFailure()

assertThat(failedResult.cause).isEqualTo(exception)
}

private suspend fun createDataSource(
elementsSessionManager: CustomerSessionElementsSessionManager = FakeCustomerSessionElementsSessionManager(),
prefsRepository: PrefsRepository = FakePrefsRepository(),
): CustomerSheetSavedSelectionDataSource {
return CustomerSessionSavedSelectionDataSource(
elementsSessionManager = elementsSessionManager,
prefsRepositoryFactory = {
prefsRepository
},
workContext = coroutineContext
)
}

private fun <T> CustomerSheetDataResult<T>.asSuccess(): CustomerSheetDataResult.Success<T> {
return this as CustomerSheetDataResult.Success<T>
}

private fun <T> CustomerSheetDataResult<T>.asFailure(): CustomerSheetDataResult.Failure<T> {
return this as CustomerSheetDataResult.Failure<T>
}
}
Loading

0 comments on commit 1c675ff

Please sign in to comment.