From dd1d40b0d40023e2c2f2db68820167a303024eae Mon Sep 17 00:00:00 2001 From: behrinml Date: Wed, 5 Aug 2026 22:52:25 +0200 Subject: [PATCH] implemented pr (worth fixing) feedback update commit --- backend/app/api/routes/finance.py | 174 +++++++----- .../app/api/routes/notification_templates.py | 1 + backend/app/api/routes/notifications.py | 2 + backend/app/core/database.py | 140 ++++++---- backend/app/main.py | 122 ++++++++- backend/app/models/archive.py | 3 + backend/app/models/finance.py | 5 + backend/app/models/notification.py | 1 + backend/app/models/notification_template.py | 6 + backend/app/models/print_queue.py | 3 + backend/app/schemas/notification.py | 2 + backend/app/schemas/notification_template.py | 10 + backend/app/services/bambu_mqtt.py | 5 +- backend/app/services/finance_balance.py | 6 +- backend/app/services/finance_billing.py | 115 ++++++-- backend/app/services/finance_budget.py | 77 +++--- backend/app/services/finance_defaults.py | 13 +- backend/app/services/notification_service.py | 33 +++ backend/app/services/print_cost_estimate.py | 14 +- backend/app/services/print_scheduler.py | 64 +++-- backend/tests/conftest.py | 1 + backend/tests/integration/test_finance_api.py | 251 +++++++++++++++++- .../integration/test_notifications_api.py | 19 ++ .../tests/integration/test_print_queue_api.py | 27 ++ .../test_scheduler_budget_reservation.py | 74 +++++- .../tests/unit/services/test_bambu_mqtt.py | 24 ++ .../services/test_finance_service_billing.py | 187 ++++++++++++- .../services/test_finance_service_defaults.py | 20 ++ .../services/test_notification_service.py | 34 +++ .../unit/services/test_print_cost_estimate.py | 16 ++ .../unit/test_billing_run_id_migration.py | 36 +++ .../unit/test_finance_table_migration.py | 16 +- .../tests/unit/test_printer_kill_switch.py | 30 ++- .../__tests__/components/PrintModal.test.tsx | 27 ++ .../components/PrintModalBilling.test.tsx | 37 +++ .../src/__tests__/hooks/useWebSocket.test.ts | 28 ++ frontend/src/api/client.ts | 3 + .../src/components/AddNotificationModal.tsx | 10 + .../components/NotificationProviderCard.tsx | 14 + .../PrintModal/CostCenterSelect.tsx | 6 + frontend/src/components/PrintModal/index.tsx | 24 +- frontend/src/hooks/useWebSocket.ts | 7 + frontend/src/i18n/locales/de.ts | 11 +- frontend/src/i18n/locales/en.ts | 9 +- frontend/src/i18n/locales/es.ts | 9 +- frontend/src/i18n/locales/fr.ts | 9 +- frontend/src/i18n/locales/it.ts | 9 +- frontend/src/i18n/locales/ja.ts | 9 +- frontend/src/i18n/locales/ko.ts | 11 +- frontend/src/i18n/locales/pt-BR.ts | 9 +- frontend/src/i18n/locales/ru.ts | 9 +- frontend/src/i18n/locales/tr.ts | 9 +- frontend/src/i18n/locales/uk.ts | 9 +- frontend/src/i18n/locales/zh-CN.ts | 9 +- frontend/src/i18n/locales/zh-TW.ts | 9 +- frontend/src/pages/FinancePage.tsx | 71 +++-- 56 files changed, 1601 insertions(+), 278 deletions(-) create mode 100644 backend/tests/unit/test_billing_run_id_migration.py diff --git a/backend/app/api/routes/finance.py b/backend/app/api/routes/finance.py index fb162dc61..dcdfeb7da 100644 --- a/backend/app/api/routes/finance.py +++ b/backend/app/api/routes/finance.py @@ -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"} diff --git a/backend/app/api/routes/notification_templates.py b/backend/app/api/routes/notification_templates.py index 1a8a5a63f..440d0f58d 100644 --- a/backend/app/api/routes/notification_templates.py +++ b/backend/app/api/routes/notification_templates.py @@ -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", diff --git a/backend/app/api/routes/notifications.py b/backend/app/api/routes/notifications.py index dd5589926..a5ea68aa8 100644 --- a/backend/app/api/routes/notifications.py +++ b/backend/app/api/routes/notifications.py @@ -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, diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 7a07cb1ec..2d3922f69 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -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. diff --git a/backend/app/main.py b/backend/app/main.py index 65129ffcd..4f340066e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/models/archive.py b/backend/app/models/archive.py index 0cdf0c6bb..a185dedf8 100644 --- a/backend/app/models/archive.py +++ b/backend/app/models/archive.py @@ -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 diff --git a/backend/app/models/finance.py b/backend/app/models/finance.py index 632aa8335..bf200e606 100644 --- a/backend/app/models/finance.py +++ b/backend/app/models/finance.py @@ -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) diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py index 1efcec13f..b0f523f91 100644 --- a/backend/app/models/notification.py +++ b/backend/app/models/notification.py @@ -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) diff --git a/backend/app/models/notification_template.py b/backend/app/models/notification_template.py index cba32156d..5052eface 100644 --- a/backend/app/models/notification_template.py +++ b/backend/app/models/notification_template.py @@ -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", diff --git a/backend/app/models/print_queue.py b/backend/app/models/print_queue.py index 901821077..f99868a94 100644 --- a/backend/app/models/print_queue.py +++ b/backend/app/models/print_queue.py @@ -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) diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py index 77c5b3602..d0b12053d 100644 --- a/backend/app/schemas/notification.py +++ b/backend/app/schemas/notification.py @@ -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 diff --git a/backend/app/schemas/notification_template.py b/backend/app/schemas/notification_template.py index 676017b08..98eccc3bf 100644 --- a/backend/app/schemas/notification_template.py +++ b/backend/app/schemas/notification_template.py @@ -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", diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index c3afa6c60..804f1cca9 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -3188,7 +3188,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: diff --git a/backend/app/services/finance_balance.py b/backend/app/services/finance_balance.py index b3fee3f13..4ed62d4eb 100644 --- a/backend/app/services/finance_balance.py +++ b/backend/app/services/finance_balance.py @@ -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) diff --git a/backend/app/services/finance_billing.py b/backend/app/services/finance_billing.py index 365198ef0..8dce36400 100644 --- a/backend/app/services/finance_billing.py +++ b/backend/app/services/finance_billing.py @@ -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 diff --git a/backend/app/services/finance_budget.py b/backend/app/services/finance_budget.py index d321efa84..add2d665e 100644 --- a/backend/app/services/finance_budget.py +++ b/backend/app/services/finance_budget.py @@ -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: diff --git a/backend/app/services/finance_defaults.py b/backend/app/services/finance_defaults.py index 2d809ec8b..fbc83c053 100644 --- a/backend/app/services/finance_defaults.py +++ b/backend/app/services/finance_defaults.py @@ -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( diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 1c95a9396..f7241b668 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -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) diff --git a/backend/app/services/print_cost_estimate.py b/backend/app/services/print_cost_estimate.py index 88baf4c94..6b0acf1a6 100644 --- a/backend/app/services/print_cost_estimate.py +++ b/backend/app/services/print_cost_estimate.py @@ -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 diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index aecfcd829..78c44c44c 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -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: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 82dd62fa8..ee0d7f146 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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, diff --git a/backend/tests/integration/test_finance_api.py b/backend/tests/integration/test_finance_api.py index 832265b32..b33a1de0d 100644 --- a/backend/tests/integration/test_finance_api.py +++ b/backend/tests/integration/test_finance_api.py @@ -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.""" diff --git a/backend/tests/integration/test_notifications_api.py b/backend/tests/integration/test_notifications_api.py index 3316f9f66..1580d112a 100644 --- a/backend/tests/integration/test_notifications_api.py +++ b/backend/tests/integration/test_notifications_api.py @@ -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.""" diff --git a/backend/tests/integration/test_print_queue_api.py b/backend/tests/integration/test_print_queue_api.py index 95b9fb792..7ebdea281 100644 --- a/backend/tests/integration/test_print_queue_api.py +++ b/backend/tests/integration/test_print_queue_api.py @@ -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( diff --git a/backend/tests/integration/test_scheduler_budget_reservation.py b/backend/tests/integration/test_scheduler_budget_reservation.py index b24f0ad07..ac2a5f853 100644 --- a/backend/tests/integration/test_scheduler_budget_reservation.py +++ b/backend/tests/integration/test_scheduler_budget_reservation.py @@ -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" diff --git a/backend/tests/unit/services/test_bambu_mqtt.py b/backend/tests/unit/services/test_bambu_mqtt.py index c36acf1bc..85815dda7 100644 --- a/backend/tests/unit/services/test_bambu_mqtt.py +++ b/backend/tests/unit/services/test_bambu_mqtt.py @@ -6672,6 +6672,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. diff --git a/backend/tests/unit/services/test_finance_service_billing.py b/backend/tests/unit/services/test_finance_service_billing.py index a2a544bac..b6f9cb232 100644 --- a/backend/tests/unit/services/test_finance_service_billing.py +++ b/backend/tests/unit/services/test_finance_service_billing.py @@ -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) diff --git a/backend/tests/unit/services/test_finance_service_defaults.py b/backend/tests/unit/services/test_finance_service_defaults.py index 6fa7af71e..ab4abc910 100644 --- a/backend/tests/unit/services/test_finance_service_defaults.py +++ b/backend/tests/unit/services/test_finance_service_defaults.py @@ -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 diff --git a/backend/tests/unit/services/test_notification_service.py b/backend/tests/unit/services/test_notification_service.py index 287efaa9c..e7210bfeb 100644 --- a/backend/tests/unit/services/test_notification_service.py +++ b/backend/tests/unit/services/test_notification_service.py @@ -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) # ======================================================================== diff --git a/backend/tests/unit/services/test_print_cost_estimate.py b/backend/tests/unit/services/test_print_cost_estimate.py index ff806b4ee..0cff79763 100644 --- a/backend/tests/unit/services/test_print_cost_estimate.py +++ b/backend/tests/unit/services/test_print_cost_estimate.py @@ -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( diff --git a/backend/tests/unit/test_billing_run_id_migration.py b/backend/tests/unit/test_billing_run_id_migration.py new file mode 100644 index 000000000..8484ea5c1 --- /dev/null +++ b/backend/tests/unit/test_billing_run_id_migration.py @@ -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() diff --git a/backend/tests/unit/test_finance_table_migration.py b/backend/tests/unit/test_finance_table_migration.py index d8ec7dd53..427a8172c 100644 --- a/backend/tests/unit/test_finance_table_migration.py +++ b/backend/tests/unit/test_finance_table_migration.py @@ -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 diff --git a/backend/tests/unit/test_printer_kill_switch.py b/backend/tests/unit/test_printer_kill_switch.py index 9e6ff2202..d2a184376 100644 --- a/backend/tests/unit/test_printer_kill_switch.py +++ b/backend/tests/unit/test_printer_kill_switch.py @@ -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 diff --git a/frontend/src/__tests__/components/PrintModal.test.tsx b/frontend/src/__tests__/components/PrintModal.test.tsx index 33dad570e..8757f8270 100644 --- a/frontend/src/__tests__/components/PrintModal.test.tsx +++ b/frontend/src/__tests__/components/PrintModal.test.tsx @@ -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( + + ); + + 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( { 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( + , + ); + + expect(await screen.findByText('Unlimited – no budget limit is set.')).not.toBeNull(); + }); }); diff --git a/frontend/src/__tests__/hooks/useWebSocket.test.ts b/frontend/src/__tests__/hooks/useWebSocket.test.ts index 70a506192..ad489dde7 100644 --- a/frontend/src/__tests__/hooks/useWebSocket.test.ts +++ b/frontend/src/__tests__/hooks/useWebSocket.test.ts @@ -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'); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8fd04572c..47dadb195 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2679,6 +2679,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; @@ -2740,6 +2741,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; @@ -2794,6 +2796,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; diff --git a/frontend/src/components/AddNotificationModal.tsx b/frontend/src/components/AddNotificationModal.tsx index 51e75eb0a..89bc88148 100644 --- a/frontend/src/components/AddNotificationModal.tsx +++ b/frontend/src/components/AddNotificationModal.tsx @@ -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 +
+
+ {t('notifications.billingChargeFailedLabel')} + {t('notifications.billingChargeFailedDescription')} +
+ +
{t('notifications.plateClearRequired')} @@ -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') }); diff --git a/frontend/src/components/NotificationProviderCard.tsx b/frontend/src/components/NotificationProviderCard.tsx index 5194159b1..4daa1e259 100644 --- a/frontend/src/components/NotificationProviderCard.tsx +++ b/frontend/src/components/NotificationProviderCard.tsx @@ -132,6 +132,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi {provider.on_print_stopped && ( {t('notifications.stopped')} )} + {provider.on_billing_charge_failed && ( + {t('notifications.billingChargeFailedLabel')} + )} {provider.on_print_progress && ( {t('notifications.progress')} )} @@ -354,6 +357,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi />
+
+
+

{t('notifications.billingChargeFailedLabel')}

+

{t('notifications.billingChargeFailedDescription')}

+
+ updateMutation.mutate({ on_billing_charge_failed: checked })} + /> +
+

{t('notifications.progressMilestones')}

diff --git a/frontend/src/components/PrintModal/CostCenterSelect.tsx b/frontend/src/components/PrintModal/CostCenterSelect.tsx index f4da18bed..8427e55cb 100644 --- a/frontend/src/components/PrintModal/CostCenterSelect.tsx +++ b/frontend/src/components/PrintModal/CostCenterSelect.tsx @@ -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 (
@@ -33,6 +34,11 @@ export function CostCenterSelect({ ))} + {selectedCostCenter?.budget_mode === 'none' && ( +

+ {t('printModal.unlimitedNoBudget', 'Unlimited – no budget limit is set.')} +

+ )}
); } diff --git a/frontend/src/components/PrintModal/index.tsx b/frontend/src/components/PrintModal/index.tsx index 7b5a13770..b35a8002c 100644 --- a/frontend/src/components/PrintModal/index.tsx +++ b/frontend/src/components/PrintModal/index.tsx @@ -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 && ( +
+ + {t('printModal.noPrintableCostCenters')} +
+ )} {/* Quantity — create multiple copies (batch). Hidden for multi-printer selection, and for multi-plate files where the per-plate steppers diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index feab6fb9e..635eb150d 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -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'); diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index a4da534ce..5c482564e 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -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', @@ -4893,6 +4896,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 @@ -5813,6 +5818,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', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index a3297de62..813e98af9 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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', @@ -4936,6 +4939,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 @@ -5857,6 +5862,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', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 6c46da64a..52ad5d082 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -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', @@ -4900,6 +4903,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 @@ -5821,6 +5826,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', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index deb1f6572..462c50d2b 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -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', @@ -4882,6 +4885,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 @@ -5803,6 +5808,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', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 95991b497..00913ba71 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -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', @@ -4881,6 +4884,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 @@ -5802,6 +5807,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', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 31b38ef89..f19bbe172 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -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スロットを確認してください', @@ -4893,6 +4896,8 @@ export default { staggerToPrinters: '{{count}}台のプリンターに段階的に送信', gcodeInjection: '自動印刷G-codeを挿入', insufficientBudget: '予算が不足しています', + unlimitedNoBudget: '無制限 – 予算上限は設定されていません。', + noPrintableCostCenters: '印刷に利用できる有効なコストセンターがありません。管理者に印刷アクセスの付与を依頼してください。', }, // Backup @@ -5814,6 +5819,8 @@ export default { firstLayerCompleteLabel: '第1層完了', firstLayerCompleteDescription: '第1層完了時にスナップショット付きで通知', missingSpoolAssignmentLabel: 'スプール割り当て不足', + billingChargeFailedLabel: '請求処理エラー', + billingChargeFailedDescription: '印刷コストを記録できなかった場合に通知します', missingSpoolAssignmentDescription: '印刷開始時に必要トレイへスプールが未割り当ての場合に通知', printFailed: '印刷失敗', printStopped: '印刷停止', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index cccac5722..729c674e9 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -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 슬롯을 확인하세요', @@ -4662,7 +4665,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 비밀을 복호화할 수 있습니다.', @@ -5532,6 +5537,8 @@ export default { firstLayerCompleteLabel: '첫 번째 레이어 완료', firstLayerCompleteDescription: '첫 번째 레이어 완료 시 스냅샷과 함께 알림', missingSpoolAssignmentLabel: '스풀 할당 누락', + billingChargeFailedLabel: '결제 처리 실패', + billingChargeFailedDescription: '인쇄 비용을 기록하지 못한 경우 알림', missingSpoolAssignmentDescription: '인쇄 시작 시 필요한 트레이에 할당된 스풀이 없을 때 알림', printFailed: '인쇄 실패', printStopped: '인쇄 중지됨', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index d44fd0b14..01d52ad51 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -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', @@ -4881,6 +4884,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 @@ -5802,6 +5807,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', diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index 74525762d..072d0a6b4 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/frontend/src/i18n/locales/ru.ts @@ -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", @@ -4652,6 +4655,8 @@ export default { staggerToPrinters: "Распределить запуск для {{count}} принтеров", gcodeInjection: "Добавить G-code автозапуска", insufficientBudget: "Недостаточно бюджета", + unlimitedNoBudget: "Без ограничений — лимит бюджета не задан.", + noPrintableCostCenters: "Нет активного центра затрат, доступного для печати. Попросите администратора предоставить вам доступ к печати.", }, backup: { includesEncryptionKey: "Локальные резервные копии включают файл ключа шифрования MFA (DATA_DIR/.mfa_encryption_key), поэтому ZIP-архив является самодостаточным. Считайте этот ZIP конфиденциальным: любой, у кого есть файл, сможет расшифровать сохранённые в нём секреты клиента OIDC и TOTP.", @@ -5519,6 +5524,8 @@ export default { firstLayerCompleteLabel: "Первый слой завершён", firstLayerCompleteDescription: "Уведомить со снимком после завершения первого слоя", missingSpoolAssignmentLabel: "Катушка не назначена", + billingChargeFailedLabel: 'Ошибка списания', + billingChargeFailedDescription: 'Уведомлять, если не удалось учесть стоимость печати', missingSpoolAssignmentDescription: "Уведомить при запуске печати, если для необходимых слотов не назначены катушки", printFailed: "Ошибка печати", printStopped: "Печать остановлена", diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index bbfe8a705..162d29012 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -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', @@ -4870,6 +4873,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 @@ -5769,6 +5774,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', diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts index 1cbe9fa5b..b67318c31 100644 --- a/frontend/src/i18n/locales/uk.ts +++ b/frontend/src/i18n/locales/uk.ts @@ -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", @@ -4935,6 +4938,8 @@ export default { staggerToPrinters: "Розподілити запуск між {{count}} принтерами", gcodeInjection: "Додати G-код автоматичного друку", insufficientBudget: "Недостатньо бюджету", + unlimitedNoBudget: "Без обмежень — ліміт бюджету не встановлено.", + noPrintableCostCenters: "Немає активного центру витрат, доступного для друку. Попросіть адміністратора надати вам доступ до друку.", }, // Backup @@ -5856,6 +5861,8 @@ export default { firstLayerCompleteLabel: "Перший шар завершено", firstLayerCompleteDescription: "Сповістити зі знімком після завершення першого шару", missingSpoolAssignmentLabel: "Відсутнє призначення котушки", + billingChargeFailedLabel: 'Помилка списання', + billingChargeFailedDescription: 'Сповіщати, якщо не вдалося облікувати вартість друку', missingSpoolAssignmentDescription: "Сповіщати, коли починається друк і для необхідних лотків не призначено котушку", printFailed: "Помилка друку", printStopped: "Друк зупинено", diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 3658b573a..19dd591f5 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -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料槽', @@ -4881,6 +4884,8 @@ export default { staggerToPrinters: '分批发送到 {{count}} 台打印机', gcodeInjection: '注入自动打印G-code', insufficientBudget: '预算不足', + unlimitedNoBudget: '无限制 – 未设置预算上限。', + noPrintableCostCenters: '没有可用于打印的有效成本中心。请联系管理员授予你打印权限。', }, // Backup @@ -5802,6 +5807,8 @@ export default { firstLayerCompleteLabel: '首层打印完成', firstLayerCompleteDescription: '首层完成时发送带照片的通知', missingSpoolAssignmentLabel: '缺少料卷分配', + billingChargeFailedLabel: '计费失败', + billingChargeFailedDescription: '无法记录打印费用时通知', missingSpoolAssignmentDescription: '当打印开始且所需料盘没有分配料卷时发送通知', printFailed: '打印失败', printStopped: '打印已停止', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index e0c014152..70961d3e3 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -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料槽', @@ -4881,6 +4884,8 @@ export default { staggerToPrinters: '分批傳送到 {{count}} 臺印表機', gcodeInjection: '注入自動列印G-code', insufficientBudget: '預算不足', + unlimitedNoBudget: '無限制 – 未設定預算上限。', + noPrintableCostCenters: '沒有可用於列印的有效成本中心。請聯絡管理員授予你列印權限。', }, // Backup @@ -5802,6 +5807,8 @@ export default { firstLayerCompleteLabel: '首層列印完成', firstLayerCompleteDescription: '首層完成時傳送帶照片的通知', missingSpoolAssignmentLabel: '缺少料卷分配', + billingChargeFailedLabel: '計費失敗', + billingChargeFailedDescription: '無法記錄列印費用時通知', missingSpoolAssignmentDescription: '當列印開始且所需料盤沒有分配料卷時傳送通知', printFailed: '列印失敗', printStopped: '列印已停止', diff --git a/frontend/src/pages/FinancePage.tsx b/frontend/src/pages/FinancePage.tsx index 10b207b33..598d0edee 100644 --- a/frontend/src/pages/FinancePage.tsx +++ b/frontend/src/pages/FinancePage.tsx @@ -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(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() {

{t('finance.title', 'Finance')}

{t('finance.subtitle', 'Wallet, personal transactions, and cost centers')}

+

+ {t('finance.budgetPolicyHint', 'Account balances track costs. Printing is limited only by the selected cost center budget; without a budget, printing is unlimited.')} +

@@ -729,7 +731,7 @@ export function FinancePage() {
- {t('finance.currentBalance', 'Personal balance')} + {t('finance.currentBalance', 'Personal account balance')} @@ -1058,7 +1060,7 @@ export function FinancePage() { {t('common.name', 'Name')} {showCostCenterAccountColumn && {t('finance.owner', 'Owner')}} - {t('finance.balance', 'Balance')} + {t('finance.balance', 'Account balance')} {t('finance.budget', 'Budget')} {showCostCenterAccountColumn && {t('common.actions', 'Actions')}} @@ -1103,7 +1105,7 @@ export function FinancePage() {
)} + + {pendingDeleteCenter && ( + setPendingDeleteCenter(null)} + /> + )} + + {pendingDeleteTransactionId != null && ( + setPendingDeleteTransactionId(null)} + /> + )}
);