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

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