feat(libra/balance): split Net Balance card per direction per currency

When a user has entries in multiple currencies that go in opposite
directions — e.g. an income entry in EUR (user owes the org) and an
expense entry in CAD (org owes user) — the previous Net Balance hero
collapsed both into a single "You owe" / "Owed to you" label driven
by the net sats. The fiat amounts were displayed via Math.abs(),
hiding the per-currency signs the backend already returns, so the
hero was actively misleading: it showed €200 and CA$300 under one
direction when in reality they point in opposite directions.

Render up to two grouped sections — "You owe" with the user-owes
currencies, "Owed to you" with the libra-owes currencies — using new
youOweFiatEntries / libraOwesFiatEntries computeds that filter the
signed fiat_balances dict by sign. Net sats moves to a small caption
labelled "Net at current rates", since sats can be netted but
distinct fiat currencies can't without a spot rate. Falls back to
the old single-amount sats display when there are no fiat balances.
This commit is contained in:
Padreug 2026-05-17 20:15:01 +02:00
commit 124cad1249

View file

@ -25,8 +25,24 @@ const totalIncomeFiat = ref<Record<string, number>>({})
const pendingTransactions = ref<Transaction[]>([]) const pendingTransactions = ref<Transaction[]>([])
const isLoading = ref(true) const isLoading = ref(true)
const fiatBalanceEntries = computed(() => // Per-currency split: sign convention from the user perspective:
Object.entries(fiatBalances.value).filter(([, amount]) => Math.abs(amount) > 0.005) // positive fiat_balance = user owes Libra, negative = Libra owes user.
// Distinct currencies can't be netted across each other (no spot rate),
// so we render them grouped by direction instead of one collapsed label.
const youOweFiatEntries = computed(() =>
Object.entries(fiatBalances.value)
.filter(([, amount]) => amount > 0.005)
.map(([currency, amount]) => [currency, amount] as [string, number])
)
const libraOwesFiatEntries = computed(() =>
Object.entries(fiatBalances.value)
.filter(([, amount]) => amount < -0.005)
.map(([currency, amount]) => [currency, Math.abs(amount)] as [string, number])
)
const hasAnyFiatBalance = computed(() =>
youOweFiatEntries.value.length > 0 || libraOwesFiatEntries.value.length > 0
) )
const expensesFiatEntries = computed(() => const expensesFiatEntries = computed(() =>
@ -126,30 +142,64 @@ function formatFiat(amount: number, currency: string): string {
<div class="rounded-xl border bg-card p-6 mb-6"> <div class="rounded-xl border bg-card p-6 mb-6">
<p class="text-sm text-muted-foreground mb-1">{{ t('libra.balance.netBalance') }}</p> <p class="text-sm text-muted-foreground mb-1">{{ t('libra.balance.netBalance') }}</p>
<div v-if="balance !== null" class="space-y-1"> <div v-if="balance !== null" class="space-y-3">
<div class="flex items-center gap-2"> <!-- Fiat split by direction (real per-currency state) -->
<div v-if="hasAnyFiatBalance" class="space-y-2">
<div v-if="youOweFiatEntries.length > 0">
<p class="text-xs uppercase tracking-wide text-red-600 dark:text-red-400 font-medium mb-0.5">
{{ t('libra.balance.youOwe') }}
</p>
<div class="flex flex-wrap gap-x-3 gap-y-0.5">
<span
v-for="[currency, amount] in youOweFiatEntries"
:key="'yo-' + currency"
class="text-2xl font-bold text-foreground"
>
{{ formatFiat(amount, currency) }}
</span>
</div>
</div>
<div v-if="libraOwesFiatEntries.length > 0">
<p class="text-xs uppercase tracking-wide text-green-600 dark:text-green-400 font-medium mb-0.5">
{{ t('libra.balance.owedToYou') }}
</p>
<div class="flex flex-wrap gap-x-3 gap-y-0.5">
<span
v-for="[currency, amount] in libraOwesFiatEntries"
:key="'lo-' + currency"
class="text-2xl font-bold text-foreground"
>
{{ formatFiat(amount, currency) }}
</span>
</div>
</div>
</div>
<!-- Sats-only fallback when no fiat balances are present -->
<div v-else class="flex items-center gap-2">
<component <component
:is="libraOwesUser ? ArrowDown : ArrowUp" :is="libraOwesUser ? ArrowDown : ArrowUp"
class="w-5 h-5" class="w-5 h-5"
:class="libraOwesUser ? 'text-green-500' : 'text-red-500'" :class="libraOwesUser ? 'text-green-500' : 'text-red-500'"
/> />
<span class="text-3xl font-bold text-foreground"> <span class="text-3xl font-bold text-foreground">{{ formatAmount(balance) }}</span>
{{ formatAmount(balance) }}
</span>
<span class="text-lg text-muted-foreground">{{ balanceCurrency }}</span> <span class="text-lg text-muted-foreground">{{ balanceCurrency }}</span>
</div> </div>
<div v-if="fiatBalanceEntries.length > 0" class="flex flex-wrap items-center gap-x-3 gap-y-1 pl-7">
<span <!-- Net sats caption (always shown when there's a balance; distinct
v-for="[currency, amount] in fiatBalanceEntries" currencies can't be netted into a single fiat number, but sats
:key="currency" can informational only, depends on current BTC rates) -->
class="text-base text-muted-foreground" <div class="flex items-center gap-1.5 text-xs text-muted-foreground">
> <component
{{ formatFiat(Math.abs(amount), currency) }} :is="libraOwesUser ? ArrowDown : ArrowUp"
class="w-3 h-3"
:class="libraOwesUser ? 'text-green-500' : 'text-red-500'"
/>
<span>
Net at current rates: {{ formatAmount(balance) }} {{ balanceCurrency }}
({{ libraOwesUser ? t('libra.balance.owedToYou').toLowerCase() : t('libra.balance.youOwe').toLowerCase() }})
</span> </span>
</div> </div>
<p class="text-sm" :class="libraOwesUser ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'">
{{ libraOwesUser ? t('libra.balance.owedToYou') : t('libra.balance.youOwe') }}
</p>
</div> </div>
<div v-else class="text-muted-foreground"> <div v-else class="text-muted-foreground">