mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-02 20:22:15 +02:00
The Finance page was the only surface in Bambuddy that read its currency from a data row rather than the `currency` setting, and it fell back to EUR where every other page falls back to USD. One variable drives every amount on that page, so the personal balance, the cost-center budgets and the whole transaction list were wrong together on any install not set to euros. It now takes the configured currency from /settings/ui-flags, which is readable by anyone who can see Finance -- /settings needs SETTINGS_READ, which a cost_centers:read_own user does not have. The backend was the other half. Of the four places that settle on a currency, three wrote a hardcoded "EUR": the wallet the API mints on demand, the wallet a print charge mints when none exists, and the balance returned for a user with no wallet row at all. All four now go through one resolver, which lives beside the rest of the balance logic. The wallet's currency column is removed outright rather than merely ignored. An install has one currency and nothing here converts between them, so a per-wallet copy could only ever drift from the setting -- and a column nothing reads is a trap for whoever finds it next. A startup migration drops it on both SQLite and PostgreSQL, after the raw CREATE TABLE that would otherwise re-add it on an install whose finance tables predate the ORM. SQLite builds older than 3.35 have no DROP COLUMN and keep it, harmlessly, since it has a default and no reader. Saving settings now invalidates the ui-flags query too. Nothing did, so a changed currency sat behind that query's staleTime before showing up. The sponsor prompt's own EUR fallback is now USD, matching AppSettings.
88 lines
3.1 KiB
Python
88 lines
3.1 KiB
Python
"""Canonical definition and synchronization of a user's personal balance."""
|
|
|
|
from sqlalchemy import and_, func, or_, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.app.models.finance import CostCenter, UserWallet, WalletTransaction
|
|
from backend.app.models.settings import Settings as AppSettingModel
|
|
from backend.app.schemas.settings import AppSettings as AppSettingsSchema
|
|
|
|
|
|
async def resolve_configured_currency(db: AsyncSession) -> str:
|
|
"""The currency this install reports balances in.
|
|
|
|
Every other surface in Bambuddy renders the ``currency`` app setting.
|
|
Finance used to answer from ``user_wallets.currency``, which three of its
|
|
four writers filled with a hardcoded "EUR", so an install configured for
|
|
AUD reported a euro balance (#3123). That column is gone; this is the one
|
|
place that answers the question.
|
|
"""
|
|
result = await db.execute(select(AppSettingModel).where(AppSettingModel.key == "currency"))
|
|
setting = result.scalar_one_or_none()
|
|
if setting and setting.value:
|
|
return setting.value
|
|
return AppSettingsSchema().currency
|
|
|
|
|
|
def transaction_affects_personal_balance(
|
|
user_id: int,
|
|
cost_center_id: int | None,
|
|
*,
|
|
is_private: bool = False,
|
|
owner_user_id: int | None = None,
|
|
) -> bool:
|
|
"""Apply the canonical definition to already-loaded transaction data."""
|
|
|
|
return cost_center_id is None or (is_private and owner_user_id == user_id)
|
|
|
|
|
|
def personal_balance_condition(user_id: int):
|
|
"""Return the SQL condition for transactions in a personal wallet.
|
|
|
|
Unassigned transactions and transactions assigned to the user's own
|
|
private cost center are personal. Shared cost centers are not.
|
|
"""
|
|
|
|
return or_(
|
|
WalletTransaction.cost_center_id.is_(None),
|
|
and_(CostCenter.is_private.is_(True), CostCenter.owner_user_id == user_id),
|
|
)
|
|
|
|
|
|
async def calculate_personal_balance(db: AsyncSession, user_id: int) -> float:
|
|
result = await db.execute(
|
|
select(func.coalesce(func.sum(WalletTransaction.amount), 0.0))
|
|
.select_from(WalletTransaction)
|
|
.outerjoin(CostCenter, WalletTransaction.cost_center_id == CostCenter.id)
|
|
.where(
|
|
WalletTransaction.user_id == user_id,
|
|
WalletTransaction.is_voided.is_(False),
|
|
personal_balance_condition(user_id),
|
|
)
|
|
)
|
|
return round(float(result.scalar_one() or 0.0), 2)
|
|
|
|
|
|
async def is_personal_transaction(db: AsyncSession, user_id: int, cost_center_id: int | None) -> bool:
|
|
if cost_center_id is None:
|
|
return True
|
|
result = await db.execute(
|
|
select(CostCenter.is_private, CostCenter.owner_user_id).where(CostCenter.id == cost_center_id)
|
|
)
|
|
center = result.one_or_none()
|
|
if center is None:
|
|
return False
|
|
return transaction_affects_personal_balance(
|
|
user_id,
|
|
cost_center_id,
|
|
is_private=bool(center.is_private),
|
|
owner_user_id=center.owner_user_id,
|
|
)
|
|
|
|
|
|
async def sync_personal_wallet_balance(db: AsyncSession, wallet: UserWallet) -> float:
|
|
balance = await calculate_personal_balance(db, wallet.user_id)
|
|
wallet.balance = balance
|
|
db.add(wallet)
|
|
return balance
|