implemented pr feedback #2

This commit is contained in:
behrinml
2026-08-05 21:25:46 +02:00
parent 43bf854bd5
commit 9a397f46e9
30 changed files with 1035 additions and 259 deletions
+30 -112
View File
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import and_, case, func, or_, select
from sqlalchemy import case, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -39,6 +39,11 @@ from backend.app.schemas.finance import (
WalletTransactionListResponse,
WalletTransactionResponse,
)
from backend.app.services.finance_balance import (
is_personal_transaction,
personal_balance_condition,
sync_personal_wallet_balance,
)
router = APIRouter(prefix="/finance", tags=["finance"])
@@ -306,35 +311,21 @@ async def _build_personal_balance_map(db: AsyncSession, user_id: int) -> dict[in
select(
WalletTransaction.id,
WalletTransaction.amount,
WalletTransaction.cost_center_id,
CostCenter.is_private,
CostCenter.owner_user_id,
)
.where(
WalletTransaction.user_id == user_id,
)
.outerjoin(CostCenter, WalletTransaction.cost_center_id == CostCenter.id)
.where(WalletTransaction.user_id == user_id, personal_balance_condition(user_id))
.order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
)
running_balance = 0.0
balance_map: dict[int, float] = {}
for transaction_id, amount, cost_center_id, is_private, owner_user_id in result.all():
is_personal_cost_center = bool(cost_center_id is not None and is_private and owner_user_id == user_id)
if cost_center_id is None or is_personal_cost_center:
running_balance += float(amount)
balance_map[int(transaction_id)] = running_balance
for transaction_id, amount in result.all():
running_balance += float(amount)
balance_map[int(transaction_id)] = running_balance
return balance_map
def _personal_balance_condition(user_id: int):
return or_(
WalletTransaction.cost_center_id.is_(None),
and_(CostCenter.is_private.is_(True), CostCenter.owner_user_id == user_id),
)
async def _create_wallet_adjustment(
db: AsyncSession,
*,
@@ -351,6 +342,7 @@ async def _create_wallet_adjustment(
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:
@@ -358,7 +350,6 @@ async def _create_wallet_adjustment(
new_balance = wallet.balance + amount
if new_balance < 0:
raise HTTPException(status_code=400, detail="Insufficient balance for withdrawal")
wallet.balance = new_balance
balance_after = new_balance
else:
# Cost-center transaction: validate against cost center balance only (global, not per-user)
@@ -372,7 +363,8 @@ async def _create_wallet_adjustment(
if new_cc_balance < 0:
raise HTTPException(status_code=400, detail="Insufficient cost center balance for withdrawal")
balance_after = new_cc_balance
# Do NOT update wallet.balance for cost-center transactions
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,
@@ -385,12 +377,13 @@ async def _create_wallet_adjustment(
)
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
if cost_center_id is None:
if affects_personal_wallet:
# Personal transaction: return user wallet balance
response_balance = _to_balance_response(wallet)
else:
@@ -416,19 +409,7 @@ async def get_my_balance(
"""Return the current user's wallet balance."""
user = await _require_authenticated_user(current_user)
wallet = await _get_or_create_wallet(db, user.id)
personal_balance_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, _personal_balance_condition(user.id))
)
personal_balance = float(personal_balance_result.scalar_one() or 0.0)
return WalletBalanceResponse(
user_id=user.id,
balance=personal_balance,
currency=wallet.currency,
updated_at=wallet.updated_at,
)
return _to_balance_response(wallet)
@router.get("/me/transactions", response_model=WalletTransactionListResponse)
@@ -504,47 +485,10 @@ async def get_all_transactions(
async def _rebuild_wallet_ledger_for_user(db: AsyncSession, user_id: int) -> None:
"""Recompute `balance_after` for all wallet transactions of a user.
"""Rebuild through the same canonical ledger repair used at startup."""
from backend.app.core.database import repair_wallet_ledger_internal
- Personal transactions (cost_center_id=None): running balance per user
- Cost-center transactions: running balance GLOBAL for entire cost center (not per-user)
- Also updates the user's wallet balance (sum of personal transactions only)
"""
result = await db.execute(
select(WalletTransaction)
.where(WalletTransaction.user_id == user_id)
.order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
)
user_transactions = result.scalars().all()
# Handle personal transactions (cost_center_id=None)
personal_balance = 0.0
for tx in user_transactions:
if tx.cost_center_id is None:
personal_balance += float(tx.amount)
tx.balance_after = personal_balance
db.add(tx)
for cc_id in {tx.cost_center_id for tx in user_transactions if tx.cost_center_id is not None}:
# Get all transactions for this cost center (all users, all time)
result_all_cc = await db.execute(
select(WalletTransaction)
.where(WalletTransaction.cost_center_id == cc_id)
.order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
)
all_cc_transactions = result_all_cc.scalars().all()
running = 0.0
for tx in all_cc_transactions:
running += float(tx.amount)
tx.balance_after = running
db.add(tx)
# Update user wallet balance (sum of all personal transactions only)
wallet = await _get_or_create_wallet(db, user_id)
wallet.balance = personal_balance
await db.flush()
await db.commit()
await repair_wallet_ledger_internal(db)
@router.delete("/transactions/{transaction_id}")
@@ -794,51 +738,19 @@ async def rebuild_balance_ledger(
"""Recompute balance_after for all wallet transactions.
This rebuilds the running balance for all users and cost centers.
- Personal transactions: per-user running balance
- 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)
# Get ALL transactions sorted by timestamp
result = await db.execute(
select(WalletTransaction).order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
)
all_transactions = result.scalars().all()
from backend.app.core.database import repair_wallet_ledger_internal
# Build running balances per (user, cost_center_id) pair
# For each cost center, track its global running balance
# For each user's personal balance, track that separately
cc_running_balances: dict[int, float] = {} # cost_center_id -> running balance
user_personal_balances: dict[int, float] = {} # user_id -> personal running balance
tx_updates: list[tuple[WalletTransaction, float]] = []
for tx in all_transactions:
if tx.cost_center_id is None:
# Personal transaction: per-user running balance
current = user_personal_balances.get(tx.user_id, 0.0)
new_balance = current + float(tx.amount)
user_personal_balances[tx.user_id] = new_balance
tx_updates.append((tx, new_balance))
else:
# Cost-center transaction: global running balance for this cost center
current = cc_running_balances.get(tx.cost_center_id, 0.0)
new_balance = current + float(tx.amount)
cc_running_balances[tx.cost_center_id] = new_balance
tx_updates.append((tx, new_balance))
# Update all transactions with the new balance_after values
for tx, new_balance in tx_updates:
tx.balance_after = new_balance
db.add(tx)
await db.flush()
await db.commit()
rebuilt = await repair_wallet_ledger_internal(db)
return {
"status": "success",
"transactions_rebuilt": len(all_transactions),
"message": f"Rebuilt balance_after for {len(all_transactions)} transactions",
"transactions_rebuilt": rebuilt,
"message": f"Rebuilt {rebuilt} wallet ledger values",
}
@@ -1053,6 +965,12 @@ async def delete_cost_center(
raise HTTPException(status_code=400, detail="Cost center can only be deleted when balance is 0")
await db.delete(center)
await db.flush()
# ON DELETE SET NULL reclassifies the former shared-center entries as
# personal transactions, so synchronize affected wallets immediately.
from backend.app.core.database import repair_wallet_ledger_internal
await repair_wallet_ledger_internal(db)
await db.commit()
return {"status": "success"}
+111 -6
View File
@@ -51,6 +51,7 @@ from backend.app.services.print_batch import (
load_progress,
refresh_batch_status,
)
from backend.app.services.print_cost_estimate import estimate_queue_source_cost
from backend.app.utils.printer_models import (
is_gcode_compatible,
)
@@ -279,6 +280,55 @@ async def _resolve_source_path(db: AsyncSession, item: PrintQueueItem) -> Path |
return None
async def _trusted_item_estimated_cost(
db: AsyncSession,
item: PrintQueueItem,
*,
printer_id: int | None,
plate_id: int | None,
ams_mapping: list[int] | str | None,
) -> float | None:
"""Recompute an existing queue item's cost from its persisted source."""
if item.archive_id:
archive = await db.scalar(select(PrintArchive).where(PrintArchive.id == item.archive_id))
return await estimate_queue_source_cost(
db,
archive=archive,
plate_id=plate_id,
ams_mapping=ams_mapping,
printer_id=printer_id,
)
if item.library_file_id:
library_file = await db.scalar(LibraryFile.active().where(LibraryFile.id == item.library_file_id))
return await estimate_queue_source_cost(
db,
library_file=library_file,
plate_id=plate_id,
ams_mapping=ams_mapping,
printer_id=printer_id,
)
result = await db.execute(
select(PrintQueueVariant, LibraryFile)
.join(LibraryFile, LibraryFile.id == PrintQueueVariant.library_file_id)
.where(PrintQueueVariant.queue_item_id == item.id)
)
estimates = [
await estimate_queue_source_cost(
db,
library_file=library_file,
plate_id=variant.plate_id,
ams_mapping=variant.ams_mapping,
printer_id=printer_id,
)
for variant, library_file in result.all()
]
if not estimates or any(cost is None for cost in estimates):
return None
return max(cost for cost in estimates if cost is not None)
def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
"""Add nested archive/printer/library_file info to response."""
# Parse ams_mapping from JSON string BEFORE model_validate
@@ -945,10 +995,39 @@ async def add_to_queue(
if not project_result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Project not found")
# Security boundary: the browser's estimated_cost is only a display hint.
# Budget enforcement and the persisted reservation value must be derived
# from the server-owned archive/library metadata and spool assignments.
if variant_specs:
variant_costs = [
await estimate_queue_source_cost(
db,
library_file=variant_file,
plate_id=spec.plate_id,
ams_mapping=spec.ams_mapping,
printer_id=data.printer_id,
)
for spec, variant_file, _model in variant_specs
]
trusted_estimated_cost = (
max(cost for cost in variant_costs if cost is not None)
if variant_costs and all(cost is not None for cost in variant_costs)
else None
)
else:
trusted_estimated_cost = await estimate_queue_source_cost(
db,
archive=archive,
library_file=library_file,
plate_id=data.plate_id,
ams_mapping=data.ams_mapping,
printer_id=data.printer_id,
)
await validate_print_budget(
db,
cost_center_id=data.cost_center_id,
estimated_cost=data.estimated_cost,
estimated_cost=trusted_estimated_cost,
current_user=current_user,
quantity=quantity,
)
@@ -1010,7 +1089,7 @@ async def add_to_queue(
archive_id=data.archive_id,
library_file_id=data.library_file_id,
cost_center_id=data.cost_center_id,
estimated_cost=data.estimated_cost,
estimated_cost=trusted_estimated_cost,
scheduled_time=data.scheduled_time,
require_previous_success=data.require_previous_success,
auto_off_after=data.auto_off_after,
@@ -1161,16 +1240,25 @@ async def bulk_update_queue_items(
skipped_count += 1
continue
item_update_data = update_data.copy()
if validates_billing_fields:
trusted_estimated_cost = await _trusted_item_estimated_cost(
db,
item,
printer_id=item_update_data.get("printer_id", item.printer_id),
plate_id=item.plate_id,
ams_mapping=item.ams_mapping,
)
item_update_data["estimated_cost"] = trusted_estimated_cost
await validate_print_budget(
db,
cost_center_id=update_data.get("cost_center_id", item.cost_center_id),
estimated_cost=update_data.get("estimated_cost", item.estimated_cost),
cost_center_id=item_update_data.get("cost_center_id", item.cost_center_id),
estimated_cost=trusted_estimated_cost,
current_user=user,
exclude_queue_item_id=item.id,
)
for field, value in update_data.items():
for field, value in item_update_data.items():
setattr(item, field, value)
updated_count += 1
@@ -1826,10 +1914,19 @@ async def update_queue_item(
json.dumps(update_data["nozzle_mapping"]) if update_data["nozzle_mapping"] else None
)
trusted_estimated_cost = await _trusted_item_estimated_cost(
db,
item,
printer_id=update_data.get("printer_id", item.printer_id),
plate_id=update_data.get("plate_id", item.plate_id),
ams_mapping=update_data.get("ams_mapping", item.ams_mapping),
)
update_data["estimated_cost"] = trusted_estimated_cost
await validate_print_budget(
db,
cost_center_id=update_data.get("cost_center_id", item.cost_center_id),
estimated_cost=update_data.get("estimated_cost", item.estimated_cost),
estimated_cost=trusted_estimated_cost,
current_user=user,
exclude_queue_item_id=item.id,
)
@@ -2169,6 +2266,14 @@ async def start_queue_item(
if item.status != "pending":
raise HTTPException(400, f"Can only start pending items, current status: '{item.status}'")
item.estimated_cost = await _trusted_item_estimated_cost(
db,
item,
printer_id=item.printer_id,
plate_id=item.plate_id,
ams_mapping=item.ams_mapping,
)
await validate_print_budget(
db,
cost_center_id=item.cost_center_id,