| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193 |
- """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
|