Skip to content
Draft
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
1 change: 1 addition & 0 deletions cmp-shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ kotlin {
implementation(projects.feature.merchants)
implementation(projects.feature.upiSetup)
implementation(projects.feature.passcode)
implementation(projects.feature.pocket)
}

desktopMain.dependencies {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import org.mifospay.feature.mpay.qr.di.MpayQrModule
import org.mifospay.feature.mpay.qr.scan.di.MpayQrScanModule
import org.mifospay.feature.notification.di.NotificationModule
import org.mifospay.feature.payments.di.PaymentsModule
import org.mifospay.feature.pocket.di.PocketModule
import org.mifospay.feature.profile.di.ProfileModule
import org.mifospay.feature.receipt.di.ReceiptModule
import org.mifospay.feature.savedcards.di.SavedCardsModule
Expand Down Expand Up @@ -96,6 +97,7 @@ object KoinModules {
FastMpayModule,
MerchantsModule,
UpiSetupModule,
PocketModule,
MifosAuthenticatorModule,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ import org.mifospay.feature.payments.PAYMENTS_ROUTE
import org.mifospay.feature.payments.RequestScreen
import org.mifospay.feature.payments.paymentsScreen
import org.mifospay.feature.payments.selectTransferType.SelectTransferTypeScreen
import org.mifospay.feature.pocket.navigation.linkAccountScreen
import org.mifospay.feature.pocket.navigation.navigateToLinkAccount
import org.mifospay.feature.pocket.navigation.navigateToPocketDashboard
import org.mifospay.feature.pocket.navigation.pocketDashboardScreen
import org.mifospay.feature.profile.navigation.navigateToProfile
import org.mifospay.feature.profile.navigation.profileNavGraph
import org.mifospay.feature.receipt.navigation.receiptScreen
Expand Down Expand Up @@ -210,6 +214,7 @@ internal fun MifosNavHost(
navController.navigateToMpayQrScreen()
},
onPay = navController::navigateToTransferOptions,
navigateToPocketDashboard = navController::navigateToPocketDashboard,
navigateToTransactionDetail = navController::navigateToSpecificTransaction,
navigateToAccountDetail = navController::navigateToSavingAccountDetails,
navigateToHistory = navController::navigateToHistory,
Expand Down Expand Up @@ -563,6 +568,17 @@ internal fun MifosNavHost(
navigateBack = navController::navigateUp,
)

pocketDashboardScreen(
navigateBack = navController::navigateUp,
navigateToLinkAccount = navController::navigateToLinkAccount,
navigateToAccountDetails = navController::navigateToSavingAccountDetails,
)

linkAccountScreen(
navigateBack = navController::navigateUp,
onLinkSuccess = { navController.navigateToPocketDashboard() },
)
Comment on lines +577 to +580

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid pushing Pocket Dashboard twice after link success.

This callback navigates to dashboard even though success handling already navigates back from the link screen, which can leave duplicate dashboard entries in back stack.

💡 Suggested fix
         linkAccountScreen(
             navigateBack = navController::navigateUp,
-            onLinkSuccess = { navController.navigateToPocketDashboard() },
+            onLinkSuccess = { /* no-op: LinkAccountScreen already navigates back on success */ },
         )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
linkAccountScreen(
navigateBack = navController::navigateUp,
onLinkSuccess = { navController.navigateToPocketDashboard() },
)
linkAccountScreen(
navigateBack = navController::navigateUp,
onLinkSuccess = { /* no-op: LinkAccountScreen already navigates back on success */ },
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt`
around lines 578 - 581, The onLinkSuccess callback in the linkAccountScreen
currently calls navController.navigateToPocketDashboard() while navigateBack is
set to navController::navigateUp, causing duplicate Pocket Dashboard entries;
remove the explicit navController.navigateToPocketDashboard() call from the
onLinkSuccess lambda (or replace it with a no-op) so that success handling
relies solely on navigateBack/navController::navigateUp to return to the
dashboard and avoid pushing the dashboard twice.


transferOptionsDialog(
onIntraBankTransferClick = navController::navigateToIntraBankHub,
onInterBankTransferClick = navController::navigateToInterbankTransfer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import org.mifospay.core.data.repository.KycLevelRepository
import org.mifospay.core.data.repository.LocalAssetRepository
import org.mifospay.core.data.repository.NotificationRepository
import org.mifospay.core.data.repository.OfficeRepository
import org.mifospay.core.data.repository.PocketRepository
import org.mifospay.core.data.repository.RecentPayeeRepository
import org.mifospay.core.data.repository.RegistrationRepository
import org.mifospay.core.data.repository.RunReportRepository
Expand All @@ -47,6 +48,7 @@ import org.mifospay.core.data.repositoryImpl.AssetRepositoryImpl
import org.mifospay.core.data.repositoryImpl.AuthenticationRepositoryImpl
import org.mifospay.core.data.repositoryImpl.BeneficiaryRepositoryImpl
import org.mifospay.core.data.repositoryImpl.ClientRepositoryImpl
import org.mifospay.core.data.repositoryImpl.DemoPocketRepository
import org.mifospay.core.data.repositoryImpl.DocumentRepositoryImpl
import org.mifospay.core.data.repositoryImpl.InterBankRepositoryImpl
import org.mifospay.core.data.repositoryImpl.InvoiceRepositoryImpl
Expand Down Expand Up @@ -111,6 +113,7 @@ val RepositoryModule = module {
single<TwoFactorAuthRepository> { TwoFactorAuthRepositoryImpl(get(), get(ioDispatcher)) }
single<UserRepository> { UserRepositoryImpl(get(), get(ioDispatcher)) }
single<OfficeRepository> { OfficeRepositoryImpl(get(), get(ioDispatcher)) }
single<PocketRepository> { DemoPocketRepository() }

singleOf(::MifosPasscodeAdapterImpl).bind<PasscodeStorageAdapter>()
singleOf(::AppLockRepositoryImpl).bind<AppLockRepository>()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* 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.data.repository

import kotlinx.coroutines.flow.Flow
import org.mifospay.core.common.DataState
import org.mifospay.core.model.pocket.Pocket

interface PocketRepository {
fun getPocket(): Flow<DataState<Pocket>>
fun linkAccount(accountId: Long, accountType: String): Flow<DataState<Unit>>
fun delinkAccount(accountId: Long): Flow<DataState<Unit>>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* 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.data.repositoryImpl

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update
import org.mifospay.core.common.DataState
import org.mifospay.core.data.repository.PocketRepository
import org.mifospay.core.model.pocket.Pocket
import org.mifospay.core.model.pocket.PocketAccount
import org.mifospay.core.model.pocket.PocketAccountType

class DemoPocketRepository : PocketRepository {
private val pocket = MutableStateFlow(
Pocket(
id = 1L,
clientId = 1L,
clientName = "Demo Client",
linkedAccounts = listOf(
PocketAccount(
accountId = 1L,
accountNumber = "000000001",
accountType = PocketAccountType.SAVINGS,
balance = 1250.75,
currency = "USD",
productName = "Voluntary Savings",
status = "Active",
),
PocketAccount(
accountId = 2L,
accountNumber = "000000002",
accountType = PocketAccountType.LOAN,
balance = -420.00,
currency = "USD",
productName = "Emergency Loan",
status = "Active",
),
),
),
)

override fun getPocket(): Flow<DataState<Pocket>> =
pocket
.map<Pocket, DataState<Pocket>> { DataState.Success(it) }
.onStart { emit(DataState.Loading) }

override fun linkAccount(accountId: Long, accountType: String): Flow<DataState<Unit>> = flow {
emit(DataState.Loading)
val type = accountType.toPocketAccountType()
pocket.update { current ->
if (current.linkedAccounts.any { it.accountId == accountId }) {
current
} else {
current.copy(
linkedAccounts = current.linkedAccounts + accountId.toDemoAccount(type),
)
}
}
emit(DataState.Success(Unit))
}

override fun delinkAccount(accountId: Long): Flow<DataState<Unit>> = flow {
emit(DataState.Loading)
pocket.update { current ->
current.copy(
linkedAccounts = current.linkedAccounts.filterNot { it.accountId == accountId },
)
}
emit(DataState.Success(Unit))
}
}

private fun String.toPocketAccountType(): PocketAccountType =
PocketAccountType.entries.firstOrNull { it.name == uppercase() } ?: PocketAccountType.SAVINGS

private fun Long.toDemoAccount(accountType: PocketAccountType): PocketAccount {
val productName = when (accountType) {
PocketAccountType.SAVINGS -> "Savings Account"
PocketAccountType.LOAN -> "Loan Account"
PocketAccountType.SHARE -> "Share Account"
}

val balance = when (accountType) {
PocketAccountType.SAVINGS -> 500.00 + this
PocketAccountType.LOAN -> -250.00 - this
PocketAccountType.SHARE -> 100.00 + this
}

return PocketAccount(
accountId = this,
accountNumber = this.toString().padStart(9, '0'),
accountType = accountType,
balance = balance,
currency = "USD",
productName = productName,
status = "Active",
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* 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.data.repositoryImpl

import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.mifospay.core.common.DataState
import org.mifospay.core.data.repository.PocketRepository
import org.mifospay.core.model.pocket.Pocket
import org.mifospay.core.model.pocket.PocketDelinkPayload
import org.mifospay.core.model.pocket.PocketLinkPayload
import org.mifospay.core.network.SelfServiceApiManager

class PocketRepositoryImpl(
private val apiManager: SelfServiceApiManager,
private val ioDispatcher: CoroutineDispatcher,
) : PocketRepository {

override fun getPocket(): Flow<DataState<Pocket>> =
apiManager.pocketApi.getPockets()
.map<Pocket, DataState<Pocket>> { DataState.Success(it) }
.onStart { emit(DataState.Loading) }
.catch { emit(DataState.Error(it)) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify broad exception catches and absence/presence of cancellation handling.
rg -nP --type=kotlin 'catch\s*\(\s*e:\s*Exception\s*\)' core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/PocketRepositoryImpl.kt
rg -n --type=kotlin 'CancellationException' core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/PocketRepositoryImpl.kt

Repository: openMF/mifos-pay

Length of output: 132


🏁 Script executed:

cat -n core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/PocketRepositoryImpl.kt

Repository: openMF/mifos-pay

Length of output: 2625


Rethrow CancellationException to preserve cooperative cancellation.

Line 35 uses Flow.catch which catches all exceptions including CancellationException, converting cancellation into DataState.Error. This breaks cancellation semantics. The .catch block at line 35 must rethrow CancellationException.

Similarly, lines 43 and 53 should explicitly rethrow CancellationException for consistency and defense against any cancellation exceptions that may propagate.

Suggested fix
+import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.CoroutineDispatcher
 import kotlinx.coroutines.flow.Flow
@@
-            .catch { emit(DataState.Error(it)) }
+            .catch {
+                if (it is CancellationException) throw it
+                emit(DataState.Error(it))
+            }
@@
-        } catch (e: Exception) {
+        } catch (e: Exception) {
+            if (e is CancellationException) throw e
             emit(DataState.Error(e))
         }
@@
-        } catch (e: Exception) {
+        } catch (e: Exception) {
+            if (e is CancellationException) throw e
             emit(DataState.Error(e))
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/PocketRepositoryImpl.kt`
at line 35, The Flow.catch blocks in PocketRepositoryImpl that currently do
`.catch { emit(DataState.Error(it)) }` must preserve cooperative cancellation:
change each catch handler to rethrow CancellationException (e.g., if (it is
CancellationException) throw it) and only emit DataState.Error for
non-cancellation exceptions; update all three catch sites in
PocketRepositoryImpl (the catch wrapping Flow emissions that emit
DataState.Error) and ensure kotlin.coroutines.cancellation.CancellationException
is referenced/imported so cancellation is not swallowed.

.flowOn(ioDispatcher)

override fun linkAccount(accountId: Long, accountType: String): Flow<DataState<Unit>> = flow {
emit(DataState.Loading)
try {
apiManager.pocketApi.linkAccounts(payload = PocketLinkPayload(accountId, accountType))
emit(DataState.Success(Unit))
} catch (e: Exception) {
emit(DataState.Error(e))
}
}.flowOn(ioDispatcher)

override fun delinkAccount(accountId: Long): Flow<DataState<Unit>> = flow {
emit(DataState.Loading)
try {
apiManager.pocketApi.delinkAccounts(payload = PocketDelinkPayload(accountId))
emit(DataState.Success(Unit))
} catch (e: Exception) {
emit(DataState.Error(e))
}
}.flowOn(ioDispatcher)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* 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.pocket

import kotlinx.serialization.Serializable

@Serializable
data class Pocket(
val id: Long,
val clientId: Long,
val clientName: String,
val linkedAccounts: List<PocketAccount> = emptyList(),
) {
val totalBalance: Double
get() = linkedAccounts.sumOf { it.balance }
}

@Serializable
data class PocketAccount(
val accountId: Long,
val accountNumber: String,
val accountType: PocketAccountType,
val balance: Double,
val currency: String,
val productName: String,
val status: String,
)

enum class PocketAccountType {
SAVINGS,
LOAN,
SHARE,
}

@Serializable
data class PocketLinkPayload(
val accountId: Long,
val accountType: String,
)

@Serializable
data class PocketDelinkPayload(
val accountId: Long,
)
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import org.mifospay.core.network.services.createInvoiceService
import org.mifospay.core.network.services.createKYCLevel1Service
import org.mifospay.core.network.services.createNotificationService
import org.mifospay.core.network.services.createOfficeService
import org.mifospay.core.network.services.createPocketService
import org.mifospay.core.network.services.createRegistrationService
import org.mifospay.core.network.services.createRunReportService
import org.mifospay.core.network.services.createSavedCardService
Expand Down Expand Up @@ -70,4 +71,6 @@ class KtorfitClient(
internal val interBankApi by lazy { ktorfit.createInterBankService() }

internal val officeApi by lazy { ktorfit.createOfficeService() }

internal val pocketApi by lazy { ktorfit.createPocketService() }
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@ class SelfServiceApiManager(
val thirdPartyTransferApi by lazy { ktorfitClient.thirdPartyTransferApi }

val accountTransfersApi by lazy { ktorfitClient.accountTransfersApi }

val pocketApi by lazy { ktorfitClient.pocketApi }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* 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.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.Query
import kotlinx.coroutines.flow.Flow
import org.mifospay.core.model.pocket.Pocket
import org.mifospay.core.model.pocket.PocketDelinkPayload
import org.mifospay.core.model.pocket.PocketLinkPayload
import org.mifospay.core.network.utils.ApiEndPoints

interface PocketService {
@GET(ApiEndPoints.POCKETS)
fun getPockets(): Flow<Pocket>
Comment on lines +23 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix method name to match return type.

The method name getPockets() is plural, but it returns Flow<Pocket> (singular). This creates a naming inconsistency. Based on the repository interface using getPocket() (singular) and the domain model structure (a single Pocket contains multiple linkedAccounts), the service method should be named getPocket() to match.

📝 Proposed fix
 `@GET`(ApiEndPoints.POCKETS)
-fun getPockets(): Flow<Pocket>
+fun getPocket(): Flow<Pocket>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@GET(ApiEndPoints.POCKETS)
fun getPockets(): Flow<Pocket>
`@GET`(ApiEndPoints.POCKETS)
fun getPocket(): Flow<Pocket>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/network/src/commonMain/kotlin/org/mifospay/core/network/services/PocketService.kt`
around lines 23 - 24, Rename the service method getPockets() to getPocket() to
match its return type Flow<Pocket> and the repo interface; update the function
declaration in PocketService (replace fun getPockets(): Flow<Pocket> with fun
getPocket(): Flow<Pocket>) and then update any callers or references to use
getPocket() so signatures remain consistent with the domain model and
repository.


@POST(ApiEndPoints.POCKETS)
suspend fun linkAccounts(
@Query("command") command: String = "linkAccounts",
@Body payload: PocketLinkPayload,
)

@POST(ApiEndPoints.POCKETS)
suspend fun delinkAccounts(
@Query("command") command: String = "delinkAccounts",
@Body payload: PocketDelinkPayload,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ object ApiEndPoints {
const val USER = "users"
const val STANDING_INSTRUCTION = "standinginstructions"
const val OFFICES = "offices"
const val POCKETS = "self/pockets"
}
Loading