NTP-70595 | Weekly Hisaab Implementation (#16581)

Co-authored-by: Sohan Reddy Atukula <sohan.reddy@navi.com>
This commit is contained in:
Sanjay P
2025-06-20 13:53:12 +05:30
committed by GitHub
parent d251c89fc0
commit 9ebf57cd3a
29 changed files with 1271 additions and 55 deletions

View File

@@ -142,6 +142,8 @@ object FirebaseRemoteConfigHelper {
const val NAVI_PAY_ENABLE_DARK_KNIGHT = "NAVI_PAY_ENABLE_DARK_KNIGHT"
const val NAVI_PAY_UPI_GLOBAL_ENABLED = "NAVI_PAY_UPI_GLOBAL_ENABLED"
const val MM_DISCOVERABILITY_ENABLED = "MM_DISCOVERABILITY_ENABLED"
const val MM_WEEKLY_HISAAB_ENABLED = "MM_WEEKLY_HISAAB_ENABLED"
const val MM_UPI_SYNC_AND_CONFIG_API_TIMEOUT_MS = "MM_UPI_SYNC_AND_CONFIG_API_TIMEOUT_MS"
const val NAVI_PAY_EMI_CONVERSION_ENABLED = "NAVI_PAY_EMI_CONVERSION_ENABLED"
const val SHOULD_USE_GLOBAL_SCOPE_FOR_DASHBOARD_SYNC =
"SHOULD_USE_GLOBAL_SCOPE_FOR_DASHBOARD_SYNC"

View File

@@ -1096,3 +1096,34 @@ interface MMDiscoverabilityEventTracker {
@EventName("mm_discoverability_entry_point_load_triggered")
fun onMMDiscoverabilityEntryPointLoadTriggered()
}
@AutoGenerate
interface MMWeeklyHisaabEventTracker {
@EventName("mm_hisaab_widget_visible") fun onMMHisaabWidgetVisible()
@EventName("mm_mrr_current_month_spends_clicked") fun onMMMRRCurrentMonthSpendsClicked()
@EventName("mm_mrr_previous_month_spends_clicked") fun onMMMRRPreviousMonthSpendsClicked()
@EventName("mm_mrr_spends_comparison_balance_insight_clicked")
fun onMMWeeklyHisaabBalanceInsightClicked()
@EventName("mm_mrr_spends_comparison_balance_insight")
fun onMMWeeklyHisaabBalanceInsight(state: String, color: String)
@EventName("mm_weekly_hisaab_remote_config_disabled")
fun onMMWeeklyHisaabDisabledViaRemoteConfig()
@EventName("mm_weekly_hisaab_config_api_called") fun onMMWeeklyHisaabConfigApiCalled()
@EventName("mm_weekly_hisaab_litmus_enabled") fun onMMWeeklyHisaablitmusEnabled()
@EventName("mm_weekly_hisaab_litmus_disabled")
fun onMMWeeklyHisaabLitmusDisabled(reason: String)
@EventName("mm_weekly_hisaab_config_api_failed")
fun onMMWeeklyHisaabConfigApiFailed(errorMessage: String, statusCode: String)
@EventName("mm_upi_txn_sync_skipped_already_running") fun onUpiSyncSkippedAlreadyRunning()
}

View File

@@ -15,7 +15,13 @@ import com.navi.moneymanager.R
import com.navi.moneymanager.common.illustration.model.IllustrationSource
import com.navi.moneymanager.common.illustration.repository.ImageRepository.Companion.CHECK_BALANCE_LOCK_ICON
import com.navi.moneymanager.common.illustration.repository.ImageRepository.Companion.IMAGE_TRANSPARENT_PLACEHOLDER_MEDIUM
import com.navi.moneymanager.common.illustration.repository.ImageRepository.Companion.SPIKE_ICON_URL
import com.navi.moneymanager.common.illustration.repository.ImageRepository.Companion.WHITE_ARROW_DOWN
import com.navi.moneymanager.common.illustration.repository.ImageRepository.Companion.YELLOW_ARROW_DIAGONAL_RIGHT_UP
import com.navi.moneymanager.common.model.CategorySummary
import com.navi.moneymanager.common.model.SpendComparisonType
import com.navi.moneymanager.common.model.SpendTrendColor
import com.navi.moneymanager.common.model.SpendingTrend
import com.navi.moneymanager.common.model.database.AccountOverview
import com.navi.moneymanager.common.navigation.navigator.MMDeeplinkNavigator.MONEY_MANAGER_ACTIVITY
import com.navi.moneymanager.common.network.model.CategoryItemData
@@ -41,6 +47,7 @@ import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceMMEntryPointSt
import com.navi.naviwidgets.utils.NaviWidgetLottieUtils.TUK_TUK_LOTTIE
import com.navi.naviwidgets.utils.ZERO
import javax.inject.Inject
import kotlin.math.roundToInt
class CheckBalanceScreenEntryPointHelper @Inject constructor() {
@@ -196,4 +203,49 @@ class CheckBalanceScreenEntryPointHelper @Inject constructor() {
),
)
}
fun evaluateSpendingTrend(
currentMonthTotalSpend: Double,
previousMonthTotalSpend: Double,
): SpendingTrend? {
if (currentMonthTotalSpend == 0.0 || previousMonthTotalSpend == 0.0) return null
val percentChange =
(((currentMonthTotalSpend - previousMonthTotalSpend) / previousMonthTotalSpend) * 100)
.roundToInt()
return when {
percentChange > 50 ->
SpendingTrend(
SpendComparisonType.HIGHER,
SpendTrendColor.RED,
percentChange,
iconUrl = SPIKE_ICON_URL,
)
percentChange in 1..50 ->
SpendingTrend(
SpendComparisonType.HIGHER,
SpendTrendColor.AMBER,
percentChange,
iconUrl = SPIKE_ICON_URL,
)
percentChange < 0 ->
SpendingTrend(
SpendComparisonType.LESS,
SpendTrendColor.GREEN,
kotlin.math.abs(percentChange),
iconUrl = WHITE_ARROW_DOWN,
)
else ->
SpendingTrend(
SpendComparisonType.SIMILAR,
SpendTrendColor.GREEN,
0,
iconUrl = YELLOW_ARROW_DIAGONAL_RIGHT_UP,
)
}
}
}

View File

@@ -20,6 +20,7 @@ import com.navi.moneymanager.common.dataprovider.utils.isSameAccount
import com.navi.moneymanager.common.datasync.model.DataSyncState
import com.navi.moneymanager.common.db.database.MMDatabase
import com.navi.moneymanager.common.model.CategorySummary
import com.navi.moneymanager.common.model.SyncStatusBundle
import com.navi.moneymanager.common.model.database.AccountOverview
import com.navi.moneymanager.common.network.di.RoomDataStoreInfoProvider
import com.navi.moneymanager.common.network.model.MMConfigResponse
@@ -300,10 +301,3 @@ constructor(
}
}
}
data class SyncStatusBundle(
val isFirstMonthSynced: Boolean,
val isTotalSyncCompleted: Boolean,
val syncState: DataSyncState,
val linkedAccounts: List<AccountOverview?>,
)

View File

@@ -0,0 +1,187 @@
/*
*
* * Copyright © 2025 by Navi Technologies Limited
* * All rights reserved. Strictly confidential
*
*/
package com.navi.moneymanager.common.dataprovider.data.checkbalance.provider
import com.navi.base.utils.formatToInrWithDecimals
import com.navi.common.utils.monthAbbreviations
import com.navi.moneymanager.common.dataprovider.data.checkbalance.helper.CheckBalanceScreenEntryPointHelper
import com.navi.moneymanager.common.dataprovider.data.dashboard.helper.MMWeeklyHisaabConfigResponseHelper
import com.navi.moneymanager.common.dataprovider.domain.upi.CheckBalanceScreenEntryPointUPIDataProvider
import com.navi.moneymanager.common.dataprovider.utils.MMQueryExecutor.Companion.executeQueryFlow
import com.navi.moneymanager.common.db.upi.database.UpiSpendDatabase
import com.navi.moneymanager.common.model.DateInfo
import com.navi.moneymanager.common.model.SelectedMonth
import com.navi.moneymanager.common.utils.Constants.DEBIT
import com.navi.moneymanager.common.utils.MonthConstants
import com.navi.moneymanager.common.utils.combineWithFlatMapLatest
import com.navi.moneymanager.common.utils.getDateInfoFromTimestamp
import com.navi.moneymanager.common.utils.getPreviousMonthDayLimit
import com.navi.moneymanager.entry.model.checkbalance.MMWeeklyHisaabWidgetData
import com.navi.moneymanager.entry.model.checkbalance.SpendComparisonData
import com.navi.moneymanager.entry.model.checkbalance.SpendComparisonViewState
import com.navi.moneymanager.entry.model.checkbalance.SpendItemData
import dagger.Lazy
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
class CheckBalanceScreenEntryPointUPIDataProviderImpl
@Inject
constructor(
private val upiSpendDatabase: Lazy<UpiSpendDatabase>,
private val entryPointHelper: CheckBalanceScreenEntryPointHelper,
private val mmWeeklyHisaabConfigResponseHelper: MMWeeklyHisaabConfigResponseHelper,
) : CheckBalanceScreenEntryPointUPIDataProvider {
override suspend fun getMMWeeklyHisaabWidgetData(): Flow<MMWeeklyHisaabWidgetData> {
val dateInfo = getDateInfoFromTimestamp(System.currentTimeMillis())
val currentMonth = dateInfo.month
val currentYear = dateInfo.year
val currentDay = dateInfo.day
val previousMonthDay = getPreviousMonthDayLimit(dateInfo)
val previousMonth = if (currentMonth == 0) 11 else currentMonth - 1
val previousYear = if (currentMonth == 0) currentYear - 1 else currentYear
return combineWithFlatMapLatest(
flow1 =
getMonthlyTransactionTotal(
month = currentMonth,
year = currentYear,
dayLimit = currentDay,
transactionType = DEBIT,
),
flow2 =
getMonthlyTransactionTotal(
month = previousMonth,
year = previousYear,
dayLimit = previousMonthDay,
transactionType = DEBIT,
),
combineTransform = { currentMonthTotalSpend, previousMonthTotalSpend ->
Pair(currentMonthTotalSpend, previousMonthTotalSpend)
},
flatMapLatestTransform = { (currentMonthTotalSpend, previousMonthTotalSpend) ->
emit(
MMWeeklyHisaabWidgetData(
monthName = MonthConstants.monthNames[currentMonth],
currentMonthSpend =
SpendItemData(
monthName = monthAbbreviations[currentMonth],
selectedMonth =
SelectedMonth(month = currentMonth, year = currentYear),
currentDay = currentDay,
spendAmount =
currentMonthTotalSpend.formatToInrWithDecimals(
showDecimalOnlyForFractional = true
),
),
previousMonthSpend =
SpendItemData(
monthName = monthAbbreviations[previousMonth],
selectedMonth =
SelectedMonth(month = previousMonth, year = previousYear),
currentDay = previousMonthDay,
spendAmount =
previousMonthTotalSpend.formatToInrWithDecimals(
showDecimalOnlyForFractional = true
),
),
spendComparison =
getSpendComparisonViewState(
currentMonthTotalSpend = currentMonthTotalSpend,
previousMonthTotalSpend = previousMonthTotalSpend,
dateInfo = dateInfo,
entryPointHelper = entryPointHelper,
mmWeeklyHisaabConfigResponseHelper =
mmWeeklyHisaabConfigResponseHelper,
),
)
)
},
)
}
suspend fun getSpendComparisonViewState(
currentMonthTotalSpend: Double,
previousMonthTotalSpend: Double,
dateInfo: DateInfo,
entryPointHelper: CheckBalanceScreenEntryPointHelper,
mmWeeklyHisaabConfigResponseHelper: MMWeeklyHisaabConfigResponseHelper,
): SpendComparisonViewState {
val isUserNotUpiLoyal =
mmWeeklyHisaabConfigResponseHelper
.getWeeklyHisaabConfig()
?.isUserUPILoyalInPreviousMonth
?.not() == true
if (isUserNotUpiLoyal) return SpendComparisonViewState.Hide
val spendingTrend =
entryPointHelper.evaluateSpendingTrend(
currentMonthTotalSpend = currentMonthTotalSpend,
previousMonthTotalSpend = previousMonthTotalSpend,
)
return spendingTrend?.let {
SpendComparisonViewState.Show(
SpendComparisonData(
percentage = spendingTrend.percentChange,
changeType = spendingTrend.text,
currentMonthName = MonthConstants.monthNames[dateInfo.month],
daysLeft = dateInfo.remainingDaysInMonth,
iconUrl = spendingTrend.iconUrl,
color = spendingTrend.color,
selectedMonth = SelectedMonth(month = dateInfo.month, year = dateInfo.year),
)
)
} ?: SpendComparisonViewState.Hide
}
/**
* Fetches total UPI transaction amount for a given month, year, and transaction type, limited
* up to the specified day (e.g., current date in month).
*
* This function directly queries the database and emits the total sum as a Flow<Double>, and
* will emit updates only when the sum changes.
*
* @param month The target month (0-based: January = 0)
* @param year The target year (e.g., 2025)
* @param dayLimit The last day of transaction range (e.g., current day in month)
* @param transactionType The type of transaction to include (e.g., "DEBIT" or "CREDIT")
* @return A Flow emitting the total transaction amount (in INR) for the given period.
*/
suspend fun getMonthlyTransactionTotal(
month: Int,
year: Int,
dayLimit: Int,
transactionType: String,
): Flow<Double> {
return executeQueryFlow(
queryName =
upiSpendDatabase.get().transactionsDao()::fetchUpiTransactionsByMonthAndType
.name,
methodName = ::getMonthlyTransactionTotal.name,
flow =
upiSpendDatabase
.get()
.transactionsDao()
.fetchUpiTransactionsByMonthAndType(
year = year,
month = month,
currentDate = dayLimit,
transactionType = transactionType,
),
)
// Convert the list of transactions into a sum of amounts
.map { txnList -> txnList.sumOf { it.txnAmount ?: 0.0 } }
.distinctUntilChanged()
}
}

View File

@@ -0,0 +1,96 @@
/*
*
* * Copyright © 2025 by Navi Technologies Limited
* * All rights reserved. Strictly confidential
*
*/
package com.navi.moneymanager.common.dataprovider.data.dashboard.helper
import com.google.gson.Gson
import com.navi.base.cache.model.NaviCacheEntity
import com.navi.base.cache.repository.NaviCacheRepository
import com.navi.moneymanager.common.network.di.MoneyManagerGsonDeserializer
import com.navi.moneymanager.common.network.di.MoneyManagerGsonSerializer
import com.navi.moneymanager.common.network.model.MMWeeklHisaabResponse
import com.navi.moneymanager.common.utils.DbCacheConstants.MONEY_MANAGER_WEEKLY_HISAAB_CONFIG_RESPONSE_KEY
import dagger.Lazy
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow
/**
* Helper class for managing the weekly Hisaab config response, including caching in memory and
* saving to persistent storage via NaviCacheRepository.
*/
@Singleton
class MMWeeklyHisaabConfigResponseHelper
@Inject
constructor(
private val naviCacheRepository: Lazy<NaviCacheRepository>,
@MoneyManagerGsonDeserializer private val gsonDeserializer: Gson,
@MoneyManagerGsonSerializer private val gsonSerializer: Gson,
) {
// In-memory cache of the weekly Hisaab config to avoid repeated DB reads
private var weeklyHisaabConfigResponse: MMWeeklHisaabResponse? = null
/**
* Saves the given weekly Hisaab config both in memory and in the Navi cache repository.
*
* @param data The response object to be saved.
*/
suspend fun saveWeeklyHisaabConfigToDB(data: MMWeeklHisaabResponse) {
weeklyHisaabConfigResponse = data
val dataJson = gsonSerializer.toJson(data)
val cacheEntity =
NaviCacheEntity(
key = MONEY_MANAGER_WEEKLY_HISAAB_CONFIG_RESPONSE_KEY,
value = dataJson,
version = 1,
)
naviCacheRepository.get().save(cacheEntity)
}
/**
* Returns the weekly Hisaab config either from memory (if cached), or from the database if not
* previously loaded.
*
* @return MMWeeklHisaabResponse if found, or null otherwise.
*/
suspend fun getWeeklyHisaabConfig(): MMWeeklHisaabResponse? {
return if (weeklyHisaabConfigResponse == null) {
getWeeklyHisaabConfigFromDB()
.filterNotNull() //
.firstOrNull() //
?.also { response -> weeklyHisaabConfigResponse = response }
} else {
weeklyHisaabConfigResponse
}
}
/**
* Returns a cold flow that emits the weekly Hisaab config from the cache repository,
* deserialized from JSON.
*
* @return Flow<MMWeeklHisaabResponse?> emitting the config when available.
*/
private fun getWeeklyHisaabConfigFromDB() = flow {
val cacheEntity =
naviCacheRepository.get().getAsFlow(MONEY_MANAGER_WEEKLY_HISAAB_CONFIG_RESPONSE_KEY)
cacheEntity.collect { entity ->
emit(
entity?.value?.let { value ->
gsonDeserializer.fromJson(value, MMWeeklHisaabResponse::class.java)
}
)
}
}
}

View File

@@ -23,6 +23,7 @@ import com.navi.moneymanager.common.network.model.CustomerProfileData
import com.navi.moneymanager.common.network.model.FetchConsentUrlResponse
import com.navi.moneymanager.common.network.model.FinarkeinDataResponse
import com.navi.moneymanager.common.network.model.MMConfigResponse
import com.navi.moneymanager.common.network.model.MMWeeklHisaabResponse
import com.navi.moneymanager.common.network.model.PollingStatusResponse
import com.navi.moneymanager.common.network.model.RefreshDataResponse
import com.navi.moneymanager.common.network.model.SpendGoalData
@@ -284,4 +285,14 @@ class RemoteDataProviderImpl @Inject constructor(private val retrofitService: Re
),
)
}
override suspend fun fetchMMWeeklyHisaabData(
screenName: String
): RepoResult<MMWeeklHisaabResponse> {
return apiResponseCallback(
response = retrofitService.fetchMMWeeklyHisaabData(),
metricInfo =
MetricInfo.AppMetric(screen = screenName, isNae = { !it.isSuccessWithData() }),
)
}
}

View File

@@ -15,6 +15,7 @@ import com.navi.moneymanager.common.dataprovider.data.addcategory.provider.AddCa
import com.navi.moneymanager.common.dataprovider.data.addcategory.provider.UpiSpendAddCategoryDataProviderImpl
import com.navi.moneymanager.common.dataprovider.data.bankaccounts.BankAccountsDataProviderImpl
import com.navi.moneymanager.common.dataprovider.data.checkbalance.provider.CheckBalanceScreenEntryPointDataProviderImpl
import com.navi.moneymanager.common.dataprovider.data.checkbalance.provider.CheckBalanceScreenEntryPointUPIDataProviderImpl
import com.navi.moneymanager.common.dataprovider.data.dashboard.provider.DashboardDataProviderImpl
import com.navi.moneymanager.common.dataprovider.data.dashboard.provider.UpiSpendDashboardDataProviderImpl
import com.navi.moneymanager.common.dataprovider.data.remote.LocalDataSyncManagerImpl
@@ -40,6 +41,7 @@ import com.navi.moneymanager.common.dataprovider.domain.RemoteDataProvider
import com.navi.moneymanager.common.dataprovider.domain.SpendAnalysisLocalDataProvider
import com.navi.moneymanager.common.dataprovider.domain.TransactionDataProvider
import com.navi.moneymanager.common.dataprovider.domain.TransactionHistoryDataProvider
import com.navi.moneymanager.common.dataprovider.domain.upi.CheckBalanceScreenEntryPointUPIDataProvider
import com.navi.moneymanager.common.dataprovider.domain.upi.TxnHistoryUpiSpendDataProvider
import com.navi.moneymanager.common.dataprovider.domain.upi.UpiSpendAddCategoryDataProvider
import com.navi.moneymanager.common.dataprovider.domain.upi.UpiSpendAnalysisLocalDataProvider
@@ -147,6 +149,11 @@ abstract class DataProviderModule {
abstract fun bindCheckBalanceScreenEntryPointDataProvider(
checkBalanceScreenEntryPointDataProvider: CheckBalanceScreenEntryPointDataProviderImpl
): CheckBalanceScreenEntryPointDataProvider
@Binds
abstract fun bindCheckBalanceScreenEntryPointUPIDataProvider(
checkBalanceScreenEntryPointUPIDataProvider: CheckBalanceScreenEntryPointUPIDataProviderImpl
): CheckBalanceScreenEntryPointUPIDataProvider
}
@Module

View File

@@ -17,6 +17,7 @@ import com.navi.moneymanager.common.network.model.CustomerProfileData
import com.navi.moneymanager.common.network.model.FetchConsentUrlResponse
import com.navi.moneymanager.common.network.model.FinarkeinDataResponse
import com.navi.moneymanager.common.network.model.MMConfigResponse
import com.navi.moneymanager.common.network.model.MMWeeklHisaabResponse
import com.navi.moneymanager.common.network.model.OnboardingStatusResponse
import com.navi.moneymanager.common.network.model.PollingStatusResponse
import com.navi.moneymanager.common.network.model.RefreshDataResponse
@@ -94,4 +95,6 @@ interface RemoteDataProvider {
requestBody: AddBillRequestBody,
billId: String,
): RepoResult<BillCalendarResponse>
suspend fun fetchMMWeeklyHisaabData(screenName: String): RepoResult<MMWeeklHisaabResponse>
}

View File

@@ -0,0 +1,16 @@
/*
*
* * Copyright © 2025 by Navi Technologies Limited
* * All rights reserved. Strictly confidential
*
*/
package com.navi.moneymanager.common.dataprovider.domain.upi
import com.navi.moneymanager.common.dataprovider.domain.LocalDataProvider
import com.navi.moneymanager.entry.model.checkbalance.MMWeeklyHisaabWidgetData
import kotlinx.coroutines.flow.Flow
interface CheckBalanceScreenEntryPointUPIDataProvider : LocalDataProvider {
suspend fun getMMWeeklyHisaabWidgetData(): Flow<MMWeeklyHisaabWidgetData>
}

View File

@@ -23,6 +23,11 @@ import com.navi.moneymanager.common.network.model.UpiSpendTransactionData
import dagger.Lazy
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@Singleton
class UpiSpendDBSyncExecutor
@@ -33,23 +38,47 @@ constructor(
private val upiSpendDatabase: Lazy<UpiSpendDatabase>,
private val mmConfigResponseHelper: MMConfigResponseHelper,
) {
private val mutex = Mutex()
private val _syncState = MutableSharedFlow<DataSyncState>(replay = 1)
private val syncState: SharedFlow<DataSyncState> = _syncState.asSharedFlow()
private var isExecuting = false
suspend fun execute(screenName: String): DataSyncState {
val config = fetchAndSaveConfigResponse(screenName) ?: return DataSyncState.Failed
suspend fun execute(screenName: String): SharedFlow<DataSyncState> {
mutex.withLock {
if (!isExecuting) {
isExecuting = true
executeInternal(screenName)
isExecuting = false
}
}
return syncState
}
private suspend fun executeInternal(screenName: String) {
val config =
fetchAndSaveConfigResponse(screenName)
?: run {
_syncState.emit(DataSyncState.Failed)
return
}
val cutoffTimestamp =
config.timestampConfig?.twelveMonthsOldTimestamp ?: return DataSyncState.Failed
config.timestampConfig?.twelveMonthsOldTimestamp
?: run {
_syncState.emit(DataSyncState.Failed)
return
}
val startTimestamp =
upiSpendLocalDataSyncManager.getMostRecentTransactionTimestamp(cutoffTimestamp)
return if (syncTransactions(startTimestamp)) {
if (syncTransactions(startTimestamp)) {
upiSpendLocalDataSyncManager.updateTotalSyncCompleteFlag()
UpiTransactionHistorySpendAnalyserEventTrackerImpl().onDataSyncCompleted()
DataSyncState.Completed
_syncState.emit(DataSyncState.Completed)
} else {
UpiTransactionHistorySpendAnalyserEventTrackerImpl().onDataSyncFailed()
DataSyncState.Failed
_syncState.emit(DataSyncState.Failed)
}
}

View File

@@ -100,4 +100,16 @@ interface UpiSpendTransactionDao {
startDate: Long,
endDate: Long,
): Flow<List<UpiTransactionSummaryData>>
@Query(
"SELECT upiTxnId, naviUpiTxnId, txnTimestamp, txnMonth, txnAmount, type, counterPartyName, finalCategory " +
"FROM $UPI_SPEND_TRANSACTION_TABLE " +
"WHERE txnYear = :year AND txnMonth = :month AND txnDay <= :currentDate AND type = :transactionType "
)
fun fetchUpiTransactionsByMonthAndType(
year: Int,
month: Int,
currentDate: Int,
transactionType: String,
): Flow<List<UpiTransactionSummaryData>>
}

View File

@@ -12,9 +12,11 @@ import com.navi.common.usecase.LitmusExperimentsUseCase
import com.navi.common.utils.Constants.LITMUS_EXPERIMENT_CHECK_BALANCE_MM_EXPERIENCE
import com.navi.common.utils.Constants.LITMUS_EXPERIMENT_HPC_MM_ROLLOUT
import com.navi.moneymanager.common.dataprovider.domain.CheckBalanceScreenEntryPointDataProvider
import com.navi.moneymanager.common.dataprovider.domain.upi.CheckBalanceScreenEntryPointUPIDataProvider
import com.navi.moneymanager.common.datasync.model.DataSyncState
import com.navi.moneymanager.common.network.model.MMConfigResponse
import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceMMEntryPointData
import com.navi.moneymanager.entry.model.checkbalance.MMWeeklyHisaabWidgetData
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
@@ -23,6 +25,7 @@ class CheckBalanceMMWidgetHelper
constructor(
private val litmusExperimentsUseCase: LitmusExperimentsUseCase,
private val checkBalanceDataProvider: CheckBalanceScreenEntryPointDataProvider,
private val checkBalanceUPIDataProvider: CheckBalanceScreenEntryPointUPIDataProvider,
) {
suspend fun getCheckBalanceScreenEntryPointData(
npciBankCode: String,
@@ -38,6 +41,10 @@ constructor(
)
}
suspend fun getMMWeeklyHisaabWidgetData(): Flow<MMWeeklyHisaabWidgetData> {
return checkBalanceUPIDataProvider.getMMWeeklyHisaabWidgetData()
}
suspend fun isMMDiscoverabilityExperimentEnabled(): Boolean {
val experimentInfo =
litmusExperimentsUseCase.execute(

View File

@@ -82,6 +82,7 @@ class ImageRepository : IllustrationRepository {
const val WHITE_ARROW_DOWN = "WHITE_ARROW_DOWN"
const val DOUBLE_RIGHT_ARROW_BLUE = "DOUBLE_RIGHT_ARROW_BLUE"
const val CONTACTS_ACCESS_GREY_BG = "CONTACTS_ACCESS_GREY_BG"
const val YELLOW_ARROW_DIAGONAL_RIGHT_UP = "YELLOW_ARROW_DIAGONAL_RIGHT_UP"
}
override fun getResourceId(illustrationName: String, isDarkThemeEnabled: Boolean): Int {
@@ -292,6 +293,8 @@ class ImageRepository : IllustrationRepository {
"https://public-assets.prod.navi-sa.in/money-manager/svg/category-details/ic_double_right_arrow_blue.svg"
CONTACTS_ACCESS_GREY_BG ->
"https://public-assets.prod.navi-sa.in/money-manager/svg/category-details/ic_contact_access_grey_bg.svg"
YELLOW_ARROW_DIAGONAL_RIGHT_UP ->
"https://public-assets.prod.navi-sa.in/money-manager/svg/common/ic_yellow_arrow_diagonal_right_up.svg"
else -> EMPTY
}
}

View File

@@ -0,0 +1,46 @@
/*
*
* * Copyright © 2025 by Navi Technologies Limited
* * All rights reserved. Strictly confidential
*
*/
package com.navi.moneymanager.common.model
import androidx.compose.ui.graphics.Color
import com.navi.moneymanager.common.datasync.model.DataSyncState
import com.navi.moneymanager.common.model.database.AccountOverview
data class SpendingTrend(
val text: SpendComparisonType,
val color: SpendTrendColor,
val percentChange: Int,
val iconUrl: String,
)
data class SyncStatusBundle(
val isFirstMonthSynced: Boolean,
val isTotalSyncCompleted: Boolean,
val syncState: DataSyncState,
val linkedAccounts: List<AccountOverview?>,
)
enum class SpendComparisonType {
HIGHER,
LESS,
SIMILAR,
}
enum class SpendTrendColor {
RED,
AMBER,
GREEN,
}
fun SpendTrendColor.toColor(): Color {
return when (this) {
SpendTrendColor.RED -> Color(0xFFCA6100)
SpendTrendColor.AMBER -> Color(0xFF906D00)
SpendTrendColor.GREEN -> Color(0xFF1B8332)
}
}

View File

@@ -0,0 +1,10 @@
/*
*
* * Copyright © 2025 by Navi Technologies Limited
* * All rights reserved. Strictly confidential
*
*/
package com.navi.moneymanager.common.model
data class DateInfo(val day: Int, val month: Int, val year: Int, val remainingDaysInMonth: Int)

View File

@@ -0,0 +1,13 @@
/*
*
* * Copyright © 2025 by Navi Technologies Limited
* * All rights reserved. Strictly confidential
*
*/
package com.navi.moneymanager.common.network.model
data class MMWeeklHisaabResponse(
val isHisaabEnabled: Boolean?,
val isUserUPILoyalInPreviousMonth: Boolean?,
)

View File

@@ -19,6 +19,7 @@ import com.navi.moneymanager.common.network.model.EmptyRequestBody
import com.navi.moneymanager.common.network.model.FetchConsentUrlResponse
import com.navi.moneymanager.common.network.model.FinarkeinDataResponse
import com.navi.moneymanager.common.network.model.MMConfigResponse
import com.navi.moneymanager.common.network.model.MMWeeklHisaabResponse
import com.navi.moneymanager.common.network.model.OnboardingStatusResponse
import com.navi.moneymanager.common.network.model.PollingStatusResponse
import com.navi.moneymanager.common.network.model.RefreshDataResponse
@@ -167,6 +168,10 @@ interface RetrofitService {
@Query("configType") configType: String = BILL_CALENDAR
): Response<GenericResponse<BillCalendarConfigResponse>>
@RetryPolicy
@GET("/money-manager/core/insights/hisaab")
suspend fun fetchMMWeeklyHisaabData(): Response<GenericResponse<MMWeeklHisaabResponse>>
private companion object {
const val MONEY_MANAGER_INSIGHTS = "MONEY_MANAGER_INSIGHTS"
const val MONEY_MANAGER = "MONEY_MANAGER"

View File

@@ -0,0 +1,379 @@
/*
*
* * Copyright © 2025 by Navi Technologies Limited
* * All rights reserved. Strictly confidential
*
*/
package com.navi.moneymanager.common.ui.composable.checkbalance
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.navi.base.utils.EMPTY
import com.navi.base.utils.orZero
import com.navi.common.utils.CommonUtils.getDisplayableAmount
import com.navi.common.utils.PERCENT
import com.navi.common.utils.onClickWithDebounce
import com.navi.design.font.FontWeightEnum
import com.navi.design.font.getFontWeight
import com.navi.design.font.naviFontFamily
import com.navi.design.utils.BackgroundDrawableData
import com.navi.design.utils.DrawableShape
import com.navi.moneymanager.R
import com.navi.moneymanager.common.analytics.MMWeeklyHisaabEventTrackerImpl
import com.navi.moneymanager.common.illustration.model.IllustrationSource
import com.navi.moneymanager.common.illustration.model.IllustrationType
import com.navi.moneymanager.common.illustration.model.ImageProperties
import com.navi.moneymanager.common.illustration.repository.ImageRepository.Companion.CHEVRON_PURPLE_RIGHT_ICON
import com.navi.moneymanager.common.illustration.repository.ImageRepository.Companion.IMAGE_TRANSPARENT_PLACEHOLDER_MEDIUM
import com.navi.moneymanager.common.illustration.repository.ImageRepository.Companion.IMAGE_TRANSPARENT_PLACEHOLDER_SMALL
import com.navi.moneymanager.common.illustration.ui.Illustration
import com.navi.moneymanager.common.model.SelectedMonth
import com.navi.moneymanager.common.model.SpendComparisonType
import com.navi.moneymanager.common.model.toColor
import com.navi.moneymanager.common.ui.composable.base.MMText
import com.navi.moneymanager.entry.model.checkbalance.MMWeeklyHisaabWidgetData
import com.navi.moneymanager.entry.model.checkbalance.SpendComparisonData
import com.navi.moneymanager.entry.model.checkbalance.SpendComparisonViewState
import com.navi.moneymanager.entry.model.checkbalance.SpendItemData
import com.navi.naviwidgets.extensions.CustomTooltip
import com.navi.naviwidgets.models.response.TooltipProperties
import com.navi.uitron.model.ui.PeakDirection
import com.navi.uitron.model.ui.PeakPosition
import com.navi.uitron.model.ui.ToolTipShapeData
@Composable
fun MMWeeklyHisaabWidget(
data: MMWeeklyHisaabWidgetData,
currentBalance: String,
hideBalance: Boolean,
isBalanceFetchLoading: Boolean,
onSpendAmountClick: (selectedMonth: SelectedMonth?) -> Unit,
getEventTracker: () -> MMWeeklyHisaabEventTrackerImpl,
) {
LaunchedEffect(Unit) { getEventTracker().onMMHisaabWidgetVisible() }
Box(
modifier =
Modifier.fillMaxWidth()
.wrapContentHeight()
.background(Color(0xFFF5F5F5))
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
) {
Column(
modifier =
Modifier.fillMaxWidth()
.background(color = Color(0xFFFFFFFF), shape = MaterialTheme.shapes.extraSmall)
.border(
width = 1.dp,
color = Color(0xFFEBEBEB),
shape = MaterialTheme.shapes.extraSmall,
)
.wrapContentHeight()
.padding(16.dp)
) {
MMText(
text = stringResource(R.string.monthly_overview_title, data.monthName.orEmpty()),
fontSize = 14.sp,
fontWeight = FontWeightEnum.NAVI_BODY_DEMI_BOLD,
color = Color(0xFF000000),
lineHeight = 20.sp,
)
Spacer(modifier = Modifier.height(10.dp))
SpendItem(
spendData = data.currentMonthSpend,
onSpendAmountClick = { selectedMonth ->
getEventTracker().onMMMRRCurrentMonthSpendsClicked()
onSpendAmountClick(selectedMonth)
},
)
when (data.spendComparison) {
is SpendComparisonViewState.Show -> {
LaunchedEffect(Unit) {
getEventTracker()
.onMMWeeklyHisaabBalanceInsight(
state = "show",
color = data.spendComparison.spendComparisonData.color.name,
)
}
CustomTooltip(
properties =
TooltipProperties(
background =
BackgroundDrawableData(
shape = DrawableShape.RECTANGLE.name,
cornerRadius = 6,
peakAspectRatio = 2.1f,
backgroundColor = "#F7F6F8",
),
tooltipShapeProperties =
ToolTipShapeData(
peakHeight = 8,
peakDirection = PeakDirection.TOP,
peakPosition = PeakPosition.CENTER,
peakRadius = 4,
bottomPeakRadius = 0,
offsetPercentage = 0.73f,
),
),
modifier =
Modifier.fillMaxWidth()
.wrapContentHeight()
.padding(top = 4.dp, bottom = 10.dp),
) {
SpendComparisonBox(
data = data.spendComparison.spendComparisonData,
currentBalance = currentBalance,
hideBalance = hideBalance,
isBalanceFetchLoading = isBalanceFetchLoading,
onClicked = { selectedMonth ->
getEventTracker().onMMWeeklyHisaabBalanceInsightClicked()
onSpendAmountClick(selectedMonth)
},
)
}
}
SpendComparisonViewState.Hide -> {
LaunchedEffect(Unit) {
getEventTracker()
.onMMWeeklyHisaabBalanceInsight(state = "hide", color = EMPTY)
}
Spacer(modifier = Modifier.height(12.dp))
}
}
SpendItem(
spendData = data.previousMonthSpend,
onSpendAmountClick = { selectedMonth ->
getEventTracker().onMMMRRPreviousMonthSpendsClicked()
onSpendAmountClick(selectedMonth)
},
)
}
}
}
@Composable
fun SpendItem(
spendData: SpendItemData?,
onSpendAmountClick: (selectedMonth: SelectedMonth?) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
MMText(
text = stringResource(R.string.spends),
fontSize = 12.sp,
fontWeight = FontWeightEnum.NAVI_BODY_REGULAR,
color = Color(0xFF141414),
lineHeight = 16.sp,
)
Spacer(modifier = Modifier.width(4.dp))
MMText(
text =
spendDateRangeText(
month = spendData?.monthName.orEmpty(),
currentDay = spendData?.currentDay.orZero(),
),
fontSize = 12.sp,
fontWeight = FontWeightEnum.NAVI_HEADLINE_REGULAR,
color = Color(0xFF141414),
lineHeight = 16.sp,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.onClickWithDebounce(
indication = null,
onClick = { onSpendAmountClick(spendData?.selectedMonth) },
),
) {
MMText(
text = spendData?.spendAmount.orEmpty(),
fontSize = 14.sp,
fontWeight = FontWeightEnum.NAVI_HEADLINE_REGULAR,
color = Color(0xFF191919),
lineHeight = 22.sp,
)
Spacer(modifier = Modifier.width(4.dp))
Illustration(
illustrationType =
IllustrationType.Image(
source =
IllustrationSource.Remote(
CHEVRON_PURPLE_RIGHT_ICON,
placeholder = IMAGE_TRANSPARENT_PLACEHOLDER_MEDIUM,
)
),
modifier = Modifier.size(16.dp),
)
}
}
}
@Composable
fun SpendComparisonBox(
data: SpendComparisonData,
currentBalance: String,
hideBalance: Boolean,
isBalanceFetchLoading: Boolean,
onClicked: (SelectedMonth) -> Unit,
) {
val percentChangeText =
getSpendChangeText(percentage = data.percentage, changeType = data.changeType)
val monthComparisonText =
stringResource(R.string.spend_change_month_comparison, data.currentMonthName)
val balanceLeftText =
getBalanceLeftText(
hideBalance = hideBalance,
currentBalance = currentBalance,
daysLeft = data.daysLeft,
currentMonthName = data.currentMonthName,
isBalanceFetchLoading = isBalanceFetchLoading,
)
Column(
modifier =
Modifier.fillMaxWidth()
.onClickWithDebounce(indication = null) { onClicked(data.selectedMonth) }
.padding(start = 6.dp, end = 6.dp, bottom = 9.dp, top = 17.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Illustration(
illustrationType =
IllustrationType.Image(
source =
IllustrationSource.Remote(
url = data.iconUrl,
placeholder = IMAGE_TRANSPARENT_PLACEHOLDER_SMALL,
),
properties =
ImageProperties(colorFilter = ColorFilter.tint(data.color.toColor())),
),
modifier = Modifier.size(16.dp),
)
MMText(
text =
buildAnnotatedString {
withStyle(
SpanStyle(
color = data.color.toColor(),
fontSize = 14.sp,
fontFamily = naviFontFamily,
fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD),
)
) {
append(percentChangeText)
}
withStyle(
SpanStyle(
color = Color(0xFF1C1C1E),
fontSize = 12.sp,
fontFamily = naviFontFamily,
fontWeight = getFontWeight(FontWeightEnum.NAVI_HEADLINE_REGULAR),
)
) {
append(" ")
append(monthComparisonText)
}
},
modifier = Modifier.wrapContentWidth().padding(start = 4.dp),
)
}
MMText(
text = balanceLeftText,
color = data.color.toColor(),
fontSize = 12.sp,
fontStyle = FontStyle.Italic,
fontWeight = FontWeightEnum.NAVI_HEADLINE_REGULAR,
modifier = Modifier.wrapContentWidth().padding(start = 18.dp, top = 2.dp),
)
}
}
@Composable
fun spendDateRangeText(month: String, currentDay: Int): String {
return if (currentDay == 1) {
stringResource(id = R.string.spend_date_single, month, currentDay)
} else {
stringResource(id = R.string.spend_date_range, month, currentDay)
}
}
@Composable
fun SpendComparisonType.toLocalizedString(): String {
return when (this) {
SpendComparisonType.HIGHER -> stringResource(id = R.string.higher)
SpendComparisonType.LESS -> stringResource(id = R.string.less)
SpendComparisonType.SIMILAR -> stringResource(id = R.string.similar_spends)
}
}
@Composable
fun getBalanceLeftText(
hideBalance: Boolean,
isBalanceFetchLoading: Boolean,
currentBalance: String,
daysLeft: Int,
currentMonthName: String,
): String {
val balanceDisplay =
if (hideBalance || isBalanceFetchLoading) ". . . ."
else currentBalance.getDisplayableAmount()
val dayText =
pluralStringResource(
id = R.plurals.balance_day_left,
count = daysLeft,
formatArgs = arrayOf(daysLeft),
)
return stringResource(R.string.budget_remaining_info, balanceDisplay, dayText, currentMonthName)
}
@Composable
fun getSpendChangeText(percentage: Int, changeType: SpendComparisonType): String {
return if (percentage == 0 && changeType == SpendComparisonType.SIMILAR) {
stringResource(R.string.similar_spends)
} else {
stringResource(
R.string.spend_change_percent_label,
"$percentage$PERCENT",
changeType.toLocalizedString(),
)
}
}

View File

@@ -10,6 +10,7 @@ package com.navi.moneymanager.common.ui.composable.upi
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -20,6 +21,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -53,7 +55,11 @@ fun UpiSpendAnalyserWidgetContainer(content: @Composable () -> Unit, onCrossIcon
.background(color = Color(0xFFF9F9FA), shape = CircleShape)
.align(Alignment.TopEnd)
.clip(CircleShape)
.clickable(onClick = onCrossIconClick),
.clickable(
onClick = onCrossIconClick,
indication = null,
interactionSource = remember { MutableInteractionSource() },
),
) {
Illustration(
illustrationType =

View File

@@ -116,6 +116,8 @@ object DbCacheConstants {
const val MONEY_MANAGER_VALUE_PROPOSITION_SCREEN_KEY =
"MONEY_MANAGER_VALUE_PROPOSITION_SCREEN_KEY"
const val MONEY_MANAGER_CONFIG_RESPONSE_KEY = "MONEY_MANAGER_CONFIG_RESPONSE_KEY"
const val MONEY_MANAGER_WEEKLY_HISAAB_CONFIG_RESPONSE_KEY =
"MONEY_MANAGER_WEEKLY_HISAAB_CONFIG_RESPONSE_KEY"
}
object ScreenNameConstants {

View File

@@ -7,9 +7,11 @@
package com.navi.moneymanager.common.utils
import android.app.Activity
import android.content.Context
import android.icu.util.Calendar
import android.icu.util.TimeZone
import android.os.Bundle
import android.view.Gravity
import android.widget.Toast
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -51,6 +53,7 @@ import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.navi.base.deeplink.DeepLinkManager
import com.navi.base.model.CtaData
import com.navi.base.model.LineItem
import com.navi.base.utils.BaseUtils.getDayMonthAndYearFromTimestamp
@@ -77,6 +80,8 @@ import com.navi.moneymanager.common.illustration.model.IllustrationType
import com.navi.moneymanager.common.illustration.repository.ImageRepository.Companion.IMAGE_TRANSPARENT_PLACEHOLDER_SMALL
import com.navi.moneymanager.common.model.ChipData
import com.navi.moneymanager.common.model.ChipIllustration
import com.navi.moneymanager.common.model.DateInfo
import com.navi.moneymanager.common.model.SelectedMonth
import com.navi.moneymanager.common.model.SingleChipSelectionData
import com.navi.moneymanager.common.model.SpendGoalState
import com.navi.moneymanager.common.model.sectionHeader.MMJourneySource
@@ -89,6 +94,8 @@ import com.navi.moneymanager.common.utils.Constants.PRODUCT_HELP_PAGE
import com.navi.moneymanager.common.utils.Constants.PRODUCT_HELP_SCREEN_NAME
import com.navi.moneymanager.common.utils.Constants.REDIRECTION_CTA
import com.navi.moneymanager.common.utils.Constants.RUPEE_SYMBOL
import com.navi.moneymanager.common.utils.Constants.SELECTED_MONTH
import com.navi.moneymanager.common.utils.Constants.SOURCE
import com.navi.moneymanager.common.utils.MonthConstants.monthNames
import com.navi.moneymanager.preonboard.launcher.model.GenericErrorBottomSheetData
import java.text.NumberFormat
@@ -580,3 +587,72 @@ fun getNormalisedPhoneNumber(phoneNumber: String?): String {
return phoneNumber.filter { it.isAsciiDigit() }.takeLast(n = 10)
}
fun getDateInfoFromTimestamp(timeStamp: Long): DateInfo {
val calendar = Calendar.getInstance()
calendar.timeInMillis = timeStamp
val day = calendar.get(Calendar.DAY_OF_MONTH)
val month = calendar.get(Calendar.MONTH) // 0-based
val year = calendar.get(Calendar.YEAR)
val maxDaysInMonth = calendar.getActualMaximum(Calendar.DAY_OF_MONTH)
val remainingDays = maxDaysInMonth - day + 1
return DateInfo(day = day, month = month, year = year, remainingDaysInMonth = remainingDays)
}
fun navigateToUpiSpendAnalysisScreen(
activity: Activity,
screenName: String,
selectedMonth: SelectedMonth,
bundle: Bundle?,
) {
val additionalData = bundle ?: Bundle()
additionalData.putParcelable(SELECTED_MONTH, selectedMonth)
DeepLinkManager.getDeepLinkListener()
?.navigateTo(
activity = activity,
ctaData =
CtaData(
url = screenName,
parameters =
listOf(LineItem(key = SOURCE, value = MMJourneySource.NAVI_UPI.name)),
),
bundle = additionalData,
finish = false,
)
}
fun getPreviousMonthDayLimit(dateInfo: DateInfo): Int {
val calendar = Calendar.getInstance()
val currentDay = dateInfo.day
val currentMonth = dateInfo.month
val currentYear = dateInfo.year
// Set calendar to current date
calendar.set(currentYear, currentMonth, currentDay)
// Check if the current day is the last day of the current month
val isLastDayOfCurrentMonth = currentDay == calendar.getActualMaximum(Calendar.DAY_OF_MONTH)
// Calculate previous month and year
val previousMonth = if (currentMonth == 0) 11 else currentMonth - 1
val previousYear = if (currentMonth == 0) currentYear - 1 else currentYear
// Set calendar to the first day of the previous month to calculate its maximum days
calendar.set(previousYear, previousMonth, 1)
val maxDayInPreviousMonth = calendar.getActualMaximum(Calendar.DAY_OF_MONTH)
// Return the appropriate day limit for the previous month:
// - If the current day is the last day of the current month,
// or if it exceeds the number of days in the previous month (e.g., March 30 vs. Feb 28),
// use the last day of the previous month.
// - Otherwise, use the same day as in the current month.
return if (isLastDayOfCurrentMonth || currentDay > maxDayInPreviousMonth) {
maxDayInPreviousMonth
} else {
currentDay
}
}

View File

@@ -0,0 +1,46 @@
/*
*
* * Copyright © 2025 by Navi Technologies Limited
* * All rights reserved. Strictly confidential
*
*/
package com.navi.moneymanager.entry.model.checkbalance
import com.navi.moneymanager.common.model.SelectedMonth
import com.navi.moneymanager.common.model.SpendComparisonType
import com.navi.moneymanager.common.model.SpendTrendColor
data class MMWeeklyHisaabWidgetData(
val monthName: String? = null,
val currentMonthSpend: SpendItemData? = null,
val spendComparison: SpendComparisonViewState,
val previousMonthSpend: SpendItemData? = null,
)
data class SpendItemData(
val monthName: String?,
val currentDay: Int,
val spendAmount: String?,
val selectedMonth: SelectedMonth,
)
data class SpendComparisonData(
val percentage: Int,
val changeType: SpendComparisonType,
val currentMonthName: String,
val daysLeft: Int,
val iconUrl: String,
val color: SpendTrendColor,
val selectedMonth: SelectedMonth,
)
sealed class SpendComparisonViewState {
// Show the view with actual spend comparison data
data class Show(val spendComparisonData: SpendComparisonData) : SpendComparisonViewState()
// Hide the view when:
// 1. Either current or previous month spend is 0
// 2. User was not loyal last month
object Hide : SpendComparisonViewState()
}

View File

@@ -10,7 +10,9 @@ package com.navi.moneymanager.postonboard.dashboard.repo
import androidx.compose.runtime.MutableState
import com.navi.base.utils.EMPTY
import com.navi.base.utils.orZero
import com.navi.common.network.models.isSuccess
import com.navi.moneymanager.common.dataprovider.data.dashboard.helper.MMConfigResponseHelper
import com.navi.moneymanager.common.dataprovider.data.dashboard.helper.MMWeeklyHisaabConfigResponseHelper
import com.navi.moneymanager.common.dataprovider.data.datastore.DataStoreInfoProvider
import com.navi.moneymanager.common.dataprovider.domain.DashboardDataProvider
import com.navi.moneymanager.common.dataprovider.domain.RemoteDataProvider
@@ -24,6 +26,7 @@ import com.navi.moneymanager.common.model.sectionHeader.MMJourneySource
import com.navi.moneymanager.common.navigation.utils.MMScreen
import com.navi.moneymanager.common.network.di.RoomDataStoreInfoProvider
import com.navi.moneymanager.common.network.model.MMConfigResponse
import com.navi.moneymanager.common.network.model.MMWeeklHisaabResponse
import com.navi.moneymanager.common.utils.Constants.USER_NAME
import com.navi.moneymanager.postonboard.dashboard.model.BankAccountsState
import com.navi.moneymanager.postonboard.dashboard.model.FeedbackSectionData
@@ -46,6 +49,7 @@ constructor(
private val database: Lazy<MMDatabase>,
private val upiSpendDatabase: Lazy<UpiSpendDatabase>,
private val mmConfigResponseHelper: MMConfigResponseHelper,
private val mmWeeklyHisaabConfigResponseHelper: MMWeeklyHisaabConfigResponseHelper,
@RoomDataStoreInfoProvider private val dbDataStoreProvider: DataStoreInfoProvider,
) {
@@ -210,4 +214,19 @@ constructor(
}
}
}
suspend fun fetchAndSaveWeeklyHisaabConfigResponse(
screenName: String,
onApiFailed: (Int?, String?) -> Unit,
): MMWeeklHisaabResponse? {
val networkResponse = remoteDataProvider.fetchMMWeeklyHisaabData(screenName = screenName)
val data = networkResponse.data
return if (networkResponse.isSuccess() && data != null) {
mmWeeklyHisaabConfigResponseHelper.saveWeeklyHisaabConfigToDB(data)
mmWeeklyHisaabConfigResponseHelper.getWeeklyHisaabConfig()
} else {
onApiFailed(networkResponse.statusCode, networkResponse.error?.message)
null
}
}
}

View File

@@ -267,4 +267,18 @@
<string name="check_spend_analysis">Check spend analysis</string>
<string name="your_spend_analysis_for">Your spend analysis for %1$s</string>
<string name="add_contacts_to_sync_contact_names">Add contacts to sync contact names automatically</string>
<string name="monthly_overview_title">%1$s overview</string>
<string name="spends">Spends</string>
<string name="spend_change_percent_label">%1$s %2$s</string>
<string name="spend_change_month_comparison"> in %1$s vs last month</string>
<string name="budget_remaining_info">\u20B9%1$s left for %2$s in %3$s</string>
<string name="spend_date_single">(%1$s %2$d)</string>
<string name="spend_date_range">(%1$s 1 - %2$d)</string>
<string name="less">less</string>
<string name="higher">higher</string>
<string name="similar_spends">Similar spends</string>
<plurals name="balance_day_left">
<item quantity="one">%d day</item>
<item quantity="other">%d days</item>
</plurals>
</resources>

View File

@@ -78,11 +78,17 @@ import com.navi.design.font.FontWeightEnum
import com.navi.design.font.getFontWeight
import com.navi.design.font.naviFontFamily
import com.navi.moneymanager.common.analytics.MMDiscoverabilityEventTrackerImpl
import com.navi.moneymanager.common.analytics.MMWeeklyHisaabEventTrackerImpl
import com.navi.moneymanager.common.model.SelectedMonth
import com.navi.moneymanager.common.navigation.utils.MMScreen
import com.navi.moneymanager.common.ui.composable.checkbalance.CheckBalanceMMEntryPointWidget
import com.navi.moneymanager.common.ui.composable.checkbalance.MMWeeklyHisaabWidget
import com.navi.moneymanager.common.utils.Constants.BANK_FIP_ID
import com.navi.moneymanager.common.utils.Constants.MASKED_ACCOUNT_NUMBER
import com.navi.moneymanager.common.utils.navigateToUpiSpendAnalysisScreen
import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceMMEntryPointData
import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceMMEntryPointState
import com.navi.moneymanager.entry.model.checkbalance.MMWeeklyHisaabWidgetData
import com.navi.naviwidgets.extensions.NaviText
import com.navi.pay.R
import com.navi.pay.analytics.NaviPayAnalytics
@@ -172,12 +178,22 @@ fun LinkedAccountBalanceScreen(
val showMMDiscoverabilityEntryPoint by
linkedAccountBalanceViewModel.showMMDiscoverabilityEntryPoint.collectAsStateWithLifecycle()
DisposableEffect(lifecycleOwner, showMMDiscoverabilityEntryPoint) {
val showMMWeeklyHisaabWidget by
linkedAccountBalanceViewModel.showMMWeeklyHisaabWidget.collectAsStateWithLifecycle()
DisposableEffect(lifecycleOwner, showMMDiscoverabilityEntryPoint, showMMWeeklyHisaabWidget) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && showMMDiscoverabilityEntryPoint) {
linkedAccountBalanceViewModel.mmDiscoverabilityEventTracker
.onMMDiscoverabilityEntryPointLoadTriggered()
linkedAccountBalanceViewModel.loadMMEntryPoint(linkedAccountEntity)
if (event == Lifecycle.Event.ON_RESUME) {
when {
showMMDiscoverabilityEntryPoint -> {
linkedAccountBalanceViewModel.mmDiscoverabilityEventTracker
.onMMDiscoverabilityEntryPointLoadTriggered()
linkedAccountBalanceViewModel.loadMMEntryPoint(linkedAccountEntity)
}
showMMWeeklyHisaabWidget -> {
linkedAccountBalanceViewModel.loadMMWeeklyHisaabWidget()
}
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
@@ -202,6 +218,9 @@ fun LinkedAccountBalanceScreen(
val mmEntryPointData by
linkedAccountBalanceViewModel.mmEntryPointData.collectAsStateWithLifecycle()
val weeklyHisaabWidgetData by
linkedAccountBalanceViewModel.mmWeeklyHisaabWidgetData.collectAsStateWithLifecycle()
val bottomSheetState =
rememberModalBottomSheetState(
skipPartiallyExpanded = true,
@@ -321,6 +340,8 @@ fun LinkedAccountBalanceScreen(
bankAccountId = linkedAccountEntity.accountId,
)
},
weeklyHisaabWidgetData = weeklyHisaabWidgetData,
mmWeeklyHisaabEventTrackerImpl = linkedAccountBalanceViewModel.mmWeeklyHisaabEventTracker,
)
}
@@ -341,6 +362,8 @@ private fun RenderLinkedAccountBalanceScreen(
mmEntryPointData: CheckBalanceMMEntryPointData,
mmDiscoverabilityEventTracker: MMDiscoverabilityEventTrackerImpl,
redirectOnClick: (CtaData) -> Unit,
weeklyHisaabWidgetData: MMWeeklyHisaabWidgetData?,
mmWeeklyHisaabEventTrackerImpl: MMWeeklyHisaabEventTrackerImpl,
) {
val context = LocalContext.current as Activity
@@ -377,6 +400,36 @@ private fun RenderLinkedAccountBalanceScreen(
mmDiscoverabilityEventTracker = mmDiscoverabilityEventTracker,
)
AnimatedVisibility(
visible = weeklyHisaabWidgetData != null,
enter =
expandVertically(
expandFrom = Alignment.Top,
animationSpec =
tween(500, easing = CubicBezierEasing(0.83f, 0.17f, 0.23f, 0.89f)),
),
exit =
shrinkVertically(shrinkTowards = Alignment.Top, animationSpec = tween(100)),
) {
weeklyHisaabWidgetData?.let {
MMWeeklyHisaabWidget(
currentBalance = currentBalance,
data = it,
hideBalance = hideBalance,
isBalanceFetchLoading = showShimmer,
getEventTracker = { mmWeeklyHisaabEventTrackerImpl },
onSpendAmountClick = { selectedMonth ->
navigateToUpiSpendAnalysisScreen(
activity = activity,
screenName = MMScreen.DASHBOARD.screen,
selectedMonth = selectedMonth ?: SelectedMonth(0, 0),
bundle = null,
)
},
)
}
}
TransactionEntryPointCard(
transactionEntrypointData = transactionEntrypointData,
accountType = linkedAccountEntity.accountType,

View File

@@ -17,16 +17,22 @@ import com.navi.base.utils.orFalse
import com.navi.common.di.CoroutineDispatcherProvider
import com.navi.common.firebaseremoteconfig.FirebaseRemoteConfigHelper
import com.navi.common.firebaseremoteconfig.FirebaseRemoteConfigHelper.MM_DISCOVERABILITY_ENABLED
import com.navi.common.firebaseremoteconfig.FirebaseRemoteConfigHelper.MM_UPI_SYNC_AND_CONFIG_API_TIMEOUT_MS
import com.navi.common.firebaseremoteconfig.FirebaseRemoteConfigHelper.MM_WEEKLY_HISAAB_ENABLED
import com.navi.common.network.ApiConstants.API_LIMIT_EXCEEDED
import com.navi.common.network.models.RepoResult
import com.navi.common.utils.SPACE
import com.navi.moneymanager.common.analytics.MMDiscoverabilityEventTrackerImpl
import com.navi.moneymanager.common.analytics.MMWeeklyHisaabEventTrackerImpl
import com.navi.moneymanager.common.datasync.MMDiscoverabilitySyncPreviousDataUseCase
import com.navi.moneymanager.common.datasync.UpiSpendDBSyncExecutor
import com.navi.moneymanager.common.datasync.model.DataSyncState
import com.navi.moneymanager.common.helper.CheckBalanceMMWidgetHelper
import com.navi.moneymanager.common.model.sectionHeader.MMJourneySource
import com.navi.moneymanager.common.utils.Constants.JOURNEY_SOURCE
import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceMMEntryPointData
import com.navi.moneymanager.entry.model.checkbalance.CheckBalanceMMEntryPointState
import com.navi.moneymanager.entry.model.checkbalance.MMWeeklyHisaabWidgetData
import com.navi.moneymanager.postonboard.dashboard.repo.DashboardScreenRepository
import com.navi.pay.R
import com.navi.pay.analytics.NaviPayAnalytics
@@ -62,16 +68,20 @@ import com.ramcosta.composedestinations.spec.Direction
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlin.time.Duration.Companion.minutes
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
@HiltViewModel
class LinkedAccountBalanceViewModel
@@ -89,6 +99,7 @@ constructor(
private val checkBalanceMMWidgetHelper: CheckBalanceMMWidgetHelper,
private val syncPreviousDataUseCase: MMDiscoverabilitySyncPreviousDataUseCase,
private val dashboardRepository: DashboardScreenRepository,
private val upiSpendDBSyncExecutor: UpiSpendDBSyncExecutor,
) : NaviPayBaseVM() {
private val linkedAccountEntity: LinkedAccountEntity = savedStateHandle["linkedAccountEntity"]!!
@@ -120,6 +131,9 @@ constructor(
)
val mmEntryPointData = _mmEntryPointData.asStateFlow()
private val _mmWeeklyHisaabWidgetData = MutableStateFlow<MMWeeklyHisaabWidgetData?>(null)
val mmWeeklyHisaabWidgetData = _mmWeeklyHisaabWidgetData.asStateFlow()
private val source: String = savedStateHandle["source"] ?: ""
private val linkedAccountBalanceScreenSource: LinkedAccountBalanceScreenSource =
@@ -178,6 +192,9 @@ constructor(
private val _showMMDiscoverabilityEntryPoint = MutableStateFlow(false)
val showMMDiscoverabilityEntryPoint = _showMMDiscoverabilityEntryPoint.asStateFlow()
private val _showMMWeeklyHisaabWidget = MutableStateFlow(false)
val showMMWeeklyHisaabWidget = _showMMWeeklyHisaabWidget.asStateFlow()
val helpCta = getHelpCtaData(screenName = screenName)
private var timerJob: Job? = null
@@ -188,6 +205,12 @@ constructor(
)
}
val mmWeeklyHisaabEventTracker by lazy {
MMWeeklyHisaabEventTrackerImpl(
parameters = mapOf(JOURNEY_SOURCE to MMJourneySource.NAVI_UPI.name)
)
}
init {
naviPayAnalytics.onCheckBalanceSuccess(source = source)
viewModelScope.launch(Dispatchers.IO) {
@@ -205,42 +228,99 @@ constructor(
}
fetchTransactionEntrypointConfig(screenName = screenName)
runCountDownTimer()
checkAndShowMMDiscoverabilityEntryPoint()
checkAndShowMMEntryPoint()
}
private fun checkAndShowMMDiscoverabilityEntryPoint() {
viewModelScope.safeLaunch(Dispatchers.IO) {
val accountType = AccountType.getAccountType(linkedAccountEntity.accountType)
val experimentEnabled = isMMDiscoverabilityExperimentEnabled()
mmDiscoverabilityEventTracker.onMMDiscoverabilityCheckStarted(
accountType = accountType.name,
experimentEnabled = experimentEnabled,
bankCode = linkedAccountEntity.bankCode,
)
if (accountType == AccountType.SAVINGS && experimentEnabled) {
val mmConfig = syncPreviousDataUseCase.fetchAndSaveConfigResponse(screenName)
if (
!checkBalanceMMWidgetHelper.isBankEligible(
linkedAccountEntity.bankCode,
mmConfig,
)
) {
mmDiscoverabilityEventTracker.onMMDiscoverabilityEntryPointNotEligible(
"bank_not_eligible"
)
@OptIn(DelicateCoroutinesApi::class)
private fun checkAndShowMMEntryPoint() {
if (upiTxnsyncJob?.isActive == true) {
// Skip if a global sync job is already running
mmWeeklyHisaabEventTracker.onUpiSyncSkippedAlreadyRunning()
return
}
upiTxnsyncJob =
GlobalScope.safeLaunch(Dispatchers.IO) {
val isWeeklyHisaabEnabledViaRemoteConfig =
FirebaseRemoteConfigHelper.getBoolean(MM_WEEKLY_HISAAB_ENABLED)
if (!isWeeklyHisaabEnabledViaRemoteConfig) {
mmWeeklyHisaabEventTracker.onMMWeeklyHisaabDisabledViaRemoteConfig()
checkMMDiscoverability()
return@safeLaunch
}
mmDiscoverabilityEventTracker.onMMDiscoverabilityEntryPointEligible()
_showMMDiscoverabilityEntryPoint.value = true
} else {
val reason =
if (accountType != AccountType.SAVINGS) "non_savings_account"
else "experiment_disabled"
mmDiscoverabilityEventTracker.onMMDiscoverabilityEntryPointNotEligible(reason)
return@safeLaunch
val accountType = AccountType.getAccountType(linkedAccountEntity.accountType)
withTimeoutOrNull(
FirebaseRemoteConfigHelper.getLong(
MM_UPI_SYNC_AND_CONFIG_API_TIMEOUT_MS,
10000L,
)
) {
val configResponse =
dashboardRepository.fetchAndSaveWeeklyHisaabConfigResponse(
screenName = screenName,
onApiFailed = { statusCode, errorMessage ->
mmWeeklyHisaabEventTracker.onMMWeeklyHisaabConfigApiFailed(
statusCode = statusCode.toString(),
errorMessage = errorMessage.orEmpty(),
)
},
)
configResponse?.let { response ->
val isHisaabEnabled = response.isHisaabEnabled == true
val isPrimaryAccount = linkedAccountEntity.isAccountPrimary
val isSavingsAccount = accountType == AccountType.SAVINGS
if (isHisaabEnabled && isPrimaryAccount && isSavingsAccount) {
mmWeeklyHisaabEventTracker.onMMWeeklyHisaablitmusEnabled()
upiSpendDBSyncExecutor.execute(screenName).collectLatest { status ->
if (status == DataSyncState.Completed) {
_showMMWeeklyHisaabWidget.value = true
}
}
return@withTimeoutOrNull true
} else {
val reason =
when {
!isHisaabEnabled -> "litmus_disabled"
!isPrimaryAccount -> "not_primary_account"
else -> "not_savings_account"
}
mmWeeklyHisaabEventTracker.onMMWeeklyHisaabLitmusDisabled(reason)
checkMMDiscoverability()
return@withTimeoutOrNull false
}
}
return@withTimeoutOrNull false
}
}
}
private suspend fun checkMMDiscoverability() {
val experimentEnabled = isMMDiscoverabilityExperimentEnabled()
val accountType = AccountType.getAccountType(linkedAccountEntity.accountType)
mmDiscoverabilityEventTracker.onMMDiscoverabilityCheckStarted(
accountType = accountType.name,
experimentEnabled = experimentEnabled,
bankCode = linkedAccountEntity.bankCode,
)
if (accountType == AccountType.SAVINGS && experimentEnabled) {
val mmConfig = syncPreviousDataUseCase.fetchAndSaveConfigResponse(screenName)
if (
!checkBalanceMMWidgetHelper.isBankEligible(linkedAccountEntity.bankCode, mmConfig)
) {
mmDiscoverabilityEventTracker.onMMDiscoverabilityEntryPointNotEligible(
"bank_not_eligible"
)
return
}
mmDiscoverabilityEventTracker.onMMDiscoverabilityEntryPointEligible()
_showMMDiscoverabilityEntryPoint.value = true
} else {
val reason =
if (accountType != AccountType.SAVINGS) "non_savings_account"
else "experiment_disabled"
mmDiscoverabilityEventTracker.onMMDiscoverabilityEntryPointNotEligible(reason)
return
}
}
@@ -260,6 +340,15 @@ constructor(
}
}
fun loadMMWeeklyHisaabWidget() {
viewModelScope.safeLaunch(Dispatchers.IO) {
checkBalanceMMWidgetHelper
.getMMWeeklyHisaabWidgetData()
.distinctUntilChanged() // emits only when the data actually changes
.collect { data -> _mmWeeklyHisaabWidgetData.update { data } }
}
}
private suspend fun isMMDiscoverabilityExperimentEnabled() =
FirebaseRemoteConfigHelper.getBoolean(MM_DISCOVERABILITY_ENABLED) &&
checkBalanceMMWidgetHelper.isMMDiscoverabilityExperimentEnabled() &&
@@ -537,6 +626,10 @@ constructor(
override val screenName: String
get() = NAVI_PAY_CHECK_BALANCE_SCREEN
companion object {
private var upiTxnsyncJob: Job? = null
}
}
sealed class LinkedAccountBalanceScreenBottomSheetUIState {

View File

@@ -90,6 +90,7 @@ import com.navi.base.utils.EMPTY
import com.navi.common.R as CommonR
import com.navi.common.utils.clickableDebounce
import com.navi.common.utils.navigateUp
import com.navi.common.utils.onClickWithDebounce
import com.navi.design.font.FontWeightEnum
import com.navi.design.font.getFontWeight
import com.navi.design.font.naviFontFamily
@@ -1034,7 +1035,10 @@ fun NewMonthView(
color = NaviPayColor.textPrimary,
)
Row(
modifier = Modifier.wrapContentWidth().clickableDebounce { onInfoIconClick?.invoke() },
modifier =
Modifier.wrapContentWidth().onClickWithDebounce(indication = null) {
onInfoIconClick?.invoke()
},
verticalAlignment = Alignment.CenterVertically,
) {
NaviText(

View File

@@ -525,10 +525,10 @@ constructor(
if (upiSpendAnalysisWidgetHelper.isTotalSyncCompleted()) {
upiSpendAnalysisFirstSyncCompleted.update { true }
}
val dataSyncState = upiDBSyncExecutor.execute(screenName)
if (dataSyncState is DataSyncState.Completed) {
isUpiSpendAnalysisSyncCompleted.update { true }
upiDBSyncExecutor.execute(screenName).collect { state ->
if (state == DataSyncState.Completed) {
isUpiSpendAnalysisSyncCompleted.update { true }
}
}
}
}