Reverts balance perspective to castle's view

Changes the displayed balance perspective to reflect the castle's point of view instead of the user's.

This involves:
- Displaying balances as positive when the user owes the castle
- Displaying balances as negative when the castle owes the user.

This change affects how balances are calculated and displayed in both the backend logic and the frontend templates.
This commit is contained in:
padreug 2025-11-10 01:40:09 +01:00
parent 0f24833e02
commit 5c1c7b1b05
2 changed files with 39 additions and 41 deletions

View file

@ -172,18 +172,18 @@ class FavaClient:
async def get_user_balance(self, user_id: str) -> Dict[str, Any]: async def get_user_balance(self, user_id: str) -> Dict[str, Any]:
""" """
Get user's total balance (what castle owes user). Get user's balance from castle's perspective.
Aggregates: Aggregates:
- Liabilities:Payable:User-{user_id} (negative balance = castle owes) - Liabilities:Payable:User-{user_id} (negative = castle owes user)
- Assets:Receivable:User-{user_id} (positive balance = user owes) - Assets:Receivable:User-{user_id} (positive = user owes castle)
Args: Args:
user_id: User ID user_id: User ID
Returns: Returns:
{ {
"balance": int (sats, positive = castle owes user), "balance": int (sats, positive = user owes castle, negative = castle owes user),
"fiat_balances": {"EUR": Decimal("100.50")}, "fiat_balances": {"EUR": Decimal("100.50")},
"accounts": [list of account dicts with balances] "accounts": [list of account dicts with balances]
} }
@ -228,12 +228,11 @@ class FavaClient:
for cost_str, amount in sats_positions.items(): for cost_str, amount in sats_positions.items():
amount_int = int(amount) amount_int = int(amount)
# For user balance perspective, negate Beancount balance # Use Beancount balance as-is (castle's perspective)
# - Payable (Liability): negative in Beancount → positive (castle owes user) # - Receivable (Asset): positive = user owes castle
# - Receivable (Asset): positive in Beancount → negative (user owes castle) # - Payable (Liability): negative = castle owes user
adjusted_amount = -amount_int total_sats += amount_int
total_sats += adjusted_amount account_balance["sats"] += amount_int
account_balance["sats"] += adjusted_amount
# Extract fiat amount from cost basis # Extract fiat amount from cost basis
# Format: "100.00 EUR" or "{100.00 EUR}" # Format: "100.00 EUR" or "{100.00 EUR}"
@ -248,22 +247,19 @@ class FavaClient:
if fiat_currency not in fiat_balances: if fiat_currency not in fiat_balances:
fiat_balances[fiat_currency] = Decimal(0) fiat_balances[fiat_currency] = Decimal(0)
# Apply same sign adjustment to fiat # Apply same sign as sats amount
# Cost basis is always positive, derive sign from amount
if amount_int < 0: if amount_int < 0:
fiat_amount = -fiat_amount fiat_amount = -fiat_amount
adjusted_fiat = -fiat_amount fiat_balances[fiat_currency] += fiat_amount
fiat_balances[fiat_currency] += adjusted_fiat
except (ValueError, IndexError): except (ValueError, IndexError):
logger.warning(f"Could not parse cost basis: {cost_str}") logger.warning(f"Could not parse cost basis: {cost_str}")
elif isinstance(sats_positions, (int, float)): elif isinstance(sats_positions, (int, float)):
# Simple number (no cost basis) # Simple number (no cost basis)
amount_int = int(sats_positions) amount_int = int(sats_positions)
# Negate Beancount balance for user perspective # Use Beancount balance as-is
adjusted_amount = -amount_int total_sats += amount_int
total_sats += adjusted_amount account_balance["sats"] += amount_int
account_balance["sats"] += adjusted_amount
accounts.append(account_balance) accounts.append(account_balance)
@ -341,31 +337,33 @@ class FavaClient:
continue continue
import re import re
# Extract SATS amount # Extract SATS amount (with sign)
sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str) sats_match = re.match(r'^(-?\d+)\s+SATS', amount_str)
if sats_match: if sats_match:
sats_amount = int(sats_match.group(1)) sats_amount = int(sats_match.group(1))
# Negate Beancount balance for user perspective # For admin/castle view, use Beancount amounts as-is:
# Payable (liability): negative in Beancount = castle owes user (positive for user) # Receivable (asset): positive in Beancount = user owes castle (positive)
# Receivable (asset): positive in Beancount = user owes castle (negative for user) # Payable (liability): negative in Beancount = castle owes user (negative)
adjusted_amount = -sats_amount user_data[user_id]["balance"] += sats_amount
user_data[user_id]["balance"] += adjusted_amount
# Extract fiat from cost syntax: {33.33 EUR, ...} # Extract fiat from cost syntax: {33.33 EUR, ...}
cost_match = re.search(r'\{([\d.]+)\s+([A-Z]+)', amount_str) cost_match = re.search(r'\{([\d.]+)\s+([A-Z]+)', amount_str)
if cost_match: if cost_match:
fiat_amount_raw = Decimal(cost_match.group(1)) fiat_amount_unsigned = Decimal(cost_match.group(1))
fiat_currency = cost_match.group(2) fiat_currency = cost_match.group(2)
if fiat_currency not in user_data[user_id]["fiat_balances"]: if fiat_currency not in user_data[user_id]["fiat_balances"]:
user_data[user_id]["fiat_balances"][fiat_currency] = Decimal(0) user_data[user_id]["fiat_balances"][fiat_currency] = Decimal(0)
# Apply same sign logic as sats # Apply the same sign as the SATS amount
if "-" in amount_str: # If SATS is negative, fiat should be negative too
fiat_amount_raw = -fiat_amount_raw if sats_match:
adjusted_fiat = -fiat_amount_raw sats_amount_for_sign = int(sats_match.group(1))
user_data[user_id]["fiat_balances"][fiat_currency] += adjusted_fiat if sats_amount_for_sign < 0:
fiat_amount_unsigned = -fiat_amount_unsigned
user_data[user_id]["fiat_balances"][fiat_currency] += fiat_amount_unsigned
return list(user_data.values()) return list(user_data.values())

View file

@ -182,7 +182,7 @@
</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-negative' : 'text-positive'"> <div :class="props.row.balance > 0 ? 'text-positive' : 'text-negative'">
{% raw %}{{ formatSats(Math.abs(props.row.balance)) }} sats{% endraw %} {% raw %}{{ formatSats(Math.abs(props.row.balance)) }} sats{% endraw %}
</div> </div>
<div v-if="props.row.fiat_balances && Object.keys(props.row.fiat_balances).length > 0" class="text-caption"> <div v-if="props.row.fiat_balances && Object.keys(props.row.fiat_balances).length > 0" class="text-caption">
@ -191,15 +191,15 @@
</span> </span>
</div> </div>
<div class="text-caption text-grey"> <div class="text-caption text-grey">
{% raw %}{{ props.row.balance > 0 ? 'You owe' : 'Owes you' }}{% endraw %} {% raw %}{{ props.row.balance > 0 ? 'Owes you' : 'You owe' }}{% endraw %}
</div> </div>
</q-td> </q-td>
</template> </template>
<template v-slot:body-cell-actions="props"> <template v-slot:body-cell-actions="props">
<q-td :props="props"> <q-td :props="props">
<!-- User owes Castle (negative balance) - Castle receives payment --> <!-- User owes Castle (positive balance) - Castle receives payment -->
<q-btn <q-btn
v-if="props.row.balance < 0" v-if="props.row.balance > 0"
flat flat
dense dense
size="sm" size="sm"
@ -209,9 +209,9 @@
> >
<q-tooltip>Settle receivable (user pays castle)</q-tooltip> <q-tooltip>Settle receivable (user pays castle)</q-tooltip>
</q-btn> </q-btn>
<!-- Castle owes User (positive balance) - Castle pays user --> <!-- Castle owes User (negative balance) - Castle pays user -->
<q-btn <q-btn
v-if="props.row.balance > 0" v-if="props.row.balance < 0"
flat flat
dense dense
size="sm" size="sm"
@ -241,7 +241,7 @@
</div> </div>
</div> </div>
<div v-if="balance !== null"> <div v-if="balance !== null">
<div class="text-h4" :class="isSuperUser ? (balance.balance >= 0 ? 'text-negative' : 'text-positive') : (balance.balance >= 0 ? 'text-positive' : 'text-negative')"> <div class="text-h4" :class="isSuperUser ? (balance.balance >= 0 ? 'text-positive' : 'text-negative') : (balance.balance >= 0 ? 'text-negative' : 'text-positive')">
{% raw %}{{ formatSats(Math.abs(balance.balance)) }} sats{% endraw %} {% raw %}{{ formatSats(Math.abs(balance.balance)) }} sats{% endraw %}
</div> </div>
<div v-if="balance.fiat_balances && Object.keys(balance.fiat_balances).length > 0" class="text-h6 q-mt-sm"> <div v-if="balance.fiat_balances && Object.keys(balance.fiat_balances).length > 0" class="text-h6 q-mt-sm">
@ -250,21 +250,21 @@
</span> </span>
</div> </div>
<div class="text-subtitle2" v-if="isSuperUser"> <div class="text-subtitle2" v-if="isSuperUser">
{% raw %}{{ balance.balance > 0 ? 'Total you owe' : balance.balance < 0 ? 'Total owed to you' : 'No outstanding balances' }}{% endraw %} {% raw %}{{ balance.balance > 0 ? 'Total owed to you' : balance.balance < 0 ? 'Total you owe' : 'No outstanding balances' }}{% endraw %}
</div> </div>
<div class="text-subtitle2" v-else> <div class="text-subtitle2" v-else>
{% raw %}{{ balance.balance >= 0 ? 'Castle owes you' : 'You owe Castle' }}{% endraw %} {% raw %}{{ balance.balance >= 0 ? 'You owe Castle' : 'Castle owes you' }}{% endraw %}
</div> </div>
<div class="q-mt-md q-gutter-sm"> <div class="q-mt-md q-gutter-sm">
<q-btn <q-btn
v-if="balance.balance < 0 && !isSuperUser" v-if="balance.balance > 0 && !isSuperUser"
color="primary" color="primary"
@click="showPayBalanceDialog" @click="showPayBalanceDialog"
> >
Pay Balance Pay Balance
</q-btn> </q-btn>
<q-btn <q-btn
v-if="balance.balance > 0 && !isSuperUser" v-if="balance.balance < 0 && !isSuperUser"
color="secondary" color="secondary"
@click="showManualPaymentDialog" @click="showManualPaymentDialog"
> >