| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559 |
- """Unit tests for billing charges applied to print archives."""
- import pytest
- from sqlalchemy import select
- from backend.app.models.archive import PrintArchive
- from backend.app.models.finance import BudgetReservation, CostCenter, UserWallet, WalletTransaction
- 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
- async def enable_billing(db_session):
- setting = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
- if setting is None:
- db_session.add(Settings(key="billing_enabled", value="true"))
- else:
- setting.value = "true"
- await db_session.commit()
- class TestFinanceBilling:
- @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)
- user = User(username="printer", role="user", is_active=True)
- archive_cost_center = CostCenter(name="Archive CC", is_active=True, is_private=False)
- override_cost_center = CostCenter(name="Override CC", is_active=True, is_private=False)
- db_session.add_all([user, archive_cost_center, override_cost_center])
- await db_session.commit()
- await db_session.refresh(user)
- await db_session.refresh(archive_cost_center)
- await db_session.refresh(override_cost_center)
- archive = PrintArchive(
- printer_id=None,
- filename="test.3mf",
- file_path="archives/test/test.3mf",
- file_size=123,
- content_hash="hash-1",
- status="completed",
- cost=7.5,
- created_by_id=user.id,
- cost_center_id=archive_cost_center.id,
- )
- db_session.add(archive)
- await db_session.commit()
- await db_session.refresh(archive)
- changed = await apply_print_charge_for_archive(
- db_session,
- archive.id,
- cost_center_id=override_cost_center.id,
- print_run_id="run-1",
- )
- await db_session.commit()
- assert changed is True
- assert archive.cost_center_id == archive_cost_center.id
- wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
- assert wallet is not None
- assert wallet.balance == -7.5
- tx = await db_session.scalar(select(WalletTransaction).where(WalletTransaction.print_run_id == "run-1"))
- assert tx is not None
- assert tx.cost_center_id == override_cost_center.id
- assert tx.print_archive_id == archive.id
- duplicate = await apply_print_charge_for_archive(
- db_session,
- archive.id,
- cost_center_id=override_cost_center.id,
- print_run_id="run-1",
- )
- assert duplicate is False
- second_run = await apply_print_charge_for_archive(
- db_session,
- archive.id,
- cost_center_id=override_cost_center.id,
- print_run_id="run-2",
- )
- await db_session.commit()
- assert second_run is True
- wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
- assert wallet is not None
- assert wallet.balance == -15.0
- rows = (
- (await db_session.execute(select(WalletTransaction).where(WalletTransaction.user_id == user.id)))
- .scalars()
- .all()
- )
- assert len(rows) == 2
- assert {row.print_run_id for row in rows} == {"run-1", "run-2"}
- @pytest.mark.asyncio
- async def test_apply_print_charge_consumes_matching_budget_reservation(self, db_session):
- await enable_billing(db_session)
- user = User(username="reserved", role="user", is_active=True)
- cost_center = CostCenter(name="Reserved CC", is_active=True, is_private=False)
- db_session.add_all([user, cost_center])
- await db_session.commit()
- await db_session.refresh(user)
- await db_session.refresh(cost_center)
- archive = PrintArchive(
- printer_id=None,
- filename="reserved.3mf",
- file_path="archives/test/reserved.3mf",
- file_size=123,
- content_hash="hash-reserved",
- status="completed",
- cost=4.0,
- created_by_id=user.id,
- cost_center_id=cost_center.id,
- )
- db_session.add(archive)
- await db_session.commit()
- await db_session.refresh(archive)
- reservation = BudgetReservation(
- cost_center_id=cost_center.id,
- amount=4.0,
- status="active",
- source_type="background_dispatch",
- source_id=42,
- print_archive_id=archive.id,
- )
- db_session.add(reservation)
- await db_session.commit()
- await db_session.refresh(reservation)
- changed = await apply_print_charge_for_archive(db_session, archive.id, print_run_id="run-reserved")
- await db_session.commit()
- assert changed is True
- await db_session.refresh(reservation)
- assert reservation.status == "consumed"
- assert reservation.released_at is not None
- @pytest.mark.asyncio
- async def test_apply_print_charge_rejects_ineligible_archive(self, db_session):
- await enable_billing(db_session)
- user = User(username="skipped", role="user", is_active=True)
- db_session.add(user)
- await db_session.commit()
- await db_session.refresh(user)
- # Reject print with unknown status
- archive = PrintArchive(
- printer_id=None,
- filename="unknown.3mf",
- file_path="archives/test/unknown.3mf",
- file_size=123,
- content_hash="hash-2",
- status="unknown",
- cost=1.0,
- created_by_id=user.id,
- )
- db_session.add(archive)
- await db_session.commit()
- await db_session.refresh(archive)
- changed = await apply_print_charge_for_archive(db_session, archive.id, print_run_id="run-unknown")
- assert changed is False
- @pytest.mark.asyncio
- async def test_apply_print_charge_skips_when_billing_disabled(self, db_session):
- user = User(username="billing_disabled", role="user", is_active=True)
- cost_center = CostCenter(name="Disabled Billing CC", is_active=True, is_private=False)
- db_session.add_all([user, cost_center])
- await db_session.commit()
- await db_session.refresh(user)
- await db_session.refresh(cost_center)
- archive = PrintArchive(
- printer_id=None,
- filename="billing-disabled.3mf",
- file_path="archives/test/billing-disabled.3mf",
- file_size=123,
- content_hash="hash-disabled-billing",
- status="completed",
- cost=7.5,
- created_by_id=user.id,
- cost_center_id=cost_center.id,
- )
- db_session.add(archive)
- await db_session.commit()
- await db_session.refresh(archive)
- reservation = BudgetReservation(
- cost_center_id=cost_center.id,
- amount=7.5,
- status="active",
- source_type="background_dispatch",
- source_id=123,
- print_archive_id=archive.id,
- )
- db_session.add(reservation)
- await db_session.commit()
- await db_session.refresh(reservation)
- changed = await apply_print_charge_for_archive(db_session, archive.id, print_run_id="run-disabled")
- await db_session.commit()
- assert changed is False
- wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
- tx = await db_session.scalar(select(WalletTransaction).where(WalletTransaction.print_run_id == "run-disabled"))
- assert wallet is None
- assert tx is None
- await db_session.refresh(reservation)
- assert reservation.status == "released"
- assert reservation.released_at is not None
- class TestPartialPrintCharges:
- """Tests for proportional charge calculation on aborted/failed/cancelled prints."""
- @pytest.mark.asyncio
- @pytest.mark.parametrize("status", ["cancelled", "aborted", "failed"])
- async def test_terminal_partial_print_uses_per_run_consumption_and_consumes_reservation(
- self,
- db_session,
- status,
- ):
- """Bambuddy stop, display abort, and printer failure share one billing path."""
- await enable_billing(db_session)
- user = User(username=f"partial_{status}", role="user", is_active=True)
- cost_center = CostCenter(name=f"Partial {status} CC", is_active=True, is_private=False)
- db_session.add_all([user, cost_center])
- await db_session.commit()
- await db_session.refresh(user)
- await db_session.refresh(cost_center)
- archive = PrintArchive(
- printer_id=None,
- filename=f"{status}.3mf",
- file_path=f"archives/test/{status}.3mf",
- file_size=100,
- content_hash=f"partial-{status}-override",
- status=status,
- # The usage tracker may already have replaced archive.cost with the
- # measured partial cost. Completion billing must use the estimate
- # captured before tracking, not discount this value a second time.
- cost=3.0,
- filament_used_grams=100.0,
- extra_data={"filament_grams_total": 100.0},
- created_by_id=user.id,
- cost_center_id=cost_center.id,
- )
- db_session.add(archive)
- await db_session.commit()
- await db_session.refresh(archive)
- reservation = BudgetReservation(
- cost_center_id=cost_center.id,
- amount=12.0,
- status="active",
- source_type="print_queue",
- source_id=archive.id,
- print_archive_id=archive.id,
- )
- db_session.add(reservation)
- await db_session.commit()
- await db_session.refresh(reservation)
- changed = await apply_print_charge_for_archive(
- db_session,
- archive.id,
- base_cost_override=12.0,
- filament_usage=(25.0, 100.0),
- )
- await db_session.commit()
- assert changed is True
- wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
- assert wallet is not None
- assert wallet.balance == -3.0
- transaction = await db_session.scalar(
- select(WalletTransaction).where(WalletTransaction.print_archive_id == archive.id)
- )
- assert transaction is not None
- assert transaction.amount == -3.0
- assert status in transaction.description.lower()
- assert "25.0g/100.0g" in transaction.description
- await db_session.refresh(reservation)
- assert reservation.status == "consumed"
- assert reservation.released_at is not None
- @pytest.mark.asyncio
- async def test_partial_print_with_missing_planned_filament_is_skipped(self, db_session):
- await enable_billing(db_session)
- user = User(username="missing_plan", role="user", is_active=True)
- cost_center = CostCenter(name="Missing Plan CC", is_active=True, is_private=False)
- db_session.add_all([user, cost_center])
- await db_session.commit()
- await db_session.refresh(user)
- await db_session.refresh(cost_center)
- archive = PrintArchive(
- printer_id=None,
- filename="missing-plan.3mf",
- file_path="archives/test/missing-plan.3mf",
- file_size=100,
- content_hash="missing-plan-hash",
- status="aborted",
- cost=12.0,
- filament_used_grams=80.0,
- created_by_id=user.id,
- cost_center_id=cost_center.id,
- )
- db_session.add(archive)
- await db_session.commit()
- await db_session.refresh(archive)
- changed = await apply_print_charge_for_archive(db_session, archive.id)
- await db_session.commit()
- assert changed is False
- wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
- assert wallet is None
- @pytest.mark.asyncio
- async def test_invalid_transaction_type_is_rejected(self, db_session):
- user = User(username="invalid_tx", role="user", is_active=True)
- db_session.add(user)
- await db_session.commit()
- await db_session.refresh(user)
- with pytest.raises(ValueError, match="Invalid transaction type"):
- WalletTransaction(
- user_id=user.id,
- transaction_type="not-a-real-type",
- amount=1.0,
- )
- @pytest.mark.asyncio
- async def test_aborted_print_with_partial_filament_charges_proportionally(self, db_session):
- """Verify aborted print charges proportionally based on filament used."""
- await enable_billing(db_session)
- user = User(username="abort_test", role="user", is_active=True)
- cost_center = CostCenter(name="Abort CC", is_active=True, is_private=False)
- db_session.add_all([user, cost_center])
- await db_session.commit()
- await db_session.refresh(user)
- await db_session.refresh(cost_center)
- # Archive with 100g planned, but only 50g used (50% filament)
- archive = PrintArchive(
- printer_id=None,
- filename="abort.3mf",
- file_path="archives/test/abort.3mf",
- file_size=100,
- content_hash="abort-hash",
- status="aborted",
- cost=10.0, # Full cost would be 10.0
- filament_used_grams=50.0,
- extra_data={"filament_grams_total": 100.0},
- created_by_id=user.id,
- cost_center_id=cost_center.id,
- )
- db_session.add(archive)
- await db_session.commit()
- await db_session.refresh(archive)
- changed = await apply_print_charge_for_archive(db_session, archive.id)
- await db_session.commit()
- assert changed is True
- wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
- assert wallet is not None
- assert wallet.balance == -5.0 # 50% of 10.0
- tx = await db_session.scalar(
- select(WalletTransaction)
- .where(WalletTransaction.user_id == user.id)
- .where(WalletTransaction.transaction_type == "print_charge")
- )
- assert tx is not None
- assert tx.amount == -5.0
- assert "aborted" in tx.description.lower()
- assert "50.0" in tx.description # filament used
- @pytest.mark.asyncio
- async def test_cancelled_print_with_zero_run_usage_is_not_charged(self, db_session):
- """A slicer estimate alone is not mistaken for actual run consumption."""
- await enable_billing(db_session)
- user = User(username="cancel_no_data", role="user", is_active=True)
- cost_center = CostCenter(name="Cancel No Data CC", is_active=True, is_private=False)
- db_session.add_all([user, cost_center])
- await db_session.commit()
- await db_session.refresh(user)
- await db_session.refresh(cost_center)
- archive = PrintArchive(
- printer_id=None,
- filename="cancel.3mf",
- file_path="archives/test/cancel.3mf",
- file_size=100,
- content_hash="cancel-hash",
- status="cancelled",
- cost=5.0,
- filament_used_grams=100.0,
- extra_data={"filament_grams_total": 100.0},
- created_by_id=user.id,
- cost_center_id=cost_center.id,
- )
- db_session.add(archive)
- await db_session.commit()
- await db_session.refresh(archive)
- reservation = BudgetReservation(
- cost_center_id=cost_center.id,
- amount=5.0,
- status="active",
- source_type="background_dispatch",
- source_id=99,
- print_archive_id=archive.id,
- )
- db_session.add(reservation)
- await db_session.commit()
- await db_session.refresh(reservation)
- changed = await apply_print_charge_for_archive(
- db_session,
- archive.id,
- filament_usage=(None, 100.0),
- )
- await db_session.commit()
- assert changed is False
- wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
- assert wallet is None # No wallet created
- await db_session.refresh(reservation)
- assert reservation.status == "released"
- assert reservation.released_at is not None
- @pytest.mark.asyncio
- async def test_failed_print_with_minimal_filament_charges_small_amount(self, db_session):
- """Verify failed print with minimal filament usage charges proportionally."""
- await enable_billing(db_session)
- user = User(username="fail_min", role="user", is_active=True)
- cost_center = CostCenter(name="Fail Min CC", is_active=True, is_private=False)
- db_session.add_all([user, cost_center])
- await db_session.commit()
- await db_session.refresh(user)
- await db_session.refresh(cost_center)
- # 5% filament used out of 100g planned
- archive = PrintArchive(
- printer_id=None,
- filename="fail_min.3mf",
- file_path="archives/test/fail_min.3mf",
- file_size=100,
- content_hash="fail-min-hash",
- status="failed",
- cost=20.0,
- filament_used_grams=5.0,
- extra_data={"filament_grams_total": 100.0},
- failure_reason="Filament runout",
- created_by_id=user.id,
- cost_center_id=cost_center.id,
- )
- db_session.add(archive)
- await db_session.commit()
- await db_session.refresh(archive)
- changed = await apply_print_charge_for_archive(db_session, archive.id)
- await db_session.commit()
- assert changed is True
- wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
- assert wallet is not None
- assert wallet.balance == pytest.approx(-1.0, abs=0.01) # 5% of 20.0
- @pytest.mark.asyncio
- async def test_completed_print_still_charges_full_cost(self, db_session):
- """Verify completed prints ignore filament ratio and charge full cost."""
- await enable_billing(db_session)
- user = User(username="completed_full", role="user", is_active=True)
- cost_center = CostCenter(name="Completed Full CC", is_active=True, is_private=False)
- db_session.add_all([user, cost_center])
- await db_session.commit()
- await db_session.refresh(user)
- await db_session.refresh(cost_center)
- archive = PrintArchive(
- printer_id=None,
- filename="complete.3mf",
- file_path="archives/test/complete.3mf",
- file_size=100,
- content_hash="complete-hash",
- status="completed",
- cost=15.0,
- filament_used_grams=100.0,
- extra_data={"filament_grams_total": 100.0},
- created_by_id=user.id,
- cost_center_id=cost_center.id,
- )
- db_session.add(archive)
- await db_session.commit()
- await db_session.refresh(archive)
- changed = await apply_print_charge_for_archive(db_session, archive.id)
- await db_session.commit()
- assert changed is True
- wallet = await db_session.scalar(select(UserWallet).where(UserWallet.user_id == user.id))
- assert wallet.balance == -15.0 # Full cost, not proportional
- @pytest.mark.asyncio
- async def test_partial_charge_with_cost_center_override(self, db_session):
- """Verify partial charges respect cost_center_id override."""
- await enable_billing(db_session)
- user = User(username="partial_cc", role="user", is_active=True)
- default_cc = CostCenter(name="Default", is_active=True, is_private=False)
- override_cc = CostCenter(name="Override", is_active=True, is_private=False)
- db_session.add_all([user, default_cc, override_cc])
- await db_session.commit()
- await db_session.refresh(user)
- await db_session.refresh(default_cc)
- await db_session.refresh(override_cc)
- archive = PrintArchive(
- printer_id=None,
- filename="partial_cc.3mf",
- file_path="archives/test/partial_cc.3mf",
- file_size=100,
- content_hash="partial-cc-hash",
- status="aborted",
- cost=8.0,
- filament_used_grams=25.0,
- extra_data={"filament_grams_total": 100.0},
- cost_center_id=default_cc.id,
- created_by_id=user.id,
- )
- db_session.add(archive)
- await db_session.commit()
- await db_session.refresh(archive)
- changed = await apply_print_charge_for_archive(db_session, archive.id, cost_center_id=override_cc.id)
- await db_session.commit()
- assert changed is True
- tx = await db_session.scalar(
- select(WalletTransaction)
- .where(WalletTransaction.user_id == user.id)
- .where(WalletTransaction.transaction_type == "print_charge")
- )
- assert tx is not None
- assert tx.cost_center_id == override_cc.id
- assert tx.amount == -2.0 # 25% of 8.0
|