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