Files
maziggy 4a85e033c0 fix(finance): show the currency the install is configured for (issue #3123)
The Finance page was the only surface in Bambuddy that read its currency
from a data row rather than the `currency` setting, and it fell back to EUR
where every other page falls back to USD. One variable drives every amount
on that page, so the personal balance, the cost-center budgets and the whole
transaction list were wrong together on any install not set to euros. It now
takes the configured currency from /settings/ui-flags, which is readable by
anyone who can see Finance -- /settings needs SETTINGS_READ, which a
cost_centers:read_own user does not have.

The backend was the other half. Of the four places that settle on a
currency, three wrote a hardcoded "EUR": the wallet the API mints on demand,
the wallet a print charge mints when none exists, and the balance returned
for a user with no wallet row at all. All four now go through one resolver,
which lives beside the rest of the balance logic.

The wallet's currency column is removed outright rather than merely ignored.
An install has one currency and nothing here converts between them, so a
per-wallet copy could only ever drift from the setting -- and a column
nothing reads is a trap for whoever finds it next. A startup migration drops
it on both SQLite and PostgreSQL, after the raw CREATE TABLE that would
otherwise re-add it on an install whose finance tables predate the ORM.
SQLite builds older than 3.35 have no DROP COLUMN and keep it, harmlessly,
since it has a default and no reader.

Saving settings now invalidates the ui-flags query too. Nothing did, so a
changed currency sat behind that query's staleTime before showing up. The
sponsor prompt's own EUR fallback is now USD, matching AppSettings.
2026-09-20 10:06:47 +02:00

1271 lines
48 KiB
Python

"""Integration tests for the finance/billing API."""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from backend.app.core.auth import get_password_hash
from backend.app.core.database import repair_wallet_ledger_internal
from backend.app.models.archive import PrintArchive
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
class TestFinanceAPI:
@pytest.fixture
async def admin_user(self, db_session):
user = User(
username="finance-admin",
email="finance-admin@example.com",
password_hash=get_password_hash("AdminPass1!"),
role="admin",
is_active=True,
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest.fixture
async def auth_headers(self, async_client: AsyncClient, db_session, admin_user):
db_session.add(Settings(key="auth_enabled", value="true"))
db_session.add(Settings(key="advanced_auth_enabled", value="false"))
# Ensure billing is enabled for finance integration tests
existing = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
if existing is None:
db_session.add(Settings(key="billing_enabled", value="true"))
else:
existing.value = "true"
await db_session.commit()
response = await async_client.post(
"/api/v1/auth/login",
json={"username": admin_user.username, "password": "AdminPass1!"},
)
assert response.status_code == 200
return {"Authorization": f"Bearer {response.json()['access_token']}"}
async def _enable_basic_user_creation(self, db_session):
return None
async def _create_user_via_api(self, async_client: AsyncClient, auth_headers: dict[str, str], username: str):
response = await async_client.post(
"/api/v1/users",
json={
"username": username,
"password": "Regularpass1!",
"email": f"{username}@example.com",
"role": "user",
},
headers=auth_headers,
)
assert response.status_code == 201
return response.json()
async def _login_user(self, async_client: AsyncClient, username: str) -> dict[str, str]:
response = await async_client.post(
"/api/v1/auth/login",
json={"username": username, "password": "Regularpass1!"},
)
assert response.status_code == 200
return {"Authorization": f"Bearer {response.json()['access_token']}"}
@pytest.mark.asyncio
@pytest.mark.integration
async def test_balance_get_does_not_create_wallet(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
target = User(
username="balance-without-wallet",
email="balance-without-wallet@example.com",
password_hash=get_password_hash("Regularpass1!"),
role="user",
is_active=True,
)
db_session.add(target)
await db_session.commit()
await db_session.refresh(target)
assert await db_session.scalar(select(UserWallet).where(UserWallet.user_id == target.id)) is None
response = await async_client.get(f"/api/v1/finance/users/{target.id}/balance", headers=auth_headers)
assert response.status_code == 200
assert response.json()["balance"] == 0
assert await db_session.scalar(select(UserWallet).where(UserWallet.user_id == target.id)) is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_wallet_ledger_rebuild_processes_more_than_one_batch(
self,
db_session,
admin_user: User,
):
wallet = UserWallet(user_id=admin_user.id, balance=0)
db_session.add(wallet)
await db_session.flush()
await db_session.execute(
WalletTransaction.__table__.insert(),
[{"user_id": admin_user.id, "transaction_type": "deposit", "amount": 0.01} for _ in range(1001)],
)
await repair_wallet_ledger_internal(db_session)
await db_session.refresh(wallet)
last_transaction = await db_session.scalar(
select(WalletTransaction)
.where(WalletTransaction.user_id == admin_user.id)
.order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
.limit(1)
)
assert wallet.balance == 10.01
assert last_transaction is not None
assert last_transaction.balance_after == 10.01
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_cost_center_assign_member_and_list_mine(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
await self._enable_basic_user_creation(db_session)
created_user = await self._create_user_via_api(async_client, auth_headers, "carol")
user_headers = await self._login_user(async_client, "carol")
negative_budget_response = await async_client.post(
"/api/v1/finance/cost-centers",
json={"name": "Invalid Budget", "total_budget": -1},
headers=auth_headers,
)
assert negative_budget_response.status_code == 422
create_response = await async_client.post(
"/api/v1/finance/cost-centers",
json={
"name": "Shared Lab",
"monthly_budget": 120.0,
"total_budget": 999.0,
"is_active": True,
},
headers=auth_headers,
)
assert create_response.status_code == 200
shared_center = create_response.json()
assert shared_center["name"] == "Shared Lab"
assert shared_center["monthly_budget"] == 120.0
assert shared_center["total_budget"] is None
assert shared_center["budget_mode"] == "monthly"
member_response = await async_client.post(
f"/api/v1/finance/cost-centers/{shared_center['id']}/members",
json={"user_id": created_user["id"], "can_print": False},
headers=auth_headers,
)
assert member_response.status_code == 200
assert member_response.json()["user_id"] == created_user["id"]
assert member_response.json()["can_print"] is False
mine_response = await async_client.get("/api/v1/finance/cost-centers/mine", headers=user_headers)
assert mine_response.status_code == 200
mine_names = {center["name"] for center in mine_response.json()}
assert "carol" in mine_names
assert "Shared Lab" in mine_names
detail_response = await async_client.get(
f"/api/v1/finance/cost-centers/{shared_center['id']}", headers=auth_headers
)
assert detail_response.status_code == 200
detail = detail_response.json()
assert len(detail["members"]) == 1
assert detail["members"][0]["user_id"] == created_user["id"]
remove_response = await async_client.delete(
f"/api/v1/finance/cost-centers/{shared_center['id']}/members/{created_user['id']}",
headers=auth_headers,
)
assert remove_response.status_code == 200
mine_after_remove = await async_client.get("/api/v1/finance/cost-centers/mine", headers=user_headers)
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
rename_response = await async_client.patch(
f"/api/v1/finance/cost-centers/{private_center.id}",
json={"name": "Renamed private center"},
headers=auth_headers,
)
assert rename_response.status_code == 400
await db_session.refresh(private_center)
assert private_center.name == "private-budget-user"
negative_budget_response = await async_client.patch(
f"/api/v1/finance/cost-centers/{private_center.id}/budgets",
json={"monthly_budget": -0.01},
headers=auth_headers,
)
assert negative_budget_response.status_code == 422
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(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
admin_user,
db_session,
):
"""A user's private cost center and unassigned entries share one wallet."""
await self._enable_basic_user_creation(db_session)
created_user = await self._create_user_via_api(async_client, auth_headers, "dave")
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
# The owner's private cost center affects both its own ledger and the wallet.
deposit = await async_client.post(
f"/api/v1/finance/users/{created_user['id']}/deposit",
json={"amount": 25.0, "description": "Initial CC top-up", "cost_center_id": private_center.id},
headers=auth_headers,
)
assert deposit.status_code == 200
assert deposit.json()["transaction"]["cost_center_id"] == private_center.id
assert deposit.json()["transaction"]["balance_after"] == 25.0 # CC balance
assert deposit.json()["balance"]["balance"] == 25.0 # Response shows CC balance
# A withdrawal updates both views by the same amount.
withdraw = await async_client.post(
f"/api/v1/finance/users/{created_user['id']}/withdraw",
json={"amount": 5.0, "description": "CC Usage", "cost_center_id": private_center.id},
headers=auth_headers,
)
assert withdraw.status_code == 200
assert withdraw.json()["transaction"]["amount"] == -5.0
assert withdraw.json()["transaction"]["balance_after"] == 20.0 # CC balance after withdraw
assert withdraw.json()["balance"]["balance"] == 20.0 # Response shows CC balance
# Personal deposit: affects user wallet
personal_deposit = await async_client.post(
f"/api/v1/finance/users/{created_user['id']}/deposit",
json={"amount": 30.0, "description": "Personal top-up", "cost_center_id": None},
headers=auth_headers,
)
assert personal_deposit.status_code == 200
assert personal_deposit.json()["transaction"]["cost_center_id"] is None
assert personal_deposit.json()["transaction"]["balance_after"] == 50.0
assert personal_deposit.json()["balance"]["balance"] == 50.0
transactions_response = await async_client.get(
f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
)
assert transactions_response.status_code == 200
transactions = transactions_response.json()
assert len(transactions) == 3
cc_txs = [tx for tx in transactions if tx["cost_center_id"] == private_center.id]
personal_txs = [tx for tx in transactions if tx["cost_center_id"] is None]
assert len(cc_txs) == 2
assert len(personal_txs) == 1
balance_response = await async_client.get(
f"/api/v1/finance/users/{created_user['id']}/balance", headers=auth_headers
)
assert balance_response.status_code == 200
assert balance_response.json()["balance"] == 50.0
# Delete personal transaction, user wallet should decrease
personal_tx = next(tx for tx in transactions if tx["cost_center_id"] is None)
delete_response = await async_client.delete(
f"/api/v1/finance/transactions/{personal_tx['id']}", headers=auth_headers
)
assert delete_response.status_code == 200
balance_after_delete = await async_client.get(
f"/api/v1/finance/users/{created_user['id']}/balance", headers=auth_headers
)
assert balance_after_delete.status_code == 200
assert balance_after_delete.json()["balance"] == 20.0
remaining = await async_client.get(
f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
)
assert remaining.status_code == 200
assert len(remaining.json()) == 2 # 2 CC transactions remain
@pytest.mark.asyncio
@pytest.mark.integration
async def test_admin_and_user_balances_agree_after_charge_adjustment_and_delete(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
created_user = await self._create_user_via_api(async_client, auth_headers, "balance-lifecycle")
user = await db_session.get(User, created_user["id"])
operators = await db_session.scalar(select(Group).where(Group.name == "Operators"))
assert operators is not None
user.groups.append(operators)
await db_session.commit()
user_headers = await self._login_user(async_client, "balance-lifecycle")
private_center = await db_session.scalar(
select(CostCenter).where(CostCenter.owner_user_id == user.id, CostCenter.is_private.is_(True))
)
assert private_center is not None
deposit = await async_client.post(
f"/api/v1/finance/users/{user.id}/deposit",
json={"amount": 20.0, "cost_center_id": private_center.id},
headers=auth_headers,
)
assert deposit.status_code == 200
archive = PrintArchive(
filename="charged.gcode.3mf",
file_path="archives/test/charged.gcode.3mf",
file_size=10,
status="completed",
cost=4.0,
created_by_id=user.id,
cost_center_id=private_center.id,
)
db_session.add(archive)
await db_session.commit()
assert await apply_print_charge_for_archive(db_session, archive.id) is True
await db_session.commit()
adjustment = await async_client.post(
f"/api/v1/finance/users/{user.id}/deposit",
json={"amount": 5.0, "description": "temporary adjustment"},
headers=auth_headers,
)
assert adjustment.status_code == 200
async def assert_views_agree(expected: float):
admin_view = await async_client.get(
f"/api/v1/finance/users/{user.id}/balance",
headers=auth_headers,
)
user_view = await async_client.get("/api/v1/finance/me/balance", headers=user_headers)
assert admin_view.status_code == 200
assert user_view.status_code == 200
assert admin_view.json()["balance"] == expected
assert user_view.json()["balance"] == expected
await assert_views_agree(21.0)
delete_response = await async_client.delete(
f"/api/v1/finance/transactions/{adjustment.json()['transaction']['id']}",
headers=auth_headers,
)
assert delete_response.status_code == 200
await assert_views_agree(16.0)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_cost_center_transaction_rebuilds_remaining_ledger(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
await self._enable_basic_user_creation(db_session)
created_user = await self._create_user_via_api(async_client, auth_headers, "erin")
shared_center = await db_session.scalar(
select(CostCenter).where(CostCenter.owner_user_id == created_user["id"], CostCenter.is_private.is_(True))
)
assert shared_center is not None
first_deposit = await async_client.post(
f"/api/v1/finance/users/{created_user['id']}/deposit",
json={"amount": 25.0, "description": "CC top-up", "cost_center_id": shared_center.id},
headers=auth_headers,
)
assert first_deposit.status_code == 200
cc_withdraw = await async_client.post(
f"/api/v1/finance/users/{created_user['id']}/withdraw",
json={"amount": 5.0, "description": "CC usage", "cost_center_id": shared_center.id},
headers=auth_headers,
)
assert cc_withdraw.status_code == 200
personal_deposit = await async_client.post(
f"/api/v1/finance/users/{created_user['id']}/deposit",
json={"amount": 12.0, "description": "Personal top-up", "cost_center_id": None},
headers=auth_headers,
)
assert personal_deposit.status_code == 200
delete_response = await async_client.delete(
f"/api/v1/finance/transactions/{first_deposit.json()['transaction']['id']}",
headers=auth_headers,
)
assert delete_response.status_code == 200
transactions_response = await async_client.get(
f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
)
assert transactions_response.status_code == 200
transactions = transactions_response.json()
assert len(transactions) == 2
cc_transaction = next(tx for tx in transactions if tx["cost_center_id"] == shared_center.id)
assert cc_transaction["amount"] == -5.0
assert cc_transaction["balance_after"] == -5.0
balance_response = await async_client.get(
f"/api/v1/finance/users/{created_user['id']}/balance", headers=auth_headers
)
assert balance_response.status_code == 200
assert balance_response.json()["balance"] == 7.0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_print_charge_stays_deleted_after_recalculate(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
await self._enable_basic_user_creation(db_session)
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,
filename="print.gcode",
file_path="archives/test/print.gcode",
file_size=10,
content_hash="hash-print",
status="completed",
cost=4.0,
created_by_id=user.id,
)
db_session.add(archive)
await db_session.flush()
tx = WalletTransaction(
user_id=user.id,
transaction_type="print_charge",
amount=-4.0,
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)))
.scalars()
.all()
)
assert len(tx_rows_before) == 1
delete_response = await async_client.delete(
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)))
.scalars()
.all()
)
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,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
"""Test that editing a transaction (user, cost_center, amount, description) rebuilds ledger."""
await self._enable_basic_user_creation(db_session)
user1 = await self._create_user_via_api(async_client, auth_headers, "user1")
user2 = await self._create_user_via_api(async_client, auth_headers, "user2")
# Create a cost center
cc_response = await async_client.post(
"/api/v1/finance/cost-centers",
json={"name": "Test Center", "is_active": True},
headers=auth_headers,
)
assert cc_response.status_code == 200
cost_center = cc_response.json()
# Get user records from DB
user1_db = await db_session.scalar(select(User).where(User.id == user1["id"]))
user2_db = await db_session.scalar(select(User).where(User.id == user2["id"]))
# Create a personal transaction for user1
tx_response = await async_client.post(
f"/api/v1/finance/users/{user1_db.id}/deposit",
json={"amount": 50.0, "description": "Initial deposit"},
headers=auth_headers,
)
assert tx_response.status_code == 200
tx_data = tx_response.json()
tx_id = tx_data["transaction"]["id"]
# Get the original transaction
original_tx = await db_session.scalar(select(WalletTransaction).where(WalletTransaction.id == tx_id))
assert original_tx.user_id == user1_db.id
assert original_tx.cost_center_id is None
assert original_tx.amount == 50.0
assert original_tx.balance_after == 50.0
# Edit the transaction: change user, add cost center, change amount
edit_response = await async_client.patch(
f"/api/v1/finance/transactions/{tx_id}",
json={
"user_id": user2_db.id,
"cost_center_id": cost_center["id"],
"amount": 75.0,
"description": "Updated deposit (Admin edit)",
},
headers=auth_headers,
)
assert edit_response.status_code == 200
edited_tx_data = edit_response.json()
# Verify transaction was updated
assert edited_tx_data["user_id"] == user2_db.id
assert edited_tx_data["cost_center_id"] == cost_center["id"]
assert edited_tx_data["amount"] == 75.0
# 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(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
"""Posting a manual print (manual_adjustment) creates a transaction and rebuilds ledger."""
await self._enable_basic_user_creation(db_session)
created_user = await self._create_user_via_api(async_client, auth_headers, "gina")
# Get user DB record
user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
assert user is not None
# Private cost center for user
private_cc = await db_session.scalar(
select(CostCenter).where(CostCenter.owner_user_id == created_user["id"], CostCenter.is_private.is_(True))
)
assert private_cc is not None
# Post manual print affecting the cost center
payload = {
"user_id": user.id,
"cost_center_id": private_cc.id,
"amount": 4.0,
"description": "Manual adjustment for a print",
"created_at": "2026-05-12T12:00:00Z",
}
response = await async_client.post("/api/v1/finance/transactions/manual", json=payload, headers=auth_headers)
assert response.status_code == 200
resp_json = response.json()
assert "transaction" in resp_json or "id" in resp_json
# Response contains the created transaction details
assert resp_json["transaction_type"] == "manual_adjustment"
assert resp_json["amount"] == -4.0
assert resp_json["cost_center_id"] == private_cc.id
# The response includes the computed running balance for the transaction
assert resp_json.get("balance_after") == -4.0
negative_amount_response = await async_client.post(
"/api/v1/finance/transactions/manual",
json={**payload, "amount": -1},
headers=auth_headers,
)
assert negative_amount_response.status_code == 422
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."""
@pytest.fixture
async def admin_user(self, db_session):
user = User(
username="partial-admin",
email="partial-admin@example.com",
password_hash=get_password_hash("AdminPass1!"),
role="admin",
is_active=True,
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest.fixture
async def auth_headers(self, async_client: AsyncClient, db_session, admin_user):
db_session.add(Settings(key="auth_enabled", value="true"))
db_session.add(Settings(key="advanced_auth_enabled", value="false"))
# Ensure billing is enabled for these partial-charge integration tests
existing = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
if existing is None:
db_session.add(Settings(key="billing_enabled", value="true"))
else:
existing.value = "true"
await db_session.commit()
response = await async_client.post(
"/api/v1/auth/login",
json={"username": admin_user.username, "password": "AdminPass1!"},
)
assert response.status_code == 200
return {"Authorization": f"Bearer {response.json()['access_token']}"}
async def _create_user_via_api(self, async_client: AsyncClient, auth_headers: dict[str, str], username: str):
response = await async_client.post(
"/api/v1/users",
json={
"username": username,
"password": "Regularpass1!",
"email": f"{username}@example.com",
"role": "user",
},
headers=auth_headers,
)
assert response.status_code == 201
return response.json()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_aborted_print_charges_proportionally_via_recalculate_endpoint(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
"""Verify aborted prints are included in recalculate and charged proportionally."""
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
# Wallet is already created by ensure_user_finance_defaults during user creation
# Archive: completed print (100% charge)
completed = PrintArchive(
printer_id=None,
filename="completed.3mf",
file_path="archives/test/completed.3mf",
file_size=100,
content_hash="partial-complete",
status="completed",
cost=10.0,
created_by_id=user.id,
)
# Archive: aborted print (50% filament used = 50% charge)
aborted = PrintArchive(
printer_id=None,
filename="aborted.3mf",
file_path="archives/test/aborted.3mf",
file_size=100,
content_hash="partial-aborted",
status="aborted",
cost=8.0,
filament_used_grams=50.0,
extra_data={"filament_grams_total": 100.0},
created_by_id=user.id,
)
# Archive: failed print (0% filament used = no charge)
failed = PrintArchive(
printer_id=None,
filename="failed.3mf",
file_path="archives/test/failed.3mf",
file_size=100,
content_hash="partial-failed",
status="failed",
cost=5.0,
filament_used_grams=0.0,
created_by_id=user.id,
)
db_session.add_all([completed, aborted, failed])
await db_session.commit()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_partial_charges_appear_in_transaction_ledger(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
"""Verify transaction descriptions indicate partial charges."""
created_user = await self._create_user_via_api(async_client, auth_headers, "grace")
user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
assert user is not None
# Wallet is already created by ensure_user_finance_defaults during user creation
cancelled = PrintArchive(
printer_id=None,
filename="cancelled.3mf",
file_path="archives/test/cancelled.3mf",
file_size=100,
content_hash="partial-cancel",
status="cancelled",
cost=12.0,
filament_used_grams=25.0,
extra_data={"filament_grams_total": 100.0},
print_name="Partially Cancelled Print",
created_by_id=user.id,
)
db_session.add(cancelled)
await db_session.commit()
from backend.app.services.finance_billing import apply_print_charge_for_archive
changed = await apply_print_charge_for_archive(db_session, cancelled.id)
assert changed is True
await db_session.commit()
tx_response = await async_client.get(
f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
)
assert tx_response.status_code == 200
transactions = tx_response.json()
assert len(transactions) == 1
tx = transactions[0]
assert tx["transaction_type"] == "print_charge"
assert tx["amount"] == -3.0 # 25% of 12.0
assert "cancelled" in tx["description"].lower()
assert "25.0g/100.0" in tx["description"] # filament amounts in description
@pytest.mark.asyncio
@pytest.mark.integration
async def test_partial_charges_with_cost_center_override(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
"""Verify partial charges respect cost_center_id when present."""
created_user = await self._create_user_via_api(async_client, auth_headers, "henry")
user = await db_session.scalar(select(User).where(User.id == created_user["id"]))
assert user is not None
# Create cost centers
default_cc = CostCenter(name="Default CC", owner_user_id=user.id, is_active=True, is_private=False)
lab_cc = CostCenter(name="Lab CC", owner_user_id=user.id, is_active=True, is_private=False)
db_session.add_all([default_cc, lab_cc])
await db_session.flush()
# Wallet is already created by ensure_user_finance_defaults during user creation
# Archive assigned to default_cc
aborted = PrintArchive(
printer_id=None,
filename="aborted_cc.3mf",
file_path="archives/test/aborted_cc.3mf",
file_size=100,
content_hash="partial-cc",
status="aborted",
cost=6.0,
filament_used_grams=30.0,
extra_data={"filament_grams_total": 100.0},
cost_center_id=default_cc.id,
created_by_id=user.id,
)
db_session.add(aborted)
await db_session.commit()
# Manually apply charge with override
from backend.app.services.finance_billing import apply_print_charge_for_archive
changed = await apply_print_charge_for_archive(
db_session,
aborted.id,
cost_center_id=lab_cc.id,
)
await db_session.commit()
assert changed is True
tx_response = await async_client.get(
f"/api/v1/finance/users/{created_user['id']}/transactions", headers=auth_headers
)
assert tx_response.status_code == 200
transactions = tx_response.json()
assert len(transactions) == 1
tx = transactions[0]
assert tx["cost_center_id"] == lab_cc.id # Overridden to lab_cc
assert tx["amount"] == pytest.approx(-1.8, abs=0.01) # 30% of 6.0
class TestFinanceUserDefaults:
"""Tests for user creation and finance defaults initialization."""
@pytest.fixture
async def admin_user(self, db_session):
user = User(
username="billing-admin",
email="billing-admin@example.com",
password_hash=get_password_hash("AdminPass1!"),
role="admin",
is_active=True,
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest.fixture
async def auth_headers(self, async_client: AsyncClient, db_session, admin_user):
db_session.add(Settings(key="auth_enabled", value="true"))
db_session.add(Settings(key="advanced_auth_enabled", value="false"))
await db_session.commit()
response = await async_client.post(
"/api/v1/auth/login",
json={"username": admin_user.username, "password": "AdminPass1!"},
)
assert response.status_code == 200
return {"Authorization": f"Bearer {response.json()['access_token']}"}
async def _create_user_via_api(self, async_client: AsyncClient, auth_headers: dict[str, str], username: str):
response = await async_client.post(
"/api/v1/users",
json={
"username": username,
"password": "Regularpass1!",
"email": f"{username}@example.com",
"role": "user",
},
headers=auth_headers,
)
assert response.status_code == 201
return response.json()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_user_initializes_wallet_and_private_cost_center(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
"""Verify user creation initializes wallet, private cost center, and membership."""
result = await async_client.post(
"/api/v1/users",
json={
"username": "alice",
"password": "Regularpass1!",
"email": "alice@example.com",
"role": "user",
},
headers=auth_headers,
)
assert result.status_code == 201
created = result.json()
assert created["username"] == "alice"
user = await db_session.scalar(select(User).where(User.username == "alice"))
assert user is not None
from backend.app.models.finance import CostCenterMember
wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
assert wallet is not None
assert wallet.balance == 0.0
private_center = await db_session.scalar(
select(CostCenter).where(CostCenter.owner_user_id == user.id, CostCenter.is_private.is_(True))
)
assert private_center is not None
assert private_center.name == "alice"
membership = await db_session.scalar(
select(CostCenterMember).where(
CostCenterMember.cost_center_id == private_center.id,
CostCenterMember.user_id == user.id,
)
)
assert membership is not None
assert membership.can_print is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_user_keeps_private_cost_center_in_sync(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
):
"""Verify user updates keep private cost center name in sync."""
created = await self._create_user_via_api(async_client, auth_headers, "bob")
response = await async_client.patch(
f"/api/v1/users/{created['id']}",
json={"username": "bobby"},
headers=auth_headers,
)
assert response.status_code == 200
assert response.json()["username"] == "bobby"
user = await db_session.scalar(select(User).where(User.id == created["id"]))
assert user is not None
private_centers = (
(
await db_session.execute(
select(CostCenter).where(CostCenter.owner_user_id == user.id, CostCenter.is_private.is_(True))
)
)
.scalars()
.all()
)
assert len(private_centers) == 1
assert private_centers[0].name == "bobby"
wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
assert wallet is not None
class TestFinanceCurrency(TestFinanceAPI):
"""#3123: every balance is reported in the install's configured currency.
Wallets used to carry a currency of their own, which three of its four
writers filled with a hardcoded "EUR" and the Finance page rendered as it
found it -- so an install set to AUD showed a euro balance. The column is
gone; these tests pin what replaced it.
"""
@pytest.fixture
async def aud_install(self, db_session):
existing = await db_session.scalar(select(Settings).where(Settings.key == "currency"))
if existing is None:
db_session.add(Settings(key="currency", value="AUD"))
else:
existing.value = "AUD"
await db_session.commit()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_balance_reports_the_configured_currency_without_a_wallet(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
admin_user,
aud_install,
):
"""The read-only path used to answer a flat "EUR" when no wallet row existed."""
assert await db_session.scalar(select(UserWallet).where(UserWallet.user_id == admin_user.id)) is None
response = await async_client.get("/api/v1/finance/me/balance", headers=auth_headers)
assert response.status_code == 200
assert response.json()["currency"] == "AUD"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_an_existing_wallet_is_reported_in_the_configured_currency(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
admin_user,
aud_install,
):
"""The reporter's case: a wallet created back when the install said EUR."""
db_session.add(UserWallet(user_id=admin_user.id, balance=12.34))
await db_session.commit()
response = await async_client.get("/api/v1/finance/me/balance", headers=auth_headers)
assert response.status_code == 200
assert response.json()["balance"] == 12.34
assert response.json()["currency"] == "AUD"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_an_adjustment_answers_in_the_configured_currency(
self,
async_client: AsyncClient,
auth_headers: dict[str, str],
db_session,
admin_user,
aud_install,
):
"""_get_or_create_wallet is the path that used to write EUR into the database."""
response = await async_client.post(
f"/api/v1/finance/users/{admin_user.id}/deposit",
json={"amount": 5.0, "description": "currency check"},
headers=auth_headers,
)
assert response.status_code == 200, response.text
assert response.json()["balance"]["currency"] == "AUD"