finance_balance.py 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
  1. """Canonical definition and synchronization of a user's personal balance."""
  2. from sqlalchemy import and_, func, or_, select
  3. from sqlalchemy.ext.asyncio import AsyncSession
  4. from backend.app.models.finance import CostCenter, UserWallet, WalletTransaction
  5. from backend.app.models.settings import Settings as AppSettingModel
  6. from backend.app.schemas.settings import AppSettings as AppSettingsSchema
  7. async def resolve_configured_currency(db: AsyncSession) -> str:
  8. """The currency this install reports balances in.
  9. Every other surface in Bambuddy renders the ``currency`` app setting.
  10. Finance used to answer from ``user_wallets.currency``, which three of its
  11. four writers filled with a hardcoded "EUR", so an install configured for
  12. AUD reported a euro balance (#3123). That column is gone; this is the one
  13. place that answers the question.
  14. """
  15. result = await db.execute(select(AppSettingModel).where(AppSettingModel.key == "currency"))
  16. setting = result.scalar_one_or_none()
  17. if setting and setting.value:
  18. return setting.value
  19. return AppSettingsSchema().currency
  20. def transaction_affects_personal_balance(
  21. user_id: int,
  22. cost_center_id: int | None,
  23. *,
  24. is_private: bool = False,
  25. owner_user_id: int | None = None,
  26. ) -> bool:
  27. """Apply the canonical definition to already-loaded transaction data."""
  28. return cost_center_id is None or (is_private and owner_user_id == user_id)
  29. def personal_balance_condition(user_id: int):
  30. """Return the SQL condition for transactions in a personal wallet.
  31. Unassigned transactions and transactions assigned to the user's own
  32. private cost center are personal. Shared cost centers are not.
  33. """
  34. return or_(
  35. WalletTransaction.cost_center_id.is_(None),
  36. and_(CostCenter.is_private.is_(True), CostCenter.owner_user_id == user_id),
  37. )
  38. async def calculate_personal_balance(db: AsyncSession, user_id: int) -> float:
  39. result = await db.execute(
  40. select(func.coalesce(func.sum(WalletTransaction.amount), 0.0))
  41. .select_from(WalletTransaction)
  42. .outerjoin(CostCenter, WalletTransaction.cost_center_id == CostCenter.id)
  43. .where(
  44. WalletTransaction.user_id == user_id,
  45. WalletTransaction.is_voided.is_(False),
  46. personal_balance_condition(user_id),
  47. )
  48. )
  49. return round(float(result.scalar_one() or 0.0), 2)
  50. async def is_personal_transaction(db: AsyncSession, user_id: int, cost_center_id: int | None) -> bool:
  51. if cost_center_id is None:
  52. return True
  53. result = await db.execute(
  54. select(CostCenter.is_private, CostCenter.owner_user_id).where(CostCenter.id == cost_center_id)
  55. )
  56. center = result.one_or_none()
  57. if center is None:
  58. return False
  59. return transaction_affects_personal_balance(
  60. user_id,
  61. cost_center_id,
  62. is_private=bool(center.is_private),
  63. owner_user_id=center.owner_user_id,
  64. )
  65. async def sync_personal_wallet_balance(db: AsyncSession, wallet: UserWallet) -> float:
  66. balance = await calculate_personal_balance(db, wallet.user_id)
  67. wallet.balance = balance
  68. db.add(wallet)
  69. return balance