Coverage Summary for Class: ConvertAccountsTotalUseCase (com.georgeci.moneysurfer.domain.usecase)
| Class |
Class, %
|
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| ConvertAccountsTotalUseCase |
100%
(1/1)
|
100%
(3/3)
|
95%
(19/20)
|
100%
(19/19)
|
98.5%
(129/131)
|
package com.georgeci.moneysurfer.domain.usecase
import com.georgeci.moneysurfer.domain.model.Account
import com.georgeci.moneysurfer.domain.model.ConvertedTotal
import com.georgeci.moneysurfer.domain.model.CurrencyTotal
import com.georgeci.moneysurfer.domain.model.ExchangeRateTable
import com.georgeci.moneysurfer.domain.model.totalsByCurrency
import com.georgeci.moneysurfer.domain.primitives.CurrencyCode
import com.georgeci.moneysurfer.domain.primitives.Money
import org.koin.core.annotation.Single
/**
* Folds every account balance into one figure in the workspace base currency, using [rates].
*
* Conversion happens per *currency total*, not per account: one rounding per currency instead of
* one per row, so the headline does not drift by a cent for every extra account.
*
* Accounts in a currency [rates] cannot price are not dropped — they come back in
* [ConvertedTotal.unconverted] for the caller to show beside the headline. With no rates cached
* at all (offline first run, or the offline build, which has no provider) that degrades to
* "base-currency accounts sum, the rest listed" rather than to a wrong number.
*/
@Single
class ConvertAccountsTotalUseCase {
operator fun invoke(
accounts: List<Account>,
baseCurrency: CurrencyCode,
rates: ExchangeRateTable?,
): ConvertedTotal {
// A table quoted against some other base cannot price this workspace: only the window
// between a base-currency change and the next refresh can produce one, and re-basing
// second-hand quotes is a worse answer than saying "not converted".
val usable = rates?.takeIf { it.baseCurrency == baseCurrency }
val byCurrency = accounts.totalsByCurrency()
var converted: Money? = null
var usedRates = false
val unconverted = mutableListOf<CurrencyTotal>()
byCurrency.forEach { bucket ->
val inBase: Money? = bucket.inBaseCurrency(baseCurrency, usable)
if (inBase == null) {
unconverted += bucket
} else {
// A bucket already in the base needed no quote; anything else got here through
// one, and that is what earns the "as of" label below.
if (bucket.currencyCode != baseCurrency) usedRates = true
converted = (converted ?: Money.zero()) + inBase
}
}
return ConvertedTotal(
baseCurrency = baseCurrency,
total = converted,
unconverted = unconverted,
// Only claim an "as of" when a quote actually moved money — a single-currency
// workspace needs no rates and must not be labelled with yesterday's date.
asOf = usable?.asOf?.takeIf { usedRates },
)
}
/** This bucket expressed in [base], or `null` when [rates] cannot price its currency. */
private fun CurrencyTotal.inBaseCurrency(base: CurrencyCode, rates: ExchangeRateTable?): Money? =
if (currencyCode == base) amount else rates?.convertToBase(amount, currencyCode)
}