diff --git a/sdk/src/main/kotlin/com/processout/sdk/api/model/event/PODynamicCheckoutEvent.kt b/sdk/src/main/kotlin/com/processout/sdk/api/model/event/PODynamicCheckoutEvent.kt new file mode 100644 index 00000000..ed82ad50 --- /dev/null +++ b/sdk/src/main/kotlin/com/processout/sdk/api/model/event/PODynamicCheckoutEvent.kt @@ -0,0 +1,56 @@ +package com.processout.sdk.api.model.event + +import com.processout.sdk.api.model.response.PODynamicCheckoutPaymentMethod +import com.processout.sdk.core.ProcessOutResult + +/** + * Defines dynamic checkout lifecycle events. + */ +sealed class PODynamicCheckoutEvent { + + /** + * Initial event that is sent prior any other event. + */ + data object WillStart : PODynamicCheckoutEvent() + + /** + * Event indicates that initialization is complete. + * Currently waiting for user input. + */ + data object DidStart : PODynamicCheckoutEvent() + + /** + * Event is sent when user asked to confirm cancellation, e.g. via dialog. + */ + data object DidRequestCancelConfirmation : PODynamicCheckoutEvent() + + /** + * Event is sent when payment method is selected by the user. + */ + data class DidSelectPaymentMethod( + val paymentMethod: PODynamicCheckoutPaymentMethod + ) : PODynamicCheckoutEvent() + + /** + * Event is sent when certain payment method has failed with retryable error. + * User can provide different payment details or try another payment method. + */ + data class DidFailPayment( + val failure: ProcessOutResult.Failure, + val paymentMethod: PODynamicCheckoutPaymentMethod + ) : PODynamicCheckoutEvent() + + /** + * Event is sent after payment was confirmed to be captured. This is a final event. + */ + data class DidCompletePayment( + val paymentMethod: PODynamicCheckoutPaymentMethod + ) : PODynamicCheckoutEvent() + + /** + * Event is sent when unretryable error occurs. This is a final event. + */ + data class DidFail( + val failure: ProcessOutResult.Failure + ) : PODynamicCheckoutEvent() +} diff --git a/sdk/src/main/kotlin/com/processout/sdk/api/model/request/POCardTokenizationPreferredSchemeRequest.kt b/sdk/src/main/kotlin/com/processout/sdk/api/model/request/POCardTokenizationPreferredSchemeRequest.kt index 002036f9..2d989dba 100644 --- a/sdk/src/main/kotlin/com/processout/sdk/api/model/request/POCardTokenizationPreferredSchemeRequest.kt +++ b/sdk/src/main/kotlin/com/processout/sdk/api/model/request/POCardTokenizationPreferredSchemeRequest.kt @@ -1,5 +1,6 @@ package com.processout.sdk.api.model.request +import com.processout.sdk.api.dispatcher.POEventDispatcher import com.processout.sdk.api.model.response.POCardIssuerInformation import com.processout.sdk.core.annotation.ProcessOutInternalApi import java.util.UUID @@ -12,5 +13,5 @@ import java.util.UUID */ data class POCardTokenizationPreferredSchemeRequest @ProcessOutInternalApi constructor( val issuerInformation: POCardIssuerInformation, - val uuid: UUID = UUID.randomUUID() -) + override val uuid: UUID = UUID.randomUUID() +) : POEventDispatcher.Request diff --git a/sdk/src/main/kotlin/com/processout/sdk/api/model/request/PONativeAlternativePaymentMethodDefaultValuesRequest.kt b/sdk/src/main/kotlin/com/processout/sdk/api/model/request/PONativeAlternativePaymentMethodDefaultValuesRequest.kt index 8143aab4..62744c9e 100644 --- a/sdk/src/main/kotlin/com/processout/sdk/api/model/request/PONativeAlternativePaymentMethodDefaultValuesRequest.kt +++ b/sdk/src/main/kotlin/com/processout/sdk/api/model/request/PONativeAlternativePaymentMethodDefaultValuesRequest.kt @@ -1,5 +1,6 @@ package com.processout.sdk.api.model.request +import com.processout.sdk.api.dispatcher.POEventDispatcher import com.processout.sdk.api.model.response.PONativeAlternativePaymentMethodParameter import com.processout.sdk.core.annotation.ProcessOutInternalApi import java.util.UUID @@ -16,5 +17,5 @@ data class PONativeAlternativePaymentMethodDefaultValuesRequest @ProcessOutInter val gatewayConfigurationId: String, val invoiceId: String, val parameters: List, - val uuid: UUID = UUID.randomUUID() -) + override val uuid: UUID = UUID.randomUUID() +) : POEventDispatcher.Request diff --git a/sdk/src/main/kotlin/com/processout/sdk/api/model/response/InvoiceResponse.kt b/sdk/src/main/kotlin/com/processout/sdk/api/model/response/InvoiceResponse.kt index a731827a..b22848d4 100644 --- a/sdk/src/main/kotlin/com/processout/sdk/api/model/response/InvoiceResponse.kt +++ b/sdk/src/main/kotlin/com/processout/sdk/api/model/response/InvoiceResponse.kt @@ -140,7 +140,8 @@ sealed class PODynamicCheckoutPaymentMethod { val name: String, val logo: POImageResource, @Json(name = "brand_color") - val brandColor: POColor + val brandColor: POColor, + val description: String? ) /** diff --git a/sdk/src/main/kotlin/com/processout/sdk/api/model/response/POCardTokenizationPreferredSchemeResponse.kt b/sdk/src/main/kotlin/com/processout/sdk/api/model/response/POCardTokenizationPreferredSchemeResponse.kt index 5a2e23af..e3e5fe19 100644 --- a/sdk/src/main/kotlin/com/processout/sdk/api/model/response/POCardTokenizationPreferredSchemeResponse.kt +++ b/sdk/src/main/kotlin/com/processout/sdk/api/model/response/POCardTokenizationPreferredSchemeResponse.kt @@ -1,5 +1,6 @@ package com.processout.sdk.api.model.response +import com.processout.sdk.api.dispatcher.POEventDispatcher import com.processout.sdk.api.model.request.POCardTokenizationPreferredSchemeRequest import java.util.UUID @@ -12,10 +13,10 @@ import java.util.UUID * @param[preferredScheme] Preferred scheme that will be used by default for card tokenization. */ data class POCardTokenizationPreferredSchemeResponse internal constructor( - val uuid: UUID, + override val uuid: UUID, val issuerInformation: POCardIssuerInformation, val preferredScheme: String? -) +) : POEventDispatcher.Response /** * Creates [POCardTokenizationPreferredSchemeResponse] from [POCardTokenizationPreferredSchemeRequest]. diff --git a/sdk/src/main/kotlin/com/processout/sdk/api/model/response/PONativeAlternativePaymentMethodDefaultValuesResponse.kt b/sdk/src/main/kotlin/com/processout/sdk/api/model/response/PONativeAlternativePaymentMethodDefaultValuesResponse.kt index d5895596..e572545b 100644 --- a/sdk/src/main/kotlin/com/processout/sdk/api/model/response/PONativeAlternativePaymentMethodDefaultValuesResponse.kt +++ b/sdk/src/main/kotlin/com/processout/sdk/api/model/response/PONativeAlternativePaymentMethodDefaultValuesResponse.kt @@ -1,5 +1,6 @@ package com.processout.sdk.api.model.response +import com.processout.sdk.api.dispatcher.POEventDispatcher import com.processout.sdk.api.model.request.PONativeAlternativePaymentMethodDefaultValuesRequest import java.util.UUID @@ -11,9 +12,9 @@ import java.util.UUID * @param[defaultValues] Map where key is [PONativeAlternativePaymentMethodParameter.key] and value is a default value for this parameter. */ data class PONativeAlternativePaymentMethodDefaultValuesResponse internal constructor( - val uuid: UUID, + override val uuid: UUID, val defaultValues: Map -) +) : POEventDispatcher.Response /** * Creates response with default values from request to use the same UUID. diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutInteractor.kt b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutInteractor.kt index e8e943ae..55c06a01 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutInteractor.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutInteractor.kt @@ -12,6 +12,8 @@ import com.processout.sdk.R import com.processout.sdk.api.dispatcher.POEventDispatcher import com.processout.sdk.api.dispatcher.card.tokenization.PODefaultCardTokenizationEventDispatcher import com.processout.sdk.api.dispatcher.napm.PODefaultNativeAlternativePaymentMethodEventDispatcher +import com.processout.sdk.api.model.event.PODynamicCheckoutEvent +import com.processout.sdk.api.model.event.PODynamicCheckoutEvent.* import com.processout.sdk.api.model.event.PONativeAlternativePaymentMethodEvent.WillSubmitParameters import com.processout.sdk.api.model.request.* import com.processout.sdk.api.model.response.* @@ -30,6 +32,7 @@ import com.processout.sdk.api.service.googlepay.POGooglePayService import com.processout.sdk.api.service.proxy3ds.POProxy3DSService import com.processout.sdk.core.POFailure.Code.* import com.processout.sdk.core.ProcessOutResult +import com.processout.sdk.core.logger.POLogAttribute import com.processout.sdk.core.logger.POLogger import com.processout.sdk.core.onFailure import com.processout.sdk.core.onSuccess @@ -75,9 +78,17 @@ internal class DynamicCheckoutInteractor( private val cardTokenizationEventDispatcher: PODefaultCardTokenizationEventDispatcher, private val nativeAlternativePayment: NativeAlternativePaymentViewModel, private val nativeAlternativePaymentEventDispatcher: PODefaultNativeAlternativePaymentMethodEventDispatcher, - private val eventDispatcher: POEventDispatcher = POEventDispatcher + private val eventDispatcher: POEventDispatcher = POEventDispatcher, + private var logAttributes: Map = logAttributes( + invoiceId = configuration.invoiceRequest.invoiceId + ) ) : BaseInteractor() { + private companion object { + fun logAttributes(invoiceId: String): Map = + mapOf(POLogAttribute.INVOICE_ID to invoiceId) + } + private val _completion = MutableStateFlow(Awaiting) val completion = _completion.asStateFlow() @@ -95,16 +106,33 @@ internal class DynamicCheckoutInteractor( private var latestInvoiceRequest: PODynamicCheckoutInvoiceRequest? = null init { - start() + interactorScope.launch { + POLogger.info("Starting dynamic checkout.") + dispatch(WillStart) + start() + POLogger.info("Started: waiting for user input.") + dispatch(DidStart) + } } - private fun start() { + private fun initState() = DynamicCheckoutInteractorState( + loading = true, + invoice = POInvoice(id = configuration.invoiceRequest.invoiceId), + isInvoiceValid = false, + paymentMethods = emptyList(), + submitActionId = ActionId.SUBMIT, + cancelActionId = ActionId.CANCEL + ) + + private suspend fun start() { handleCompletions() dispatchEvents() collectInvoice() collectInvoiceAuthorizationRequest() collectAuthorizeInvoiceResult() collectTokenizedCard() + collectPreferredScheme() + collectDefaultValues() fetchConfiguration() } @@ -113,118 +141,138 @@ internal class DynamicCheckoutInteractor( reason: PODynamicCheckoutInvoiceInvalidationReason ) { configuration = configuration.copy(invoiceRequest = invoiceRequest) - val selectedPaymentMethodId = when (reason) { - is PODynamicCheckoutInvoiceInvalidationReason.Failure -> - if (reason.failure.code == Cancelled) - _state.value.selectedPaymentMethodId else null - else -> _state.value.selectedPaymentMethodId - } - val errorMessage = when (reason) { - is PODynamicCheckoutInvoiceInvalidationReason.Failure -> - if (reason.failure.code == Cancelled) null - else app.getString(R.string.po_dynamic_checkout_error_generic) - else -> null + logAttributes = logAttributes(invoiceId = invoiceRequest.invoiceId) + var didFailPaymentEvent: DidFailPayment? = null + val selectedPaymentMethod: PaymentMethod? + val errorMessage: String? + with(_state.value) { + when (reason) { + is PODynamicCheckoutInvoiceInvalidationReason.Failure -> { + val paymentMethod = processingPaymentMethod ?: this.selectedPaymentMethod + if (paymentMethod != null) { + didFailPaymentEvent = DidFailPayment( + failure = reason.failure, + paymentMethod = paymentMethod.original + ) + } + when (reason.failure.code) { + Cancelled -> { + selectedPaymentMethod = this.selectedPaymentMethod + errorMessage = null + } + else -> { + selectedPaymentMethod = null + errorMessage = app.getString(R.string.po_dynamic_checkout_error_generic) + } + } + } + PODynamicCheckoutInvoiceInvalidationReason.PaymentMethodChanged -> { + selectedPaymentMethod = this.selectedPaymentMethod + errorMessage = null + } + } } reset( state = _state.value.copy( - invoice = POInvoice(id = configuration.invoiceRequest.invoiceId), - selectedPaymentMethodId = selectedPaymentMethodId, - processingPaymentMethodId = null, + invoice = POInvoice(id = invoiceRequest.invoiceId), + selectedPaymentMethod = selectedPaymentMethod, + processingPaymentMethod = null, errorMessage = errorMessage ) ) - start() + didFailPaymentEvent?.let { dispatch(it) } + interactorScope.launch { start() } } private fun reset(state: DynamicCheckoutInteractorState) { interactorScope.coroutineContext.cancelChildren() latestInvoiceRequest = null - cardTokenization.reset() - nativeAlternativePayment.reset() + resetPaymentMethods() _completion.update { Awaiting } _state.update { state } } - private fun initState() = DynamicCheckoutInteractorState( - loading = true, - invoice = POInvoice(id = configuration.invoiceRequest.invoiceId), - isInvoiceValid = false, - paymentMethods = emptyList(), - submitActionId = ActionId.SUBMIT, - cancelActionId = ActionId.CANCEL - ) + private fun resetPaymentMethods() { + cardTokenization.reset() + nativeAlternativePayment.reset() + } - private fun fetchConfiguration() { - interactorScope.launch { - invoicesService.invoice(configuration.invoiceRequest) - .onSuccess { invoice -> - when (invoice.transaction?.status()) { - WAITING -> setStartedState(invoice) - AUTHORIZED, COMPLETED -> handleSuccess() - else -> _completion.update { - Failure( - ProcessOutResult.Failure( - code = Generic(), - message = "Unsupported invoice state. Please create new invoice and restart dynamic checkout." - ) + private suspend fun fetchConfiguration() { + invoicesService.invoice(configuration.invoiceRequest) + .onSuccess { invoice -> + when (invoice.transaction?.status()) { + WAITING -> setStartedState(invoice) + AUTHORIZED, COMPLETED -> handleSuccess() + else -> _completion.update { + Failure( + ProcessOutResult.Failure( + code = Generic(), + message = "Unsupported invoice state. Please create new invoice and restart dynamic checkout." ) - } + ) } - }.onFailure { failure -> - _completion.update { Failure(failure) } } - } + }.onFailure { failure -> + _completion.update { Failure(failure) } + } } - private fun setStartedState(invoice: POInvoice) { - interactorScope.launch { - val paymentMethods = invoice.paymentMethods - if (paymentMethods.isNullOrEmpty()) { - _completion.update { - Failure( - ProcessOutResult.Failure( - code = Generic(), - message = "Missing payment methods configuration." - ) + private suspend fun setStartedState(invoice: POInvoice) { + val paymentMethods = invoice.paymentMethods + if (paymentMethods.isNullOrEmpty()) { + _completion.update { + Failure( + ProcessOutResult.Failure( + code = Generic(), + message = "Missing payment methods configuration." ) - } - return@launch - } - val mappedPaymentMethods = paymentMethods.map( - amount = invoice.amount, - currency = invoice.currency - ) - preloadAllImages( - paymentMethods = mappedPaymentMethods, - coroutineScope = this@launch - ) - _state.update { - it.copy( - loading = false, - invoice = invoice, - isInvoiceValid = true, - paymentMethods = mappedPaymentMethods ) } - _state.value.selectedPaymentMethodId?.let { id -> - paymentMethod(id)?.let { start(it) } - .orElse { - _state.update { - it.copy( - selectedPaymentMethodId = null, - errorMessage = app.getString(R.string.po_dynamic_checkout_error_method_unavailable) - ) - } - } - } - _state.value.pendingSubmitPaymentMethodId?.let { id -> - _state.update { it.copy(pendingSubmitPaymentMethodId = null) } - paymentMethod(id)?.let { submit(it) } - .orElse { - _state.update { - it.copy(errorMessage = app.getString(R.string.po_dynamic_checkout_error_method_unavailable)) - } + return + } + val mappedPaymentMethods = paymentMethods.map( + amount = invoice.amount, + currency = invoice.currency + ) + preloadAllImages(paymentMethods = mappedPaymentMethods) + _state.update { + it.copy( + loading = false, + invoice = invoice, + isInvoiceValid = true, + paymentMethods = mappedPaymentMethods + ) + } + restoreSelectedPaymentMethod() + handlePendingSubmit() + } + + private fun restoreSelectedPaymentMethod() { + _state.value.selectedPaymentMethod?.id?.let { id -> + paymentMethod(id)?.let { start(it) } + .orElse { + _state.update { + it.copy( + selectedPaymentMethod = null, + errorMessage = app.getString(R.string.po_dynamic_checkout_error_method_unavailable) + ) } + } + } + } + + private fun handlePendingSubmit() { + _state.value.pendingSubmitPaymentMethod?.id?.let { id -> + _state.update { it.copy(pendingSubmitPaymentMethod = null) } + paymentMethod(id)?.let { + submit( + paymentMethod = it, + dispatchEvents = false + ) + }.orElse { + _state.update { + it.copy(errorMessage = app.getString(R.string.po_dynamic_checkout_error_method_unavailable)) + } } } } @@ -236,6 +284,7 @@ internal class DynamicCheckoutInteractor( when (paymentMethod) { is PODynamicCheckoutPaymentMethod.Card -> Card( id = PaymentMethodId.CARD, + original = paymentMethod, configuration = paymentMethod.configuration, display = paymentMethod.display ) @@ -249,6 +298,7 @@ internal class DynamicCheckoutInteractor( if (googlePayService.isReadyToPay(isReadyToPayRequest)) GooglePay( id = paymentMethod.configuration.gatewayMerchantId, + original = paymentMethod, allowedPaymentMethods = POGooglePayRequestBuilder .allowedPaymentMethods(configuration.card) .toString(), @@ -260,6 +310,7 @@ internal class DynamicCheckoutInteractor( if (redirectUrl != null) { AlternativePayment( id = paymentMethod.configuration.gatewayConfigurationId, + original = paymentMethod, redirectUrl = redirectUrl, display = paymentMethod.display, isExpress = paymentMethod.flow == express @@ -267,6 +318,7 @@ internal class DynamicCheckoutInteractor( } else { NativeAlternativePayment( id = paymentMethod.configuration.gatewayConfigurationId, + original = paymentMethod, gatewayConfigurationId = paymentMethod.configuration.gatewayConfigurationId, display = paymentMethod.display ) @@ -274,17 +326,19 @@ internal class DynamicCheckoutInteractor( } is CardCustomerToken -> CustomerToken( id = paymentMethod.configuration.customerTokenId, + original = paymentMethod, configuration = paymentMethod.configuration, display = paymentMethod.display, isExpress = paymentMethod.flow == express ) is AlternativePaymentCustomerToken -> CustomerToken( id = paymentMethod.configuration.customerTokenId, + original = paymentMethod, configuration = paymentMethod.configuration, display = paymentMethod.display, isExpress = paymentMethod.flow == express ) - else -> null + Unknown -> null } } @@ -342,24 +396,23 @@ internal class DynamicCheckoutInteractor( //region Images - private suspend fun preloadAllImages( - paymentMethods: List, - coroutineScope: CoroutineScope - ) { - val logoUrls = mutableListOf() - paymentMethods.forEach { - when (it) { - is Card -> logoUrls.addAll(it.display.logoUrls()) - is AlternativePayment -> logoUrls.addAll(it.display.logoUrls()) - is NativeAlternativePayment -> logoUrls.addAll(it.display.logoUrls()) - is CustomerToken -> logoUrls.addAll(it.display.logoUrls()) - else -> {} + private suspend fun preloadAllImages(paymentMethods: List) { + coroutineScope { + val logoUrls = mutableListOf() + paymentMethods.forEach { + when (it) { + is Card -> logoUrls.addAll(it.display.logoUrls()) + is AlternativePayment -> logoUrls.addAll(it.display.logoUrls()) + is NativeAlternativePayment -> logoUrls.addAll(it.display.logoUrls()) + is CustomerToken -> logoUrls.addAll(it.display.logoUrls()) + else -> {} + } } + val deferredResults = logoUrls.map { url -> + async { preloadImage(url) } + } + deferredResults.awaitAll() } - val deferredResults = logoUrls.map { url -> - coroutineScope.async { preloadImage(url) } - } - deferredResults.awaitAll() } private fun Display.logoUrls(): List { @@ -379,64 +432,37 @@ internal class DynamicCheckoutInteractor( //endregion - private fun paymentMethod(id: String): PaymentMethod? = - _state.value.paymentMethods.find { it.id == id } - - private fun selectedPaymentMethod(): PaymentMethod? = - _state.value.selectedPaymentMethodId?.let { - paymentMethod(it) - } - - private fun originalPaymentMethod(id: String): PODynamicCheckoutPaymentMethod? = - _state.value.invoice.paymentMethods?.find { - when (it) { - is PODynamicCheckoutPaymentMethod.Card -> PaymentMethodId.CARD == id - is PODynamicCheckoutPaymentMethod.GooglePay -> it.configuration.gatewayMerchantId == id - is PODynamicCheckoutPaymentMethod.AlternativePayment -> it.configuration.gatewayConfigurationId == id - is AlternativePaymentCustomerToken -> it.configuration.customerTokenId == id - is CardCustomerToken -> it.configuration.customerTokenId == id - Unknown -> false - } - } - fun onEvent(event: DynamicCheckoutEvent) { when (event) { is PaymentMethodSelected -> onPaymentMethodSelected(event) is FieldValueChanged -> onFieldValueChanged(event) is FieldFocusChanged -> onFieldFocusChanged(event) is Action -> onAction(event) - is ActionConfirmationRequested -> { - // TODO - } - is Dismiss -> { - if (_state.value.delayedSuccess) { - _completion.update { Success } - } else { - POLogger.warn("Dismissed: %s", event.failure) - _completion.update { Failure(event.failure) } - } - } + is ActionConfirmationRequested -> onActionConfirmationRequested(event) + is Dismiss -> dismiss(event) } } + private fun paymentMethod(id: String): PaymentMethod? = + _state.value.paymentMethods.find { it.id == id } + private fun onPaymentMethodSelected(event: PaymentMethodSelected) { - if (event.id == _state.value.selectedPaymentMethodId) { + if (event.id == _state.value.selectedPaymentMethod?.id) { return } paymentMethod(event.id)?.let { paymentMethod -> - if (_state.value.processingPaymentMethodId != null) { + dispatch(DidSelectPaymentMethod(paymentMethod = paymentMethod.original)) + resetPaymentMethods() + if (_state.value.processingPaymentMethod != null) { invalidateInvoice( reason = PODynamicCheckoutInvoiceInvalidationReason.PaymentMethodChanged ) - } - cardTokenization.reset() - nativeAlternativePayment.reset() - if (_state.value.isInvoiceValid) { + } else { start(paymentMethod) } _state.update { it.copy( - selectedPaymentMethodId = event.id, + selectedPaymentMethod = paymentMethod, errorMessage = null ) } @@ -504,7 +530,12 @@ internal class DynamicCheckoutInteractor( private fun onAction(event: Action) { val paymentMethod = event.paymentMethodId?.let { paymentMethod(it) } when (event.actionId) { - ActionId.SUBMIT -> paymentMethod?.let { submit(it) } + ActionId.SUBMIT -> paymentMethod?.let { + submit( + paymentMethod = it, + dispatchEvents = true + ) + } ActionId.CANCEL -> cancel() else -> when (paymentMethod) { is Card -> cardTokenization.onEvent( @@ -518,6 +549,13 @@ internal class DynamicCheckoutInteractor( } } + private fun onActionConfirmationRequested(event: ActionConfirmationRequested) { + POLogger.debug("Requested the user to confirm the action: %s", event.id) + if (event.id == ActionId.CANCEL) { + dispatch(DidRequestCancelConfirmation) + } + } + private fun PaymentMethod.isExpress(): Boolean = when (this) { is Card, is NativeAlternativePayment -> false @@ -526,62 +564,66 @@ internal class DynamicCheckoutInteractor( is CustomerToken -> isExpress } - private fun submit(paymentMethod: PaymentMethod) { - if (paymentMethod.id == _state.value.processingPaymentMethodId) { + private fun submit( + paymentMethod: PaymentMethod, + dispatchEvents: Boolean + ) { + if (paymentMethod.id == _state.value.processingPaymentMethod?.id) { return } if (paymentMethod.isExpress()) { - cardTokenization.reset() - nativeAlternativePayment.reset() + if (dispatchEvents) { + dispatch(DidSelectPaymentMethod(paymentMethod = paymentMethod.original)) + } + resetPaymentMethods() _state.update { it.copy( - selectedPaymentMethodId = null, + selectedPaymentMethod = null, errorMessage = null ) } } - if (_state.value.processingPaymentMethodId != null) { - _state.update { it.copy(pendingSubmitPaymentMethodId = paymentMethod.id) } + if (_state.value.processingPaymentMethod != null) { + _state.update { it.copy(pendingSubmitPaymentMethod = paymentMethod) } invalidateInvoice( reason = PODynamicCheckoutInvoiceInvalidationReason.PaymentMethodChanged ) return } when (paymentMethod) { - is GooglePay -> { - interactorScope.launch { - _state.update { it.copy(processingPaymentMethodId = paymentMethod.id) } - _submitEvents.send( - DynamicCheckoutSubmitEvent.GooglePay( - paymentDataRequest = paymentMethod.paymentDataRequest - ) - ) - } - } - is AlternativePayment -> submitAlternativePayment( - id = paymentMethod.id, - redirectUrl = paymentMethod.redirectUrl - ) - is CustomerToken -> { - val redirectUrl = paymentMethod.configuration.redirectUrl - if (redirectUrl != null) { - submitAlternativePayment( - id = paymentMethod.id, - redirectUrl = redirectUrl - ) - } else { - _state.update { it.copy(processingPaymentMethodId = paymentMethod.id) } - authorizeInvoice(source = paymentMethod.configuration.customerTokenId) - } - } + is GooglePay -> submitGooglePay(paymentMethod) + is AlternativePayment -> submitAlternativePayment(paymentMethod) + is CustomerToken -> submitCustomerToken(paymentMethod) else -> {} } } - private fun submitAlternativePayment( - id: String, - redirectUrl: String - ) { + private fun submitGooglePay(paymentMethod: GooglePay) { + interactorScope.launch { + _state.update { it.copy(processingPaymentMethod = paymentMethod) } + _submitEvents.send( + DynamicCheckoutSubmitEvent.GooglePay( + paymentDataRequest = paymentMethod.paymentDataRequest + ) + ) + } + } + + private fun submitAlternativePayment(paymentMethod: PaymentMethod) { + val redirectUrl = when (paymentMethod) { + is AlternativePayment -> paymentMethod.redirectUrl + is CustomerToken -> paymentMethod.configuration.redirectUrl + else -> null + } + if (redirectUrl.isNullOrBlank()) { + handleAlternativePayment( + ProcessOutResult.Failure( + code = Generic(), + message = "Missing redirect URL in alternative payment configuration." + ) + ) + return + } val returnUrl = configuration.alternativePayment.returnUrl if (returnUrl.isNullOrBlank()) { handleAlternativePayment( @@ -593,7 +635,7 @@ internal class DynamicCheckoutInteractor( return } interactorScope.launch { - _state.update { it.copy(processingPaymentMethodId = id) } + _state.update { it.copy(processingPaymentMethod = paymentMethod) } _submitEvents.send( DynamicCheckoutSubmitEvent.AlternativePayment( redirectUrl = redirectUrl, @@ -603,6 +645,15 @@ internal class DynamicCheckoutInteractor( } } + private fun submitCustomerToken(paymentMethod: CustomerToken) { + if (paymentMethod.configuration.redirectUrl != null) { + submitAlternativePayment(paymentMethod) + } else { + _state.update { it.copy(processingPaymentMethod = paymentMethod) } + authorizeInvoice(source = paymentMethod.configuration.customerTokenId) + } + } + fun handleGooglePay(result: ProcessOutResult) { result.onSuccess { response -> authorizeInvoice(source = response.card.id) @@ -626,17 +677,6 @@ internal class DynamicCheckoutInteractor( } } - private fun cancel() { - _completion.update { - Failure( - ProcessOutResult.Failure( - code = Cancelled, - message = "Cancelled by the user with cancel action." - ).also { POLogger.info("Cancelled: %s", it) } - ) - } - } - private fun invalidateInvoice(reason: PODynamicCheckoutInvoiceInvalidationReason) { interactorScope.launch { _state.update { it.copy(isInvoiceValid = false) } @@ -677,24 +717,10 @@ internal class DynamicCheckoutInteractor( } } - private fun dispatchEvents() { - interactorScope.launch { - cardTokenizationEventDispatcher.events.collect { eventDispatcher.send(it) } - } - interactorScope.launch { - nativeAlternativePaymentEventDispatcher.events.collect { event -> - if (event is WillSubmitParameters) { - _state.update { it.copy(processingPaymentMethodId = selectedPaymentMethod()?.id) } - } - eventDispatcher.send(event) - } - } - } - private fun collectTokenizedCard() { interactorScope.launch { cardTokenizationEventDispatcher.processTokenizedCardRequest.collect { request -> - _state.update { it.copy(processingPaymentMethodId = selectedPaymentMethod()?.id) } + _state.update { it.copy(processingPaymentMethod = _state.value.selectedPaymentMethod) } authorizeInvoice( source = request.card.id, saveSource = request.saveCard, @@ -710,19 +736,7 @@ internal class DynamicCheckoutInteractor( allowFallbackToSale: Boolean = false, clientSecret: String? = null ) { - val paymentMethodId = _state.value.processingPaymentMethodId - if (paymentMethodId == null) { - invalidateInvoice( - reason = PODynamicCheckoutInvoiceInvalidationReason.Failure( - failure = ProcessOutResult.Failure( - code = Internal(), - message = "Failed to authorize invoice: payment method ID is null." - ) - ) - ) - return - } - val paymentMethod = originalPaymentMethod(paymentMethodId) + val paymentMethod = _state.value.processingPaymentMethod if (paymentMethod == null) { invalidateInvoice( reason = PODynamicCheckoutInvoiceInvalidationReason.Failure( @@ -737,13 +751,13 @@ internal class DynamicCheckoutInteractor( interactorScope.launch { val request = PODynamicCheckoutInvoiceAuthorizationRequest( request = POInvoiceAuthorizationRequest( - invoiceId = _state.value.invoice.id, + invoiceId = configuration.invoiceRequest.invoiceId, source = source, saveSource = saveSource, allowFallbackToSale = allowFallbackToSale, clientSecret = clientSecret ), - paymentMethod = paymentMethod + paymentMethod = paymentMethod.original ) eventDispatcher.send(request) } @@ -763,10 +777,9 @@ internal class DynamicCheckoutInteractor( private fun collectAuthorizeInvoiceResult() { interactorScope.launch { invoicesService.authorizeInvoiceResult.collect { result -> - if (selectedPaymentMethod() is Card) { - cardTokenizationEventDispatcher.complete(result) - } else { - result.onSuccess { + when (_state.value.selectedPaymentMethod) { + is Card -> cardTokenizationEventDispatcher.complete(result) + else -> result.onSuccess { handleSuccess() }.onFailure { failure -> invalidateInvoice( @@ -779,6 +792,17 @@ internal class DynamicCheckoutInteractor( } private fun handleCompletions() { + interactorScope.launch { + _completion.collect { completion -> + when (completion) { + Success -> _state.value.processingPaymentMethod?.let { paymentMethod -> + dispatch(DidCompletePayment(paymentMethod.original)) + } + is Failure -> dispatch(DidFail(completion.failure)) + else -> {} + } + } + } interactorScope.launch { cardTokenization.completion.collect { completion -> when (completion) { @@ -812,6 +836,76 @@ internal class DynamicCheckoutInteractor( } ?: _completion.update { Success } } + private fun dispatch(event: PODynamicCheckoutEvent) { + interactorScope.launch { + eventDispatcher.send(event) + } + } + + private fun dispatchEvents() { + interactorScope.launch { + cardTokenizationEventDispatcher.events.collect { eventDispatcher.send(it) } + } + interactorScope.launch { + cardTokenizationEventDispatcher.preferredSchemeRequest.collect { request -> + eventDispatcher.send(request) + } + } + interactorScope.launch { + nativeAlternativePaymentEventDispatcher.events.collect { event -> + if (event is WillSubmitParameters) { + _state.update { it.copy(processingPaymentMethod = _state.value.selectedPaymentMethod) } + } + eventDispatcher.send(event) + } + } + interactorScope.launch { + nativeAlternativePaymentEventDispatcher.defaultValuesRequest.collect { request -> + eventDispatcher.send(request) + } + } + } + + private fun collectPreferredScheme() { + eventDispatcher.subscribeForResponse( + coroutineScope = interactorScope + ) { response -> + interactorScope.launch { + cardTokenizationEventDispatcher.preferredScheme(response) + } + } + } + + private fun collectDefaultValues() { + eventDispatcher.subscribeForResponse( + coroutineScope = interactorScope + ) { response -> + interactorScope.launch { + nativeAlternativePaymentEventDispatcher.provideDefaultValues(response) + } + } + } + + private fun cancel() { + _completion.update { + Failure( + ProcessOutResult.Failure( + code = Cancelled, + message = "Cancelled by the user with cancel action." + ).also { POLogger.info("Cancelled: %s", it) } + ) + } + } + + private fun dismiss(event: Dismiss) { + if (_state.value.delayedSuccess) { + _completion.update { Success } + } else { + POLogger.warn("Dismissed: %s", event.failure) + _completion.update { Failure(event.failure) } + } + } + fun onCleared() { threeDSService.close() handler.removeCallbacksAndMessages(null) diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutInteractorState.kt b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutInteractorState.kt index 50c37c62..52d0d72a 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutInteractorState.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutInteractorState.kt @@ -1,5 +1,6 @@ package com.processout.sdk.ui.checkout +import com.processout.sdk.api.model.response.PODynamicCheckoutPaymentMethod import com.processout.sdk.api.model.response.PODynamicCheckoutPaymentMethod.* import com.processout.sdk.api.model.response.POInvoice import org.json.JSONObject @@ -11,9 +12,9 @@ internal data class DynamicCheckoutInteractorState( val paymentMethods: List, val submitActionId: String, val cancelActionId: String, - val selectedPaymentMethodId: String? = null, - val processingPaymentMethodId: String? = null, - val pendingSubmitPaymentMethodId: String? = null, + val selectedPaymentMethod: PaymentMethod? = null, + val processingPaymentMethod: PaymentMethod? = null, + val pendingSubmitPaymentMethod: PaymentMethod? = null, val errorMessage: String? = null, val delayedSuccess: Boolean = false ) { @@ -21,21 +22,25 @@ internal data class DynamicCheckoutInteractorState( sealed interface PaymentMethod { val id: String + val original: PODynamicCheckoutPaymentMethod data class Card( override val id: String, + override val original: PODynamicCheckoutPaymentMethod, val configuration: CardConfiguration, val display: Display ) : PaymentMethod data class GooglePay( override val id: String, + override val original: PODynamicCheckoutPaymentMethod, val allowedPaymentMethods: String, val paymentDataRequest: JSONObject ) : PaymentMethod data class AlternativePayment( override val id: String, + override val original: PODynamicCheckoutPaymentMethod, val redirectUrl: String, val display: Display, val isExpress: Boolean @@ -43,12 +48,14 @@ internal data class DynamicCheckoutInteractorState( data class NativeAlternativePayment( override val id: String, + override val original: PODynamicCheckoutPaymentMethod, val gatewayConfigurationId: String, val display: Display ) : PaymentMethod data class CustomerToken( override val id: String, + override val original: PODynamicCheckoutPaymentMethod, val configuration: CustomerTokenConfiguration, val display: Display, val isExpress: Boolean diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutViewModel.kt b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutViewModel.kt index 5c09f19b..d6016e28 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutViewModel.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutViewModel.kt @@ -17,7 +17,6 @@ import com.processout.sdk.api.service.proxy3ds.PODefaultProxy3DSService import com.processout.sdk.core.ProcessOutResult import com.processout.sdk.ui.card.tokenization.CardTokenizationViewModel import com.processout.sdk.ui.card.tokenization.CardTokenizationViewModelState -import com.processout.sdk.ui.checkout.DynamicCheckoutInteractorState.PaymentMethod import com.processout.sdk.ui.checkout.DynamicCheckoutInteractorState.PaymentMethod.* import com.processout.sdk.ui.checkout.DynamicCheckoutViewModelState.* import com.processout.sdk.ui.checkout.DynamicCheckoutViewModelState.RegularPayment.Content @@ -124,9 +123,6 @@ internal class DynamicCheckoutViewModel private constructor( } } - private fun DynamicCheckoutInteractorState.selectedPaymentMethod(): PaymentMethod? = - paymentMethods.find { it.id == selectedPaymentMethodId } - private fun cancelAction( interactorState: DynamicCheckoutInteractorState, cardTokenizationState: CardTokenizationViewModelState, @@ -135,7 +131,7 @@ internal class DynamicCheckoutViewModel private constructor( val defaultText = app.getString(R.string.po_dynamic_checkout_button_cancel) val defaultCancelAction = configuration.cancelButton?.toActionState(interactorState, defaultText) val defaultCancelActionText = defaultCancelAction?.text ?: defaultText - return when (interactorState.selectedPaymentMethod()) { + return when (interactorState.selectedPaymentMethod) { is Card -> cardTokenizationState.secondaryAction?.copy( text = defaultCancelActionText, confirmation = defaultCancelAction?.confirmation @@ -170,7 +166,7 @@ internal class DynamicCheckoutViewModel private constructor( id = interactorState.cancelActionId, text = text ?: defaultText, primary = false, - enabled = interactorState.processingPaymentMethodId == null, + enabled = interactorState.processingPaymentMethod == null, confirmation = confirmation?.map() ) @@ -196,18 +192,20 @@ internal class DynamicCheckoutViewModel private constructor( id = interactorState.submitActionId, text = String(), primary = true, - enabled = id != interactorState.processingPaymentMethodId + enabled = id != interactorState.processingPaymentMethod?.id ) ) is AlternativePayment -> if (paymentMethod.isExpress) expressPayment( id = id, + text = paymentMethod.display.name, display = paymentMethod.display, interactorState = interactorState ) else null is CustomerToken -> if (paymentMethod.isExpress) expressPayment( id = id, + text = paymentMethod.display.description ?: paymentMethod.display.name, display = paymentMethod.display, interactorState = interactorState ) else null @@ -217,6 +215,7 @@ internal class DynamicCheckoutViewModel private constructor( private fun expressPayment( id: String, + text: String, display: Display, interactorState: DynamicCheckoutInteractorState ) = ExpressPayment.Express( @@ -225,9 +224,9 @@ internal class DynamicCheckoutViewModel private constructor( brandColor = display.brandColor, submitAction = POActionState( id = interactorState.submitActionId, - text = display.name, + text = text, primary = true, - enabled = id != interactorState.processingPaymentMethodId + enabled = id != interactorState.processingPaymentMethod?.id ) ) @@ -238,7 +237,7 @@ internal class DynamicCheckoutViewModel private constructor( ): POImmutableList = interactorState.paymentMethods.mapNotNull { paymentMethod -> val id = paymentMethod.id - val selected = id == interactorState.selectedPaymentMethodId + val selected = id == interactorState.selectedPaymentMethod?.id val submitButtonText = configuration.submitButtonText ?: app.getString(R.string.po_dynamic_checkout_button_pay) when (paymentMethod) { is Card -> RegularPayment( @@ -265,7 +264,7 @@ internal class DynamicCheckoutViewModel private constructor( id = interactorState.submitActionId, text = submitButtonText, primary = true, - loading = interactorState.processingPaymentMethodId != null + loading = interactorState.processingPaymentMethod != null ) ) else null is NativeAlternativePayment -> RegularPayment( diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/PODynamicCheckoutDelegate.kt b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/PODynamicCheckoutDelegate.kt index 513d683e..0f81ade2 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/PODynamicCheckoutDelegate.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/PODynamicCheckoutDelegate.kt @@ -1,10 +1,9 @@ package com.processout.sdk.ui.checkout import com.processout.sdk.api.model.event.POCardTokenizationEvent +import com.processout.sdk.api.model.event.PODynamicCheckoutEvent import com.processout.sdk.api.model.event.PONativeAlternativePaymentMethodEvent -import com.processout.sdk.api.model.request.PODynamicCheckoutInvoiceInvalidationReason -import com.processout.sdk.api.model.request.POInvoiceAuthorizationRequest -import com.processout.sdk.api.model.request.POInvoiceRequest +import com.processout.sdk.api.model.request.* import com.processout.sdk.api.model.response.PODynamicCheckoutPaymentMethod import com.processout.sdk.api.model.response.POInvoice import com.processout.sdk.ui.core.annotation.ProcessOutInternalApi @@ -13,6 +12,8 @@ import com.processout.sdk.ui.core.annotation.ProcessOutInternalApi @ProcessOutInternalApi interface PODynamicCheckoutDelegate { + fun onEvent(event: PODynamicCheckoutEvent) {} + fun onEvent(event: POCardTokenizationEvent) {} fun onEvent(event: PONativeAlternativePaymentMethodEvent) {} @@ -33,4 +34,12 @@ interface PODynamicCheckoutDelegate { request: POInvoiceAuthorizationRequest, paymentMethod: PODynamicCheckoutPaymentMethod ): POInvoiceAuthorizationRequest = request + + suspend fun preferredScheme( + request: POCardTokenizationPreferredSchemeRequest + ): String? = null + + suspend fun defaultValues( + request: PONativeAlternativePaymentMethodDefaultValuesRequest + ): Map = emptyMap() } diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/PODynamicCheckoutLauncher.kt b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/PODynamicCheckoutLauncher.kt index 32df29a1..8046ec7a 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/PODynamicCheckoutLauncher.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/PODynamicCheckoutLauncher.kt @@ -8,9 +8,12 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.processout.sdk.api.dispatcher.POEventDispatcher import com.processout.sdk.api.model.event.POCardTokenizationEvent +import com.processout.sdk.api.model.event.PODynamicCheckoutEvent import com.processout.sdk.api.model.event.PONativeAlternativePaymentMethodEvent +import com.processout.sdk.api.model.request.POCardTokenizationPreferredSchemeRequest import com.processout.sdk.api.model.request.PODynamicCheckoutInvoiceAuthorizationRequest import com.processout.sdk.api.model.request.PODynamicCheckoutInvoiceRequest +import com.processout.sdk.api.model.request.PONativeAlternativePaymentMethodDefaultValuesRequest import com.processout.sdk.api.model.response.toResponse import com.processout.sdk.api.service.PO3DSService import com.processout.sdk.api.service.proxy3ds.POProxy3DSServiceRequest @@ -88,10 +91,15 @@ class PODynamicCheckoutLauncher private constructor( dispatchEvents() dispatchInvoice() dispatchInvoiceAuthorizationRequest() + dispatchPreferredScheme() + dispatchDefaultValues() dispatch3DSService() } private fun dispatchEvents() { + eventDispatcher.subscribe( + coroutineScope = scope + ) { delegate.onEvent(it) } eventDispatcher.subscribe( coroutineScope = scope ) { delegate.onEvent(it) } @@ -128,6 +136,28 @@ class PODynamicCheckoutLauncher private constructor( } } + private fun dispatchPreferredScheme() { + eventDispatcher.subscribeForRequest( + coroutineScope = scope + ) { request -> + scope.launch { + val preferredScheme = delegate.preferredScheme(request) + eventDispatcher.send(request.toResponse(preferredScheme)) + } + } + } + + private fun dispatchDefaultValues() { + eventDispatcher.subscribeForRequest( + coroutineScope = scope + ) { request -> + scope.launch { + val defaultValues = delegate.defaultValues(request) + eventDispatcher.send(request.toResponse(defaultValues)) + } + } + } + private fun dispatch3DSService() { eventDispatcher.subscribeForRequest( coroutineScope = scope