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

Feat: Migrated Make Transfer Module to KMP #1809

Merged
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
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,14 @@ that can be used as a dependency in any other wallet based project. It is develo
| :feature:account | Done | ✅ | ✅ | ❔ | ✅ | ❔ |
| :feature:invoices | Done | ✅ | ✅ | ❔ | ✅ | ❔ |
| :feature:kyc | Done | ✅ | ✅ | ❔ | ✅ | ❔ |
| :feature:make-transfer | Not started | ❌ | | | | |
| :feature:make-transfer | Done | ✅ | | | | |
| :feature:merchants | Not started | ❌ | ❌ | ❌ | ❌ | ❌ |
| :feature:notification | Done | ✅ | ✅ | ❔ | ✅ | ❔ |
| :feature:qr | Not started | ❌ | ❌ | ❌ | ❌ | ❌ |
| :feature:receipt | Done | ✅ | ✅ | ❔ | ✅ | ❔ |
| :feature:request-money | Done | ✅ | ✅ | ❔ | ✅ | ❔ |
| :feature:saved-cards | Done | ✅ | ✅ | ❔ | ✅ | ❔ |
| :feature:search | Not started | ❌ | ❌ | ❌ | ❌ | ❌ |
| :feature:send-money | Not started | ❌ | ❌ | ❌ | ❌ | ❌ |
| :feature:saved-cards | Done | ✅ | ✅ | ❔ | ✅ | ❔ |
| :feature:send-money | Done | ✅ | ✅ | ❔ | ✅ | ❔ |
| :feature:standing-instruction | Done | ✅ | ✅ | ❔ | ✅ | ❔ |
| :feature:upi-setup | Not started | ❌ | ❌ | ❌ | ❌ | ❌ |
| lint | Not started | ❌ | ❌ | ❌ | ❌ | ❌ |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ fun maskString(input: String, maskChar: Char = '*'): String {
append(input.takeLast(visibleCount))
}
}

fun String.capitalizeWords(): String = split(" ").joinToString(" ") { it ->
it.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ package org.mifospay.core.data.repository

import kotlinx.coroutines.flow.Flow
import org.mifospay.core.common.DataState
import org.mifospay.core.model.account.Account
import org.mifospay.core.model.account.AccountTransferPayload
import org.mifospay.core.model.savingsaccount.Transaction
import org.mifospay.core.model.savingsaccount.TransferDetail
import org.mifospay.core.model.search.AccountResult
Expand All @@ -21,4 +23,8 @@ interface AccountRepository {
fun getAccountTransfer(transferId: Long): Flow<DataState<TransferDetail>>

fun searchAccounts(query: String): Flow<DataState<List<AccountResult>>>

fun getSelfAccounts(clientId: Long): Flow<DataState<List<Account>>>

suspend fun makeTransfer(payload: AccountTransferPayload): DataState<String>
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.mifospay.core.common.DataState
import org.mifospay.core.common.asDataStateFlow
import org.mifospay.core.data.mapper.toAccount
import org.mifospay.core.data.mapper.toModel
import org.mifospay.core.data.repository.AccountRepository
import org.mifospay.core.data.util.Constants
import org.mifospay.core.model.account.Account
import org.mifospay.core.model.account.AccountTransferPayload
import org.mifospay.core.model.savingsaccount.Transaction
import org.mifospay.core.model.savingsaccount.TransferDetail
import org.mifospay.core.model.search.AccountResult
Expand Down Expand Up @@ -50,4 +55,23 @@ class AccountRepositoryImpl(
.catch { DataState.Error(it, null) }
.asDataStateFlow().flowOn(ioDispatcher)
}

override fun getSelfAccounts(clientId: Long): Flow<DataState<List<Account>>> {
return apiManager.clientsApi
.getAccounts(clientId, Constants.SAVINGS)
.map { it.toAccount() }
.asDataStateFlow().flowOn(ioDispatcher)
}

override suspend fun makeTransfer(payload: AccountTransferPayload): DataState<String> {
return try {
withContext(ioDispatcher) {
apiManager.accountTransfersApi.makeTransfer(payload)
}

DataState.Success("Transaction Successful")
} catch (e: Exception) {
DataState.Error(e, null)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
*/
package org.mifospay.core.data.util

import io.ktor.utils.io.core.toByteArray
import io.ktor.util.decodeBase64String
import io.ktor.util.encodeBase64
import org.mifospay.core.model.utils.PaymentQrData
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi

/**
Expand Down Expand Up @@ -39,47 +39,51 @@ object UpiQrCodeProcessor {
// Build UPI string
val requestPaymentString = buildString {
append("upi://pay")
append("?pa=${paymentQrData.vpaId}")

// Add amount if not empty
if (paymentQrData.amount.isNotEmpty()) {
append("&am=${paymentQrData.amount}")
}

append("&pn=${paymentQrData.name}")
append("&ac=${paymentQrData.accountNo}")
append("?ci=${paymentQrData.clientId}")
append("&am=${paymentQrData.amount}")
append("&cn=${paymentQrData.clientName}")
append("&an=${paymentQrData.accountNo}")
append("&ai=${paymentQrData.accountId}")
append("&cu=${paymentQrData.currency}")
append("&oi=${paymentQrData.officeId}")
append("&pi=${paymentQrData.accountTypeId}")
append("&mode=02")
append("&s=000000")
}

return Base64.encode(requestPaymentString.toByteArray())
return requestPaymentString.encodeBase64()
}

/**
* Decodes a Base64 encoded UPI payment string
* @param encodedString Base64 encoded UPI payment string
* @return Decoded RequestQrData object
* @return Decoded PaymentQrData object
* @throws IllegalArgumentException for invalid encoded string
*/
fun decodeUpiString(encodedString: String): PaymentQrData {
// Decode the Base64 string
val decodedString = try {
Base64.decode(encodedString).toString()
} catch (e: Exception) {
throw IllegalArgumentException("Invalid Base64 encoded string")
}
val decodedString = encodedString.decodeBase64String()

// Extract parameters
val params = parseUpiString(decodedString)

// Create RequestQrData
// Create PaymentQrData
val requestQrData = PaymentQrData(
vpaId = params["pa"] ?: throw IllegalArgumentException("Missing VPA"),
name = params["pn"] ?: throw IllegalArgumentException("Missing payee name"),
accountNo = params["ac"] ?: throw IllegalArgumentException("Missing account number"),
currency = params["cu"] ?: throw IllegalArgumentException("Missing currency"),
clientId = params["ci"]?.toLongOrNull()
?: throw IllegalArgumentException("Missing client ID"),
clientName = params["cn"]
?: throw IllegalArgumentException("Missing client name"),
accountNo = params["an"]
?: throw IllegalArgumentException("Missing account number"),
amount = params["am"] ?: "",
accountId = params["ai"]?.toLongOrNull()
?: throw IllegalArgumentException("Missing account ID"),
currency = params["cu"]
?: PaymentQrData.DEFAULT_CURRENCY,
officeId = params["oi"]?.toLongOrNull()
?: PaymentQrData.OFFICE_ID,
accountTypeId = params["pi"]?.toLongOrNull()
?: PaymentQrData.ACCOUNT_TYPE_ID,
)

// Validate the created object
Expand All @@ -89,22 +93,18 @@ object UpiQrCodeProcessor {
}

/**
* Validates the RequestQrData
* Validates the PaymentQrData
* @param data UPI payment request details to validate
* @throws IllegalArgumentException for any validation failures
*/
private fun validate(data: PaymentQrData) {
// VPA validation
// require(VPA_PATTERN.matches(data.vpaId)) {
// "Invalid VPA format. Must be in username@provider format"
// }

// Name validation
require(data.name.isNotBlank()) {
"Payee name cannot be empty"
require(data.clientName.isNotBlank()) {
"Client name cannot be empty"
}
require(data.name.length <= 50) {
"Payee name too long (max 50 characters)"

require(data.clientName.length <= 50) {
"Client name too long (max 50 characters)"
}

// Account number validation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import androidx.compose.material.icons.filled.Photo
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.filled.QrCode2
import androidx.compose.material.icons.filled.RadioButtonChecked
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
Expand Down Expand Up @@ -125,4 +127,6 @@ object MifosIcons {
val Badge = Icons.Filled.Badge
val DataInfo = Icons.Filled.Description
val Scan = Icons.Outlined.QrCodeScanner
val RadioButtonUnchecked = Icons.Default.RadioButtonUnchecked
val RadioButtonChecked = Icons.Filled.RadioButtonChecked
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
package org.mifospay.core.domain

import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import org.mifospay.core.common.DataState
import org.mifospay.core.data.repository.AuthenticationRepository
import org.mifospay.core.data.repository.ClientRepository
Expand All @@ -23,7 +24,11 @@ class LoginUseCase(
private val ioDispatcher: CoroutineDispatcher,
) {
suspend operator fun invoke(username: String, password: String): DataState<UserInfo> {
return when (val result = repository.authenticate(username, password)) {
val result = withContext(ioDispatcher) {
repository.authenticate(username, password)
}

return when (result) {
is DataState.Loading -> DataState.Loading
is DataState.Error -> DataState.Error(Exception("Invalid credentials"))
is DataState.Success -> {
Expand All @@ -36,8 +41,10 @@ class LoginUseCase(
}

private suspend fun updateUserInfo(userInfo: UserInfo): DataState<UserInfo> {
val updateResult =
val updateResult = withContext(ioDispatcher) {
userPreferencesRepository.updateToken(userInfo.base64EncodedAuthenticationKey)
}

return when (updateResult) {
is DataState.Success -> updateClientInfo(userInfo)
is DataState.Error -> DataState.Error(Exception("Something went wrong"))
Expand All @@ -46,10 +53,16 @@ class LoginUseCase(
}

private suspend fun updateClientInfo(userInfo: UserInfo): DataState<UserInfo> {
return when (val clientInfo = clientRepository.getClient(userInfo.clients.first())) {
val clientInfo = withContext(ioDispatcher) {
clientRepository.getClient(userInfo.clients.first())
}

return when (clientInfo) {
is DataState.Success -> {
userPreferencesRepository.updateClientInfo(clientInfo.data)
userPreferencesRepository.updateUserInfo(userInfo)
withContext(ioDispatcher) {
userPreferencesRepository.updateClientInfo(clientInfo.data)
userPreferencesRepository.updateUserInfo(userInfo)
}

DataState.Success(userInfo)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright 2024 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md
*/
package org.mifospay.core.model.account

import kotlinx.serialization.Serializable
import org.mifospay.core.common.Parcelable
import org.mifospay.core.common.Parcelize

@Serializable
@Parcelize
data class AccountTransferPayload(
val fromOfficeId: Long,
val fromClientId: Long,
val fromAccountType: Long,
val fromAccountId: Long,

val toOfficeId: Long,
val toClientId: Long,
val toAccountType: Long,
val toAccountId: Long,

val transferAmount: String,
val transferDescription: String,

val locale: String,
val dateFormat: String,
val transferDate: String,
) : Parcelable
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,33 @@ import org.mifospay.core.common.Parcelize

/**
* Parcelable data class representing the UPI payment request details
* @property name Payee name
* @property vpaId Virtual Payment Address (VPA) of the payee
* @property clientName Payee name
* @property clientId Virtual Payment Address (VPA) of the payee
* @property accountNo Account number
* @property currency Currency code
* @property amount Payment amount as a string
*/
@Parcelize
data class PaymentQrData(
val name: String,
val vpaId: String,
val clientId: Long,
val clientName: String,
val accountNo: String,
val currency: String,
val amount: String,
) : Parcelable
val accountId: Long,
val currency: String = DEFAULT_CURRENCY,
val officeId: Long = OFFICE_ID,
val accountTypeId: Long = ACCOUNT_TYPE_ID,
) : Parcelable {

/**
* Companion object containing constants for default values
* currently Savings Account to Savings Account Transaction are allowed
*/
companion object {
const val DEFAULT_CURRENCY = "USD"
const val OFFICE_ID: Long = 1

// WALLET
const val ACCOUNT_TYPE_ID: Long = 2
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@
*/
package org.mifospay.core.network.services

import de.jensklingenberg.ktorfit.http.Body
import de.jensklingenberg.ktorfit.http.GET
import de.jensklingenberg.ktorfit.http.POST
import de.jensklingenberg.ktorfit.http.Path
import de.jensklingenberg.ktorfit.http.Query
import kotlinx.coroutines.flow.Flow
import org.mifospay.core.model.account.AccountTransferPayload
import org.mifospay.core.model.savingsaccount.TransactionsEntity
import org.mifospay.core.model.savingsaccount.TransferDetail
import org.mifospay.core.model.search.AccountResult
Expand All @@ -34,4 +37,9 @@ interface AccountTransfersService {
@Query("query") query: String,
@Query("resource") resource: String,
): Flow<List<AccountResult>>

@POST(ApiEndPoints.ACCOUNT_TRANSFER)
suspend fun makeTransfer(
@Body payload: AccountTransferPayload,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ package org.mifospay.core.ui.utils

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ class HomeViewModel(
is DataState.Loading -> ViewState.Loading

is DataState.Success -> {
if (state.defaultAccountId == null && result.data.accounts.isNotEmpty()) {
val accountId = result.data.accounts.first().id
preferencesRepository.updateDefaultAccount(accountId)
}

ViewState.Content(
accounts = result.data.accounts,
transactions = result.data.transactions,
Expand Down
Loading
Loading