implemented pr (worth fixing) feedback

update commit
This commit is contained in:
maziggy
2026-08-15 14:40:25 +02:00
56 changed files with 1601 additions and 278 deletions
+107 -67
View File
@@ -10,7 +10,6 @@ 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.archive import PrintArchive
from backend.app.models.finance import (
BudgetReservation,
CostCenter,
@@ -20,7 +19,6 @@ from backend.app.models.finance import (
WalletTransaction,
normalize_transaction_type,
)
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.settings import Settings
from backend.app.models.user import User
from backend.app.schemas.finance import (
@@ -44,6 +42,7 @@ from backend.app.services.finance_balance import (
personal_balance_condition,
sync_personal_wallet_balance,
)
from backend.app.services.finance_budget import get_cost_center_reserved_map
router = APIRouter(prefix="/finance", tags=["finance"])
@@ -132,6 +131,7 @@ async def _get_cost_center_usage_maps(
.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)
)
@@ -143,6 +143,7 @@ async def _get_cost_center_usage_maps(
.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)
@@ -165,6 +166,7 @@ async def _get_cost_center_balance_map(
.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)
)
@@ -175,31 +177,7 @@ async def _get_cost_center_reserved_map(
db: AsyncSession,
cost_center_ids: list[int],
) -> dict[int, float]:
if not cost_center_ids:
return {}
budget_rows = await db.execute(
select(BudgetReservation.cost_center_id, func.coalesce(func.sum(BudgetReservation.amount), 0.0))
.where(
BudgetReservation.cost_center_id.in_(cost_center_ids),
BudgetReservation.status == "active",
)
.group_by(BudgetReservation.cost_center_id)
)
reserved_map = {int(center_id): float(value) for center_id, value in budget_rows.all() if center_id is not None}
queue_rows = await db.execute(
select(PrintQueueItem.cost_center_id, func.coalesce(func.sum(PrintQueueItem.estimated_cost), 0.0))
.where(
PrintQueueItem.cost_center_id.in_(cost_center_ids),
PrintQueueItem.status.in_(("pending", "printing")),
)
.group_by(PrintQueueItem.cost_center_id)
)
for center_id, value in queue_rows.all():
if center_id is not None:
reserved_map[int(center_id)] = reserved_map.get(int(center_id), 0.0) + float(value or 0.0)
return reserved_map
return await get_cost_center_reserved_map(db, cost_center_ids)
def _budget_mode_and_limit(center: CostCenter) -> tuple[str, float | None]:
@@ -306,24 +284,47 @@ def _to_balance_response(wallet: UserWallet) -> WalletBalanceResponse:
)
async def _build_personal_balance_map(db: AsyncSession, user_id: int) -> dict[int, float]:
result = await db.execute(
select(
WalletTransaction.id,
WalletTransaction.amount,
)
.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())
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."""
wallet = await db.scalar(select(UserWallet).where(UserWallet.user_id == user_id))
if wallet is not None:
return _to_balance_response(wallet)
return WalletBalanceResponse(
user_id=user_id,
balance=await calculate_personal_balance(db, user_id),
currency="EUR",
updated_at=None,
)
running_balance = 0.0
balance_map: dict[int, float] = {}
for transaction_id, amount in result.all():
running_balance += float(amount)
balance_map[int(transaction_id)] = running_balance
return balance_map
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(
@@ -356,6 +357,7 @@ async def _create_wallet_adjustment(
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)
@@ -423,19 +425,22 @@ async def get_my_transactions(
user = await _require_authenticated_user(current_user)
total_result = await db.execute(
select(func.count(WalletTransaction.id)).where(WalletTransaction.user_id == user.id)
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)
.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)
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(
@@ -460,7 +465,7 @@ async def get_all_transactions(
"""Return wallet ledger entries across users for admin finance view."""
await _require_authenticated_user(current_user)
conditions = []
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)
@@ -500,21 +505,23 @@ async def delete_transaction(
"""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))
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
if tx.transaction_type == "print_charge" and tx.print_archive_id is not None:
archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == tx.print_archive_id))
archive = archive_result.scalar_one_or_none()
if archive is not None:
archive.wallet_charge_skipped = True
db.add(archive)
await db.delete(tx)
# 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)
@@ -532,16 +539,24 @@ async def edit_transaction(
"""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))
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 request.cost_center_id is not None:
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:
@@ -573,6 +588,8 @@ async def create_manual_print(
):
"""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
@@ -680,7 +697,7 @@ async def get_user_transactions(
result = await db.execute(
select(WalletTransaction)
.where(WalletTransaction.user_id == user_id)
.where(WalletTransaction.user_id == user_id, WalletTransaction.is_voided.is_(False))
.order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
.limit(limit)
.offset(offset)
@@ -857,6 +874,12 @@ async def update_cost_center(
await _require_authenticated_user(current_user)
center = await _get_cost_center_or_404(db, cost_center_id)
if center.is_private and body.is_active is False:
raise HTTPException(
status_code=400,
detail="Private cost centers cannot be deactivated; 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:
@@ -959,18 +982,35 @@ async def delete_cost_center(
if center.is_private:
raise HTTPException(status_code=400, detail="Private cost centers cannot be deleted")
balance_map = await _get_cost_center_balance_map(db, [center.id])
total_balance = balance_map.get(center.id, 0.0)
if abs(total_balance) > 1e-9:
raise HTTPException(status_code=400, detail="Cost center can only be deleted when balance is 0")
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()
# 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"}
@@ -30,6 +30,7 @@ EVENT_NAMES = {
"print_failed": "Print Failed",
"print_stopped": "Print Stopped",
"print_progress": "Print Progress",
"billing_charge_failed": "Billing Charge Failed",
"printer_offline": "Printer Offline",
"printer_error": "Printer Error",
"filament_low": "Filament Low",
+2
View File
@@ -44,6 +44,7 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
"on_print_stopped": provider.on_print_stopped,
"on_print_progress": provider.on_print_progress,
"on_print_missing_spool_assignment": provider.on_print_missing_spool_assignment,
"on_billing_charge_failed": provider.on_billing_charge_failed,
# Printer status events
"on_printer_offline": provider.on_printer_offline,
"on_printer_error": provider.on_printer_error,
@@ -126,6 +127,7 @@ async def create_notification_provider(
on_print_stopped=provider_data.on_print_stopped,
on_print_progress=provider_data.on_print_progress,
on_print_missing_spool_assignment=provider_data.on_print_missing_spool_assignment,
on_billing_charge_failed=provider_data.on_billing_charge_failed,
# Printer status events
on_printer_offline=provider_data.on_printer_offline,
on_printer_error=provider_data.on_printer_error,
+82 -58
View File
@@ -1097,6 +1097,7 @@ async def _migrate_create_finance_tables(conn) -> None:
print_run_id VARCHAR(100),
print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
print_queue_id INTEGER REFERENCES print_queue(id) ON DELETE SET NULL,
is_voided BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT ck_wallet_transactions_transaction_type CHECK (
transaction_type IN ('print_charge', 'deposit', 'withdraw', 'manual_adjustment')
@@ -1177,6 +1178,7 @@ async def _migrate_create_finance_tables(conn) -> None:
print_run_id VARCHAR(100),
print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
print_queue_id INTEGER REFERENCES print_queue(id) ON DELETE SET NULL,
is_voided BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT ck_wallet_transactions_transaction_type CHECK (
transaction_type IN ('print_charge', 'deposit', 'withdraw', 'manual_adjustment')
@@ -2969,6 +2971,30 @@ async def run_migrations(conn):
# Migration: Store estimated print cost for budget checks before queued jobs start
await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN estimated_cost FLOAT")
await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN billing_run_id VARCHAR(36)")
await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN billing_run_id VARCHAR(36)")
if is_sqlite():
await _safe_execute(conn, "ALTER TABLE wallet_transactions ADD COLUMN is_voided BOOLEAN DEFAULT 0 NOT NULL")
else:
await _safe_execute(conn, "ALTER TABLE wallet_transactions ADD COLUMN is_voided BOOLEAN DEFAULT FALSE NOT NULL")
await _safe_execute(
conn,
"CREATE INDEX IF NOT EXISTS ix_wallet_transactions_is_voided ON wallet_transactions (is_voided)",
)
await _safe_execute(
conn,
"ALTER TABLE notification_providers ADD COLUMN on_billing_charge_failed BOOLEAN DEFAULT 1",
)
# Reprints reuse their source archive, so archive uniqueness must only be
# the legacy fallback for rows without a per-run UUID. The globally unique
# print_run_id is the idempotency key for all new charges.
await _safe_execute(conn, "DROP INDEX IF EXISTS uq_wallet_transactions_archive")
await _safe_execute(
conn,
"CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_transactions_archive"
" ON wallet_transactions (transaction_type, print_archive_id) WHERE print_run_id IS NULL",
)
# Migration: Persist active budget reservations for accepted background dispatch jobs.
if is_sqlite():
@@ -4919,39 +4945,16 @@ async def seed_color_catalog():
logger.info("Seeded %d default color catalog entries", len(DEFAULT_COLOR_CATALOG))
async def repair_wallet_ledger():
"""Repair wallet ledger balance_after values.
This is called during database initialization to ensure all balance_after
values are correct after code changes. Fixes old balance_after values to:
- Personal transactions: unassigned plus the user's own private cost center
- Cost-center transactions: global running balance for the entire cost center
Also updates UserWallet.balance to the canonical personal ledger sum.
"""
async with async_session() as session:
updated_count = await repair_wallet_ledger_internal(session)
await session.commit()
if updated_count > 0:
logger.info("Repaired wallet ledger: updated %d transactions/wallets", updated_count)
async def repair_wallet_ledger_internal(session: AsyncSession):
"""Internal helper that repairs wallet ledger using an existing session.
Used by API endpoints that need to rebuild the ledger within their own transaction.
"""
from sqlalchemy import select
from sqlalchemy import bindparam, select
from backend.app.models.finance import CostCenter, UserWallet, WalletTransaction
from backend.app.services.finance_balance import transaction_affects_personal_balance
# Get ALL transactions sorted by timestamp
result = await session.execute(
select(WalletTransaction).order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
)
all_transactions = result.scalars().all()
center_rows = await session.execute(select(CostCenter.id, CostCenter.is_private, CostCenter.owner_user_id))
centers = {
int(center_id): (bool(is_private), owner_user_id) for center_id, is_private, owner_user_id in center_rows
@@ -4961,41 +4964,62 @@ async def repair_wallet_ledger_internal(session: AsyncSession):
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:
center_is_private, center_owner_user_id = centers.get(tx.cost_center_id, (False, None))
affects_personal = transaction_affects_personal_balance(
tx.user_id,
tx.cost_center_id,
is_private=center_is_private,
owner_user_id=center_owner_user_id,
)
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))
# The owner's private cost center is also part of that user's
# personal wallet. Shared centers never enter this sum.
if affects_personal:
user_personal_balances[tx.user_id] = user_personal_balances.get(tx.user_id, 0.0) + float(tx.amount)
# Update all transactions with the new balance_after values
updated_count = 0
for tx, new_balance in tx_updates:
if tx.balance_after != new_balance:
tx.balance_after = new_balance
session.add(tx)
updated_count += 1
batch_size = 1000
batch_offset = 0
while True:
rows = (
await session.execute(
select(
WalletTransaction.id,
WalletTransaction.user_id,
WalletTransaction.cost_center_id,
WalletTransaction.amount,
WalletTransaction.balance_after,
)
.where(WalletTransaction.is_voided.is_(False))
.order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
.offset(batch_offset)
.limit(batch_size)
)
).all()
if not rows:
break
updates: list[dict[str, object]] = []
for transaction_id, user_id, cost_center_id, amount, balance_after in rows:
amount_value = float(amount)
center_is_private, center_owner_user_id = centers.get(cost_center_id, (False, None))
affects_personal = transaction_affects_personal_balance(
user_id,
cost_center_id,
is_private=center_is_private,
owner_user_id=center_owner_user_id,
)
if cost_center_id is None:
new_balance = round(user_personal_balances.get(user_id, 0.0) + amount_value, 2)
user_personal_balances[user_id] = new_balance
else:
new_balance = round(cc_running_balances.get(cost_center_id, 0.0) + amount_value, 2)
cc_running_balances[cost_center_id] = new_balance
if affects_personal:
user_personal_balances[user_id] = round(
user_personal_balances.get(user_id, 0.0) + amount_value,
2,
)
if balance_after is None or round(float(balance_after), 2) != new_balance:
updates.append({"_transaction_id": transaction_id, "_balance_after": new_balance})
if updates:
statement = (
WalletTransaction.__table__.update()
.where(WalletTransaction.__table__.c.id == bindparam("_transaction_id"))
.values(balance_after=bindparam("_balance_after"))
)
await session.execute(statement, updates)
updated_count += len(updates)
batch_offset += len(rows)
# Update every wallet, including stale wallets whose canonical balance is
# now zero because their last personal transaction was deleted.
+109 -13
View File
@@ -426,6 +426,12 @@ _first_layer_notified: dict[int, bool] = {}
# Track whether we already sent a kill-switch stop for the current unauthorized print
_unauthorized_print_kill_sent: set[int] = set()
# The MQTT status callback is a hot path. Cache the two-setting kill-switch
# lookup briefly so an unknown active print does not query the database on
# every status frame. A short TTL keeps settings changes responsive.
_KILL_SWITCH_SETTING_CACHE_TTL_SECONDS = 5.0
_kill_switch_setting_cache: tuple[bool, float] | None = None
# Provider notification started when the kill switch stops a print. The later
# MQTT print-complete callback awaits this task and only sends its regular
# provider notification when the immediate attempt failed.
@@ -695,6 +701,35 @@ def _build_status_print_keys(printer_id: int, state: PrinterState) -> list[tuple
return possible_keys
def _is_bambuddy_authorized_print_in_memory(printer_id: int, state: PrinterState) -> bool:
"""Check the cheap, process-local print ownership signals."""
if printer_manager.get_current_print_user(printer_id):
return True
return any(key in _expected_prints or key in _active_prints for key in _build_status_print_keys(printer_id, state))
async def _is_printer_kill_switch_enabled_cached() -> bool:
"""Return the kill-switch setting without querying on every MQTT frame."""
global _kill_switch_setting_cache
now = time.monotonic()
if _kill_switch_setting_cache is not None:
enabled, expires_at = _kill_switch_setting_cache
if now < expires_at:
return enabled
async with async_session() as db:
from backend.app.services.finance_budget import is_printer_kill_switch_enabled
enabled = await is_printer_kill_switch_enabled(db)
_kill_switch_setting_cache = (enabled, now + _KILL_SWITCH_SETTING_CACHE_TTL_SECONDS)
return enabled
async def _is_bambuddy_authorized_print(printer_id: int, state: PrinterState, db) -> bool | None:
"""Resolve whether the current print was started by Bambuddy.
@@ -703,12 +738,10 @@ async def _is_bambuddy_authorized_print(printer_id: int, state: PrinterState, db
frames after a restart may arrive before all subtask fields are populated.
"""
if printer_manager.get_current_print_user(printer_id):
if _is_bambuddy_authorized_print_in_memory(printer_id, state):
return True
possible_keys = _build_status_print_keys(printer_id, state)
if any(key in _expected_prints or key in _active_prints for key in possible_keys):
return True
# In-memory ownership is lost on every Bambuddy restart. The archive row is
# the durable source of truth; subtask_id is minted per print and avoids
@@ -1429,16 +1462,21 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
is_active_print = state.state in _ACTIVE_PRINT_STATES
if not is_active_print:
_unauthorized_print_kill_sent.discard(printer_id)
elif printer_id in _unauthorized_print_kill_sent:
# stop_print() was already sent for this print; avoid all further
# ownership and settings work until the printer leaves an active state.
pass
elif _is_bambuddy_authorized_print_in_memory(printer_id, state):
# Normal Bambuddy-started prints stay entirely on the in-memory path.
_unauthorized_print_kill_sent.discard(printer_id)
else:
kill_switch_enabled = False
authorization: bool | None = None
status_logger = logging.getLogger(__name__)
try:
async with async_session() as db:
from backend.app.services.finance_budget import is_printer_kill_switch_enabled
kill_switch_enabled = await is_printer_kill_switch_enabled(db)
if kill_switch_enabled:
kill_switch_enabled = await _is_printer_kill_switch_enabled_cached()
if kill_switch_enabled:
async with async_session() as db:
authorization = await _is_bambuddy_authorized_print(printer_id, state, db)
except Exception as e:
# Fail safe: a database/reconciliation error must never turn into an
@@ -1456,8 +1494,6 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
"[KILL SWITCH] Deferring authorization for printer %s until archive state is reconciled",
printer_id,
)
elif printer_id in _unauthorized_print_kill_sent:
pass
else:
try:
stopped = printer_manager.stop_print(printer_id)
@@ -5244,6 +5280,10 @@ async def on_print_complete(printer_id: int, data: dict):
# so queue items don't get stuck in "printing" when archive lookup fails.
# Uses run_with_retry to handle SQLite "database is locked" errors (#897).
queue_item_id = None
billing_run_id: str | None = None
billing_user_id: int | None = None
billing_cost_center_id: int | None = None
billing_plate_id: int | None = None
queue_status = None
queue_auto_off = False
try:
@@ -5251,6 +5291,7 @@ async def on_print_complete(printer_id: int, data: dict):
from backend.app.models.print_queue import PrintQueueItem
async def _update_queue_status(db):
nonlocal billing_run_id, billing_user_id, billing_cost_center_id, billing_plate_id
nonlocal queue_item_id, queue_status, queue_auto_off
result = await db.execute(
select(PrintQueueItem)
@@ -5283,6 +5324,10 @@ async def on_print_complete(printer_id: int, data: dict):
await db.commit()
queue_item_id = item.id
billing_run_id = item.billing_run_id
billing_user_id = item.created_by_id
billing_cost_center_id = item.cost_center_id
billing_plate_id = item.plate_id
queue_auto_off = item.auto_off_after
logger.info("Updated queue item %s status to %s", item.id, queue_status)
@@ -5409,6 +5454,7 @@ async def on_print_complete(printer_id: int, data: dict):
billing_planned_grams, billing_base_cost = _plate_scoped_run_estimate(
billing_archive,
billing_path,
billing_plate_id if billing_plate_id is not None else _get_start_plate_id(archive_id),
)
except Exception as e:
logger.warning("[FINANCE] Failed to capture planned usage for archive %s: %s", archive_id, e)
@@ -5650,6 +5696,8 @@ async def on_print_complete(printer_id: int, data: dict):
from backend.app.services.finance_billing import apply_print_charge_for_archive
archive = await db.get(PrintArchive, archive_id)
if archive and billing_run_id is None:
billing_run_id = getattr(archive, "billing_run_id", None)
if archive and archive.created_by_id is None and _print_user_info:
archive.created_by_id = _print_user_info.get("user_id")
await db.flush()
@@ -5665,12 +5713,16 @@ async def on_print_complete(printer_id: int, data: dict):
usage_results,
)
filament_usage = (actual_run_grams, billing_planned_grams) if run_status != "completed" else None
cost_center_id = _print_cost_center_ids.pop(archive_id, None)
in_memory_cost_center_id = _print_cost_center_ids.pop(archive_id, None)
charged = await apply_print_charge_for_archive(
db,
archive_id,
cost_center_id=cost_center_id,
print_run_id=archive.subtask_id if archive else None,
charged_user_id=billing_user_id,
cost_center_id=(
billing_cost_center_id if billing_cost_center_id is not None else in_memory_cost_center_id
),
print_queue_id=queue_item_id,
print_run_id=billing_run_id,
base_cost_override=billing_base_cost,
filament_usage=filament_usage,
)
@@ -5679,6 +5731,50 @@ async def on_print_complete(printer_id: int, data: dict):
logger.info("[FINANCE] Applied print charge for archive %s", archive_id)
except Exception as e:
logger.warning("[FINANCE] Failed to apply print charge for archive %s: %s", archive_id, e)
printer_info = printer_manager.get_printer(printer_id)
billing_printer_name = printer_info.name if printer_info else f"Printer {printer_id}"
billing_filename = filename or subtask_name or "Unknown"
billing_error = str(e)
try:
await ws_manager.broadcast(
{
"type": "billing_charge_failed",
"printer_id": printer_id,
"printer_name": billing_printer_name,
"filename": billing_filename,
"archive_id": archive_id,
}
)
except Exception as notification_error:
logger.error(
"[FINANCE] Failed to broadcast billing error for archive %s: %s",
archive_id,
notification_error,
)
async def _notify_billing_charge_failed() -> None:
try:
async with async_session() as notification_db:
await notification_service.on_billing_charge_failed(
printer_id,
billing_printer_name,
billing_filename,
archive_id,
billing_error,
notification_db,
)
except Exception as provider_error:
logger.error(
"[FINANCE] Failed to send provider billing alert for archive %s: %s",
archive_id,
provider_error,
exc_info=True,
)
spawn_background_task(
_notify_billing_charge_failed(),
name=f"billing-charge-failed-{archive_id}",
)
log_timing("Finance charge update")
+3
View File
@@ -74,6 +74,9 @@ class PrintArchive(Base):
# if the same subtask_id reappears after restart, we know it's the same
# print and keep the original row instead of cancel-then-create.
subtask_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# Durable Bambuddy UUID for billing idempotency. Unlike subtask_id, this is
# not constrained by printer firmware and is replaced for every reprint.
billing_run_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
# Which plate of a multi-plate 3MF this print was for (1-based), copied from
# the queue item at dispatch (#2603). A whole multi-plate 3MF is uploaded
+5
View File
@@ -147,6 +147,11 @@ class WalletTransaction(Base):
print_queue_id: Mapped[int | None] = mapped_column(
ForeignKey("print_queue.id", ondelete="SET NULL"), nullable=True, index=True
)
# Voided ledger rows stay persisted as run-scoped idempotency tombstones.
# They are excluded from balances and API listings, but their print_run_id
# prevents a delayed duplicate completion callback from recreating a charge
# that an administrator deliberately removed.
is_voided: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
+1
View File
@@ -66,6 +66,7 @@ class NotificationProvider(Base):
on_print_stopped = Column(Boolean, default=True) # User cancelled/stopped print
on_print_progress = Column(Boolean, default=False) # 25%, 50%, 75% milestones
on_print_missing_spool_assignment = Column(Boolean, default=False) # Print started with unassigned required tray(s)
on_billing_charge_failed = Column(Boolean, default=True) # A completed/stopped print could not be charged
# Event triggers - printer status
on_printer_offline = Column(Boolean, default=False)
@@ -61,6 +61,12 @@ DEFAULT_TEMPLATES = [
"title_template": "Missing Spool Assignment",
"body_template": "{printer}: print started with missing spool assignments\nSlots: {missing_slots}\nExpected profile:\n{missing_slot_details}",
},
{
"event_type": "billing_charge_failed",
"name": "Billing Charge Failed",
"title_template": "Billing Charge Failed",
"body_template": "{printer}: {filename}\nThe print charge could not be recorded. The budget reservation was retained.\nArchive: {archive_id}",
},
{
"event_type": "printer_offline",
"name": "Printer Offline",
+3
View File
@@ -36,6 +36,9 @@ class PrintQueueItem(Base):
ForeignKey("cost_centers.id", ondelete="SET NULL"), nullable=True
)
estimated_cost: Mapped[float | None] = mapped_column(Float, nullable=True)
# Bambuddy-owned globally unique identity for one physical dispatch. This
# must not reuse the printer protocol's 31-bit subtask_id.
billing_run_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
batch_id: Mapped[int | None] = mapped_column(ForeignKey("print_batches.id", ondelete="SET NULL"), nullable=True)
+2
View File
@@ -40,6 +40,7 @@ class NotificationProviderBase(BaseModel):
default=False,
description="Notify when a print starts with required trays missing spool assignments",
)
on_billing_charge_failed: bool = Field(default=True, description="Notify when a print charge cannot be recorded")
# Event triggers - printer status
on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
@@ -137,6 +138,7 @@ class NotificationProviderUpdate(BaseModel):
on_print_stopped: bool | None = None
on_print_progress: bool | None = None
on_print_missing_spool_assignment: bool | None = None
on_billing_charge_failed: bool | None = None
# Event triggers - printer status
on_printer_offline: bool | None = None
@@ -16,6 +16,7 @@ class EventType(StrEnum):
PRINT_STOPPED = "print_stopped"
PRINT_PROGRESS = "print_progress"
PRINT_MISSING_SPOOL_ASSIGNMENT = "print_missing_spool_assignment"
BILLING_CHARGE_FAILED = "billing_charge_failed"
PRINTER_OFFLINE = "printer_offline"
PRINTER_ERROR = "printer_error"
FILAMENT_LOW = "filament_low"
@@ -71,6 +72,7 @@ EVENT_VARIABLES: dict[str, list[str]] = {
"timestamp",
"app_name",
],
"billing_charge_failed": ["printer", "filename", "archive_id", "error", "timestamp", "app_name"],
"printer_offline": ["printer", "timestamp", "app_name"],
"printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"],
"filament_low": ["printer", "slot", "remaining_percent", "color", "timestamp", "app_name"],
@@ -157,6 +159,14 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
"timestamp": "2024-01-15 14:30",
"app_name": "Bambuddy",
},
"billing_charge_failed": {
"printer": "Bambu X1C",
"filename": "Benchy.3mf",
"archive_id": "123",
"error": "The transaction could not be persisted",
"timestamp": "2024-01-15 15:48",
"app_name": "Bambuddy",
},
"printer_offline": {
"printer": "Bambu X1C",
"timestamp": "2024-01-15 14:30",
+4 -1
View File
@@ -3207,7 +3207,10 @@ class BambuMQTTClient:
if "subtask_id" in data:
self.state.subtask_id = data["subtask_id"]
if "mc_percent" in data:
# Save last non-zero progress for usage tracking (firmware resets to 0 on cancel)
# Billing: retain this frame's latest positive value immediately.
# A display-side abort may be the very next frame (and may omit
# mc_percent entirely), so retaining only the previous frame can
# lose the only usable estimate for proportional charging.
previous_progress = self.state.progress
new_progress = float(data["mc_percent"])
if new_progress > 0:
+5 -1
View File
@@ -36,7 +36,11 @@ async def calculate_personal_balance(db: AsyncSession, user_id: int) -> float:
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))
.where(
WalletTransaction.user_id == user_id,
WalletTransaction.is_voided.is_(False),
personal_balance_condition(user_id),
)
)
return round(float(result.scalar_one() or 0.0), 2)
+88 -27
View File
@@ -1,4 +1,5 @@
import logging
import uuid
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
@@ -12,6 +13,10 @@ from backend.app.services.finance_budget import is_billing_enabled, release_budg
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,
@@ -43,6 +48,7 @@ async def _get_balance_after_for_transaction(
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)
@@ -110,7 +116,9 @@ 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,
@@ -124,7 +132,12 @@ async def apply_print_charge_for_archive(
"""
try:
if not await is_billing_enabled(db):
await release_budget_reservation(db, print_archive_id=archive_id, status="released")
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
@@ -135,6 +148,10 @@ async def apply_print_charge_for_archive(
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
@@ -144,7 +161,8 @@ async def apply_print_charge_for_archive(
logger.info(f"Archive ID {archive_id} has status {archive.status}, which is not chargeable.")
return False
if archive.created_by_id is None:
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
@@ -153,15 +171,33 @@ async def apply_print_charge_for_archive(
logger.info(f"Base cost for archive ID {archive_id} is zero or negative.")
return False
tx_conditions = [WalletTransaction.transaction_type == TransactionType.PRINT_CHARGE.value]
if print_run_id:
tx_conditions.append(WalletTransaction.print_run_id == print_run_id)
else:
tx_conditions.append(WalletTransaction.print_archive_id == archive.id)
# 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)
@@ -171,62 +207,87 @@ async def apply_print_charge_for_archive(
filament_usage=filament_usage,
)
if charge <= 0:
await release_budget_reservation(db, print_archive_id=archive.id, status="released")
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 == archive.created_by_id))
).scalar_one_or_none()
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=archive.created_by_id, balance=0.0, currency="EUR")
wallet = UserWallet(user_id=actual_user_id, balance=0.0, currency="EUR")
db.add(wallet)
await db.flush()
logger.info(f"Created new wallet for user ID {archive.created_by_id}.")
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, archive.created_by_id, actual_cost_center_id, -charge
)
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=archive.created_by_id,
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=print_run_id or archive.subtask_id,
print_run_id=effective_run_id,
print_archive_id=archive.id,
print_queue_id=print_queue_id,
)
db.add(tx)
# Ensure the transaction is flushed to detect unique/index constraint violations
# 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:
await db.flush()
async with db.begin_nested():
db.add(tx)
# Flush inside the savepoint to detect unique/index conflicts.
await db.flush()
except IntegrityError as e:
# Another concurrent worker likely created the same transaction
logger.info("Transaction already exists for archive ID %s (concurrent), skipping: %s", archive_id, e)
await db.rollback()
return False
# 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
await release_budget_reservation(db, print_archive_id=archive.id, status="consumed")
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)
return False
raise
except ValueError as e:
logger.error(f"Value error in apply_print_charge_for_archive: {e}", exc_info=True)
return False
+45 -32
View File
@@ -76,6 +76,7 @@ async def _cost_center_spend(db: AsyncSession, cost_center_id: int, *, monthly:
conditions = [
WalletTransaction.cost_center_id == cost_center_id,
WalletTransaction.cost_center_id.is_not(None),
WalletTransaction.is_voided.is_(False),
]
if monthly:
conditions.append(WalletTransaction.created_at >= await _get_budget_window_start_utc(db))
@@ -84,12 +85,24 @@ async def _cost_center_spend(db: AsyncSession, cost_center_id: int, *, monthly:
return float(result.scalar() or 0.0)
async def _cost_center_open_queue_reservations(
async def get_cost_center_reserved_map(
db: AsyncSession,
cost_center_id: int,
cost_center_ids: list[int],
*,
exclude_queue_item_id: int | None = None,
) -> float:
exclude_reservation_source_type: str | None = None,
exclude_reservation_source_id: int | None = None,
) -> dict[int, float]:
"""Return active holds plus unreserved open queue estimates per cost center.
Queue items that already have an active ``print_queue`` reservation are
excluded from the queue sum because the reservation is their replacement,
not an additional hold.
"""
if not cost_center_ids:
return {}
active_queue_reservation = (
select(BudgetReservation.id)
.where(
@@ -99,38 +112,41 @@ async def _cost_center_open_queue_reservations(
)
.exists()
)
conditions = [
PrintQueueItem.cost_center_id == cost_center_id,
queue_conditions = [
PrintQueueItem.cost_center_id.in_(cost_center_ids),
PrintQueueItem.status.in_(("pending", "printing")),
~active_queue_reservation,
]
if exclude_queue_item_id is not None:
conditions.append(PrintQueueItem.id != exclude_queue_item_id)
queue_conditions.append(PrintQueueItem.id != exclude_queue_item_id)
result = await db.execute(select(func.coalesce(func.sum(PrintQueueItem.estimated_cost), 0.0)).where(*conditions))
return float(result.scalar() or 0.0)
queue_rows = await db.execute(
select(PrintQueueItem.cost_center_id, func.coalesce(func.sum(PrintQueueItem.estimated_cost), 0.0))
.where(*queue_conditions)
.group_by(PrintQueueItem.cost_center_id)
)
reserved_map = {int(center_id): float(value) for center_id, value in queue_rows.all() if center_id is not None}
async def _cost_center_active_budget_reservations(
db: AsyncSession,
cost_center_id: int,
*,
exclude_source_type: str | None = None,
exclude_source_id: int | None = None,
) -> float:
conditions = [
BudgetReservation.cost_center_id == cost_center_id,
reservation_conditions = [
BudgetReservation.cost_center_id.in_(cost_center_ids),
BudgetReservation.status == "active",
]
if exclude_source_type is not None and exclude_source_id is not None:
conditions.append(
if exclude_reservation_source_type is not None and exclude_reservation_source_id is not None:
reservation_conditions.append(
~(
(BudgetReservation.source_type == exclude_source_type)
& (BudgetReservation.source_id == exclude_source_id)
(BudgetReservation.source_type == exclude_reservation_source_type)
& (BudgetReservation.source_id == exclude_reservation_source_id)
)
)
result = await db.execute(select(func.coalesce(func.sum(BudgetReservation.amount), 0.0)).where(*conditions))
return float(result.scalar() or 0.0)
reservation_rows = await db.execute(
select(BudgetReservation.cost_center_id, func.coalesce(func.sum(BudgetReservation.amount), 0.0))
.where(*reservation_conditions)
.group_by(BudgetReservation.cost_center_id)
)
for center_id, value in reservation_rows.all():
if center_id is not None:
reserved_map[int(center_id)] = reserved_map.get(int(center_id), 0.0) + float(value or 0.0)
return reserved_map
async def validate_print_budget(
@@ -179,17 +195,14 @@ async def validate_print_budget(
return
used = await _cost_center_spend(db, cost_center_id, monthly=center.monthly_budget is not None)
reserved = await _cost_center_open_queue_reservations(
reserved_map = await get_cost_center_reserved_map(
db,
cost_center_id,
[cost_center_id],
exclude_queue_item_id=exclude_queue_item_id,
exclude_reservation_source_type=exclude_reservation_source_type,
exclude_reservation_source_id=exclude_reservation_source_id,
)
reserved += await _cost_center_active_budget_reservations(
db,
cost_center_id,
exclude_source_type=exclude_reservation_source_type,
exclude_source_id=exclude_reservation_source_id,
)
reserved = reserved_map.get(cost_center_id, 0.0)
requested = estimated_cost * max(1, quantity)
available = float(budget_limit) - used - reserved
if requested > available:
+10 -3
View File
@@ -49,9 +49,16 @@ async def ensure_user_finance_defaults(db: AsyncSession, user: User) -> bool:
db.add(private_center)
await db.flush()
changed = True
elif private_center.name != user.username:
private_center.name = user.username
changed = True
else:
# A private center is the billing fallback for its owner and therefore
# must remain active. A zero budget is the supported way to prevent
# printing from it.
if not private_center.is_active:
private_center.is_active = True
changed = True
if private_center.name != user.username:
private_center.name = user.username
changed = True
membership = (
await db.execute(
@@ -1353,6 +1353,39 @@ class NotificationService:
variables=variables,
)
async def on_billing_charge_failed(
self,
printer_id: int,
printer_name: str,
filename: str,
archive_id: int | None,
error: str,
db: AsyncSession,
) -> None:
"""Notify providers that a terminal print could not be charged."""
providers = await self._get_providers_for_event(db, "on_billing_charge_failed", printer_id)
if not providers:
return
variables = {
"printer": printer_name,
"filename": self._clean_filename(filename),
"archive_id": str(archive_id) if archive_id is not None else "Unknown",
"error": error,
}
title, message = await self._build_message_from_template(db, "billing_charge_failed", variables)
await self._send_to_providers(
providers,
title,
message,
db,
"billing_charge_failed",
printer_id,
printer_name,
force_immediate=True,
variables=variables,
)
async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
"""Handle printer offline event."""
providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id)
+12 -2
View File
@@ -99,8 +99,18 @@ async def estimate_queue_source_cost(
if archive is not None:
archive_path = settings.base_dir / archive.file_path
_grams, cost = plate_scoped_run_estimate(archive, archive_path, plate_id)
return float(cost) if cost is not None and cost > 0 else None
grams, cost = plate_scoped_run_estimate(archive, archive_path, plate_id)
if cost is not None and cost > 0:
return float(cost)
# Older archives and imports can have trustworthy filament usage but
# no stored cost. Model-based and multi-printer jobs have no single
# spool mapping at enqueue time, so use the server setting rather than
# requiring the browser to provide an estimate.
if grams is None or grams <= 0:
return None
default_cost = await _default_cost_per_kg(db)
estimated_cost = (grams / 1000.0) * default_cost
return max(0.01, round(estimated_cost, 2)) if estimated_cost > 0 else None
if library_file is None:
return None
+38 -26
View File
@@ -4,6 +4,7 @@ import asyncio
import json
import logging
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
@@ -1297,7 +1298,7 @@ class PrintScheduler:
# reservation survives only after start_print() accepted the
# command. Failure, cancellation, deferral, and exceptions all
# release it here.
await asyncio.shield(self._release_unconfirmed_budget_reservation(item_db, item_id))
await asyncio.shield(self._release_unconfirmed_budget_reservation(item_id))
# Release the claim on every exit. Once dispatch has finished the
# row's status carries the lock (printing/failed/cancelled are all
# != pending), so the token is only needed for the duration of the
@@ -1329,37 +1330,37 @@ class PrintScheduler:
exc_info=True,
)
async def _release_unconfirmed_budget_reservation(self, db: AsyncSession, item_id: int) -> None:
"""Release a queue reservation when dispatch ended before MQTT send."""
async def _release_unconfirmed_budget_reservation(self, item_id: int) -> None:
"""Release a queue reservation without touching the dispatch session."""
if item_id not in self._unconfirmed_budget_reservations:
return
for attempt in range(1, 4):
try:
await db.rollback()
await release_budget_reservation(
db,
source_type="print_queue",
source_id=item_id,
status="released",
)
await db.commit()
self._unconfirmed_budget_reservations.discard(item_id)
return
except Exception as exc:
async with async_session() as cleanup_db:
try:
await db.rollback()
except Exception:
pass
if attempt == 3:
logger.error(
"Queue item %s: failed to release budget reservation after %d attempts: %s",
item_id,
attempt,
exc,
await release_budget_reservation(
cleanup_db,
source_type="print_queue",
source_id=item_id,
status="released",
)
await cleanup_db.commit()
self._unconfirmed_budget_reservations.discard(item_id)
return
await asyncio.sleep(0.5 * attempt)
except Exception as exc:
try:
await cleanup_db.rollback()
except Exception:
pass
if attempt == 3:
logger.error(
"Queue item %s: failed to release budget reservation after %d attempts: %s",
item_id,
attempt,
exc,
)
return
await asyncio.sleep(0.5 * attempt)
async def _claim_for_dispatch(self, db: AsyncSession, item_id: int) -> bool:
"""Atomically stamp ``dispatching_at`` on a still-pending, unclaimed row.
@@ -4203,11 +4204,12 @@ class PrintScheduler:
# rowcount==0 means the user won the race; bail out, best-effort delete
# the file we just uploaded, do NOT send start_print.
now_utc = datetime.now(timezone.utc)
billing_run_id = str(uuid.uuid4())
cas = await db.execute(
update(PrintQueueItem)
.where(PrintQueueItem.id == item.id)
.where(PrintQueueItem.status == "pending")
.values(status="printing", started_at=now_utc)
.values(status="printing", started_at=now_utc, billing_run_id=billing_run_id)
)
await db.commit()
if cas.rowcount == 0:
@@ -4244,6 +4246,16 @@ class PrintScheduler:
# item.started_at sees the values we just persisted.
item.status = "printing"
item.started_at = now_utc
item.billing_run_id = billing_run_id
if archive is not None:
archive.billing_run_id = billing_run_id
# Legacy transaction deletion used an archive-wide skip flag.
# A newly dispatched run has its own UUID/tombstone, so it must be
# billable independently of any older deleted run on this archive.
archive.wallet_charge_skipped = False
# Persist before MQTT send so completion and restart recovery can
# always recover the internal billing identity.
await db.commit()
for cleanup_path in cleanup_disk_paths:
try:
+1
View File
@@ -525,6 +525,7 @@ def notification_provider_factory(db_session):
"on_print_stopped": True,
"on_print_progress": False,
"on_print_missing_spool_assignment": False,
"on_billing_charge_failed": True,
"on_printer_offline": False,
"on_printer_error": False,
"on_filament_low": False,
+247 -4
View File
@@ -6,8 +6,9 @@ from sqlalchemy import select
from backend.app.core.auth import get_password_hash
from backend.app.models.archive import PrintArchive
from backend.app.models.finance import CostCenter, UserWallet, WalletTransaction
from backend.app.models.finance import BudgetReservation, CostCenter, UserWallet, WalletTransaction
from backend.app.models.group import Group
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.settings import Settings
from backend.app.models.user import User
from backend.app.services.finance_billing import apply_print_charge_for_archive
@@ -136,6 +137,174 @@ class TestFinanceAPI:
assert mine_after_remove.status_code == 200
assert {center["name"] for center in mine_after_remove.json()} == {"carol"}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_private_cost_center_cannot_be_deactivated_but_can_have_zero_budget(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
created_user = await self._create_user_via_api(async_client, auth_headers, "private-budget-user")
private_center = await db_session.scalar(
select(CostCenter).where(
CostCenter.owner_user_id == created_user["id"],
CostCenter.is_private.is_(True),
)
)
assert private_center is not None
deactivate_response = await async_client.patch(
f"/api/v1/finance/cost-centers/{private_center.id}",
json={"is_active": False},
headers=auth_headers,
)
assert deactivate_response.status_code == 400
assert "cannot be deactivated" in deactivate_response.json()["detail"]
await db_session.refresh(private_center)
assert private_center.is_active is True
budget_response = await async_client.patch(
f"/api/v1/finance/cost-centers/{private_center.id}/budgets",
json={"total_budget": 0},
headers=auth_headers,
)
assert budget_response.status_code == 200
assert budget_response.json()["total_budget"] == 0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_cost_center_available_budget_does_not_double_count_queue_reservation(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
center_response = await async_client.post(
"/api/v1/finance/cost-centers",
json={"name": "Reserved Once", "total_budget": 10.0},
headers=auth_headers,
)
assert center_response.status_code == 200
center_id = center_response.json()["id"]
reserved_item = PrintQueueItem(
cost_center_id=center_id,
estimated_cost=3.0,
status="pending",
position=1,
)
legacy_unreserved_item = PrintQueueItem(
cost_center_id=center_id,
estimated_cost=2.0,
status="pending",
position=2,
)
db_session.add_all([reserved_item, legacy_unreserved_item])
await db_session.flush()
db_session.add(
BudgetReservation(
cost_center_id=center_id,
amount=3.0,
status="active",
source_type="print_queue",
source_id=reserved_item.id,
)
)
await db_session.commit()
response = await async_client.get("/api/v1/finance/cost-centers", headers=auth_headers)
assert response.status_code == 200
center = next(item for item in response.json() if item["id"] == center_id)
# 3.00 active reservation + 2.00 legacy queue estimate, not 3 + 3 + 2.
assert center["budget_available"] == 5.0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_cost_center_with_balanced_transactions_cannot_be_deleted(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
admin_user,
db_session,
):
center_response = await async_client.post(
"/api/v1/finance/cost-centers",
json={"name": "Balanced History"},
headers=auth_headers,
)
center_id = center_response.json()["id"]
db_session.add_all(
[
WalletTransaction(
user_id=admin_user.id,
cost_center_id=center_id,
transaction_type="deposit",
amount=50.0,
balance_after=50.0,
),
WalletTransaction(
user_id=admin_user.id,
cost_center_id=center_id,
transaction_type="withdraw",
amount=-50.0,
balance_after=0.0,
),
]
)
await db_session.commit()
response = await async_client.delete(
f"/api/v1/finance/cost-centers/{center_id}",
headers=auth_headers,
)
assert response.status_code == 400
assert "transactions reference it" in response.json()["detail"]
transactions = (
(await db_session.execute(select(WalletTransaction).where(WalletTransaction.cost_center_id == center_id)))
.scalars()
.all()
)
assert len(transactions) == 2
@pytest.mark.asyncio
@pytest.mark.integration
async def test_cost_center_with_active_reservation_cannot_be_deleted(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
center_response = await async_client.post(
"/api/v1/finance/cost-centers",
json={"name": "Active Hold"},
headers=auth_headers,
)
center_id = center_response.json()["id"]
db_session.add(
BudgetReservation(
cost_center_id=center_id,
amount=3.0,
status="active",
source_type="direct_print",
source_id=123,
)
)
await db_session.commit()
response = await async_client.delete(
f"/api/v1/finance/cost-centers/{center_id}",
headers=auth_headers,
)
assert response.status_code == 400
assert "active budget reservations" in response.json()["detail"]
assert await db_session.get(CostCenter, center_id) is not None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_wallet_adjustments_and_transaction_ledger_rebuild(
@@ -363,6 +532,7 @@ class TestFinanceAPI:
created_user = await self._create_user_via_api(async_client, auth_headers, "frank")
user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
assert user is not None
user_id = user.id
archive = PrintArchive(
printer_id=None,
@@ -384,13 +554,15 @@ class TestFinanceAPI:
balance_after=-4.0,
description="Print charge: print.gcode",
created_by_user_id=None,
print_run_id="deleted-print-run",
print_archive_id=archive.id,
)
db_session.add(tx)
await db_session.commit()
archive_id = archive.id
tx_rows_before = (
(await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user.id)))
(await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user_id)))
.scalars()
.all()
)
@@ -400,13 +572,43 @@ class TestFinanceAPI:
f"/api/v1/finance/transactions/{tx_rows_before[0].id}", headers=auth_headers
)
assert delete_response.status_code == 200
db_session.expire_all()
tx_rows_after = (
(await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user.id)))
(await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user_id)))
.scalars()
.all()
)
assert tx_rows_after == []
assert len(tx_rows_after) == 1
assert tx_rows_after[0].is_voided is True
# The voided run remains an idempotency tombstone and cannot be
# recreated by a delayed duplicate completion callback.
assert (
await apply_print_charge_for_archive(
db_session,
archive_id,
print_run_id="deleted-print-run",
)
) is False
# A later reprint of the same archive has a distinct run identity and
# must still be charged normally.
assert (
await apply_print_charge_for_archive(
db_session,
archive_id,
charged_user_id=user_id,
print_run_id="later-reprint-run",
)
) is True
await db_session.commit()
visible = await async_client.get(
f"/api/v1/finance/users/{user_id}/transactions",
headers=auth_headers,
)
assert visible.status_code == 200
assert [row["print_run_id"] for row in visible.json()] == ["later-reprint-run"]
async def test_edit_transaction_updates_ledger(
self,
@@ -470,6 +672,31 @@ class TestFinanceAPI:
# Description should have "(Admin edit)" appended
assert "(Admin edit)" in edited_tx_data["description"]
# An explicit null moves the transaction back to the personal ledger.
clear_response = await async_client.patch(
f"/api/v1/finance/transactions/{tx_id}",
json={"cost_center_id": None},
headers=auth_headers,
)
assert clear_response.status_code == 200
assert clear_response.json()["cost_center_id"] is None
invalid_user_response = await async_client.patch(
f"/api/v1/finance/transactions/{tx_id}",
json={"user_id": 2147483647},
headers=auth_headers,
)
assert invalid_user_response.status_code == 404
assert invalid_user_response.json()["detail"] == "User not found"
invalid_center_response = await async_client.patch(
f"/api/v1/finance/transactions/{tx_id}",
json={"cost_center_id": 2147483647},
headers=auth_headers,
)
assert invalid_center_response.status_code == 404
assert invalid_center_response.json()["detail"] == "Cost center not found"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_manual_print_and_recalculates_ledger(
@@ -514,6 +741,22 @@ class TestFinanceAPI:
# The response includes the computed running balance for the transaction
assert resp_json.get("balance_after") == -4.0
invalid_user_response = await async_client.post(
"/api/v1/finance/transactions/manual",
json={**payload, "user_id": 2147483647},
headers=auth_headers,
)
assert invalid_user_response.status_code == 404
assert invalid_user_response.json()["detail"] == "User not found"
invalid_center_response = await async_client.post(
"/api/v1/finance/transactions/manual",
json={**payload, "cost_center_id": 2147483647},
headers=auth_headers,
)
assert invalid_center_response.status_code == 404
assert invalid_center_response.json()["detail"] == "Cost center not found"
class TestPartialPrintChargesIntegration:
"""Integration tests for partial print charge calculation."""
@@ -427,6 +427,25 @@ class TestNotificationsAPI:
response = await async_client.get(f"/api/v1/notifications/{provider.id}")
assert response.json()["on_print_missing_spool_assignment"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_billing_charge_failed_toggle(
self, async_client: AsyncClient, notification_provider_factory, db_session
):
"""Billing alerts can be enabled independently for each provider."""
provider = await notification_provider_factory(on_billing_charge_failed=True)
response = await async_client.patch(
f"/api/v1/notifications/{provider.id}",
json={"on_billing_charge_failed": False},
)
assert response.status_code == 200
assert response.json()["on_billing_charge_failed"] is False
response = await async_client.get(f"/api/v1/notifications/{provider.id}")
assert response.json()["on_billing_charge_failed"] is False
class TestNotificationTemplatesAPI:
"""Integration tests for /api/v1/notification-templates/ endpoints."""
@@ -199,6 +199,33 @@ class TestPrintQueueAPI:
assert response.status_code == 200
assert response.json()["estimated_cost"] == 1.25
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_model_based_queue_item_derives_cost_without_client_estimate(
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""Model dispatch has no printer-side estimate but remains billable."""
await enable_billing(db_session)
await printer_factory(model="X1C")
archive = await archive_factory(cost=1.25, filament_used_grams=50.0, sliced_for_model="X1C")
cost_center = CostCenter(name="Model Budget CC", is_active=True, is_private=False, monthly_budget=10.0)
db_session.add(cost_center)
await db_session.commit()
await db_session.refresh(cost_center)
response = await async_client.post(
"/api/v1/queue/",
json={
"target_model": "X1C",
"archive_id": archive.id,
"cost_center_id": cost_center.id,
},
)
assert response.status_code == 200
assert response.json()["printer_id"] is None
assert response.json()["estimated_cost"] == 1.25
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_rejects_tampered_client_cost_when_server_cost_exceeds_budget(
@@ -14,7 +14,7 @@ import backend.app.models # noqa: F401 - populate Base.metadata
import backend.app.services.print_scheduler as scheduler_module
from backend.app.core.database import Base
from backend.app.models.archive import PrintArchive
from backend.app.models.finance import BudgetReservation, CostCenter
from backend.app.models.finance import BudgetReservation, CostCenter, UserWallet
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
from backend.app.models.settings import Settings
@@ -153,6 +153,15 @@ async def test_successful_scheduler_dispatch_keeps_one_active_reservation(billin
assert reservation.print_archive_id == billing_dispatch_case.ids.archive_id
start_print.assert_called_once()
async with billing_dispatch_case.session_maker() as db:
item = await db.get(PrintQueueItem, billing_dispatch_case.ids.item_id)
archive = await db.get(PrintArchive, billing_dispatch_case.ids.archive_id)
assert item.billing_run_id is not None
assert archive.billing_run_id == item.billing_run_id
# The internal UUID is deliberately independent from Bambu's 31-bit
# task/subtask identifier.
assert len(item.billing_run_id) == 36
# The printing queue row and its persisted reservation represent the same
# €4 hold. A second €6 job must fit exactly; €6.01 must not.
async with billing_dispatch_case.session_maker() as db:
@@ -184,6 +193,26 @@ async def test_successful_scheduler_dispatch_keeps_one_active_reservation(billin
)
@pytest.mark.asyncio
async def test_cost_center_without_budget_is_unlimited_regardless_of_wallet_balance(billing_dispatch_case):
"""Wallet balance is accounting data; only an explicit cost-center budget gates printing."""
async with billing_dispatch_case.session_maker() as db:
user = await db.get(User, billing_dispatch_case.ids.user_id)
center = await db.get(CostCenter, billing_dispatch_case.ids.cost_center_id)
center.monthly_budget = None
center.total_budget = None
wallet = UserWallet(user_id=user.id, balance=-100.0, currency="EUR")
db.add(wallet)
await db.commit()
await validate_print_budget(
db,
cost_center_id=center.id,
estimated_cost=1_000_000.0,
current_user=user,
)
@pytest.mark.asyncio
async def test_upload_failure_releases_scheduler_reservation(billing_dispatch_case):
start_print = await _dispatch(billing_dispatch_case, uploaded=False)
@@ -244,3 +273,46 @@ async def test_cancel_during_upload_releases_scheduler_reservation(billing_dispa
)
assert item.status == "cancelled"
assert active_count == 0
@pytest.mark.asyncio
async def test_cleanup_session_does_not_rollback_failed_dispatch_status(billing_dispatch_case):
scheduler = PrintScheduler()
async def fail_after_reserving(db, item):
db.add(
BudgetReservation(
cost_center_id=item.cost_center_id,
amount=4.0,
status="active",
source_type="print_queue",
source_id=item.id,
print_archive_id=item.archive_id,
)
)
await db.commit()
scheduler._unconfirmed_budget_reservations.add(item.id)
item.status = "failed"
item.error_message = "dispatch failed after reservation"
raise RuntimeError("simulated dispatch failure")
with (
patch.object(scheduler_module, "async_session", billing_dispatch_case.session_maker),
patch.object(scheduler, "_start_print", fail_after_reserving),
pytest.raises(RuntimeError, match="simulated dispatch failure"),
):
await scheduler._dispatch_one(billing_dispatch_case.ids.item_id)
async with billing_dispatch_case.session_maker() as db:
item = await db.get(PrintQueueItem, billing_dispatch_case.ids.item_id)
reservation = await db.scalar(
select(BudgetReservation).where(
BudgetReservation.source_type == "print_queue",
BudgetReservation.source_id == billing_dispatch_case.ids.item_id,
)
)
assert item.status == "failed"
assert item.error_message == "dispatch failed after reservation"
assert item.dispatching_at is None
assert reservation.status == "released"
@@ -6765,6 +6765,30 @@ class TestNoLastLayerFinishPhotoTrigger:
assert events == []
class TestBillingProgressTracking:
"""The latest positive MQTT progress is the fallback for partial billing."""
@pytest.fixture
def mqtt_client(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
return BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="TEST123",
access_code="12345678",
)
def test_current_frame_is_retained_and_zero_reset_does_not_overwrite_it(self, mqtt_client):
"""Even the first positive frame must survive an immediate printer abort."""
mqtt_client._process_message({"print": {"mc_percent": 25}})
assert mqtt_client._last_valid_progress == 25
mqtt_client._process_message({"print": {"mc_percent": 0}})
assert mqtt_client._last_valid_progress == 25
class TestPrintProgressCallback:
"""#2547: `on_print_progress` keeps the finish-photo frame bank fresh.
@@ -1,13 +1,17 @@
"""Unit tests for billing charges applied to print archives."""
from unittest.mock import AsyncMock
import pytest
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from backend.app.models.archive import PrintArchive
from backend.app.models.finance import BudgetReservation, CostCenter, UserWallet, WalletTransaction
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.settings import Settings
from backend.app.models.user import User
from backend.app.services.finance_billing import apply_print_charge_for_archive
from backend.app.services.finance_billing import BillingRunIdCollisionError, apply_print_charge_for_archive
async def enable_billing(db_session):
@@ -20,6 +24,187 @@ async def enable_billing(db_session):
class TestFinanceBilling:
@pytest.mark.asyncio
async def test_run_context_charges_initiator_and_consumes_only_its_reservation(self, db_session):
"""Concurrent reprints of one archive keep owner, center and hold run-scoped."""
await enable_billing(db_session)
archive_owner = User(username="archive_owner", role="user", is_active=True)
first_user = User(username="first_reprinter", role="user", is_active=True)
second_user = User(username="second_reprinter", role="user", is_active=True)
first_center = CostCenter(name="First run CC", is_active=True, is_private=False)
second_center = CostCenter(name="Second run CC", is_active=True, is_private=False)
db_session.add_all([archive_owner, first_user, second_user, first_center, second_center])
await db_session.flush()
archive = PrintArchive(
filename="shared-source.3mf",
file_path="archives/test/shared-source.3mf",
file_size=123,
content_hash="shared-source-runs",
status="completed",
cost=4.0,
created_by_id=archive_owner.id,
)
db_session.add(archive)
await db_session.flush()
first_item = PrintQueueItem(
archive_id=archive.id,
cost_center_id=first_center.id,
estimated_cost=4.0,
position=1,
status="printing",
created_by_id=first_user.id,
billing_run_id="first-reprint-run",
plate_id=1,
)
second_item = PrintQueueItem(
archive_id=archive.id,
cost_center_id=second_center.id,
estimated_cost=4.0,
position=1,
status="printing",
created_by_id=second_user.id,
billing_run_id="second-reprint-run",
plate_id=2,
)
db_session.add_all([first_item, second_item])
await db_session.flush()
first_reservation = BudgetReservation(
cost_center_id=first_center.id,
amount=4.0,
status="active",
source_type="print_queue",
source_id=first_item.id,
print_archive_id=archive.id,
)
second_reservation = BudgetReservation(
cost_center_id=second_center.id,
amount=4.0,
status="active",
source_type="print_queue",
source_id=second_item.id,
print_archive_id=archive.id,
)
db_session.add_all([first_reservation, second_reservation])
await db_session.commit()
changed = await apply_print_charge_for_archive(
db_session,
archive.id,
charged_user_id=first_user.id,
cost_center_id=first_center.id,
print_queue_id=first_item.id,
print_run_id=first_item.billing_run_id,
)
await db_session.commit()
assert changed is True
tx = await db_session.scalar(
select(WalletTransaction).where(WalletTransaction.print_run_id == first_item.billing_run_id)
)
assert tx is not None
assert tx.user_id == first_user.id
assert tx.user_id != archive_owner.id
assert tx.cost_center_id == first_center.id
assert tx.print_queue_id == first_item.id
await db_session.refresh(first_reservation)
await db_session.refresh(second_reservation)
assert first_reservation.status == "consumed"
assert second_reservation.status == "active"
@pytest.mark.asyncio
async def test_run_id_collision_with_another_archive_is_loud(self, db_session):
await enable_billing(db_session)
user = User(username="collision", role="user", is_active=True)
db_session.add(user)
await db_session.flush()
first = PrintArchive(
filename="first.3mf",
file_path="archives/test/first.3mf",
file_size=123,
content_hash="collision-first",
status="completed",
cost=2.0,
created_by_id=user.id,
billing_run_id="same-run-id",
)
second = PrintArchive(
filename="second.3mf",
file_path="archives/test/second.3mf",
file_size=123,
content_hash="collision-second",
status="completed",
cost=3.0,
created_by_id=user.id,
billing_run_id="same-run-id",
)
db_session.add_all([first, second])
await db_session.commit()
assert await apply_print_charge_for_archive(db_session, first.id, print_run_id="same-run-id") is True
await db_session.commit()
with pytest.raises(BillingRunIdCollisionError, match="already assigned to another archive"):
await apply_print_charge_for_archive(db_session, second.id, print_run_id="same-run-id")
transactions = (
(await db_session.execute(select(WalletTransaction).where(WalletTransaction.print_run_id == "same-run-id")))
.scalars()
.all()
)
assert len(transactions) == 1
assert transactions[0].print_archive_id == first.id
@pytest.mark.asyncio
async def test_concurrent_charge_conflict_preserves_callers_pending_changes(self, db_session, monkeypatch):
await enable_billing(db_session)
user = User(username="concurrent_charge", role="user", is_active=True)
archive = PrintArchive(
filename="concurrent.3mf",
file_path="archives/test/concurrent.3mf",
file_size=123,
content_hash="concurrent-charge",
status="completed",
cost=2.0,
created_by_id=None,
)
db_session.add_all([user, archive])
await db_session.commit()
archive_id = archive.id
user_id = user.id
# Mirrors on_print_complete's owner backfill immediately before it
# hands the still-open session to the billing service.
archive.created_by_id = user_id
original_flush = db_session.flush
original_rollback = db_session.rollback
async def conflict_on_transaction_flush(objects=None):
if any(isinstance(obj, WalletTransaction) for obj in db_session.new):
raise IntegrityError("duplicate print charge", {}, Exception("unique violation"))
return await original_flush(objects)
rollback = AsyncMock()
monkeypatch.setattr(db_session, "flush", conflict_on_transaction_flush)
monkeypatch.setattr(db_session, "rollback", rollback)
with pytest.raises(IntegrityError, match="unique violation"):
await apply_print_charge_for_archive(db_session, archive_id, print_run_id="concurrent-run")
rollback.assert_not_awaited()
# Restore normal session methods so the caller can commit its own work.
monkeypatch.setattr(db_session, "flush", original_flush)
monkeypatch.setattr(db_session, "rollback", original_rollback)
await db_session.commit()
db_session.expire_all()
persisted_archive = await db_session.get(PrintArchive, archive_id)
assert persisted_archive.created_by_id == user_id
assert (
await db_session.scalar(select(WalletTransaction).where(WalletTransaction.print_run_id == "concurrent-run"))
is None
)
@pytest.mark.asyncio
async def test_apply_print_charge_uses_print_run_id_and_cost_center_override(self, db_session):
await enable_billing(db_session)
@@ -70,3 +70,23 @@ class TestFinanceDefaults:
idempotent_changed = await ensure_user_finance_defaults(db_session, user)
assert idempotent_changed is False
@pytest.mark.asyncio
async def test_reactivates_existing_private_center(self, db_session):
user = User(username="carol", role="user", is_active=True)
db_session.add(user)
await db_session.flush()
center = CostCenter(
name=user.username,
is_active=False,
is_private=True,
owner_user_id=user.id,
)
db_session.add(center)
await db_session.commit()
changed = await ensure_user_finance_defaults(db_session, user)
await db_session.commit()
assert changed is True
assert center.is_active is True
@@ -97,6 +97,40 @@ class TestNotificationService:
mock_send.assert_not_called()
@pytest.mark.asyncio
async def test_billing_charge_failure_uses_provider_event(self, service, mock_provider, mock_db):
"""A failed charge is routed to providers that enabled the billing event."""
with (
patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
):
mock_get.return_value = [mock_provider]
mock_build.return_value = ("Billing Charge Failed", "The reservation was retained")
await service.on_billing_charge_failed(
printer_id=7,
printer_name="Printer B",
filename="paid-job.3mf",
archive_id=42,
error="unique constraint",
db=mock_db,
)
mock_get.assert_awaited_once_with(mock_db, "on_billing_charge_failed", 7)
mock_build.assert_awaited_once_with(
mock_db,
"billing_charge_failed",
{
"printer": "Printer B",
"filename": "paid-job",
"archive_id": "42",
"error": "unique constraint",
},
)
assert mock_send.await_args.args[4:7] == ("billing_charge_failed", 7, "Printer B")
assert mock_send.await_args.kwargs["force_immediate"] is True
# ========================================================================
# Tests for on_print_complete (status routing)
# ========================================================================
@@ -13,6 +13,22 @@ def library_file(tmp_path):
return SimpleNamespace(file_path=str(path), file_metadata={})
@pytest.mark.asyncio
async def test_archive_without_stored_cost_uses_server_default(monkeypatch):
archive = SimpleNamespace(
id=7,
file_path="missing.gcode.3mf",
plate_id=None,
filament_used_grams=100.0,
cost=None,
)
monkeypatch.setattr(print_cost_estimate, "_default_cost_per_kg", AsyncMock(return_value=20.0))
cost = await print_cost_estimate.estimate_queue_source_cost(SimpleNamespace(), archive=archive)
assert cost == 2.0
@pytest.mark.asyncio
async def test_library_estimate_uses_server_default_cost(monkeypatch, library_file):
monkeypatch.setattr(
@@ -0,0 +1,36 @@
"""Migration coverage for durable per-dispatch billing identities."""
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
import backend.app.models # noqa: F401 - populate Base.metadata
import backend.app.models.external_link # noqa: F401 - required by a legacy ALTER in run_migrations
import backend.app.models.print_log # noqa: F401 - required by a legacy ALTER in run_migrations
from backend.app.core.database import Base, run_migrations
@pytest.mark.asyncio
async def test_billing_run_columns_and_legacy_archive_index_are_migrated(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'billing-run.db'}")
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await run_migrations(conn)
queue_columns = {row[1] for row in (await conn.execute(text("PRAGMA table_info(print_queue)"))).all()}
archive_columns = {row[1] for row in (await conn.execute(text("PRAGMA table_info(print_archives)"))).all()}
notification_columns = {
row[1] for row in (await conn.execute(text("PRAGMA table_info(notification_providers)"))).all()
}
archive_index_sql = await conn.scalar(
text("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'uq_wallet_transactions_archive'")
)
assert "billing_run_id" in queue_columns
assert "billing_run_id" in archive_columns
assert "on_billing_charge_failed" in notification_columns
assert archive_index_sql is not None
assert "WHERE print_run_id IS NULL" in archive_index_sql
finally:
await engine.dispose()
@@ -18,7 +18,6 @@ EXPECTED_TABLES = {
"wallet_transactions",
"budget_reservations",
"cost_center_members",
"cost_center_invitations",
"user_wallets",
}
@@ -38,11 +37,22 @@ async def test_finance_tables_are_created_idempotently_on_sqlite():
"SELECT name FROM sqlite_master "
"WHERE type = 'table' AND name IN "
"('cost_centers', 'wallet_transactions', 'budget_reservations', "
"'cost_center_members', 'cost_center_invitations', 'user_wallets')"
"'cost_center_members', 'user_wallets')"
)
)
invitation_table = await conn.scalar(
text("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'cost_center_invitations'")
)
wallet_columns = await conn.execute(text("PRAGMA table_info(user_wallets)"))
transaction_columns = await conn.execute(text("PRAGMA table_info(wallet_transactions)"))
assert {row[0] for row in rows} == EXPECTED_TABLES
assert invitation_table is None
assert {row[1]: row[2] for row in wallet_columns}["balance"] == "NUMERIC(14,2)"
transaction_types = {row[1]: row[2] for row in transaction_columns}
assert transaction_types["amount"] == "NUMERIC(14,2)"
assert transaction_types["balance_after"] == "NUMERIC(14,2)"
assert transaction_types["is_voided"] == "BOOLEAN"
finally:
await engine.dispose()
@@ -114,6 +124,8 @@ async def test_postgres_finance_ddl_uses_postgres_types():
assert all("DATETIME" not in sql for sql in create_statements)
assert all("id SERIAL PRIMARY KEY" in sql for sql in create_statements)
assert "TIMESTAMP" in "\n".join(create_statements)
assert "NUMERIC(14,2)" in "\n".join(create_statements)
assert "is_voided BOOLEAN NOT NULL DEFAULT FALSE" in "\n".join(create_statements)
created_tables = {
sql.split("CREATE TABLE IF NOT EXISTS", 1)[1].split("(", 1)[0].strip() for sql in create_statements
+26 -4
View File
@@ -8,6 +8,7 @@ from backend.app import main as main_module
@pytest.fixture(autouse=True)
def clear_kill_switch_state():
main_module._kill_switch_setting_cache = None
main_module._unauthorized_print_kill_sent.clear()
main_module._kill_switch_notification_tasks.clear()
main_module._expected_prints.clear()
@@ -24,6 +25,7 @@ def clear_kill_switch_state():
main_module._active_prints.clear()
main_module._expected_print_registered_at.clear()
main_module._printer_reconciled_since_connect.clear()
main_module._kill_switch_setting_cache = None
def test_gcode_3mf_status_filename_matches_registered_expected_print():
@@ -51,8 +53,7 @@ async def test_unauthorized_active_print_triggers_stop(monkeypatch):
async def kill_switch_enabled(_db):
return True
async def unauthorized(*_args):
return False
unauthorized = AsyncMock(return_value=False)
monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
monkeypatch.setattr(
@@ -91,9 +92,11 @@ async def test_unauthorized_active_print_triggers_stop(monkeypatch):
gcode_file="foreign_job.gcode",
)
await main_module.on_printer_status_change(7, state)
await main_module.on_printer_status_change(7, state)
assert stop_calls == [7]
unauthorized.assert_awaited_once()
assert 7 in main_module._unauthorized_print_kill_sent
broadcast.assert_awaited_once_with(
{
@@ -139,8 +142,7 @@ async def test_bambuddy_authorized_print_is_not_stopped(monkeypatch):
async def fake_status(*args, **kwargs):
return None
async def kill_switch_enabled(_db):
return True
kill_switch_enabled = AsyncMock(return_value=True)
monkeypatch.setattr(main_module.printer_manager, "get_current_print_user", lambda printer_id: None)
monkeypatch.setattr(
@@ -179,6 +181,26 @@ async def test_bambuddy_authorized_print_is_not_stopped(monkeypatch):
assert stop_calls == []
assert 7 not in main_module._unauthorized_print_kill_sent
kill_switch_enabled.assert_not_awaited()
@pytest.mark.asyncio
async def test_kill_switch_setting_is_cached(monkeypatch):
kill_switch_enabled = AsyncMock(return_value=True)
class FakeSessionContext:
async def __aenter__(self):
return SimpleNamespace()
async def __aexit__(self, *_args):
return False
monkeypatch.setattr(main_module, "async_session", FakeSessionContext)
monkeypatch.setattr("backend.app.services.finance_budget.is_printer_kill_switch_enabled", kill_switch_enabled)
assert await main_module._is_printer_kill_switch_enabled_cached() is True
assert await main_module._is_printer_kill_switch_enabled_cached() is True
kill_switch_enabled.assert_awaited_once()
@pytest.mark.asyncio
@@ -150,6 +150,33 @@ describe('PrintModal', () => {
expect(submitButton).toBeInTheDocument();
});
it('explains and blocks printing when billing has no printable cost center', async () => {
server.use(
http.get('/api/v1/settings/', () => HttpResponse.json({
billing_enabled: true,
default_filament_cost: 25,
currency: 'USD',
})),
http.get('/api/v1/finance/cost-centers/mine', () => HttpResponse.json([])),
);
render(
<PrintModal
mode="create"
archiveId={1}
archiveName="Benchy"
initialSelectedPrinterIds={[1]}
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(await screen.findByRole('alert')).toHaveTextContent(
'No active cost center is available for printing. Ask an administrator to grant you print access.',
);
expect(screen.getByRole('button', { name: /^print$/i })).toBeDisabled();
});
it('has cancel button', () => {
render(
<PrintModal
@@ -192,4 +192,41 @@ describe('PrintModal billing payloads', () => {
expect(mockShowToast).toHaveBeenCalled();
});
});
it('labels a cost center without a budget as unlimited', async () => {
server.use(
http.get('/api/v1/finance/cost-centers/mine', () => {
return HttpResponse.json([
{
id: 42,
name: 'Unlimited Lab',
is_private: false,
owner_user_id: null,
is_active: true,
total_balance: -25,
total_budget: null,
monthly_budget: null,
budget_mode: 'none',
budget_limit: null,
budget_used: null,
budget_available: null,
can_print: true,
},
]);
}),
);
renderWithProviders(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Billing Print"
queueItem={mockQueueItem as never}
onClose={vi.fn()}
onSuccess={vi.fn()}
/>,
);
expect(await screen.findByText('Unlimited – no budget limit is set.')).not.toBeNull();
});
});
@@ -27,6 +27,10 @@ vi.mock('react-i18next', () => ({
const { printer, filename } = options as { printer: string; filename: string };
return `Billing kill switch stopped ${filename} on ${printer}`;
}
if (key === 'printers.toast.billingChargeFailed' && options) {
const { printer, filename } = options as { printer: string; filename: string };
return `Billing failed for ${filename} on ${printer}. The budget reservation was retained; check the server logs.`;
}
return key;
},
i18n: {},
@@ -516,6 +520,30 @@ describe('useWebSocket hook', () => {
expect(toast.parentElement).toHaveClass('bg-red-500/10');
});
it('shows an error toast when a completed print could not be charged', async () => {
const { useWebSocket } = await import('../../hooks/useWebSocket');
renderHook(() => useWebSocket(), {
wrapper: createWrapper(queryClient),
});
const ws = await waitForWs();
act(() => {
ws.open();
ws.simulateMessage({
type: 'billing_charge_failed',
printer_id: 7,
printer_name: 'Printer B',
filename: 'paid-job.3mf',
});
});
const toast = screen.getByText(
'Billing failed for paid-job.3mf on Printer B. The budget reservation was retained; check the server logs.',
);
expect(toast.parentElement).toHaveClass('bg-red-500/10');
});
it('handles spool_assignment_verified messages (success and failure) without error', async () => {
const { useWebSocket } = await import('../../hooks/useWebSocket');
+3
View File
@@ -2694,6 +2694,7 @@ export interface NotificationProvider {
on_print_stopped: boolean;
on_print_progress: boolean;
on_print_missing_spool_assignment: boolean;
on_billing_charge_failed: boolean;
// Printer status events
on_printer_offline: boolean;
on_printer_error: boolean;
@@ -2755,6 +2756,7 @@ export interface NotificationProviderCreate {
on_print_stopped?: boolean;
on_print_progress?: boolean;
on_print_missing_spool_assignment?: boolean;
on_billing_charge_failed?: boolean;
// Printer status events
on_printer_offline?: boolean;
on_printer_error?: boolean;
@@ -2809,6 +2811,7 @@ export interface NotificationProviderUpdate {
on_print_stopped?: boolean;
on_print_progress?: boolean;
on_print_missing_spool_assignment?: boolean;
on_billing_charge_failed?: boolean;
// Printer status events
on_printer_offline?: boolean;
on_printer_error?: boolean;
@@ -36,6 +36,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
const [onPrintFailed, setOnPrintFailed] = useState(provider?.on_print_failed ?? true);
const [onPrintStopped, setOnPrintStopped] = useState(provider?.on_print_stopped ?? true);
const [onPrintProgress, setOnPrintProgress] = useState(provider?.on_print_progress ?? false);
const [onBillingChargeFailed, setOnBillingChargeFailed] = useState(provider?.on_billing_charge_failed ?? true);
const [onPrinterOffline, setOnPrinterOffline] = useState(provider?.on_printer_offline ?? false);
const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false);
const [onAiFailureDetection, setOnAiFailureDetection] = useState(provider?.on_ai_failure_detection ?? false);
@@ -192,6 +193,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
on_print_failed: onPrintFailed,
on_print_stopped: onPrintStopped,
on_print_progress: onPrintProgress,
on_billing_charge_failed: onBillingChargeFailed,
on_printer_offline: onPrinterOffline,
on_printer_error: onPrinterError,
on_ai_failure_detection: onAiFailureDetection,
@@ -605,6 +607,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
</div>
<Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">{t('notifications.billingChargeFailedLabel')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.billingChargeFailedDescription')}</span>
</div>
<Toggle checked={onBillingChargeFailed} onChange={setOnBillingChargeFailed} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">{t('notifications.plateClearRequired')}</span>
@@ -692,6 +701,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
if (onPrintFailed) enabledEvents.push({ key: 'on_print_failed', label: t('notifications.failed') });
if (onPrintStopped) enabledEvents.push({ key: 'on_print_stopped', label: t('notifications.stopped') });
if (onPrintProgress) enabledEvents.push({ key: 'on_print_progress', label: t('notifications.progress') });
if (onBillingChargeFailed) enabledEvents.push({ key: 'on_billing_charge_failed', label: t('notifications.billingChargeFailedLabel') });
if (onPlateClearRequired) enabledEvents.push({ key: 'on_plate_clear_required', label: t('notifications.plateClearRequired') });
if (onBedCooled) enabledEvents.push({ key: 'on_bed_cooled', label: t('notifications.bedCooled') });
if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
@@ -132,6 +132,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{provider.on_print_stopped && (
<span className="px-2 py-0.5 bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-400 text-xs rounded">{t('notifications.stopped')}</span>
)}
{provider.on_billing_charge_failed && (
<span className="px-2 py-0.5 bg-red-100 dark:bg-red-600/20 text-red-700 dark:text-red-300 text-xs rounded">{t('notifications.billingChargeFailedLabel')}</span>
)}
{provider.on_print_progress && (
<span className="px-2 py-0.5 bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400 text-xs rounded">{t('notifications.progress')}</span>
)}
@@ -354,6 +357,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">{t('notifications.billingChargeFailedLabel')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.billingChargeFailedDescription')}</p>
</div>
<Toggle
checked={provider.on_billing_charge_failed ?? true}
onChange={(checked) => updateMutation.mutate({ on_billing_charge_failed: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">{t('notifications.progressMilestones')}</p>
@@ -15,6 +15,7 @@ export function CostCenterSelect({
const { t } = useTranslation();
if (costCenters.length === 0) return null;
const selectedCostCenter = costCenters.find((center) => center.id === selectedCostCenterId);
return (
<div className="space-y-1">
@@ -33,6 +34,11 @@ export function CostCenterSelect({
</option>
))}
</select>
{selectedCostCenter?.budget_mode === 'none' && (
<p className="text-xs text-bambu-gray">
{t('printModal.unlimitedNoBudget', 'Unlimited – no budget limit is set.')}
</p>
)}
</div>
);
}
+23 -1
View File
@@ -315,7 +315,7 @@ export function PrintModal({
queryFn: api.getPrinters,
});
const { data: myCostCenters } = useQuery({
const { data: myCostCenters, isLoading: loadingCostCenters } = useQuery({
queryKey: ['finance', 'cost-centers', 'mine'],
queryFn: api.getMyCostCenters,
enabled: !!user && billingEnabled,
@@ -824,6 +824,11 @@ export function PrintModal({
const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => {
e?.preventDefault();
if (billingEnabled && selectedCostCenter == null) {
showToast(t('printModal.noPrintableCostCenters'), 'error');
return;
}
if (
!options?.skipFilamentCheck &&
!settings?.disable_filament_warnings &&
@@ -1277,6 +1282,11 @@ export function PrintModal({
const canSubmit = useMemo(() => {
if (isPending) return false;
// Billing requires a server-authorized cost center. Wait for the query and
// keep submission disabled when the user has no printable center, rather
// than letting the API fail with an unexplained 400.
if (billingEnabled && (loadingCostCenters || selectedCostCenter == null)) return false;
// Need valid printer/model selection
if (assignmentMode === 'printer' && selectedPrinters.length === 0) return false;
// Both are about the single-model case. A cross-model job has no one target
@@ -1313,6 +1323,9 @@ export function PrintModal({
perPlateReqsFailed,
printerStatusLoading,
isCrossModel,
billingEnabled,
loadingCostCenters,
selectedCostCenter,
]);
// Quantity only applies for single-printer or model-based assignment (not multi-printer)
@@ -1714,6 +1727,15 @@ export function PrintModal({
onChange={setSelectedCostCenterId}
/>
)}
{billingEnabled && !loadingCostCenters && printableCostCenters.length === 0 && (
<div
role="alert"
className="p-3 bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-500/50 rounded-lg text-sm text-yellow-800 dark:text-yellow-300 flex items-start gap-2"
>
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
{t('printModal.noPrintableCostCenters')}
</div>
)}
{/* Quantity — create multiple copies (batch). Hidden for multi-printer
selection, and for multi-plate files where the per-plate steppers
+7
View File
@@ -346,6 +346,13 @@ export function useWebSocket() {
break;
}
case 'billing_charge_failed': {
const printer = message.printer_name || `Printer ${message.printer_id ?? '?'}`;
const filename = message.filename || t('common.unknown');
showToast(t('printers.toast.billingChargeFailed', { printer, filename }), 'error');
break;
}
case 'archive_created':
debouncedInvalidate('archives');
debouncedInvalidate('archiveStats');
+9 -2
View File
@@ -168,7 +168,9 @@ export default {
costCentersHint: 'Budgetgrenzen prüfen und Kosten im Blick behalten',
noCostCenters: 'Keine Kostenstellen gefunden.',
owner: 'Besitzer',
balance: 'Saldo',
balance: 'Kontostand',
unlimited: 'Unbegrenzt',
budgetPolicyHint: 'Kontostände dienen der Kostenübersicht. Drucke werden ausschließlich durch das Budget der ausgewählten Kostenstelle begrenzt; ohne Budget kann unbegrenzt gedruckt werden.',
budget: 'Budget',
shared: 'Geteilt',
cannotEditPrivateCostCenter: 'Private Kostenstellen können hier nicht bearbeitet werden',
@@ -182,7 +184,7 @@ export default {
noTransactions: 'Keine Transaktionen verfügbar.',
noTransactionsForFilter: 'Keine Transaktionen entsprechen den ausgewählten Filtern.',
costCenter: 'Kostenstelle',
balanceAfter: 'Saldo nachher',
balanceAfter: 'Kontostand danach',
userWithId: 'Benutzer #{{id}}',
partial: 'Teilweise',
editTransaction: 'Transaktion bearbeiten',
@@ -448,6 +450,7 @@ export default {
printerDeleted: 'Drucker gelöscht',
missingSpoolAssignment: 'Druck gestartet auf {{printer}}. Fehlende Spulenzuordnung für: {{slots}}',
killSwitchTriggered: 'Der Billing-Kill-Switch hat einen nicht autorisierten Druck auf {{printer}} gestoppt: {{filename}}',
billingChargeFailed: 'Die Abrechnung für {{filename}} auf {{printer}} ist fehlgeschlagen. Die Budgetreservierung bleibt bestehen; prüfe die Serverprotokolle.',
assignmentVerified: 'Filament in Slot {{slot}} geladen ({{printer}})',
assignmentVerifiedNoKprofile: 'Slot {{slot}} auf {{printer}} geladen, aber das Fluss-Kalibrierungsprofil (K-Profil) wurde nicht übernommen',
assignmentNotConfirmed: 'Zuordnung für Slot {{slot}} auf {{printer}} konnte nicht bestätigt werden – bitte den AMS-Slot prüfen',
@@ -4905,6 +4908,8 @@ export default {
staggerToPrinters: 'Gestaffelt an {{count}} Drucker senden',
gcodeInjection: 'Auto-Print G-code einfügen',
insufficientBudget: 'Budget nicht ausreichend',
unlimitedNoBudget: 'Unbegrenzt – es ist kein Budgetlimit festgelegt.',
noPrintableCostCenters: 'Es ist keine aktive Kostenstelle zum Drucken verfügbar. Bitte einen Administrator, dir Druckzugriff zu gewähren.',
},
// Backup
@@ -5897,6 +5902,8 @@ export default {
firstLayerCompleteLabel: 'Erste Schicht fertig',
firstLayerCompleteDescription: 'Benachrichtigung mit Foto nach erster Schicht',
missingSpoolAssignmentLabel: 'Fehlende Spulenzuordnung',
billingChargeFailedLabel: 'Abrechnungsfehler',
billingChargeFailedDescription: 'Benachrichtigen, wenn Druckkosten nicht verbucht werden konnten',
missingSpoolAssignmentDescription: 'Benachrichtigen, wenn ein Druck startet und benoetigte Schaechte keine zugeordnete Spule haben',
printFailed: 'Druck fehlgeschlagen',
printStopped: 'Druck gestoppt',
+8 -1
View File
@@ -168,7 +168,9 @@ export default {
costCentersHint: 'Review budget limits and keep costs under control',
noCostCenters: 'No cost centers found.',
owner: 'Owner',
balance: 'Balance',
balance: 'Account balance',
unlimited: 'Unlimited',
budgetPolicyHint: 'Account balances track costs. Printing is limited only by the selected cost center budget; without a budget, printing is unlimited.',
budget: 'Budget',
shared: 'Shared',
cannotEditPrivateCostCenter: 'Private cost centers cannot be edited here',
@@ -451,6 +453,7 @@ export default {
printerDeleted: 'Printer deleted',
missingSpoolAssignment: 'Print started on {{printer}}. Missing spool assignment for: {{slots}}',
killSwitchTriggered: 'The billing kill switch stopped an unauthorized print on {{printer}}: {{filename}}',
billingChargeFailed: 'Billing failed for {{filename}} on {{printer}}. The budget reservation was retained; check the server logs.',
assignmentVerified: 'Filament loaded on slot {{slot}} ({{printer}})',
assignmentVerifiedNoKprofile: 'Slot {{slot}} on {{printer}} loaded, but the flow calibration (K-profile) was not applied',
assignmentNotConfirmed: 'Could not confirm the assignment for slot {{slot}} on {{printer}} — check the AMS slot',
@@ -4948,6 +4951,8 @@ export default {
staggerToPrinters: 'Stagger to {{count}} printers',
gcodeInjection: 'Inject auto-print G-code',
insufficientBudget: 'Insufficient Budget',
unlimitedNoBudget: 'Unlimited – no budget limit is set.',
noPrintableCostCenters: 'No active cost center is available for printing. Ask an administrator to grant you print access.',
},
// Backup
@@ -5946,6 +5951,8 @@ export default {
firstLayerCompleteLabel: 'First Layer Complete',
firstLayerCompleteDescription: 'Notify with snapshot when first layer finishes',
missingSpoolAssignmentLabel: 'Missing Spool Assignment',
billingChargeFailedLabel: 'Billing Charge Failed',
billingChargeFailedDescription: 'Notify when print costs could not be recorded',
missingSpoolAssignmentDescription: 'Notify when print starts and required trays have no assigned spool',
printFailed: 'Print Failed',
printStopped: 'Print Stopped',
+8 -1
View File
@@ -168,7 +168,9 @@ export default {
costCentersHint: 'Revisa los límites presupuestarios y mantén los costes bajo control',
noCostCenters: 'No se encontraron centros de costes.',
owner: 'Propietario',
balance: 'Saldo',
balance: 'Saldo de cuenta',
unlimited: 'Ilimitado',
budgetPolicyHint: 'Los saldos de cuenta registran los costes. La impresión solo está limitada por el presupuesto del centro de costes seleccionado; sin presupuesto, es ilimitada.',
budget: 'Presupuesto',
shared: 'Compartido',
cannotEditPrivateCostCenter: 'Los centros de costes privados no se pueden editar aquí',
@@ -448,6 +450,7 @@ export default {
printerDeleted: 'Impresora eliminada',
missingSpoolAssignment: 'Impresión iniciada en {{printer}}. Falta la asignación de bobina para: {{slots}}',
killSwitchTriggered: 'El interruptor de seguridad de facturación detuvo una impresión no autorizada en {{printer}}: {{filename}}',
billingChargeFailed: 'La facturación de {{filename}} en {{printer}} ha fallado. Se conservó la reserva de presupuesto; revisa los registros del servidor.',
assignmentVerified: 'Filamento cargado en la ranura {{slot}} ({{printer}})',
assignmentVerifiedNoKprofile: 'Ranura {{slot}} en {{printer}} cargada, pero no se aplicó el perfil de calibración de flujo (perfil K)',
assignmentNotConfirmed: 'No se pudo confirmar la asignación de la ranura {{slot}} en {{printer}}: revisa la ranura AMS',
@@ -4912,6 +4915,8 @@ export default {
staggerToPrinters: 'Escalonar en {{count}} impresoras',
gcodeInjection: 'Inyectar G-code de impresión automática',
insufficientBudget: 'Presupuesto insuficiente',
unlimitedNoBudget: 'Ilimitado: no se ha establecido ningún límite de presupuesto.',
noPrintableCostCenters: 'No hay ningún centro de costes activo disponible para imprimir. Pide a un administrador que te conceda acceso de impresión.',
},
// Backup
@@ -5905,6 +5910,8 @@ export default {
firstLayerCompleteLabel: 'Primera capa completada',
firstLayerCompleteDescription: 'Notificar con una captura cuando termina la primera capa',
missingSpoolAssignmentLabel: 'Falta la asignación de bobina',
billingChargeFailedLabel: 'Error de facturación',
billingChargeFailedDescription: 'Notificar cuando no se puedan registrar los costes de impresión',
missingSpoolAssignmentDescription: 'Notificar cuando la impresión comienza y las bandejas necesarias no tienen ninguna bobina asignada',
printFailed: 'Impresión fallida',
printStopped: 'Impresión detenida',
+8 -1
View File
@@ -168,7 +168,9 @@ export default {
costCentersHint: 'Vérifier les limites budgétaires et garder les coûts sous contrôle',
noCostCenters: 'Aucun centre de coûts trouvé.',
owner: 'Propriétaire',
balance: 'Solde',
balance: 'Solde du compte',
unlimited: 'Illimité',
budgetPolicyHint: "Les soldes de compte servent au suivi des coûts. L’impression est limitée uniquement par le budget du centre de coûts sélectionné ; sans budget, elle est illimitée.",
budget: 'Budget',
shared: 'Partagé',
cannotEditPrivateCostCenter: 'Les centres de coûts privés ne peuvent pas être modifiés ici',
@@ -448,6 +450,7 @@ export default {
printerDeleted: 'Imprimante supprimée',
missingSpoolAssignment: 'Impression démarrée sur {{printer}}. Attribution de bobine manquante pour : {{slots}}',
killSwitchTriggered: 'Le coupe-circuit de facturation a arrêté une impression non autorisée sur {{printer}} : {{filename}}',
billingChargeFailed: 'La facturation de {{filename}} sur {{printer}} a échoué. La réservation budgétaire a été conservée ; consultez les journaux du serveur.',
assignmentVerified: 'Filament chargé dans l\'emplacement {{slot}} ({{printer}})',
assignmentVerifiedNoKprofile: 'Emplacement {{slot}} sur {{printer}} chargé, mais le profil de calibration de débit (profil K) n\'a pas été appliqué',
assignmentNotConfirmed: 'Impossible de confirmer l\'attribution de l\'emplacement {{slot}} sur {{printer}} — vérifiez l\'emplacement AMS',
@@ -4894,6 +4897,8 @@ export default {
staggerToPrinters: 'Échelonner sur {{count}} imprimantes',
gcodeInjection: 'Injecter le G-code auto-impression',
insufficientBudget: 'Budget insuffisant',
unlimitedNoBudget: 'Illimité – aucune limite de budget n’est définie.',
noPrintableCostCenters: 'Aucun centre de coûts actif n’est disponible pour l’impression. Demandez à un administrateur de vous accorder l’accès à l’impression.',
},
// Backup
@@ -5887,6 +5892,8 @@ export default {
firstLayerCompleteLabel: 'Première couche terminée',
firstLayerCompleteDescription: 'Notification avec photo après la première couche',
missingSpoolAssignmentLabel: 'Affectation de bobine manquante',
billingChargeFailedLabel: 'Échec de facturation',
billingChargeFailedDescription: "Notifier lorsque les coûts d'impression ne peuvent pas être enregistrés",
missingSpoolAssignmentDescription: 'Notifier quand une impression démarre et que des bacs requis n\'ont pas de bobine assignée',
printFailed: 'Impression échouée',
printStopped: 'Impression arrêtée',
+8 -1
View File
@@ -168,7 +168,9 @@ export default {
costCentersHint: 'Controlla i limiti di budget e mantieni i costi sotto controllo',
noCostCenters: 'Nessun centro di costo trovato.',
owner: 'Proprietario',
balance: 'Saldo',
balance: 'Saldo conto',
unlimited: 'Illimitato',
budgetPolicyHint: 'I saldi dei conti registrano i costi. La stampa è limitata solo dal budget del centro di costo selezionato; senza budget è illimitata.',
budget: 'Budget',
shared: 'Condiviso',
cannotEditPrivateCostCenter: 'I centri di costo privati non possono essere modificati qui',
@@ -448,6 +450,7 @@ export default {
printerDeleted: 'Stampante eliminata',
missingSpoolAssignment: 'Stampa avviata su {{printer}}. Mancano assegnazioni bobina per: {{slots}}',
killSwitchTriggered: 'L’interruttore di sicurezza della fatturazione ha arrestato una stampa non autorizzata su {{printer}}: {{filename}}',
billingChargeFailed: 'La fatturazione di {{filename}} su {{printer}} non è riuscita. La prenotazione del budget è stata mantenuta; controlla i log del server.',
assignmentVerified: 'Filamento caricato nello slot {{slot}} ({{printer}})',
assignmentVerifiedNoKprofile: 'Slot {{slot}} su {{printer}} caricato, ma il profilo di calibrazione del flusso (profilo K) non è stato applicato',
assignmentNotConfirmed: 'Impossibile confermare l\'assegnazione dello slot {{slot}} su {{printer}} — controlla lo slot AMS',
@@ -4893,6 +4896,8 @@ export default {
staggerToPrinters: 'Scagliona a {{count}} stampanti',
gcodeInjection: 'Inietta G-code auto-stampa',
insufficientBudget: 'Budget insufficiente',
unlimitedNoBudget: 'Illimitato – non è impostato alcun limite di budget.',
noPrintableCostCenters: 'Non è disponibile alcun centro di costo attivo per la stampa. Chiedi a un amministratore di concederti l’accesso alla stampa.',
},
// Backup
@@ -5886,6 +5891,8 @@ export default {
firstLayerCompleteLabel: 'Primo strato completato',
firstLayerCompleteDescription: 'Notifica con foto al termine del primo strato',
missingSpoolAssignmentLabel: 'Assegnazione bobina mancante',
billingChargeFailedLabel: 'Errore di addebito',
billingChargeFailedDescription: 'Notifica quando non è possibile registrare i costi di stampa',
missingSpoolAssignmentDescription: 'Notifica quando una stampa parte e i vassoi richiesti non hanno una bobina assegnata',
printFailed: 'Stampa fallita',
printStopped: 'Stampa interrotta',
+8 -1
View File
@@ -168,7 +168,9 @@ export default {
costCentersHint: '予算限度額を確認し、コストを管理下に置きます',
noCostCenters: 'コストセンターが見つかりません。',
owner: '所有者',
balance: '残高',
balance: '口座残高',
unlimited: '無制限',
budgetPolicyHint: '口座残高はコストの記録に使用されます。印刷は選択したコストセンターの予算によってのみ制限され、予算がなければ無制限です。',
budget: '予算',
shared: '共有',
cannotEditPrivateCostCenter: 'プライベートコストセンターはここで編集できません',
@@ -447,6 +449,7 @@ export default {
printerDeleted: 'プリンターを削除しました',
missingSpoolAssignment: '{{printer}}で印刷を開始しました。以下のスプール割り当てがありません: {{slots}}',
killSwitchTriggered: '課金キルスイッチが{{printer}}で未承認の印刷を停止しました:{{filename}}',
billingChargeFailed: '{{printer}} の {{filename}} を課金できませんでした。予算予約は保持されています。サーバーログを確認してください。',
assignmentVerified: 'スロット{{slot}}にフィラメントを読み込みました({{printer}})',
assignmentVerifiedNoKprofile: '{{printer}}のスロット{{slot}}を読み込みましたが、フロー校正プロファイル(Kプロファイル)は適用されませんでした',
assignmentNotConfirmed: '{{printer}}のスロット{{slot}}の割り当てを確認できませんでした。AMSスロットを確認してください',
@@ -4905,6 +4908,8 @@ export default {
staggerToPrinters: '{{count}}台のプリンターに段階的に送信',
gcodeInjection: '自動印刷G-codeを挿入',
insufficientBudget: '予算が不足しています',
unlimitedNoBudget: '無制限 – 予算上限は設定されていません。',
noPrintableCostCenters: '印刷に利用できる有効なコストセンターがありません。管理者に印刷アクセスの付与を依頼してください。',
},
// Backup
@@ -5898,6 +5903,8 @@ export default {
firstLayerCompleteLabel: '第1層完了',
firstLayerCompleteDescription: '第1層完了時にスナップショット付きで通知',
missingSpoolAssignmentLabel: 'スプール割り当て不足',
billingChargeFailedLabel: '請求処理エラー',
billingChargeFailedDescription: '印刷コストを記録できなかった場合に通知します',
missingSpoolAssignmentDescription: '印刷開始時に必要トレイへスプールが未割り当ての場合に通知',
printFailed: '印刷失敗',
printStopped: '印刷停止',
+9 -2
View File
@@ -164,7 +164,9 @@ export default {
costCentersHint: '예산 한도를 검토하고 비용을 관리하세요',
noCostCenters: '비용 센터를 찾을 수 없습니다.',
owner: '소유자',
balance: '잔액',
balance: '계정 잔액',
unlimited: '무제한',
budgetPolicyHint: '계정 잔액은 비용을 기록합니다. 인쇄는 선택한 비용 센터의 예산으로만 제한되며, 예산이 없으면 무제한입니다.',
budget: '예산',
shared: '공유',
cannotEditPrivateCostCenter: '비공개 비용 센터는 여기에서 편집할 수 없습니다',
@@ -422,6 +424,7 @@ export default {
printerDeleted: '프린터가 삭제되었습니다',
missingSpoolAssignment: '{{printer}}에서 인쇄가 시작되었습니다. 슬롯 할당 누락: {{slots}}',
killSwitchTriggered: '결제 킬 스위치가 {{printer}}에서 승인되지 않은 인쇄를 중지했습니다: {{filename}}',
billingChargeFailed: '{{printer}}의 {{filename}} 결제에 실패했습니다. 예산 예약은 유지되었습니다. 서버 로그를 확인하세요.',
assignmentVerified: '슬롯 {{slot}}에 필라멘트가 로드되었습니다 ({{printer}})',
assignmentVerifiedNoKprofile: '{{printer}}의 슬롯 {{slot}}이(가) 로드되었지만 유량 보정 프로파일(K 프로파일)이 적용되지 않았습니다',
assignmentNotConfirmed: '{{printer}}의 슬롯 {{slot}} 할당을 확인할 수 없습니다. AMS 슬롯을 확인하세요',
@@ -4674,7 +4677,9 @@ export default {
staggerTotal: '합계: {{minutes}}분',
staggerToPrinters: '{{count}}대 프린터에 분산',
gcodeInjection: '자동 인쇄 G-code 삽입',
insufficientBudget: '예산 부족'
insufficientBudget: '예산 부족',
unlimitedNoBudget: '무제한 – 예산 한도가 설정되지 않았습니다.',
noPrintableCostCenters: '인쇄에 사용할 수 있는 활성 비용 센터가 없습니다. 관리자에게 인쇄 권한을 요청하세요.',
},
backup: {
includesEncryptionKey: '로컬 백업에는 MFA 암호화 키 파일(DATA_DIR/.mfa_encryption_key)이 포함되어 백업 ZIP이 자체 완결됩니다. ZIP 파일을 민감하게 취급하세요 — 파일을 가진 누구나 내부에 저장된 OIDC 클라이언트 비밀과 TOTP 비밀을 복호화할 수 있습니다.',
@@ -5616,6 +5621,8 @@ export default {
firstLayerCompleteLabel: '첫 번째 레이어 완료',
firstLayerCompleteDescription: '첫 번째 레이어 완료 시 스냅샷과 함께 알림',
missingSpoolAssignmentLabel: '스풀 할당 누락',
billingChargeFailedLabel: '결제 처리 실패',
billingChargeFailedDescription: '인쇄 비용을 기록하지 못한 경우 알림',
missingSpoolAssignmentDescription: '인쇄 시작 시 필요한 트레이에 할당된 스풀이 없을 때 알림',
printFailed: '인쇄 실패',
printStopped: '인쇄 중지됨',
+8 -1
View File
@@ -168,7 +168,9 @@ export default {
costCentersHint: 'Revise os limites de orçamento e mantenha os custos sob controle',
noCostCenters: 'Nenhum centro de custo encontrado.',
owner: 'Proprietário',
balance: 'Saldo',
balance: 'Saldo da conta',
unlimited: 'Ilimitado',
budgetPolicyHint: 'Os saldos das contas registram os custos. A impressão é limitada apenas pelo orçamento do centro de custo selecionado; sem orçamento, é ilimitada.',
budget: 'Orçamento',
shared: 'Compartilhado',
cannotEditPrivateCostCenter: 'Centros de custo privados não podem ser editados aqui',
@@ -448,6 +450,7 @@ export default {
printerDeleted: 'Impressora excluída',
missingSpoolAssignment: 'Impressão iniciada em {{printer}}. Atribuição de bobina ausente para: {{slots}}',
killSwitchTriggered: 'O bloqueio de segurança de cobrança interrompeu uma impressão não autorizada em {{printer}}: {{filename}}',
billingChargeFailed: 'A cobrança de {{filename}} em {{printer}} falhou. A reserva do orçamento foi mantida; verifique os logs do servidor.',
assignmentVerified: 'Filamento carregado no compartimento {{slot}} ({{printer}})',
assignmentVerifiedNoKprofile: 'Compartimento {{slot}} em {{printer}} carregado, mas o perfil de calibração de fluxo (perfil K) não foi aplicado',
assignmentNotConfirmed: 'Não foi possível confirmar a atribuição do compartimento {{slot}} em {{printer}} — verifique o compartimento AMS',
@@ -4893,6 +4896,8 @@ export default {
staggerToPrinters: 'Escalonar para {{count}} impressoras',
gcodeInjection: 'Injetar G-code de auto-impressão',
insufficientBudget: 'Orçamento insuficiente',
unlimitedNoBudget: 'Ilimitado – nenhum limite de orçamento foi definido.',
noPrintableCostCenters: 'Não há nenhum centro de custo ativo disponível para impressão. Peça a um administrador para conceder acesso de impressão.',
},
// Backup
@@ -5886,6 +5891,8 @@ export default {
firstLayerCompleteLabel: 'Primeira camada concluída',
firstLayerCompleteDescription: 'Notificar com foto quando a primeira camada terminar',
missingSpoolAssignmentLabel: 'Atribuição de bobina ausente',
billingChargeFailedLabel: 'Falha na cobrança',
billingChargeFailedDescription: 'Notificar quando os custos de impressão não puderem ser registrados',
missingSpoolAssignmentDescription: 'Notificar quando a impressão iniciar e bandejas necessárias não tiverem bobina atribuída',
printFailed: 'Impressão Falhou',
printStopped: 'Impressão Parada',
+8 -1
View File
@@ -164,7 +164,9 @@ export default {
costCentersHint: "Проверяйте бюджетные лимиты и контролируйте расходы",
noCostCenters: "Центры затрат не найдены.",
owner: "Владелец",
balance: "Баланс",
balance: "Баланс счёта",
unlimited: "Без ограничений",
budgetPolicyHint: "Баланс счёта используется для учёта затрат. Печать ограничивается только бюджетом выбранного центра затрат; без бюджета ограничений нет.",
budget: "Бюджет",
shared: "Общий",
cannotEditPrivateCostCenter: "Личные центры затрат нельзя редактировать здесь",
@@ -427,6 +429,7 @@ export default {
printerDeleted: "Принтер удалён",
missingSpoolAssignment: "На принтере {{printer}} началась печать. Не назначены катушки для слотов: {{slots}}",
killSwitchTriggered: 'Аварийный выключатель биллинга остановил несанкционированную печать на {{printer}}: {{filename}}',
billingChargeFailed: 'Не удалось начислить стоимость {{filename}} на {{printer}}. Резерв бюджета сохранён; проверьте журналы сервера.',
assignmentVerified: "Филамент загружен в слот {{slot}} ({{printer}})",
assignmentVerifiedNoKprofile: "Слот {{slot}} на {{printer}} загружен, но профиль калибровки потока (K-профиль) не применён",
assignmentNotConfirmed: "Не удалось подтвердить назначение слота {{slot}} на {{printer}} — проверьте слот AMS",
@@ -4664,6 +4667,8 @@ export default {
staggerToPrinters: "Распределить запуск для {{count}} принтеров",
gcodeInjection: "Добавить G-code автозапуска",
insufficientBudget: "Недостаточно бюджета",
unlimitedNoBudget: "Без ограничений — лимит бюджета не задан.",
noPrintableCostCenters: "Нет активного центра затрат, доступного для печати. Попросите администратора предоставить вам доступ к печати.",
},
backup: {
includesEncryptionKey: "Локальные резервные копии включают файл ключа шифрования MFA (DATA_DIR/.mfa_encryption_key), поэтому ZIP-архив является самодостаточным. Считайте этот ZIP конфиденциальным: любой, у кого есть файл, сможет расшифровать сохранённые в нём секреты клиента OIDC и TOTP.",
@@ -5603,6 +5608,8 @@ export default {
firstLayerCompleteLabel: "Первый слой завершён",
firstLayerCompleteDescription: "Уведомить со снимком после завершения первого слоя",
missingSpoolAssignmentLabel: "Катушка не назначена",
billingChargeFailedLabel: 'Ошибка списания',
billingChargeFailedDescription: 'Уведомлять, если не удалось учесть стоимость печати',
missingSpoolAssignmentDescription: "Уведомить при запуске печати, если для необходимых слотов не назначены катушки",
printFailed: "Ошибка печати",
printStopped: "Печать остановлена",
+8 -1
View File
@@ -168,7 +168,9 @@ export default {
costCentersHint: 'Bütçe sınırlarını gözden geçirin ve maliyetleri kontrol altında tutun',
noCostCenters: 'Masraf merkezi bulunamadı.',
owner: 'Sahip',
balance: 'Bakiye',
balance: 'Hesap bakiyesi',
unlimited: 'Sınırsız',
budgetPolicyHint: 'Hesap bakiyeleri maliyetleri kaydeder. Baskı yalnızca seçilen masraf merkezinin bütçesiyle sınırlıdır; bütçe yoksa sınırsızdır.',
budget: 'Bütçe',
shared: 'Paylaşılan',
cannotEditPrivateCostCenter: 'Özel masraf merkezleri burada düzenlenemez',
@@ -448,6 +450,7 @@ export default {
printerDeleted: 'Yazıcı silindi',
missingSpoolAssignment: '{{printer}} üzerinde baskı başladı. Şunlar için eksik makara ataması: {{slots}}',
killSwitchTriggered: 'Faturalandırma durdurma anahtarı {{printer}} üzerindeki yetkisiz baskıyı durdurdu: {{filename}}',
billingChargeFailed: '{{printer}} üzerindeki {{filename}} için faturalandırma başarısız oldu. Bütçe rezervasyonu korundu; sunucu günlüklerini kontrol edin.',
assignmentVerified: '{{slot}} yuvasına filament yüklendi ({{printer}})',
assignmentVerifiedNoKprofile: '{{printer}} üzerindeki {{slot}} yuvası yüklendi, ancak akış kalibrasyonu profili (K profili) uygulanmadı',
assignmentNotConfirmed: '{{printer}} üzerindeki {{slot}} yuvası ataması doğrulanamadı — AMS yuvasını kontrol edin',
@@ -4882,6 +4885,8 @@ export default {
staggerToPrinters: '{{count}} yazıcıya kademelendir',
gcodeInjection: 'Otomatik baskı G-kodu enjekte et',
insufficientBudget: 'Yetersiz bütçe',
unlimitedNoBudget: 'Sınırsız – bütçe limiti belirlenmemiş.',
noPrintableCostCenters: 'Yazdırma için kullanılabilir etkin bir masraf merkezi yok. Bir yöneticiden yazdırma erişimi vermesini isteyin.',
},
// Yedekleme
@@ -5853,6 +5858,8 @@ export default {
firstLayerCompleteLabel: 'İlk Katman Tamamlandı',
firstLayerCompleteDescription: 'İlk katman bittiğinde anlık görüntüyle bildir',
missingSpoolAssignmentLabel: 'Eksik Makara Ataması',
billingChargeFailedLabel: 'Ücretlendirme hatası',
billingChargeFailedDescription: 'Baskı maliyetleri kaydedilemediğinde bildirim gönder',
missingSpoolAssignmentDescription: 'Baskı başladığında ve gerekli tepsilerin atanmış makarası olmadığında bildir',
printFailed: 'Baskı Başarısız',
printStopped: 'Baskı Durduruldu',
+8 -1
View File
@@ -168,7 +168,9 @@ export default {
costCentersHint: "Переглядайте бюджетні ліміти та контролюйте витрати",
noCostCenters: "Центрів витрат не знайдено.",
owner: "Власник",
balance: "Баланс",
balance: "Баланс рахунку",
unlimited: "Без обмежень",
budgetPolicyHint: "Баланс рахунку використовується для обліку витрат. Друк обмежується лише бюджетом вибраного центру витрат; без бюджету обмежень немає.",
budget: "Бюджет",
shared: "Спільний",
cannotEditPrivateCostCenter: "Особисті центри витрат не можна редагувати тут",
@@ -451,6 +453,7 @@ export default {
printerDeleted: "Принтер видалено",
missingSpoolAssignment: "Друк на {{printer}} розпочато. Для слотів {{slots}} не призначено котушки.",
killSwitchTriggered: 'Аварійний вимикач білінгу зупинив несанкціонований друк на {{printer}}: {{filename}}',
billingChargeFailed: 'Не вдалося нарахувати вартість {{filename}} на {{printer}}. Резерв бюджету збережено; перевірте журнали сервера.',
assignmentVerified: "Філамент завантажено в слот AMS {{slot}} принтера {{printer}}",
assignmentVerifiedNoKprofile: "Слот AMS {{slot}} на {{printer}} завантажено, але калібрування потоку (K-профіль) не застосовано",
assignmentNotConfirmed: "Не вдалося підтвердити призначення для слота {{slot}} на {{printer}} — перевірте слот AMS",
@@ -4947,6 +4950,8 @@ export default {
staggerToPrinters: "Розподілити запуск між {{count}} принтерами",
gcodeInjection: "Додати G-код автоматичного друку",
insufficientBudget: "Недостатньо бюджету",
unlimitedNoBudget: "Без обмежень — ліміт бюджету не встановлено.",
noPrintableCostCenters: "Немає активного центру витрат, доступного для друку. Попросіть адміністратора надати вам доступ до друку.",
},
// Backup
@@ -5940,6 +5945,8 @@ export default {
firstLayerCompleteLabel: "Перший шар завершено",
firstLayerCompleteDescription: "Сповістити зі знімком після завершення першого шару",
missingSpoolAssignmentLabel: "Відсутнє призначення котушки",
billingChargeFailedLabel: 'Помилка списання',
billingChargeFailedDescription: 'Сповіщати, якщо не вдалося облікувати вартість друку',
missingSpoolAssignmentDescription: "Сповіщати, коли починається друк і для необхідних лотків не призначено котушку",
printFailed: "Помилка друку",
printStopped: "Друк зупинено",
+8 -1
View File
@@ -168,7 +168,9 @@ export default {
costCentersHint: '审查预算限制并将成本控制在可控范围内',
noCostCenters: '未找到成本中心。',
owner: '所有者',
balance: '余额',
balance: '账户余额',
unlimited: '无限制',
budgetPolicyHint: '账户余额用于记录成本。打印仅受所选成本中心预算限制;未设置预算时不受限制。',
budget: '预算',
shared: '共享',
cannotEditPrivateCostCenter: '私人成本中心无法在此处编辑',
@@ -448,6 +450,7 @@ export default {
printerDeleted: '打印机已删除',
missingSpoolAssignment: '已在{{printer}}上开始打印。以下料槽未分配耗材: {{slots}}',
killSwitchTriggered: '计费终止开关已停止 {{printer}} 上的未授权打印:{{filename}}',
billingChargeFailed: '{{printer}} 上的 {{filename}} 计费失败。预算预留已保留;请检查服务器日志。',
assignmentVerified: '耗材已加载到料槽{{slot}}({{printer}})',
assignmentVerifiedNoKprofile: '{{printer}}的料槽{{slot}}已加载,但流量校准配置(K配置)未应用',
assignmentNotConfirmed: '无法确认{{printer}}上料槽{{slot}}的分配,请检查AMS料槽',
@@ -4893,6 +4896,8 @@ export default {
staggerToPrinters: '分批发送到 {{count}} 台打印机',
gcodeInjection: '注入自动打印G-code',
insufficientBudget: '预算不足',
unlimitedNoBudget: '无限制 – 未设置预算上限。',
noPrintableCostCenters: '没有可用于打印的有效成本中心。请联系管理员授予你打印权限。',
},
// Backup
@@ -5886,6 +5891,8 @@ export default {
firstLayerCompleteLabel: '首层打印完成',
firstLayerCompleteDescription: '首层完成时发送带照片的通知',
missingSpoolAssignmentLabel: '缺少料卷分配',
billingChargeFailedLabel: '计费失败',
billingChargeFailedDescription: '无法记录打印费用时通知',
missingSpoolAssignmentDescription: '当打印开始且所需料盘没有分配料卷时发送通知',
printFailed: '打印失败',
printStopped: '打印已停止',
+8 -1
View File
@@ -168,7 +168,9 @@ export default {
costCentersHint: '檢視預算限制並將成本保持在控制範圍內',
noCostCenters: '未找到成本中心。',
owner: '擁有者',
balance: '餘額',
balance: '帳戶餘額',
unlimited: '無限制',
budgetPolicyHint: '帳戶餘額用於記錄成本。列印僅受所選成本中心預算限制;未設定預算時不受限制。',
budget: '預算',
shared: '共用',
cannotEditPrivateCostCenter: '無法在此處編輯私人成本中心',
@@ -448,6 +450,7 @@ export default {
printerDeleted: '印表機已刪除',
missingSpoolAssignment: '已在{{printer}}上開始列印。以下料槽未分配耗材: {{slots}}',
killSwitchTriggered: '計費終止開關已停止 {{printer}} 上的未授權列印:{{filename}}',
billingChargeFailed: '{{printer}} 上的 {{filename}} 計費失敗。預算保留已保留;請檢查伺服器記錄。',
assignmentVerified: '耗材已載入料槽{{slot}}({{printer}})',
assignmentVerifiedNoKprofile: '{{printer}}的料槽{{slot}}已載入,但流量校準設定檔(K設定檔)未套用',
assignmentNotConfirmed: '無法確認{{printer}}上料槽{{slot}}的分配,請檢查AMS料槽',
@@ -4893,6 +4896,8 @@ export default {
staggerToPrinters: '分批傳送到 {{count}} 臺印表機',
gcodeInjection: '注入自動列印G-code',
insufficientBudget: '預算不足',
unlimitedNoBudget: '無限制 – 未設定預算上限。',
noPrintableCostCenters: '沒有可用於列印的有效成本中心。請聯絡管理員授予你列印權限。',
},
// Backup
@@ -5886,6 +5891,8 @@ export default {
firstLayerCompleteLabel: '首層列印完成',
firstLayerCompleteDescription: '首層完成時傳送帶照片的通知',
missingSpoolAssignmentLabel: '缺少料卷分配',
billingChargeFailedLabel: '計費失敗',
billingChargeFailedDescription: '無法記錄列印費用時通知',
missingSpoolAssignmentDescription: '當列印開始且所需料盤沒有分配料卷時傳送通知',
printFailed: '列印失敗',
printStopped: '列印已停止',
+51 -20
View File
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { api, type ManualPrintRequest, type TransactionEditRequest, type WalletTransaction } from '../api/client';
import { Button } from '../components/Button';
import { Card, CardContent, CardHeader } from '../components/Card';
import { ConfirmModal } from '../components/ConfirmModal';
import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../contexts/ToastContext';
import { getCurrencySymbol } from '../utils/currency';
@@ -159,6 +160,8 @@ export function FinancePage() {
const [manualPrintAmount, setManualPrintAmount] = useState('');
const [manualPrintDescription, setManualPrintDescription] = useState('');
const [manualPrintDate, setManualPrintDate] = useState(formatLocalDateTime(new Date()));
const [pendingDeleteCenter, setPendingDeleteCenter] = useState<{ id: number; name: string } | null>(null);
const [pendingDeleteTransactionId, setPendingDeleteTransactionId] = useState<number | null>(null);
const hasAdminFinanceControls =
canReadAllFinance ||
@@ -440,31 +443,27 @@ export function FinancePage() {
setShowEditCenterModal(true);
};
const handleDeleteCenter = async (centerId: number, centerName: string) => {
const confirmed = window.confirm(
t('finance.confirmDeleteCostCenter', 'Delete cost center "{{name}}"?', { name: centerName })
);
if (!confirmed) return;
const confirmDeleteCenter = async () => {
if (!pendingDeleteCenter) return;
try {
await deleteCostCenterMutation.mutateAsync(centerId);
await deleteCostCenterMutation.mutateAsync(pendingDeleteCenter.id);
queryClient.invalidateQueries({ queryKey: ['finance'] });
if (selectedManageCenterId === centerId) {
if (selectedManageCenterId === pendingDeleteCenter.id) {
setSelectedManageCenterId(null);
}
showToast(t('finance.costCenterDeleted', 'Cost center deleted'));
} catch (error) {
showToast((error as Error).message || t('finance.costCenterDeleteFailed', 'Failed to delete cost center'), 'error');
} finally {
setPendingDeleteCenter(null);
}
};
const handleDeleteTransaction = (transactionId: number) => {
const confirmed = window.confirm(
t('finance.deleteTransactionConfirm', 'Delete this transaction? Balances will be recalculated automatically.')
);
if (!confirmed) return;
deleteTransactionMutation.mutate(transactionId);
const confirmDeleteTransaction = () => {
if (pendingDeleteTransactionId == null) return;
deleteTransactionMutation.mutate(pendingDeleteTransactionId, {
onSettled: () => setPendingDeleteTransactionId(null),
});
};
const handleEditTransaction = (tx: WalletTransaction) => {
@@ -645,7 +644,7 @@ export function FinancePage() {
};
const formatBudgetProgress = (center: { budget_available: number | null; budget_limit: number | null }) => {
if (center.budget_limit == null || center.budget_available == null) return '-';
if (center.budget_limit == null || center.budget_available == null) return t('finance.unlimited', 'Unlimited');
return `${currencySymbol}${center.budget_available.toFixed(2)}/${currencySymbol}${center.budget_limit.toFixed(2)}`;
};
@@ -677,6 +676,9 @@ export function FinancePage() {
<h1 className="text-2xl font-bold text-white">{t('finance.title', 'Finance')}</h1>
</div>
<p className="text-bambu-gray mt-2 max-w-2xl">{t('finance.subtitle', 'Wallet, personal transactions, and cost centers')}</p>
<p className="text-xs text-bambu-gray mt-1 max-w-2xl">
{t('finance.budgetPolicyHint', 'Account balances track costs. Printing is limited only by the selected cost center budget; without a budget, printing is unlimited.')}
</p>
</div>
<div className="flex flex-col gap-2 lg:items-end">
@@ -729,7 +731,7 @@ export function FinancePage() {
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<span className="text-sm text-bambu-gray">{t('finance.currentBalance', 'Personal balance')}</span>
<span className="text-sm text-bambu-gray">{t('finance.currentBalance', 'Personal account balance')}</span>
<Wallet className="w-4 h-4 text-bambu-green/90" />
</CardHeader>
<CardContent className="pt-1">
@@ -1058,7 +1060,7 @@ export function FinancePage() {
<tr className="border-b border-bambu-dark-tertiary bg-bambu-dark text-bambu-gray">
<th className={tableHeadCellClass}>{t('common.name', 'Name')}</th>
{showCostCenterAccountColumn && <th className={tableHeadCellClass}>{t('finance.owner', 'Owner')}</th>}
<th className={tableHeadCellClass}>{t('finance.balance', 'Balance')}</th>
<th className={tableHeadCellClass}>{t('finance.balance', 'Account balance')}</th>
<th className={tableHeadCellClass}>{t('finance.budget', 'Budget')}</th>
{showCostCenterAccountColumn && <th className={tableHeadCellClass}>{t('common.actions', 'Actions')}</th>}
</tr>
@@ -1103,7 +1105,7 @@ export function FinancePage() {
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteCenter(center.id, center.name)}
onClick={() => setPendingDeleteCenter({ id: center.id, name: center.name })}
disabled={deleteCostCenterMutation.isPending}
title={t('common.delete', 'Delete')}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10 p-1.5 sm:p-2"
@@ -1239,7 +1241,7 @@ export function FinancePage() {
<Button
size="sm"
variant="danger"
onClick={() => handleDeleteTransaction(tx.id)}
onClick={() => setPendingDeleteTransactionId(tx.id)}
disabled={deleteTransactionMutation.isPending}
>
<Trash2 className="w-4 h-4" />
@@ -1459,6 +1461,35 @@ export function FinancePage() {
</div>
</FinanceModal>
)}
{pendingDeleteCenter && (
<ConfirmModal
title={t('common.delete', 'Delete')}
message={t('finance.confirmDeleteCostCenter', 'Delete cost center "{{name}}"?', {
name: pendingDeleteCenter.name,
})}
confirmText={t('common.delete', 'Delete')}
variant="danger"
isLoading={deleteCostCenterMutation.isPending}
onConfirm={confirmDeleteCenter}
onCancel={() => setPendingDeleteCenter(null)}
/>
)}
{pendingDeleteTransactionId != null && (
<ConfirmModal
title={t('finance.deleteTransaction', 'Delete transaction')}
message={t(
'finance.deleteTransactionConfirm',
'Delete this transaction? Balances will be recalculated automatically.',
)}
confirmText={t('common.delete', 'Delete')}
variant="danger"
isLoading={deleteTransactionMutation.isPending}
onConfirm={confirmDeleteTransaction}
onCancel={() => setPendingDeleteTransactionId(null)}
/>
)}
</div>
</div>
);