Skip to content

Commit

Permalink
Added Credit Card payment Support to the Mobile SDK
Browse files Browse the repository at this point in the history
  • Loading branch information
AmniX committed Sep 25, 2024
1 parent 213dbf1 commit cffff63
Show file tree
Hide file tree
Showing 44 changed files with 538 additions and 165 deletions.
3 changes: 2 additions & 1 deletion android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
<action android:name="android.intent.action.VIEW" />

<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT"/>

<data android:scheme="komapp" />
<data android:scheme="@string/komoju_consumer_app_scheme" />
</intent-filter>
</activity>
</application>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,20 @@ import kotlinx.coroutines.launch
private const val ANIMATION_DURATION = 500

internal class KomojuPaymentActivity : ComponentActivity() {
private val viewModel by viewModels<KomojuPaymentViewModel>()

private val configuration: KomojuSDK.Configuration by lazy {
IntentCompat.getParcelableExtra(
/* in = */
intent,
/* name = */
KomojuSDK.CONFIGURATION_KEY,
/* clazz = */
KomojuSDK.Configuration::class.java,
) ?: error("komoju sdk configuration is null")
}
private val viewModel by viewModels<KomojuPaymentViewModel>(
factoryProducer = {
KomojuPaymentViewModelFactory(
configuration = IntentCompat.getParcelableExtra(
/* in = */
intent,
/* name = */
KomojuSDK.CONFIGURATION_KEY,
/* clazz = */
KomojuSDK.Configuration::class.java,
) ?: error("komoju sdk configuration is null"),
)
},
)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Expand All @@ -82,14 +84,14 @@ internal class KomojuPaymentActivity : ComponentActivity() {
.navigationBarsPadding(),
contentAlignment = Alignment.BottomCenter,
) {
KomojuMobileSdkTheme(configuration.language) {
KomojuMobileSdkTheme(viewModel.configuration.language) {
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(.9f),
) {
Navigator(
KomojuPaymentScreen(configuration),
KomojuPaymentScreen(viewModel.configuration),
) { navigator ->
SlideTransition(navigator)
RouterEffect(router, viewModel::onRouteConsumed)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ package com.degica.komoju.android.sdk

import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.degica.komoju.android.sdk.ui.screens.KomojuPaymentRoute
import com.degica.komoju.android.sdk.ui.screens.Router
import com.degica.komoju.android.sdk.ui.screens.failed.Reason
import com.degica.komoju.android.sdk.utils.DeeplinkEntity
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow

internal class KomojuPaymentViewModel : ViewModel() {
internal class KomojuPaymentViewModel(internal val configuration: KomojuSDK.Configuration) : ViewModel() {

private val _isVisible = MutableStateFlow(false)
val isVisible = _isVisible.asStateFlow()
Expand All @@ -27,10 +27,26 @@ internal class KomojuPaymentViewModel : ViewModel() {

fun onNewDeeplink(deeplink: String) {
val deeplinkEntity = DeeplinkEntity.from(deeplink)
when (deeplinkEntity.status) {
"success", "captured" -> _router.value = Router.ReplaceAll(KomojuPaymentRoute.PaymentSuccess)
else -> _router.value = Router.ReplaceAll(KomojuPaymentRoute.PaymentFailed(Reason.USER_CANCEL))
}
_router.value = Router.ReplaceAll(
KomojuPaymentRoute.ProcessPayment(
configuration = configuration,
processType = when (deeplinkEntity) {
is DeeplinkEntity.Verify.BySecureToken -> KomojuPaymentRoute.ProcessPayment.ProcessType.VerifyTokenAndPay(
deeplinkEntity.secureTokenId,
amount = deeplinkEntity.amount,
currency = deeplinkEntity.currency,
)
DeeplinkEntity.Verify.BySessionId -> KomojuPaymentRoute.ProcessPayment.ProcessType.Session
},
),
)
Log.d("Aman", "handleIntentAction $deeplinkEntity")
}
}

internal class KomojuPaymentViewModelFactory(private val configuration: KomojuSDK.Configuration) : ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return KomojuPaymentViewModel(configuration) as T
}
}
30 changes: 17 additions & 13 deletions android/src/main/java/com/degica/komoju/android/sdk/KomojuSDK.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
import kotlinx.parcelize.Parcelize

class KomojuSDK(private val configuration: Configuration) {
object KomojuSDK {
@Parcelize
data class Configuration(
internal val language: Language,
internal val currency: Currency,
internal val publishableKey: String?,
internal val isDebugMode: Boolean,
internal val sessionId: String?,
internal val redirectURL: String = "",
) : Parcelable {
class Builder(private var publishableKey: String, private var sessionId: String) {
private var language: Language = Language.ENGLISH
Expand Down Expand Up @@ -44,19 +45,22 @@ class KomojuSDK(private val configuration: Configuration) {
}
}

companion object {
internal const val CONFIGURATION_KEY: String = "KomojuSDK.Configuration"
fun show(context: Context, configuration: Configuration) {
context.preChecks()
val intent = android.content.Intent(context, KomojuPaymentActivity::class.java)
intent.putExtra(CONFIGURATION_KEY, configuration)
context.startActivity(intent)
}
internal const val CONFIGURATION_KEY: String = "KomojuSDK.Configuration"
fun show(context: Context, configuration: Configuration) {
context.preChecks()
val intent = android.content.Intent(context, KomojuPaymentActivity::class.java)
intent.putExtra(
CONFIGURATION_KEY,
configuration.copy(
redirectURL = "${context.resources.getString(R.string.komoju_consumer_app_scheme)}://",
),
)
context.startActivity(intent)
}

private fun Context.preChecks() {
if (resources.getString(R.string.komoju_consumer_app_scheme) == "this-should-not-be-the-case") {
error("Please set komoju_consumer_app_scheme in strings.xml with your app scheme")
}
private fun Context.preChecks() {
if (resources.getString(R.string.komoju_consumer_app_scheme) == "this-should-not-be-the-case") {
error("Please set komoju_consumer_app_scheme in strings.xml with your app scheme")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import com.degica.komoju.android.sdk.ui.screens.awating.KonbiniAwaitingPaymentSc
import com.degica.komoju.android.sdk.ui.screens.failed.PaymentFailedScreen
import com.degica.komoju.android.sdk.ui.screens.failed.Reason
import com.degica.komoju.android.sdk.ui.screens.success.PaymentSuccessScreen
import com.degica.komoju.android.sdk.ui.screens.verify.ProcessPaymentScreen
import com.degica.komoju.android.sdk.ui.screens.webview.WebViewScreen
import com.degica.komoju.mobile.sdk.entities.Payment

Expand All @@ -23,20 +24,32 @@ internal sealed class Router {
data class Replace(val route: KomojuPaymentRoute) : Router()
data class ReplaceAll(val route: KomojuPaymentRoute) : Router()
data class Handle(val url: String) : Router()
data class Browser(val url: String) : Router()
}

internal sealed interface KomojuPaymentRoute {
data class KonbiniAwaitingPayment(val configuration: KomojuSDK.Configuration, val payment: Payment) : KomojuPaymentRoute
data class WebView(val url: String, val canComeBack: Boolean = false) : KomojuPaymentRoute

data class WebView(val url: String, val canComeBack: Boolean = false, val isJavaScriptEnabled: Boolean = false) : KomojuPaymentRoute

data object PaymentSuccess : KomojuPaymentRoute
data class PaymentFailed(val reason: Reason) : KomojuPaymentRoute
data class ProcessPayment(val configuration: KomojuSDK.Configuration, val processType: ProcessType) : KomojuPaymentRoute {
sealed interface ProcessType {
data object Session : ProcessType
data class VerifyTokenAndPay(val token: String, val amount: String, val currency: String) : ProcessType

data class PayByToken(val token: String, val amount: String, val currency: String) : ProcessType
}
}

val screen
get() = when (this) {
is WebView -> WebViewScreen(this)
is KonbiniAwaitingPayment -> KonbiniAwaitingPaymentScreen(this)
is PaymentFailed -> PaymentFailedScreen(reason)
is PaymentFailed -> PaymentFailedScreen(this)
is PaymentSuccess -> PaymentSuccessScreen()
is ProcessPayment -> ProcessPaymentScreen(this)
}
}

Expand All @@ -55,7 +68,9 @@ internal fun RouterEffect(router: Router?, onHandled: () -> Unit) {
true -> context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(router.url)))
false -> navigator.push(KomojuPaymentRoute.WebView(router.url).screen)
}
else -> Unit

is Router.Browser -> context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(router.url)))
null -> Unit
}
onHandled()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ private fun KonbiniPaymentStatusPreview() {
konbiniStoreKey = "lawson",
email = "",
instructionURL = "",
amount = 110.00,
amount = "110",
currency = "JPY",
receiptNumber = "123456789",
confirmationCode = "123456",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ internal class KonbiniAwaitingPaymentScreenModel(private val config: KomojuSDK.C
mutableState.update {
it.copy(isLoading = true)
}
komojuApi.sessions.refreshPayment(sessionId).onSuccess { payment ->
komojuApi.sessions.verifyPaymentBySessionID(sessionId).onSuccess { payment ->
mutableState.update {
it.copy(payment = payment, isLoading = false)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,25 @@ import cafe.adriel.voyager.core.screen.Screen
import com.degica.komoju.android.sdk.R
import com.degica.komoju.android.sdk.types.Language
import com.degica.komoju.android.sdk.ui.composables.PrimaryButton
import com.degica.komoju.android.sdk.ui.screens.KomojuPaymentRoute
import com.degica.komoju.android.sdk.ui.theme.KomojuMobileSdkTheme
import com.degica.komoju.android.sdk.ui.theme.LocalI18nTextsProvider

internal class PaymentFailedScreen(val reason: Reason = Reason.USER_CANCEL) : Screen {
internal class PaymentFailedScreen(private val route: KomojuPaymentRoute.PaymentFailed) : Screen {
@Composable
override fun Content() {
PaymentFailedScreenContent(reason)
PaymentFailedScreenContent(route)
}
}

enum class Reason {
USER_CANCEL,
CREDIT_CARD_ERROR,
OTHER,
}

@Composable
private fun PaymentFailedScreenContent(reason: Reason) {
private fun PaymentFailedScreenContent(route: KomojuPaymentRoute.PaymentFailed) {
val onBackPressDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val i18nTexts = LocalI18nTextsProvider.current
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Expand All @@ -61,9 +63,10 @@ private fun PaymentFailedScreenContent(reason: Reason) {
Spacer(Modifier.height(16.dp))
Text(i18nTexts["PAYMENT_FAILED"], fontSize = 24.sp, fontWeight = FontWeight.Bold)
Text(
text = when (reason) {
text = when (route.reason) {
Reason.USER_CANCEL -> i18nTexts["PAYMENT_CANCELLED_MSG"]
Reason.OTHER -> i18nTexts["PAYMENT_RE_TRY_MSG_OTHERS"]
Reason.CREDIT_CARD_ERROR -> i18nTexts["PAYMENT_RE_TRY_MSG"]
},
modifier = Modifier.padding(16.dp),
textAlign = TextAlign.Center,
Expand All @@ -84,6 +87,6 @@ private fun PaymentFailedScreenContent(reason: Reason) {
@Preview
private fun PaymentSuccessScreenContentPreview() {
KomojuMobileSdkTheme(Language.ENGLISH) {
PaymentFailedScreenContent(Reason.USER_CANCEL)
PaymentFailedScreenContent(KomojuPaymentRoute.PaymentFailed(Reason.USER_CANCEL))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import cafe.adriel.voyager.core.model.screenModelScope
import com.degica.komoju.android.sdk.KomojuSDK
import com.degica.komoju.android.sdk.ui.screens.KomojuPaymentRoute
import com.degica.komoju.android.sdk.ui.screens.Router
import com.degica.komoju.android.sdk.ui.screens.failed.Reason
import com.degica.komoju.android.sdk.utils.CreditCardUtils.isValidCVV
import com.degica.komoju.android.sdk.utils.CreditCardUtils.isValidCardHolderNameChar
import com.degica.komoju.android.sdk.utils.CreditCardUtils.isValidCardNumber
Expand All @@ -13,6 +14,12 @@ import com.degica.komoju.android.sdk.utils.isValidEmail
import com.degica.komoju.mobile.sdk.entities.Payment
import com.degica.komoju.mobile.sdk.entities.PaymentMethod
import com.degica.komoju.mobile.sdk.entities.PaymentRequest
import com.degica.komoju.mobile.sdk.entities.SecureTokenRequest
import com.degica.komoju.mobile.sdk.entities.SecureTokenResponse.Status.ERRORED
import com.degica.komoju.mobile.sdk.entities.SecureTokenResponse.Status.NEEDS_VERIFY
import com.degica.komoju.mobile.sdk.entities.SecureTokenResponse.Status.OK
import com.degica.komoju.mobile.sdk.entities.SecureTokenResponse.Status.SKIPPED
import com.degica.komoju.mobile.sdk.entities.SecureTokenResponse.Status.UNKNOWN
import com.degica.komoju.mobile.sdk.remote.apis.KomojuRemoteApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
Expand Down Expand Up @@ -85,14 +92,40 @@ internal class KomojuPaymentScreenModel(private val config: KomojuSDK.Configurat
fun onPaymentRequested(paymentMethod: PaymentMethod) {
if (paymentMethod.validate()) {
mutableState.update { it.copy(isLoading = true) }
val request = paymentMethod.toPaymentRequest()
screenModelScope.launch {
komojuApi.sessions.pay(config.sessionId.orEmpty(), request).onSuccess { payment ->
mutableState.update { it.copy(isLoading = true) }
payment.handle()
}.onFailure {
mutableState.update { it.copy(isLoading = false) }
if (paymentMethod is PaymentMethod.CreditCard) {
paymentMethod.createSecureTokens()
} else {
val request = paymentMethod.toPaymentRequest()
screenModelScope.launch {
komojuApi.sessions.pay(config.sessionId.orEmpty(), request).onSuccess { payment ->
mutableState.update { it.copy(isLoading = true) }
payment.handle()
}.onFailure {
mutableState.update { it.copy(isLoading = false) }
}
}
}
}
}

private fun PaymentMethod.CreditCard.createSecureTokens() {
val request = toSecureTokenRequest()
screenModelScope.launch {
komojuApi.tokens.generateSecureToken(request).onSuccess {
when (it.status) {
OK, SKIPPED ->
_router.value =
Router.ReplaceAll(
KomojuPaymentRoute.ProcessPayment(
config,
processType = KomojuPaymentRoute.ProcessPayment.ProcessType.PayByToken(it.id, request.amount, request.currency),
),
)
NEEDS_VERIFY -> _router.value = Router.ReplaceAll(KomojuPaymentRoute.WebView(url = it.authURL, isJavaScriptEnabled = true))
ERRORED, UNKNOWN -> _router.value = Router.ReplaceAll(KomojuPaymentRoute.PaymentFailed(Reason.CREDIT_CARD_ERROR))
}
}.onFailure {
_router.value = Router.ReplaceAll(KomojuPaymentRoute.PaymentFailed(Reason.CREDIT_CARD_ERROR))
}
}
}
Expand Down Expand Up @@ -153,12 +186,23 @@ internal class KomojuPaymentScreenModel(private val config: KomojuSDK.Configurat
return nameError == null && emailError == null && konbiniBrandNullError == null
}

private fun PaymentMethod.CreditCard.toSecureTokenRequest() = SecureTokenRequest(
amount = amount.toInt().toString(),
currency = currency,
returnUrl = config.redirectURL + "creditCard?amount=$amount&currency=$currency",
cardNumber = state.value.creditCardDisplayData.creditCardNumber,
cardHolderName = state.value.creditCardDisplayData.fullNameOnCard,
expiryMonth = state.value.creditCardDisplayData.creditCardExpiryDate.take(2),
expiryYear = state.value.creditCardDisplayData.creditCardExpiryDate.takeLast(2),
cvv = state.value.creditCardDisplayData.creditCardCvv,
)

private fun PaymentMethod.toPaymentRequest(): PaymentRequest = when (this) {
is PaymentMethod.AliPay -> TODO()
is PaymentMethod.AuPay -> TODO()
is PaymentMethod.BankTransfer -> TODO()
is PaymentMethod.BitCash -> TODO()
is PaymentMethod.CreditCard -> TODO()
is PaymentMethod.CreditCard -> error("Credit Card needs to generate tokens first!")
is PaymentMethod.Konbini -> PaymentRequest.Konbini(
paymentMethod = this,
konbiniBrand = state.value.konbiniDisplayData.selectedKonbiniBrand!!,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ internal data class CreditCardDisplayData(
val creditCardExpiryDate: String = String.empty,
val creditCardCvv: String = String.empty,
val creditCardError: String? = null,
val canSaveCard: Boolean = false,
val saveCard: Boolean = false,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ private fun AppPayFormPreview() {
hashedGateway = "paypay",
exchangeRate = 1.0,
currency = "JPY",
amount = 100.0,
amount = "100",
additionalFields = listOf(),
isOffsite = false,
),
Expand Down
Loading

0 comments on commit cffff63

Please sign in to comment.