Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,15 @@ import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.monext.sdk.Appearance
import com.monext.sdk.FakeTestActivity
import com.monext.sdk.LocalAppearance
import com.monext.sdk.PaymentOverlayToggle
import com.monext.sdk.PaymentResult
import com.monext.sdk.*
import com.monext.sdk.internal.api.model.response.SessionState
import com.monext.sdk.internal.api.model.response.SessionStateType
import com.monext.sdk.internal.data.LocalSessionStateRepo
import com.monext.sdk.internal.data.SessionStateRepository
import com.monext.sdk.internal.data.sessionstate.ActiveWaiting
import com.monext.sdk.internal.data.sessionstate.CustomMessage
import com.monext.sdk.internal.data.sessionstate.PaymentOnholdPartner
import com.monext.sdk.internal.data.FormData
import com.monext.sdk.internal.data.sessionstate.*
import com.monext.sdk.internal.presentation.PaymentAttempt
import com.monext.sdk.internal.presentation.PaymentContainer
import com.monext.sdk.internal.preview.PreviewSamples.Companion.buildSessionState
import io.mockk.impl.annotations.RelaxedMockK
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
Expand Down Expand Up @@ -157,6 +148,78 @@ class PaymentContainerTest {
"back_button" to null))
}

@Test
fun withPaymentRedirectWithJavascript_triggersMakePaymentForLoadedMethod() {
// Given une réponse PAYMENT_REDIRECT_WITH_JAVASCRIPT contenant un moyen de paiement
// avec un script à exécuter (ex : empreinte device PayPal).
val paymentMethodData = PaymentMethodData(
cardCode = "PAYPAL_APIREST",
contractNumber = "PAYPAL_APIREST",
disabled = false,
hasForm = true,
form = PaymentForm(
displayButton = true,
formScript = FormScript(
content = "console.log('fingerprint');",
wrapIntoScriptTag = true,
formScriptEnum = "CUSTOM"
),
formType = "CUSTOM"
),
hasLogo = false,
logo = null,
isIsolated = false,
options = emptyList(),
paymentMethodAction = 0,
additionalData = null,
requestContext = null,
shouldBeInTopPosition = false,
state = "AVAILABLE"
)
val sessionState = SessionState(
token = "fake_token",
type = SessionStateType.PAYMENT_REDIRECT_WITH_JAVASCRIPT,
creationDate = "Tue Mar 25 12:33:22 CET 2025",
cancelUrl = "https://www.payline.com",
pointOfSale = "POS_Fake",
language = "fr",
returnUrl = "https://www.monext.fr",
automaticRedirectAtSessionsEnd = false,
isSandbox = true,
paymentMethodsList = PaymentMethodsList(
isOriginalCreditTransfer = false,
needsDeviceFingerprint = true,
paymentMethodsData = listOf(paymentMethodData),
scoringNeeded = null,
sensitiveInputContentMasked = false,
shouldChangePaymentMethodPosition = false,
wallets = emptyList()
)
)

var capturedAttempt: PaymentAttempt? = null

// When
composeTestRule.activity.setTestComposable {
CompositionLocalProvider(LocalAppearance provides appearance) {
PaymentContainer(
sessionState,
{ paymentMethodList, sessionInfo -> },
{},
{ attempt -> capturedAttempt = attempt },
{},
{ },
{ }
) { state -> stateHistory.add(state) }
}
}

// Then : le paiement est déclenché automatiquement pour le moyen de paiement chargé
composeTestRule.waitUntil(timeoutMillis = 10_000) { capturedAttempt != null }
assertEquals("PAYPAL_APIREST", capturedAttempt?.selectedPaymentMethod?.cardCode)
assertTrue(capturedAttempt?.paymentFormData is FormData.AlternativePaymentMethodForm)
}

private fun executeSessionStateTest(
sessionState: SessionState,
expectedTransactionState: PaymentResult.TransactionState,
Expand All @@ -172,6 +235,7 @@ class PaymentContainerTest {
{ paymentMethodList, sessionInfo -> },
{},
{},
{},
{ result ->
paymentResult = result
},
Expand Down
8 changes: 8 additions & 0 deletions monext/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />

<application>
<service
android:name=".internal.service.PaymentForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />
</application>
</manifest>
7 changes: 5 additions & 2 deletions monext/src/main/kotlin/com/monext/sdk/PaymentSheet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ fun PaymentSheet(isShowing: Boolean, sessionToken: String, sdkContext: MnxtSDKCo
)
)
if (!sheetState.isVisible) {
onIsShowingChange?.invoke(false)
}
onIsShowingChange?.invoke(false)
}
},
modifier = Modifier.statusBarsPadding().testTag("payment_bottom_sheet"),
sheetState = sheetState,
Expand Down Expand Up @@ -192,6 +192,9 @@ fun PaymentSheet(isShowing: Boolean, sessionToken: String, sdkContext: MnxtSDKCo
)
},
onRedirectionComplete = { viewModel.updateSessionState(sessionToken) },
onMakePayment = { attempt ->
viewModel.makePayment(attempt, context) { showingOverlay = it }
},
onRetry = { /* TODO: implement RETRY */ },
onResult = onResult,
onIsShowingChange = isShowingChangeWrapper,
Expand Down
16 changes: 13 additions & 3 deletions monext/src/main/kotlin/com/monext/sdk/internal/api/PaymentAPI.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import java.net.URL
internal interface PaymentAPI {

@Throws(NetworkError::class)
suspend fun stateCurrent(sessionToken: String): SessionState
suspend fun stateCurrent(sessionToken: String, merchantReturnUrl: String): SessionState

@Throws(NetworkError::class)
suspend fun payment(sessionToken: String, params: PaymentRequest): SessionState
Expand Down Expand Up @@ -78,10 +78,20 @@ internal class PaymentAPIImpl(
* GET /token/{token}/state/current
*/
@Throws(NetworkError::class)
override suspend fun stateCurrent(sessionToken: String): SessionState {
override suspend fun stateCurrent(sessionToken: String, merchantReturnUrl: String): SessionState {
val baseUrl = buildBaseUrl()
val url = appendPath(baseUrl, sessionToken, "state", "current")
val httpRequest = buildHttpRequest(url, HttpMethod.GET)

val uriWithQuery = URI(
url.protocol,
url.authority,
url.path,
"merchantReturnUrl=$merchantReturnUrl",
null
)
val finalUrl = uriWithQuery.toURL()

val httpRequest = buildHttpRequest(finalUrl, HttpMethod.GET)
val sessionStateResponse = makeRequest<SessionState>(httpRequest)

// on récupère la configuration pour le remoteLogger
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ internal enum class SessionStateType {

PAYMENT_METHODS_LIST,
PAYMENT_REDIRECT_NO_RESPONSE,
PAYMENT_REDIRECT_WITH_JAVASCRIPT, // Redirection après avoir executé le Javascript
PAYMENT_SUCCESS,
PAYMENT_FAILURE,
PAYMENT_ONHOLD_PARTNER,
Expand All @@ -59,7 +60,7 @@ internal enum class SessionStateType {

fun toTransactionState(): PaymentResult.TransactionState? =
when (this) {
PAYMENT_METHODS_LIST, PAYMENT_REDIRECT_NO_RESPONSE, SDK_CHALLENGE, ACTIVE_WAITING -> PaymentResult.TransactionState.PAYMENT_INCOMPLETE
PAYMENT_METHODS_LIST, PAYMENT_REDIRECT_NO_RESPONSE, PAYMENT_REDIRECT_WITH_JAVASCRIPT, SDK_CHALLENGE, ACTIVE_WAITING -> PaymentResult.TransactionState.PAYMENT_INCOMPLETE
PAYMENT_SUCCESS -> PaymentResult.TransactionState.PAYMENT_SUCCESS
PAYMENT_FAILURE, PAYMENT_FAILURE_WITH_RETRY -> PaymentResult.TransactionState.PAYMENT_FAILURE
PAYMENT_CANCELED -> PaymentResult.TransactionState.PAYMENT_CANCELED
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ internal class SessionStateRepository(

suspend fun updateSessionState(token: String) {
makeRequest {
val sState = paymentAPI.stateCurrent(token)
val sState = paymentAPI.stateCurrent(sessionToken = token, merchantReturnUrl = this.returnURLString)
this.token = sState.token
sState
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,32 @@ internal data class PaymentForm(
val description: String? = null,
val buttonText: String? = null,
val formFields: List<PaymentMethodFormField> = emptyList(),
val formScript: FormScript? = null,
val formType: String? = null
) : Parcelable

@Parcelize
@Serializable
internal data class FormScript(
val content: String? = null,
val wrapIntoScriptTag: Boolean? = null,
val formScriptEnum: String? = null
) : Parcelable {

/**
* Retourne le contenu HTML prêt à être chargé dans la WebView.
* Si [wrapIntoScriptTag] vaut true, le contenu est entouré d'une balise <script></script>.
*/
fun htmlContent(): String? {
val script = content ?: return null
return if (wrapIntoScriptTag == true) {
"<script>$script</script>"
} else {
script
}
}
}

@Parcelize
@Serializable
internal data class PaymentMethodFormField(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.monext.sdk.internal.presentation

import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.monext.sdk.PaymentOverlayToggle
import com.monext.sdk.PaymentResult
import com.monext.sdk.PaymentResult.PaymentCompleted
Expand All @@ -17,9 +18,11 @@ import com.monext.sdk.internal.presentation.status.LoadingSection
import com.monext.sdk.internal.presentation.status.PaymentCanceledScreen
import com.monext.sdk.internal.presentation.status.PaymentFailureScreen
import com.monext.sdk.internal.presentation.status.PaymentPendingScreen
import com.monext.sdk.internal.presentation.status.PaymentJavascriptRedirectionScreen
import com.monext.sdk.internal.presentation.status.PaymentRedirectionScreen
import com.monext.sdk.internal.presentation.status.PaymentSuccessScreen
import com.monext.sdk.internal.presentation.status.TokenExpiredScreen
import com.monext.sdk.internal.service.PaymentForegroundService
import com.monext.sdk.internal.threeds.view.PaymentSdkChallengeScreen

internal data class PaymentAttempt(
Expand All @@ -38,13 +41,17 @@ internal fun PaymentContainer(
sessionState: SessionState?,
paymentMethodsScreen: @Composable (PaymentMethodsList, SessionInfo) -> Unit,
onRedirectionComplete: () -> Unit,
onMakePayment: (PaymentAttempt) -> Unit,
onRetry: () -> Unit,
onResult: (PaymentResult) -> Unit,
onIsShowingChange: ((Boolean) -> Unit)? = null,
showOverlay: (PaymentOverlayToggle) -> Unit
) {

val context = LocalContext.current

if (sessionState?.type?.isFinalState() == true) {
PaymentForegroundService.stop(context)
when(sessionState.type) {
SessionStateType.PAYMENT_SUCCESS -> onResult(
PaymentCompleted(
Expand Down Expand Up @@ -92,6 +99,30 @@ internal fun PaymentContainer(
} ?: LoadingSection()
}

SessionStateType.PAYMENT_REDIRECT_WITH_JAVASCRIPT -> {
// On a les memes data que "paymentMethodsList".
// On exécute le Javascript dans une WebView invisible (seul un spinner est affiché),
// puis on déclenche automatiquement le paiement du moyen de paiement chargé,
// sans afficher le bouton ni demander d'interaction à l'acheteur.
val paymentMethod = sessionState.paymentMethodsList?.paymentMethods?.firstOrNull()
val formScript = paymentMethod?.data?.form?.formScript
if (paymentMethod != null && formScript != null) {
PaymentJavascriptRedirectionScreen(formScript) {
onMakePayment(
PaymentAttempt(
selectedPaymentMethod = paymentMethod,
paymentFormData = FormData.AlternativePaymentMethodForm(saveCard = false),
selectedWallet = null,
walletFormData = null
)
)
}
} else {
// Ne peut normalement pas arriver car si le backend renvoit ce type de réponse, c'est qu'il a controlé les données.
LoadingSection()
}
}

SessionStateType.SDK_CHALLENGE -> {
sessionState.paymentSdkChallenge?.sdkChallengeData?.let { data ->
PaymentSdkChallengeScreen(sdkChallengeData = data, showOverlay)
Expand Down
Loading
Loading