Files
maziggy 4a85e033c0 fix(finance): show the currency the install is configured for (issue #3123)
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.
2026-09-20 10:06:47 +02:00

1054 lines
38 KiB
Python

import calendar
from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import case, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_auth_if_enabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.finance import (
BudgetReservation,
CostCenter,
CostCenterMember,
TransactionType,
UserWallet,
WalletTransaction,
normalize_transaction_type,
)
from backend.app.models.settings import Settings
from backend.app.models.user import User
from backend.app.schemas.finance import (
CostCenterBudgetUpdateRequest,
CostCenterCreateRequest,
CostCenterDetailResponse,
CostCenterMemberRequest,
CostCenterMemberResponse,
CostCenterSummaryResponse,
CostCenterUpdateRequest,
ManualPrintRequest,
TransactionEditRequest,
WalletAdjustmentRequest,
WalletAdjustmentResponse,
WalletBalanceResponse,
WalletTransactionListResponse,
WalletTransactionResponse,
)
from backend.app.services.finance_balance import (
calculate_personal_balance,
is_personal_transaction,
personal_balance_condition,
resolve_configured_currency,
sync_personal_wallet_balance,
)
from backend.app.services.finance_budget import get_cost_center_reserved_map
router = APIRouter(prefix="/finance", tags=["finance"])
def _serialize_wallet_transaction(tx: WalletTransaction) -> WalletTransactionResponse:
transaction_type = (
tx.transaction_type.value if isinstance(tx.transaction_type, TransactionType) else tx.transaction_type
)
return WalletTransactionResponse.model_construct(
id=tx.id,
user_id=tx.user_id,
cost_center_id=tx.cost_center_id,
transaction_type=transaction_type,
amount=tx.amount,
balance_after=tx.balance_after,
description=tx.description,
created_by_user_id=tx.created_by_user_id,
print_run_id=tx.print_run_id,
print_archive_id=tx.print_archive_id,
print_queue_id=tx.print_queue_id,
created_at=tx.created_at,
)
def _clamp_day(year: int, month: int, desired_day: int) -> int:
return min(max(1, desired_day), calendar.monthrange(year, month)[1])
async def _get_budget_window_start_utc(db: AsyncSession) -> datetime:
"""Resolve monthly budget window start in UTC using configurable reset day/timezone.
Defaults preserve current behavior: day=1, timezone=UTC.
"""
desired_day = 1
tz_name = "UTC"
result = await db.execute(
select(Settings).where(Settings.key.in_(["finance_budget_reset_day", "finance_budget_reset_timezone"]))
)
for setting in result.scalars().all():
if setting.key == "finance_budget_reset_day":
try:
parsed = int(setting.value)
if 1 <= parsed <= 31:
desired_day = parsed
except (TypeError, ValueError):
pass
elif setting.key == "finance_budget_reset_timezone":
value = (setting.value or "").strip()
if value:
tz_name = value
try:
tz = ZoneInfo(tz_name)
except ZoneInfoNotFoundError:
tz = timezone.utc
now_local = datetime.now(tz)
current_month_reset_day = _clamp_day(now_local.year, now_local.month, desired_day)
if now_local.day >= current_month_reset_day:
start_local = datetime(now_local.year, now_local.month, current_month_reset_day, tzinfo=tz)
else:
prev_year = now_local.year
prev_month = now_local.month - 1
if prev_month == 0:
prev_month = 12
prev_year -= 1
prev_month_reset_day = _clamp_day(prev_year, prev_month, desired_day)
start_local = datetime(prev_year, prev_month, prev_month_reset_day, tzinfo=tz)
return start_local.astimezone(timezone.utc)
async def _get_cost_center_usage_maps(
db: AsyncSession,
cost_center_ids: list[int],
) -> tuple[dict[int, float], dict[int, float]]:
if not cost_center_ids:
return {}, {}
spend_expr = case((WalletTransaction.amount < 0, -WalletTransaction.amount), else_=0.0)
total_rows = await db.execute(
select(WalletTransaction.cost_center_id, func.coalesce(func.sum(spend_expr), 0.0))
.where(
WalletTransaction.cost_center_id.in_(cost_center_ids),
WalletTransaction.cost_center_id.is_not(None),
WalletTransaction.is_voided.is_(False),
)
.group_by(WalletTransaction.cost_center_id)
)
budget_window_start_utc = await _get_budget_window_start_utc(db)
month_rows = await db.execute(
select(WalletTransaction.cost_center_id, func.coalesce(func.sum(spend_expr), 0.0))
.where(
WalletTransaction.cost_center_id.in_(cost_center_ids),
WalletTransaction.cost_center_id.is_not(None),
WalletTransaction.is_voided.is_(False),
WalletTransaction.created_at >= budget_window_start_utc,
)
.group_by(WalletTransaction.cost_center_id)
)
total_map = {int(center_id): float(value) for center_id, value in total_rows.all() if center_id is not None}
month_map = {int(center_id): float(value) for center_id, value in month_rows.all() if center_id is not None}
return total_map, month_map
async def _get_cost_center_balance_map(
db: AsyncSession,
cost_center_ids: list[int],
) -> dict[int, float]:
if not cost_center_ids:
return {}
rows = await db.execute(
select(WalletTransaction.cost_center_id, func.coalesce(func.sum(WalletTransaction.amount), 0.0))
.where(
WalletTransaction.cost_center_id.in_(cost_center_ids),
WalletTransaction.cost_center_id.is_not(None),
WalletTransaction.is_voided.is_(False),
)
.group_by(WalletTransaction.cost_center_id)
)
return {int(center_id): float(value) for center_id, value in rows.all() if center_id is not None}
async def _get_cost_center_reserved_map(
db: AsyncSession,
cost_center_ids: list[int],
) -> dict[int, float]:
return await get_cost_center_reserved_map(db, cost_center_ids)
def _budget_mode_and_limit(center: CostCenter) -> tuple[str, float | None]:
# Monthly takes precedence if legacy data still has both set.
if center.monthly_budget is not None:
return "monthly", float(center.monthly_budget)
if center.total_budget is not None:
return "total", float(center.total_budget)
return "none", None
def _to_cost_center_summary(
center: CostCenter,
*,
can_print: bool,
total_usage: float,
month_usage: float,
total_balance: float,
reserved: float = 0.0,
) -> CostCenterSummaryResponse:
budget_mode, budget_limit = _budget_mode_and_limit(center)
budget_used = month_usage if budget_mode == "monthly" else total_usage if budget_mode == "total" else None
budget_available = (
max(0.0, budget_limit - budget_used - reserved)
if budget_limit is not None and budget_used is not None
else None
)
return CostCenterSummaryResponse(
id=center.id,
name=center.name,
is_private=center.is_private,
owner_user_id=center.owner_user_id,
is_active=center.is_active,
total_balance=total_balance,
total_budget=center.total_budget,
monthly_budget=center.monthly_budget,
budget_mode=budget_mode,
budget_limit=budget_limit,
budget_used=budget_used,
budget_available=budget_available,
can_print=can_print,
)
async def _require_authenticated_user(current_user: User | None) -> User:
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required")
return current_user
def _has_cost_center_admin_access(user: User) -> bool:
return user.has_any_permission(
Permission.COST_CENTERS_READ_ALL.value,
Permission.COST_CENTERS_MODIFY.value,
Permission.COST_CENTERS_CREATE.value,
)
async def _require_cost_center_admin_access(current_user: User | None) -> User:
user = await _require_authenticated_user(current_user)
if not _has_cost_center_admin_access(user):
raise HTTPException(status_code=403, detail="Missing required permissions for cost center administration")
return user
async def _get_or_create_wallet(db: AsyncSession, user_id: int) -> UserWallet:
result = await db.execute(select(UserWallet).where(UserWallet.user_id == user_id))
wallet = result.scalar_one_or_none()
if wallet:
return wallet
wallet = UserWallet(user_id=user_id, balance=0.0)
db.add(wallet)
await db.flush()
await db.refresh(wallet)
return wallet
async def _get_user_or_404(db: AsyncSession, user_id: int) -> User:
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return user
async def _get_cost_center_or_404(db: AsyncSession, cost_center_id: int) -> CostCenter:
result = await db.execute(
select(CostCenter).options(selectinload(CostCenter.members)).where(CostCenter.id == cost_center_id)
)
center = result.scalar_one_or_none()
if center is None:
raise HTTPException(status_code=404, detail="Cost center not found")
return center
def _to_balance_response(wallet: UserWallet, currency: str) -> WalletBalanceResponse:
"""Serialize a wallet, reporting the install's configured currency.
The wallet row holds no currency of its own: an install has exactly one,
and an admin who changes it expects every balance to follow, the way the
rest of the app does (#3123).
"""
return WalletBalanceResponse(
user_id=wallet.user_id,
balance=wallet.balance,
currency=currency,
updated_at=wallet.updated_at,
)
async def _get_wallet_balance_read_only(db: AsyncSession, user_id: int) -> WalletBalanceResponse:
"""Return a balance without creating a wallet row from a GET request."""
currency = await resolve_configured_currency(db)
wallet = await db.scalar(select(UserWallet).where(UserWallet.user_id == user_id))
if wallet is not None:
return _to_balance_response(wallet, currency)
return WalletBalanceResponse(
user_id=user_id,
balance=await calculate_personal_balance(db, user_id),
currency=currency,
updated_at=None,
)
async def _build_personal_balance_map(
db: AsyncSession,
user_id: int,
transaction_ids: list[int],
) -> dict[int, float]:
"""Return running balances only for transactions on the requested page."""
if not transaction_ids:
return {}
running = (
select(
WalletTransaction.id.label("transaction_id"),
func.sum(WalletTransaction.amount)
.over(order_by=(WalletTransaction.created_at.asc(), WalletTransaction.id.asc()))
.label("running_balance"),
)
.outerjoin(CostCenter, WalletTransaction.cost_center_id == CostCenter.id)
.where(
WalletTransaction.user_id == user_id,
WalletTransaction.is_voided.is_(False),
personal_balance_condition(user_id),
)
.subquery()
)
result = await db.execute(
select(running.c.transaction_id, running.c.running_balance).where(running.c.transaction_id.in_(transaction_ids))
)
return {int(transaction_id): round(float(balance), 2) for transaction_id, balance in result.all()}
async def _create_wallet_adjustment(
db: AsyncSession,
*,
target_user_id: int,
actor_user_id: int,
amount: float,
transaction_type: str,
description: str | None,
cost_center_id: int | None,
) -> WalletAdjustmentResponse:
transaction_type = normalize_transaction_type(transaction_type)
if cost_center_id is not None:
await _get_cost_center_or_404(db, cost_center_id)
wallet = await _get_or_create_wallet(db, target_user_id)
affects_personal_wallet = await is_personal_transaction(db, target_user_id, cost_center_id)
# Calculate balance_after for this specific transaction context
if cost_center_id is None:
# Personal transaction: validate and update user wallet
new_balance = wallet.balance + amount
if new_balance < 0:
raise HTTPException(status_code=400, detail="Insufficient balance for withdrawal")
balance_after = new_balance
else:
# Cost-center transaction: validate against cost center balance only (global, not per-user)
result = await db.execute(
select(func.coalesce(func.sum(WalletTransaction.amount), 0.0)).where(
WalletTransaction.cost_center_id == cost_center_id,
WalletTransaction.is_voided.is_(False),
)
)
current_cc_balance = float(result.scalar() or 0.0)
new_cc_balance = current_cc_balance + amount
if new_cc_balance < 0:
raise HTTPException(status_code=400, detail="Insufficient cost center balance for withdrawal")
balance_after = new_cc_balance
if affects_personal_wallet and wallet.balance + amount < 0:
raise HTTPException(status_code=400, detail="Insufficient balance for withdrawal")
tx = WalletTransaction(
user_id=target_user_id,
cost_center_id=cost_center_id,
transaction_type=transaction_type,
amount=amount,
balance_after=balance_after,
description=description,
created_by_user_id=actor_user_id,
)
db.add(tx)
await db.flush()
await sync_personal_wallet_balance(db, wallet)
await db.commit()
await db.refresh(wallet)
await db.refresh(tx)
# Return appropriate balance based on transaction type
currency = await resolve_configured_currency(db)
if affects_personal_wallet:
# Personal transaction: return user wallet balance
response_balance = _to_balance_response(wallet, currency)
else:
# Cost-center transaction: return cost-center balance as if it were a wallet
response_balance = WalletBalanceResponse(
user_id=target_user_id,
balance=balance_after,
currency=currency,
updated_at=tx.created_at,
)
return WalletAdjustmentResponse(
transaction=_serialize_wallet_transaction(tx),
balance=response_balance,
)
@router.get("/me/balance", response_model=WalletBalanceResponse)
async def get_my_balance(
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_OWN),
):
"""Return the current user's wallet balance."""
user = await _require_authenticated_user(current_user)
return await _get_wallet_balance_read_only(db, user.id)
@router.get("/me/transactions", response_model=WalletTransactionListResponse)
async def get_my_transactions(
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_OWN),
):
"""Return wallet ledger entries for the current user."""
user = await _require_authenticated_user(current_user)
total_result = await db.execute(
select(func.count(WalletTransaction.id)).where(
WalletTransaction.user_id == user.id,
WalletTransaction.is_voided.is_(False),
)
)
total = int(total_result.scalar_one() or 0)
result = await db.execute(
select(WalletTransaction)
.where(WalletTransaction.user_id == user.id, WalletTransaction.is_voided.is_(False))
.order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
.limit(limit)
.offset(offset)
)
transactions = result.scalars().all()
personal_balance_map = await _build_personal_balance_map(db, user.id, [tx.id for tx in transactions])
return WalletTransactionListResponse(
items=[
_serialize_wallet_transaction(tx).model_copy(
update={"balance_after": personal_balance_map.get(tx.id, tx.balance_after)}
)
for tx in transactions
],
total=total,
limit=limit,
offset=offset,
)
@router.get("/transactions", response_model=WalletTransactionListResponse)
async def get_all_transactions(
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
user_id: int | None = Query(None, description="Optional filter by user id"),
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
):
"""Return wallet ledger entries across users for admin finance view."""
await _require_authenticated_user(current_user)
conditions = [WalletTransaction.is_voided.is_(False)]
if user_id is not None:
await _get_user_or_404(db, user_id)
conditions.append(WalletTransaction.user_id == user_id)
total_result = await db.execute(select(func.count(WalletTransaction.id)).where(*conditions))
total = int(total_result.scalar_one() or 0)
result = await db.execute(
select(WalletTransaction)
.where(*conditions)
.order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
.limit(limit)
.offset(offset)
)
transactions = result.scalars().all()
return WalletTransactionListResponse(
items=[_serialize_wallet_transaction(tx) for tx in transactions],
total=total,
limit=limit,
offset=offset,
)
async def _rebuild_wallet_ledger_for_user(db: AsyncSession, user_id: int) -> None:
"""Rebuild through the same canonical ledger repair used at startup."""
from backend.app.core.database import repair_wallet_ledger_internal
await repair_wallet_ledger_internal(db)
@router.delete("/transactions/{transaction_id}")
async def delete_transaction(
transaction_id: int,
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
):
"""Delete a wallet transaction and rebuild the user's ledger to keep balances consistent."""
await _require_authenticated_user(current_user)
result = await db.execute(
select(WalletTransaction).where(
WalletTransaction.id == transaction_id,
WalletTransaction.is_voided.is_(False),
)
)
tx = result.scalar_one_or_none()
if tx is None:
raise HTTPException(status_code=404, detail="Transaction not found")
user_id = tx.user_id
# Keep a hidden, zero-effect tombstone for the billing_run_id. A delayed
# duplicate completion therefore cannot recreate this deliberately removed
# charge, while a later reprint of the same archive has its own run ID and
# remains billable.
tx.is_voided = True
await db.flush()
await _rebuild_wallet_ledger_for_user(db, user_id)
return {"status": "success"}
@router.patch("/transactions/{transaction_id}", response_model=WalletTransactionResponse)
async def edit_transaction(
transaction_id: int,
request: TransactionEditRequest,
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
):
"""Edit a wallet transaction (user_id, cost_center_id, amount, description) and rebuild ledger."""
await _require_authenticated_user(current_user)
result = await db.execute(
select(WalletTransaction).where(
WalletTransaction.id == transaction_id,
WalletTransaction.is_voided.is_(False),
)
)
tx = result.scalar_one_or_none()
if tx is None:
raise HTTPException(status_code=404, detail="Transaction not found")
# Apply edits
if request.user_id is not None:
await _get_user_or_404(db, request.user_id)
tx.user_id = request.user_id
if "cost_center_id" in request.model_fields_set:
if request.cost_center_id is not None:
await _get_cost_center_or_404(db, request.cost_center_id)
tx.cost_center_id = request.cost_center_id
if request.amount is not None:
tx.amount = request.amount
if request.description is not None:
# Append "(Admin edit)" marker if not already present
new_desc = request.description
if not new_desc.endswith("(Admin edit)"):
new_desc = f"{new_desc} (Admin edit)"
tx.description = new_desc
db.add(tx)
await db.flush()
# Rebuild full ledger using the current session
from backend.app.core.database import repair_wallet_ledger_internal
await repair_wallet_ledger_internal(db)
await db.refresh(tx)
return tx
@router.post("/transactions/manual", response_model=WalletTransactionResponse)
async def create_manual_print(
request: ManualPrintRequest,
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
):
"""Create a manual print charge transaction (for admin purposes)."""
await _require_authenticated_user(current_user)
await _get_user_or_404(db, request.user_id)
await _get_cost_center_or_404(db, request.cost_center_id)
from datetime import timezone
# Use provided created_at or current time
created_at = request.created_at or datetime.now(timezone.utc)
# Ensure manual print charges are negative amounts (charges reduce wallet)
amount = request.amount
if amount > 0:
amount = -abs(amount)
# Create transaction
tx = WalletTransaction(
user_id=request.user_id,
cost_center_id=request.cost_center_id,
transaction_type=TransactionType.MANUAL_ADJUSTMENT.value,
amount=amount,
balance_after=None, # Will be set by repair_wallet_ledger_internal
description=request.description or "Manual print charge",
created_by_user_id=current_user.id if current_user else None,
created_at=created_at,
)
db.add(tx)
await db.flush()
# Rebuild full ledger using the current session
from backend.app.core.database import repair_wallet_ledger_internal
await repair_wallet_ledger_internal(db)
await db.refresh(tx)
return tx
@router.get("/cost-centers/mine", response_model=list[CostCenterSummaryResponse])
async def get_my_cost_centers(
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(require_auth_if_enabled),
):
"""Return private and assigned cost centers for the current user."""
user = await _require_authenticated_user(current_user)
result = await db.execute(
select(CostCenter, CostCenterMember.can_print)
.outerjoin(
CostCenterMember,
(CostCenterMember.cost_center_id == CostCenter.id) & (CostCenterMember.user_id == user.id),
)
.where(
CostCenter.is_active.is_(True),
or_(
(CostCenter.is_private.is_(True) & (CostCenter.owner_user_id == user.id)),
(CostCenterMember.user_id == user.id),
),
)
.order_by(CostCenter.is_private.desc(), CostCenter.name.asc())
)
rows = result.all()
centers_only = [center for center, _ in rows]
center_ids = [center.id for center in centers_only]
total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, center_ids)
total_balance_map = await _get_cost_center_balance_map(db, center_ids)
reserved_map = await _get_cost_center_reserved_map(db, center_ids)
centers: list[CostCenterSummaryResponse] = []
for center, can_print in rows:
centers.append(
_to_cost_center_summary(
center,
can_print=True if center.is_private and center.owner_user_id == user.id else bool(can_print),
total_usage=total_usage_map.get(center.id, 0.0),
month_usage=month_usage_map.get(center.id, 0.0),
total_balance=total_balance_map.get(center.id, 0.0),
reserved=reserved_map.get(center.id, 0.0),
)
)
return centers
@router.get("/users/{user_id}/balance", response_model=WalletBalanceResponse)
async def get_user_balance(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
):
"""Return a specific user's wallet balance."""
await _require_authenticated_user(current_user)
user = await _get_user_or_404(db, user_id)
return await _get_wallet_balance_read_only(db, user.id)
@router.get("/users/{user_id}/transactions", response_model=list[WalletTransactionResponse])
async def get_user_transactions(
user_id: int,
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
):
"""Return wallet ledger entries for a specific user."""
await _require_authenticated_user(current_user)
await _get_user_or_404(db, user_id)
result = await db.execute(
select(WalletTransaction)
.where(WalletTransaction.user_id == user_id, WalletTransaction.is_voided.is_(False))
.order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
.limit(limit)
.offset(offset)
)
return [_serialize_wallet_transaction(tx) for tx in result.scalars().all()]
@router.post("/users/{user_id}/deposit", response_model=WalletAdjustmentResponse)
async def deposit_user_balance(
user_id: int,
body: WalletAdjustmentRequest,
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
):
"""Add funds to a user's wallet."""
actor = await _require_authenticated_user(current_user)
await _get_user_or_404(db, user_id)
return await _create_wallet_adjustment(
db,
target_user_id=user_id,
actor_user_id=actor.id,
amount=body.amount,
transaction_type=TransactionType.DEPOSIT.value,
description=body.description,
cost_center_id=body.cost_center_id,
)
@router.post("/users/{user_id}/withdraw", response_model=WalletAdjustmentResponse)
async def withdraw_user_balance(
user_id: int,
body: WalletAdjustmentRequest,
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
):
"""Withdraw funds from a user's wallet."""
actor = await _require_authenticated_user(current_user)
await _get_user_or_404(db, user_id)
return await _create_wallet_adjustment(
db,
target_user_id=user_id,
actor_user_id=actor.id,
amount=-body.amount,
transaction_type=TransactionType.WITHDRAW.value,
description=body.description,
cost_center_id=body.cost_center_id,
)
@router.post("/rebuild-balance-ledger")
async def rebuild_balance_ledger(
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
):
"""Recompute balance_after for all wallet transactions.
This rebuilds the running balance for all users and cost centers.
- Personal transactions: unassigned plus the user's own private cost center
- Cost-center transactions: global running balance for the entire cost center
"""
await _require_authenticated_user(current_user)
from backend.app.core.database import repair_wallet_ledger_internal
rebuilt = await repair_wallet_ledger_internal(db)
return {
"status": "success",
"transactions_rebuilt": rebuilt,
"message": f"Rebuilt {rebuilt} wallet ledger values",
}
@router.get("/cost-centers", response_model=list[CostCenterSummaryResponse])
async def list_cost_centers(
include_inactive: bool = Query(False),
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(require_auth_if_enabled),
):
"""List all cost centers.
Requires admin-level finance permissions.
"""
await _require_cost_center_admin_access(current_user)
query = select(CostCenter).order_by(CostCenter.is_private.desc(), CostCenter.name.asc())
if not include_inactive:
query = query.where(CostCenter.is_active.is_(True))
result = await db.execute(query)
centers = result.scalars().all()
center_ids = [center.id for center in centers]
total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, center_ids)
total_balance_map = await _get_cost_center_balance_map(db, center_ids)
reserved_map = await _get_cost_center_reserved_map(db, center_ids)
return [
_to_cost_center_summary(
center,
can_print=True,
total_usage=total_usage_map.get(center.id, 0.0),
month_usage=month_usage_map.get(center.id, 0.0),
total_balance=total_balance_map.get(center.id, 0.0),
reserved=reserved_map.get(center.id, 0.0),
)
for center in centers
]
@router.post("/cost-centers", response_model=CostCenterSummaryResponse)
async def create_cost_center(
body: CostCenterCreateRequest,
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_CREATE),
):
"""Create a shared cost center."""
await _require_authenticated_user(current_user)
total_budget = body.total_budget
monthly_budget = body.monthly_budget
if monthly_budget is not None:
total_budget = None
elif total_budget is not None:
monthly_budget = None
center = CostCenter(
name=body.name.strip(),
is_active=body.is_active,
is_private=False,
owner_user_id=None,
total_budget=total_budget,
monthly_budget=monthly_budget,
)
db.add(center)
await db.flush()
await db.commit()
await db.refresh(center)
return _to_cost_center_summary(center, can_print=True, total_usage=0.0, month_usage=0.0, total_balance=0.0)
@router.get("/cost-centers/{cost_center_id}", response_model=CostCenterDetailResponse)
async def get_cost_center(
cost_center_id: int,
db: AsyncSession = Depends(get_db),
current_user: User | None = Depends(require_auth_if_enabled),
):
"""Get one cost center with its memberships."""
await _require_cost_center_admin_access(current_user)
center = await _get_cost_center_or_404(db, cost_center_id)
total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, [center.id])
total_balance_map = await _get_cost_center_balance_map(db, [center.id])
reserved_map = await _get_cost_center_reserved_map(db, [center.id])
summary = _to_cost_center_summary(
center,
can_print=True,
total_usage=total_usage_map.get(center.id, 0.0),
month_usage=month_usage_map.get(center.id, 0.0),
total_balance=total_balance_map.get(center.id, 0.0),
reserved=reserved_map.get(center.id, 0.0),
)
return CostCenterDetailResponse(
**summary.model_dump(),
members=[CostCenterMemberResponse.model_validate(m) for m in center.members],
)
@router.patch("/cost-centers/{cost_center_id}", response_model=CostCenterSummaryResponse)
async def update_cost_center(
cost_center_id: int,
body: CostCenterUpdateRequest,
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
):
"""Update name or active-state of a cost center."""
await _require_authenticated_user(current_user)
center = await _get_cost_center_or_404(db, cost_center_id)
if center.is_private:
raise HTTPException(
status_code=400,
detail=("Private cost centers cannot be deactivated or renamed; set their budget to 0 to prevent printing"),
)
if body.name is not None:
center.name = body.name.strip()
if body.is_active is not None:
center.is_active = body.is_active
await db.flush()
total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, [center.id])
total_balance_map = await _get_cost_center_balance_map(db, [center.id])
reserved_map = await _get_cost_center_reserved_map(db, [center.id])
return _to_cost_center_summary(
center,
can_print=True,
total_usage=total_usage_map.get(center.id, 0.0),
month_usage=month_usage_map.get(center.id, 0.0),
total_balance=total_balance_map.get(center.id, 0.0),
reserved=reserved_map.get(center.id, 0.0),
)
@router.patch("/cost-centers/{cost_center_id}/budgets", response_model=CostCenterSummaryResponse)
async def update_cost_center_budgets(
cost_center_id: int,
body: CostCenterBudgetUpdateRequest,
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
):
"""Update budget values of a cost center."""
await _require_authenticated_user(current_user)
center = await _get_cost_center_or_404(db, cost_center_id)
if body.monthly_budget is not None:
center.monthly_budget = body.monthly_budget
center.total_budget = None
elif body.total_budget is not None:
center.total_budget = body.total_budget
center.monthly_budget = None
else:
center.total_budget = None
center.monthly_budget = None
await db.flush()
total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, [center.id])
total_balance_map = await _get_cost_center_balance_map(db, [center.id])
reserved_map = await _get_cost_center_reserved_map(db, [center.id])
return _to_cost_center_summary(
center,
can_print=True,
total_usage=total_usage_map.get(center.id, 0.0),
month_usage=month_usage_map.get(center.id, 0.0),
total_balance=total_balance_map.get(center.id, 0.0),
reserved=reserved_map.get(center.id, 0.0),
)
@router.post("/cost-centers/{cost_center_id}/members", response_model=CostCenterMemberResponse)
async def upsert_cost_center_member(
cost_center_id: int,
body: CostCenterMemberRequest,
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
):
"""Assign or update a user's membership on a cost center."""
await _require_authenticated_user(current_user)
center = await _get_cost_center_or_404(db, cost_center_id)
if center.is_private:
raise HTTPException(status_code=400, detail="Private cost center memberships cannot be modified")
await _get_user_or_404(db, body.user_id)
existing = await db.execute(
select(CostCenterMember).where(
CostCenterMember.cost_center_id == cost_center_id,
CostCenterMember.user_id == body.user_id,
)
)
member = existing.scalar_one_or_none()
if member is None:
member = CostCenterMember(cost_center_id=cost_center_id, user_id=body.user_id, can_print=body.can_print)
db.add(member)
else:
member.can_print = body.can_print
await db.flush()
await db.commit()
return CostCenterMemberResponse.model_validate(member)
@router.delete("/cost-centers/{cost_center_id}")
async def delete_cost_center(
cost_center_id: int,
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
):
"""Delete a shared cost center."""
await _require_authenticated_user(current_user)
center = await _get_cost_center_or_404(db, cost_center_id)
if center.is_private:
raise HTTPException(status_code=400, detail="Private cost centers cannot be deleted")
transaction_id = await db.scalar(
select(WalletTransaction.id)
.where(
WalletTransaction.cost_center_id == center.id,
WalletTransaction.is_voided.is_(False),
)
.limit(1)
)
if transaction_id is not None:
# ON DELETE SET NULL would turn these shared-center entries into
# personal transactions and silently rewrite the affected wallets.
raise HTTPException(status_code=400, detail="Cost center cannot be deleted while transactions reference it")
active_reservation_id = await db.scalar(
select(BudgetReservation.id)
.where(
BudgetReservation.cost_center_id == center.id,
BudgetReservation.status == "active",
)
.limit(1)
)
if active_reservation_id is not None:
raise HTTPException(
status_code=400,
detail="Cost center cannot be deleted while active budget reservations reference it",
)
await db.delete(center)
await db.flush()
await db.commit()
return {"status": "success"}
@router.delete("/cost-centers/{cost_center_id}/members/{user_id}")
async def remove_cost_center_member(
cost_center_id: int,
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
):
"""Remove a user from a shared cost center."""
await _require_authenticated_user(current_user)
center = await _get_cost_center_or_404(db, cost_center_id)
if center.is_private:
raise HTTPException(status_code=400, detail="Private cost center memberships cannot be modified")
result = await db.execute(
select(CostCenterMember).where(
CostCenterMember.cost_center_id == cost_center_id,
CostCenterMember.user_id == user_id,
)
)
member = result.scalar_one_or_none()
if member is None:
raise HTTPException(status_code=404, detail="Membership not found")
await db.delete(member)
await db.commit()
return {"status": "success"}