mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +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.
294 lines
13 KiB
Python
294 lines
13 KiB
Python
import logging
|
|
import uuid
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.app.models.archive import PrintArchive
|
|
from backend.app.models.finance import TransactionType, UserWallet, WalletTransaction
|
|
from backend.app.services.finance_balance import sync_personal_wallet_balance
|
|
from backend.app.services.finance_budget import is_billing_enabled, release_budget_reservation
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class BillingRunIdCollisionError(RuntimeError):
|
|
"""A billing idempotency key points at a different physical print run."""
|
|
|
|
|
|
async def _get_balance_after_for_transaction(
|
|
db: AsyncSession,
|
|
user_id: int,
|
|
cost_center_id: int | None,
|
|
amount: float,
|
|
) -> float:
|
|
"""Calculate balance_after for a transaction.
|
|
|
|
For cost-center transactions: sum of ALL transactions for that cost center (global).
|
|
For personal transactions (cost_center_id=None): user's wallet balance (personal).
|
|
|
|
Args:
|
|
user_id: The user making the transaction
|
|
cost_center_id: The cost center (None for personal)
|
|
amount: The transaction amount (positive/negative)
|
|
|
|
Returns:
|
|
The balance after this transaction would be applied
|
|
"""
|
|
try:
|
|
if cost_center_id is None:
|
|
# Personal transaction: use user wallet balance
|
|
wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == user_id))).scalar_one_or_none()
|
|
if wallet is None:
|
|
return float(amount)
|
|
return float(wallet.balance) + amount
|
|
else:
|
|
# Cost-center transaction: sum of ALL transactions for this cost center (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_balance = float(result.scalar() or 0.0)
|
|
return current_balance + amount
|
|
except SQLAlchemyError as e:
|
|
logger.error(f"Database error in _get_balance_after_for_transaction: {e}", exc_info=True)
|
|
raise
|
|
|
|
|
|
def _calculate_partial_charge(
|
|
archive: PrintArchive,
|
|
base_cost: float,
|
|
*,
|
|
filament_usage: tuple[float | None, float | None] | None = None,
|
|
) -> tuple[float, str]:
|
|
"""Calculate proportional charge for partial prints based on filament usage.
|
|
|
|
Returns (charge_amount, description_suffix) where:
|
|
- charge_amount: absolute cost to charge (0 if insufficient data)
|
|
- description_suffix: reason/details for transaction description
|
|
"""
|
|
try:
|
|
# Only apply proportional calculation for non-completed prints
|
|
if archive.status == "completed":
|
|
return round(float(base_cost), 2), ""
|
|
|
|
if filament_usage is not None:
|
|
actual_grams, planned_grams = filament_usage
|
|
filament_used = float(actual_grams or 0.0)
|
|
filament_planned = float(planned_grams) if planned_grams is not None else None
|
|
else:
|
|
# Backwards-compatible fallback for recalculation and callers that
|
|
# do not have per-run telemetry. At print completion main.py passes
|
|
# the measured/progress-scaled run usage explicitly: the archive
|
|
# field is the slicer's planned amount and must not be mistaken for
|
|
# the amount consumed by an aborted run.
|
|
filament_used = float(archive.filament_used_grams or 0.0)
|
|
filament_planned = None
|
|
|
|
if archive.extra_data and isinstance(archive.extra_data, dict):
|
|
filament_planned = archive.extra_data.get("filament_grams_total")
|
|
if filament_planned is not None:
|
|
filament_planned = float(filament_planned)
|
|
|
|
# If we don't have reliable planned filament data, do not guess a partial charge.
|
|
# Charging a failed/aborted print without an estimated baseline can overcharge users.
|
|
if filament_planned is None or filament_planned <= 0:
|
|
return 0.0, f"[{archive.status}: insufficient filament data]"
|
|
|
|
# Calculate proportional cost
|
|
filament_ratio = min(1.0, max(0.0, filament_used / filament_planned)) # Clamp to [0, 1]
|
|
charge = float(base_cost) * filament_ratio
|
|
|
|
# Round charges to 2 decimals for consistent persistence
|
|
charge = round(charge, 2)
|
|
|
|
suffix = f"[{archive.status}: {filament_ratio:.1%} filament ({filament_used:.1f}g/{filament_planned:.1f}g)]"
|
|
return charge, suffix
|
|
except ValueError as e:
|
|
logger.error(f"Value error in _calculate_partial_charge: {e}", exc_info=True)
|
|
raise
|
|
|
|
|
|
async def apply_print_charge_for_archive(
|
|
db: AsyncSession,
|
|
archive_id: int,
|
|
*,
|
|
charged_user_id: int | None = None,
|
|
cost_center_id: int | None = None,
|
|
print_queue_id: int | None = None,
|
|
print_run_id: str | None = None,
|
|
base_cost_override: float | None = None,
|
|
filament_usage: tuple[float | None, float | None] | None = None,
|
|
) -> bool:
|
|
"""Apply an idempotent wallet charge for a print archive.
|
|
|
|
Charges completed prints at full cost, and partial/failed prints proportionally
|
|
based on actual filament used vs. planned filament.
|
|
|
|
Returns True when a new wallet transaction was created.
|
|
"""
|
|
try:
|
|
if not await is_billing_enabled(db):
|
|
if print_queue_id is not None:
|
|
await release_budget_reservation(
|
|
db, source_type="print_queue", source_id=print_queue_id, status="released"
|
|
)
|
|
else:
|
|
await release_budget_reservation(db, print_archive_id=archive_id, status="released")
|
|
logger.info("Billing is disabled; skipping print charge for archive ID %s.", archive_id)
|
|
return False
|
|
|
|
archive = (
|
|
await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id).with_for_update())
|
|
).scalar_one_or_none()
|
|
if archive is None:
|
|
logger.warning(f"Archive with ID {archive_id} not found.")
|
|
return False
|
|
|
|
effective_run_id = print_run_id or archive.billing_run_id
|
|
# The archive-level flag is retained only for legacy deleted charges.
|
|
# A new scheduler dispatch clears it while persisting its new run UUID;
|
|
# current deletions are represented by a voided transaction instead.
|
|
if archive.wallet_charge_skipped:
|
|
logger.info(f"Wallet charge skipped for archive ID {archive_id}.")
|
|
return False
|
|
|
|
# Accept completed, aborted, cancelled, and failed prints
|
|
if archive.status not in ("completed", "aborted", "cancelled", "failed"):
|
|
logger.info(f"Archive ID {archive_id} has status {archive.status}, which is not chargeable.")
|
|
return False
|
|
|
|
actual_user_id = charged_user_id if charged_user_id is not None else archive.created_by_id
|
|
if actual_user_id is None:
|
|
logger.warning(f"Archive ID {archive_id} has no creator ID.")
|
|
return False
|
|
|
|
base_cost = float(base_cost_override if base_cost_override is not None else (archive.cost or 0.0))
|
|
if base_cost <= 0:
|
|
logger.info(f"Base cost for archive ID {archive_id} is zero or negative.")
|
|
return False
|
|
|
|
# New dispatches persist a UUID before sending the printer command.
|
|
# Generate one here only for legacy/in-flight rows created before that
|
|
# migration; the locked archive row makes this fallback durable.
|
|
if not effective_run_id:
|
|
effective_run_id = str(uuid.uuid4())
|
|
archive.billing_run_id = effective_run_id
|
|
|
|
tx_conditions = [
|
|
WalletTransaction.transaction_type == TransactionType.PRINT_CHARGE.value,
|
|
WalletTransaction.print_run_id == effective_run_id,
|
|
]
|
|
|
|
existing_tx = (await db.execute(select(WalletTransaction).where(*tx_conditions))).scalar_one_or_none()
|
|
if existing_tx is not None:
|
|
if existing_tx.print_archive_id != archive.id:
|
|
logger.critical(
|
|
"BILLING RUN ID COLLISION: run %s belongs to archive %s, not archive %s; charge aborted",
|
|
effective_run_id,
|
|
existing_tx.print_archive_id,
|
|
archive.id,
|
|
)
|
|
raise BillingRunIdCollisionError(
|
|
f"Billing run ID {effective_run_id} is already assigned to another archive"
|
|
)
|
|
logger.info(f"Transaction already exists for archive ID {archive_id}.")
|
|
if existing_tx.is_voided:
|
|
logger.info("Print charge for run %s was voided by an administrator.", effective_run_id)
|
|
return False
|
|
|
|
# Calculate charge (full for completed, partial for others)
|
|
charge, reason_suffix = _calculate_partial_charge(
|
|
archive,
|
|
base_cost,
|
|
filament_usage=filament_usage,
|
|
)
|
|
if charge <= 0:
|
|
if print_queue_id is not None:
|
|
await release_budget_reservation(
|
|
db, source_type="print_queue", source_id=print_queue_id, status="released"
|
|
)
|
|
else:
|
|
await release_budget_reservation(db, print_archive_id=archive.id, status="released")
|
|
logger.info(f"Calculated charge for archive ID {archive_id} is zero or negative.")
|
|
return False
|
|
|
|
actual_cost_center_id = cost_center_id if cost_center_id is not None else archive.cost_center_id
|
|
|
|
wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == actual_user_id))).scalar_one_or_none()
|
|
if wallet is None:
|
|
wallet = UserWallet(user_id=actual_user_id, balance=0.0)
|
|
db.add(wallet)
|
|
await db.flush()
|
|
logger.info("Created new wallet for user ID %s.", actual_user_id)
|
|
|
|
label = archive.print_name or archive.filename or f"Archive {archive.id}"
|
|
description = f"Print charge: {label}{' ' + reason_suffix if reason_suffix else ''}"
|
|
|
|
balance_after = await _get_balance_after_for_transaction(db, actual_user_id, actual_cost_center_id, -charge)
|
|
if balance_after is not None:
|
|
balance_after = round(float(balance_after), 2)
|
|
|
|
tx = WalletTransaction(
|
|
user_id=actual_user_id,
|
|
cost_center_id=actual_cost_center_id,
|
|
transaction_type=TransactionType.PRINT_CHARGE.value,
|
|
amount=-charge,
|
|
balance_after=balance_after,
|
|
description=description,
|
|
created_by_user_id=None,
|
|
print_run_id=effective_run_id,
|
|
print_archive_id=archive.id,
|
|
print_queue_id=print_queue_id,
|
|
)
|
|
# Limit a concurrent deduplication conflict to a savepoint. The caller
|
|
# owns the outer transaction, which may already contain archive-owner
|
|
# backfills and other completion updates that must survive this race.
|
|
try:
|
|
async with db.begin_nested():
|
|
db.add(tx)
|
|
# Flush inside the savepoint to detect unique/index conflicts.
|
|
await db.flush()
|
|
except IntegrityError as e:
|
|
# Distinguish a legitimate concurrent retry of this exact run from
|
|
# a collision or an unrelated constraint failure. Only the former
|
|
# is an idempotent no-op; everything else must remain loud so the
|
|
# caller rolls back and the budget reservation stays active.
|
|
concurrent_tx = (await db.execute(select(WalletTransaction).where(*tx_conditions))).scalar_one_or_none()
|
|
if concurrent_tx is not None and concurrent_tx.print_archive_id == archive.id:
|
|
logger.info("Transaction already exists for archive ID %s (concurrent), skipping", archive_id)
|
|
return False
|
|
logger.critical(
|
|
"Failed to persist billing charge for archive %s and run %s: %s",
|
|
archive_id,
|
|
effective_run_id,
|
|
e,
|
|
exc_info=True,
|
|
)
|
|
if concurrent_tx is not None:
|
|
raise BillingRunIdCollisionError(
|
|
f"Billing run ID {effective_run_id} is already assigned to another archive"
|
|
) from e
|
|
raise
|
|
|
|
# Rebuild from the canonical personal-ledger definition. A shared cost
|
|
# center charge must not debit the user's personal wallet.
|
|
new_wallet_balance = await sync_personal_wallet_balance(db, wallet)
|
|
|
|
# Consume matching budget reservations after the transaction is persisted
|
|
if print_queue_id is not None:
|
|
await release_budget_reservation(db, source_type="print_queue", source_id=print_queue_id, status="consumed")
|
|
else:
|
|
await release_budget_reservation(db, print_archive_id=archive.id, status="consumed")
|
|
logger.info(f"Applied print charge for archive ID {archive_id}. New balance: {new_wallet_balance}.")
|
|
return True
|
|
except SQLAlchemyError as e:
|
|
logger.error(f"Database error in apply_print_charge_for_archive: {e}", exc_info=True)
|
|
raise
|
|
except ValueError as e:
|
|
logger.error(f"Value error in apply_print_charge_for_archive: {e}", exc_info=True)
|
|
return False
|