Show Outstanding Balances split 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 row collapsed
both into a single "Owes you" / "You owe" label driven by the net
sats balance. The fiat amounts were displayed via Math.abs(), hiding
the per-currency signs the backend already returns, so the row 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 lines instead — "Owes you €200.00" and
"You owe CA$300.00" — using new owesYouFiat / youOweFiat helpers
that filter the signed fiat_balances dict by sign. Net sats stays
as a small caption with an explicit "(receivable)"/"(payable)"
qualifier, since sats can be netted but distinct fiat currencies
can't without a spot rate. Falls back to the old single-line render
when there are no fiat balances (sats-only entries).
This commit is contained in:
Padreug 2026-05-17 20:12:51 +02:00
commit 0a7c39adcb
2 changed files with 44 additions and 8 deletions

View file

@ -1645,6 +1645,31 @@ window.app = Vue.createApp({
isIncomeEntry(entry) { isIncomeEntry(entry) {
return Array.isArray(entry.tags) && entry.tags.includes('income-entry') return Array.isArray(entry.tags) && entry.tags.includes('income-entry')
}, },
// Per-currency split for multi-currency balances. Sign convention from the
// super-user perspective: positive fiat = user owes Libra (Receivable),
// negative fiat = Libra owes user (Payable). Distinct currencies can't be
// netted across each other (no spot rate), so we render them grouped by
// direction instead of one collapsed label.
owesYouFiat(fiatBalances) {
if (!fiatBalances) return {}
return Object.fromEntries(
Object.entries(fiatBalances).filter(([_, amount]) => Number(amount) > 0.005)
)
},
youOweFiat(fiatBalances) {
if (!fiatBalances) return {}
return Object.fromEntries(
Object.entries(fiatBalances)
.filter(([_, amount]) => Number(amount) < -0.005)
.map(([cur, amount]) => [cur, Math.abs(Number(amount))])
)
},
hasOwesYouFiat(fiatBalances) {
return Object.keys(this.owesYouFiat(fiatBalances)).length > 0
},
hasYouOweFiat(fiatBalances) {
return Object.keys(this.youOweFiat(fiatBalances)).length > 0
},
formatFiat(amount, currency) { formatFiat(amount, currency) {
return new Intl.NumberFormat('en-US', { return new Intl.NumberFormat('en-US', {
style: 'currency', style: 'currency',

View file

@ -187,16 +187,27 @@
</template> </template>
<template v-slot:body-cell-balance="props"> <template v-slot:body-cell-balance="props">
<q-td :props="props"> <q-td :props="props">
<div :class="props.row.balance > 0 ? 'text-positive' : 'text-negative'"> <!-- User owes you (org), per currency -->
{% raw %}{{ formatSats(Math.abs(props.row.balance)) }} sats{% endraw %} <div v-if="hasOwesYouFiat(props.row.fiat_balances)" class="text-positive">
<div v-for="(amount, currency) in owesYouFiat(props.row.fiat_balances)" :key="'oy-' + currency">
Owes you {% raw %}{{ formatFiat(amount, currency) }}{% endraw %}
</div>
</div> </div>
<div v-if="props.row.fiat_balances && Object.keys(props.row.fiat_balances).length > 0" class="text-caption"> <!-- You (org) owe user, per currency -->
<span v-for="(amount, currency) in props.row.fiat_balances" :key="currency" class="q-mr-sm"> <div v-if="hasYouOweFiat(props.row.fiat_balances)" class="text-negative">
{% raw %}{{ formatFiat(Math.abs(amount), currency) }}{% endraw %} <div v-for="(amount, currency) in youOweFiat(props.row.fiat_balances)" :key="'yo-' + currency">
</span> You owe {% raw %}{{ formatFiat(amount, currency) }}{% endraw %}
</div>
</div> </div>
<div class="text-caption text-grey"> <!-- Fallback when there are no fiat balances (sats-only entries) -->
{% raw %}{{ props.row.balance > 0 ? 'Owes you' : 'You owe' }}{% endraw %} <div v-if="!hasOwesYouFiat(props.row.fiat_balances) && !hasYouOweFiat(props.row.fiat_balances)"
:class="props.row.balance > 0 ? 'text-positive' : 'text-negative'">
{% raw %}{{ props.row.balance > 0 ? 'Owes you' : 'You owe' }} {{ formatSats(Math.abs(props.row.balance)) }} sats{% endraw %}
</div>
<!-- Net sats footnote (current-rate-derived; can't be netted across currencies) -->
<div v-if="hasOwesYouFiat(props.row.fiat_balances) || hasYouOweFiat(props.row.fiat_balances)"
class="text-caption text-grey q-mt-xs">
Net (current rates): {% raw %}{{ formatSats(Math.abs(props.row.balance)) }} sats {{ props.row.balance > 0 ? '(receivable)' : '(payable)' }}{% endraw %}
</div> </div>
</q-td> </q-td>
</template> </template>