Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added Credit Card payment Support to the Mobile SDK #14

Merged
merged 3 commits into from
Sep 25, 2024
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
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
Loading