diff --git a/backend/lcfs/tests/compliance_report/conftest.py b/backend/lcfs/tests/compliance_report/conftest.py index 032962699..1422e631d 100644 --- a/backend/lcfs/tests/compliance_report/conftest.py +++ b/backend/lcfs/tests/compliance_report/conftest.py @@ -263,12 +263,14 @@ def mock_fuel_supply_repo(): def mock_fuel_export_repo(): return AsyncMock(spec=FuelExportRepository) + @pytest.fixture def mock_other_uses_repo(): mock_repo = MagicMock() mock_repo.get_effective_other_uses = AsyncMock(return_value=MagicMock()) return mock_repo + @pytest.fixture def compliance_report_summary_service( mock_repo, @@ -276,7 +278,7 @@ def compliance_report_summary_service( mock_notional_transfer_service, mock_fuel_supply_repo, mock_fuel_export_repo, - mock_other_uses_repo + mock_other_uses_repo, ): service = ComplianceReportSummaryService() service.repo = mock_repo @@ -290,13 +292,14 @@ def compliance_report_summary_service( @pytest.fixture def compliance_report_update_service( - mock_repo, compliance_report_summary_service, mock_user_profile + mock_repo, mock_org_service, compliance_report_summary_service, mock_user_profile ): service = ComplianceReportUpdateService() service.repo = mock_repo service.summary_service = compliance_report_summary_service service.request = MagicMock() service.request.user = mock_user_profile + service.org_service = mock_org_service return service diff --git a/backend/lcfs/tests/compliance_report/test_update_service.py b/backend/lcfs/tests/compliance_report/test_update_service.py index a1c768149..2fa7485f4 100644 --- a/backend/lcfs/tests/compliance_report/test_update_service.py +++ b/backend/lcfs/tests/compliance_report/test_update_service.py @@ -9,6 +9,7 @@ ComplianceReportStatusEnum, ) from lcfs.db.models.compliance.ComplianceReportSummary import ComplianceReportSummary +from lcfs.db.models.transaction.Transaction import TransactionActionEnum from lcfs.db.models.user.Role import RoleEnum from lcfs.web.api.compliance_report.schema import ( ComplianceReportUpdateSchema, @@ -53,6 +54,7 @@ def mock_environment_vars(): def mock_org_service(): mock_org_service = MagicMock() mock_org_service.adjust_balance = AsyncMock() # Mock the adjust_balance method + mock_org_service.calculate_available_balance = AsyncMock(return_value=1000) return mock_org_service @@ -594,3 +596,186 @@ async def test_handle_submitted_no_sign( with pytest.raises(ServiceException): await compliance_report_update_service.handle_submitted_status(mock_report) + + +@pytest.mark.anyio +async def test_handle_submitted_status_no_credits( + compliance_report_update_service, + mock_repo, + mock_user_has_roles, + mock_org_service, + compliance_report_summary_service, +): + """ + Scenario: The report requires deficit units to be reserved (-100), + but available_balance is 0, so no transaction is created. + """ + report_id = 1 + mock_report = MagicMock(spec=ComplianceReport) + mock_report.compliance_report_id = report_id + mock_report.organization_id = 123 + # Deficit units is nonzero + mock_report.summary = MagicMock( + spec=ComplianceReportSummary, line_20_surplus_deficit_units=-100 + ) + # No existing transaction + mock_report.transaction = None + + # Required roles are present + mock_user_has_roles.return_value = True + compliance_report_update_service.request = MagicMock() + compliance_report_update_service.request.user = MagicMock() + + # Mock the summary so we skip deeper logic + mock_repo.get_summary_by_report_id.return_value = None + + # Pretend the final summary can_sign is True + calculated_summary = ComplianceReportSummarySchema( + can_sign=True, + compliance_report_id=report_id, + renewable_fuel_target_summary=[], + low_carbon_fuel_target_summary=[], + non_compliance_penalty_summary=[], + ) + compliance_report_summary_service.calculate_compliance_report_summary = AsyncMock( + return_value=calculated_summary + ) + + # available_balance = 0 + mock_org_service.calculate_available_balance.return_value = 0 + # If adjust_balance is called, we'll see an assertion fail + mock_org_service.adjust_balance = AsyncMock() + + # Execute + await compliance_report_update_service.handle_submitted_status(mock_report) + + # Assertions: + # 1) We did NOT call adjust_balance, because balance = 0 + mock_org_service.adjust_balance.assert_not_awaited() + # 2) No transaction is created + assert mock_report.transaction is None + + +@pytest.mark.anyio +async def test_handle_submitted_status_insufficient_credits( + compliance_report_update_service, + mock_repo, + mock_user_has_roles, + mock_org_service, + compliance_report_summary_service, +): + """ + Scenario: The report requires deficit units of 100, + but the org only has 50 credits available. We reserve partial (-50) + to match the actual available balance. + """ + report_id = 1 + mock_report = MagicMock(spec=ComplianceReport) + mock_report.compliance_report_id = report_id + mock_report.organization_id = 123 + # Need 100 credits, but only 50 are available + mock_report.summary = MagicMock(spec=ComplianceReportSummary) + mock_report.summary.line_20_surplus_deficit_units = -100 + mock_report.transaction = None + + mock_user_has_roles.return_value = True + compliance_report_update_service.request = MagicMock() + compliance_report_update_service.request.user = MagicMock() + + # Skip deeper summary logic + mock_repo.get_summary_by_report_id.return_value = None + mock_repo.save_compliance_report_summary = AsyncMock( + return_value=mock_report.summary + ) + mock_repo.add_compliance_report_summary = AsyncMock( + return_value=mock_report.summary + ) + calculated_summary = ComplianceReportSummarySchema( + can_sign=True, + compliance_report_id=report_id, + renewable_fuel_target_summary=[], + low_carbon_fuel_target_summary=[], + non_compliance_penalty_summary=[], + ) + compliance_report_summary_service.calculate_compliance_report_summary = AsyncMock( + return_value=calculated_summary + ) + + # Org only has 50 + mock_org_service.calculate_available_balance = AsyncMock(return_value=50) + # Mock the result of adjust_balance + mock_transaction = MagicMock() + mock_org_service.adjust_balance.return_value = mock_transaction + + # Execute + await compliance_report_update_service.handle_submitted_status(mock_report) + + # We should have called adjust_balance with -50 units (reserving partial) + mock_org_service.adjust_balance.assert_awaited_once_with( + transaction_action=TransactionActionEnum.Reserved, + compliance_units=-50, + organization_id=123, + ) + # And a transaction object is assigned back to the report + assert mock_report.transaction == mock_transaction + + +@pytest.mark.anyio +async def test_handle_submitted_status_sufficient_credits( + compliance_report_update_service, + mock_repo, + mock_user_has_roles, + mock_org_service, + compliance_report_summary_service, +): + """ + Scenario: The report requires deficit units of -100, + and the org has 200 credits available. We reserve all -100. + """ + report_id = 1 + mock_report = MagicMock(spec=ComplianceReport) + mock_report.compliance_report_id = report_id + mock_report.organization_id = 123 + # Need 100 credits + mock_report.summary = MagicMock(spec=ComplianceReportSummary) + mock_report.summary.line_20_surplus_deficit_units = -100 + mock_report.transaction = None + + mock_user_has_roles.return_value = True + compliance_report_update_service.request = MagicMock() + compliance_report_update_service.request.user = MagicMock() + + # Skip deeper summary logic + mock_repo.get_summary_by_report_id.return_value = None + mock_repo.save_compliance_report_summary = AsyncMock( + return_value=mock_report.summary + ) + mock_repo.add_compliance_report_summary = AsyncMock( + return_value=mock_report.summary + ) + calculated_summary = ComplianceReportSummarySchema( + can_sign=True, + compliance_report_id=report_id, + renewable_fuel_target_summary=[], + low_carbon_fuel_target_summary=[], + non_compliance_penalty_summary=[], + ) + compliance_report_summary_service.calculate_compliance_report_summary = AsyncMock( + return_value=calculated_summary + ) + + # Org has enough + mock_org_service.calculate_available_balance.return_value = 200 + mock_transaction = MagicMock() + mock_org_service.adjust_balance.return_value = mock_transaction + + # Execute + await compliance_report_update_service.handle_submitted_status(mock_report) + + # We should have called adjust_balance with the full -100 + mock_org_service.adjust_balance.assert_awaited_once_with( + transaction_action=TransactionActionEnum.Reserved, + compliance_units=-100, + organization_id=123, + ) + assert mock_report.transaction == mock_transaction diff --git a/backend/lcfs/web/api/compliance_report/update_service.py b/backend/lcfs/web/api/compliance_report/update_service.py index c7ff24133..d53d8c835 100644 --- a/backend/lcfs/web/api/compliance_report/update_service.py +++ b/backend/lcfs/web/api/compliance_report/update_service.py @@ -251,19 +251,29 @@ async def handle_submitted_status(self, report: ComplianceReport): # Update the report with the new summary report.summary = new_summary - if report.summary.line_20_surplus_deficit_units != 0: + credit_change = report.summary.line_20_surplus_deficit_units + if credit_change != 0: if report.transaction is not None: # Update existing transaction - report.transaction.compliance_units = ( - report.summary.line_20_surplus_deficit_units - ) + report.transaction.compliance_units = credit_change else: - # Create a new reserved transaction for receiving organization - report.transaction = await self.org_service.adjust_balance( - transaction_action=TransactionActionEnum.Reserved, - compliance_units=report.summary.line_20_surplus_deficit_units, - organization_id=report.organization_id, + available_balance = await self.org_service.calculate_available_balance( + report.organization_id ) + # Only need a Transaction if they have credits + if available_balance > 0: + units_to_reserve = credit_change + + # If not enough credits, reserve what is left + if credit_change < 0 and abs(credit_change) > available_balance: + units_to_reserve = available_balance * -1 + + report.transaction = await self.org_service.adjust_balance( + transaction_action=TransactionActionEnum.Reserved, + compliance_units=units_to_reserve, + organization_id=report.organization_id, + ) + await self.repo.update_compliance_report(report) return calculated_summary