Skip to content

Commit 6ad86fb

Browse files
fix: negative balance
1 parent 1cf5539 commit 6ad86fb

File tree

4 files changed

+265
-148
lines changed

4 files changed

+265
-148
lines changed

backend/lcfs/tests/compliance_report/conftest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,9 @@ def mock_org_service():
272272
mock_org_service = MagicMock()
273273
mock_org_service.adjust_balance = AsyncMock() # Mock the adjust_balance method
274274
mock_org_service.calculate_available_balance = AsyncMock(return_value=1000)
275+
mock_org_service.calculate_available_balance_for_period = AsyncMock(
276+
return_value=1000
277+
)
275278
return mock_org_service
276279

277280

backend/lcfs/tests/compliance_report/test_update_service.py

Lines changed: 123 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -598,189 +598,126 @@ async def test_handle_submitted_no_sign(
598598

599599

600600
@pytest.mark.anyio
601-
async def test_handle_submitted_status_no_credits(
601+
async def test_handle_submitted_status_skips_reserve_without_pre_deadline_balance(
602602
compliance_report_update_service,
603-
mock_repo,
604603
mock_user_has_roles,
605604
mock_org_service,
606605
mock_summary_service,
607-
mock_summary_repo,
608606
):
609-
"""
610-
Scenario: The report requires deficit units to be reserved (-100),
611-
but available_balance is 0, so no transaction is created.
612-
"""
613-
report_id = 1
614-
mock_report = MagicMock(spec=ComplianceReport)
615-
mock_report.compliance_report_id = report_id
616-
mock_report.organization_id = 123
617-
mock_report.summary = None
618-
# No existing transaction
619-
mock_report.transaction = None
620-
621-
# Required roles are present
607+
"""Scenario: No eligible credits exist before the deadline, so no reserve is created."""
622608
mock_user_has_roles.return_value = True
623-
compliance_report_update_service.request = MagicMock()
624-
compliance_report_update_service.request.user = MagicMock()
609+
report = MagicMock(spec=ComplianceReport)
610+
report.compliance_report_id = 1
611+
report.organization_id = 123
612+
report.compliance_period = MagicMock(description="2024")
613+
report.summary = MagicMock(spec=ComplianceReportSummary)
614+
report.summary.line_20_surplus_deficit_units = -150
615+
report.transaction = None
625616

626-
# Mock the summary so we skip deeper logic
627-
mock_summary_repo.get_summary_by_report_id.return_value = None
628-
629-
# Mock calculated summary - should be model-like object with line_20_surplus_deficit_units
630-
calculated_summary = MagicMock(spec=ComplianceReportSummary)
631-
calculated_summary.line_20_surplus_deficit_units = -100
632-
mock_summary_service.calculate_compliance_report_summary = AsyncMock(
633-
return_value=calculated_summary
617+
mock_summary_service.calculate_compliance_report_summary.return_value = (
618+
report.summary
634619
)
635-
# available_balance = 0
636-
mock_org_service.calculate_available_balance.return_value = 0
637-
# If adjust_balance is called, we'll see an assertion fail
638-
mock_org_service.adjust_balance = AsyncMock()
620+
mock_org_service.calculate_available_balance.return_value = 100000
621+
mock_org_service.calculate_available_balance_for_period.return_value = 0
639622

640-
# Execute
641623
await compliance_report_update_service.handle_submitted_status(
642-
mock_report, UserProfile()
624+
report, UserProfile()
643625
)
644626

645-
# Assertions:
646-
# 1) Summary was assigned and calculated twice (once for creation, once for recalculation)
647-
assert mock_report.summary == calculated_summary
648-
assert mock_summary_service.calculate_compliance_report_summary.call_count == 2
649-
# 2) We did NOT call adjust_balance, because balance = 0
650627
mock_org_service.adjust_balance.assert_not_awaited()
651-
# 3) No transaction is created
652-
assert mock_report.transaction is None
628+
assert report.transaction is None
629+
mock_org_service.calculate_available_balance.assert_awaited_once_with(
630+
report.organization_id
631+
)
632+
mock_org_service.calculate_available_balance_for_period.assert_awaited_once_with(
633+
report.organization_id, 2024
634+
)
653635

654636

655637
@pytest.mark.anyio
656-
async def test_handle_submitted_status_insufficient_credits(
638+
async def test_handle_submitted_status_caps_to_pre_deadline_balance(
657639
compliance_report_update_service,
658-
mock_repo,
659-
mock_summary_repo,
660640
mock_user_has_roles,
661641
mock_org_service,
662642
mock_summary_service,
663643
):
664-
"""
665-
Scenario: The report requires deficit units of 100,
666-
but the org only has 50 credits available. We reserve partial (-50)
667-
to match the actual available balance.
668-
"""
669-
report_id = 1
670-
mock_report = MagicMock(spec=ComplianceReport)
671-
mock_report.compliance_report_id = report_id
672-
mock_report.organization_id = 123
673-
# Need 100 credits, but only 50 are available
674-
mock_report.summary = MagicMock(spec=ComplianceReportSummary)
675-
mock_report.summary.line_20_surplus_deficit_units = -100
676-
mock_report.transaction = None
677-
644+
"""Scenario: Eligible credits before the deadline cap the reserve even though the live balance is higher."""
678645
mock_user_has_roles.return_value = True
679-
compliance_report_update_service.request = MagicMock()
680-
compliance_report_update_service.request.user = MagicMock()
681-
682-
# Skip deeper summary logic
683-
mock_summary_repo.get_summary_by_report_id.return_value = None
684-
mock_summary_repo.save_compliance_report_summary = AsyncMock(
685-
return_value=mock_report.summary
686-
)
687-
mock_summary_repo.add_compliance_report_summary = AsyncMock(
688-
return_value=mock_report.summary
689-
)
690-
calculated_summary = ComplianceReportSummarySchema(
691-
can_sign=True,
692-
compliance_report_id=report_id,
693-
renewable_fuel_target_summary=[],
694-
low_carbon_fuel_target_summary=[],
695-
non_compliance_penalty_summary=[],
696-
)
697-
mock_summary_service.calculate_compliance_report_summary = AsyncMock(
698-
return_value=calculated_summary
699-
)
700-
701-
# Org only has 50
702-
mock_org_service.calculate_available_balance = AsyncMock(return_value=50)
703-
# Mock the result of adjust_balance
646+
report = MagicMock(spec=ComplianceReport)
647+
report.compliance_report_id = 2
648+
report.organization_id = 321
649+
report.compliance_period = MagicMock(description="2024")
650+
report.summary = MagicMock(spec=ComplianceReportSummary)
651+
report.summary.line_20_surplus_deficit_units = -120000
652+
report.transaction = None
653+
654+
mock_summary_service.calculate_compliance_report_summary.return_value = (
655+
report.summary
656+
)
657+
mock_org_service.calculate_available_balance.return_value = 120000
658+
mock_org_service.calculate_available_balance_for_period.return_value = 80000
704659
mock_transaction = MagicMock()
705660
mock_org_service.adjust_balance.return_value = mock_transaction
706661

707-
# Execute
708662
await compliance_report_update_service.handle_submitted_status(
709-
mock_report, UserProfile()
663+
report, UserProfile()
710664
)
711665

712-
# We should have called adjust_balance with -50 units (reserving partial)
713666
mock_org_service.adjust_balance.assert_awaited_once_with(
714667
transaction_action=TransactionActionEnum.Reserved,
715-
compliance_units=-50,
716-
organization_id=123,
668+
compliance_units=-80000,
669+
organization_id=321,
670+
)
671+
assert report.transaction is mock_transaction
672+
mock_org_service.calculate_available_balance.assert_awaited_once_with(
673+
report.organization_id
674+
)
675+
mock_org_service.calculate_available_balance_for_period.assert_awaited_once_with(
676+
report.organization_id, 2024
717677
)
718-
# And a transaction object is assigned back to the report
719-
assert mock_report.transaction == mock_transaction
720678

721679

722680
@pytest.mark.anyio
723-
async def test_handle_submitted_status_sufficient_credits(
681+
async def test_handle_submitted_status_caps_to_live_balance_when_smaller(
724682
compliance_report_update_service,
725-
mock_repo,
726-
mock_summary_repo,
727683
mock_user_has_roles,
728684
mock_org_service,
729685
mock_summary_service,
730686
):
731-
"""
732-
Scenario: The report requires deficit units of -100,
733-
and the org has 200 credits available. We reserve all -100.
734-
"""
735-
report_id = 1
736-
mock_report = MagicMock(spec=ComplianceReport)
737-
mock_report.compliance_report_id = report_id
738-
mock_report.organization_id = 123
739-
# Need 100 credits
740-
mock_report.summary = MagicMock(spec=ComplianceReportSummary)
741-
mock_report.summary.line_20_surplus_deficit_units = -100
742-
mock_report.transaction = None
743-
687+
"""Scenario: Live balance is lower than pre-deadline total, so reserve is limited by current availability."""
744688
mock_user_has_roles.return_value = True
745-
compliance_report_update_service.request = MagicMock()
746-
compliance_report_update_service.request.user = MagicMock()
747-
748-
# Skip deeper summary logic
749-
mock_summary_repo.get_summary_by_report_id.return_value = None
750-
mock_summary_repo.save_compliance_report_summary = AsyncMock(
751-
return_value=mock_report.summary
752-
)
753-
mock_summary_repo.add_compliance_report_summary = AsyncMock(
754-
return_value=mock_report.summary
755-
)
756-
calculated_summary = ComplianceReportSummarySchema(
757-
can_sign=True,
758-
compliance_report_id=report_id,
759-
renewable_fuel_target_summary=[],
760-
low_carbon_fuel_target_summary=[],
761-
non_compliance_penalty_summary=[],
762-
)
763-
mock_summary_service.calculate_compliance_report_summary = AsyncMock(
764-
return_value=calculated_summary
765-
)
766-
767-
# Org has enough
768-
mock_org_service.calculate_available_balance.return_value = 200
689+
report = MagicMock(spec=ComplianceReport)
690+
report.compliance_report_id = 3
691+
report.organization_id = 555
692+
report.compliance_period = MagicMock(description="2024")
693+
report.summary = MagicMock(spec=ComplianceReportSummary)
694+
report.summary.line_20_surplus_deficit_units = -120000
695+
report.transaction = None
696+
697+
mock_summary_service.calculate_compliance_report_summary.return_value = (
698+
report.summary
699+
)
700+
mock_org_service.calculate_available_balance.return_value = 40000
701+
mock_org_service.calculate_available_balance_for_period.return_value = 100000
769702
mock_transaction = MagicMock()
770703
mock_org_service.adjust_balance.return_value = mock_transaction
771704

772-
# Execute
773705
await compliance_report_update_service.handle_submitted_status(
774-
mock_report, UserProfile()
706+
report, UserProfile()
775707
)
776708

777-
# We should have called adjust_balance with the full -100
778709
mock_org_service.adjust_balance.assert_awaited_once_with(
779710
transaction_action=TransactionActionEnum.Reserved,
780-
compliance_units=-100,
781-
organization_id=123,
711+
compliance_units=-40000,
712+
organization_id=555,
713+
)
714+
assert report.transaction is mock_transaction
715+
mock_org_service.calculate_available_balance.assert_awaited_once_with(
716+
report.organization_id
717+
)
718+
mock_org_service.calculate_available_balance_for_period.assert_awaited_once_with(
719+
report.organization_id, 2024
782720
)
783-
assert mock_report.transaction == mock_transaction
784721

785722

786723
# Fixture to create a real instance of OrganizationsService with its actual adjust_balance logic.
@@ -959,8 +896,10 @@ async def test_handle_assessed_status_not_superseded(
959896
mock_report_model.version = mock_compliance_report_assessed.version
960897
# Set a mock transaction object on the model
961898
mock_report_model.transaction = MagicMock()
899+
mock_report_model.transaction.compliance_units = 100
962900
# Set is_non_assessment to False to enter transaction logic
963901
mock_report_model.is_non_assessment = False
902+
mock_report_model.compliance_period = MagicMock(description="2024")
964903
# Mock the summary that should already be locked from "Recommended by Analyst" step
965904
mock_summary = MagicMock()
966905
mock_summary.line_20_surplus_deficit_units = 100
@@ -997,6 +936,52 @@ async def test_handle_assessed_status_not_superseded(
997936
mock_repo.update_compliance_report.assert_called_once_with(mock_report_model)
998937

999938

939+
@pytest.mark.anyio
940+
async def test_handle_assessed_status_caps_to_pre_deadline(
941+
compliance_report_update_service: ComplianceReportUpdateService,
942+
mock_repo: AsyncMock,
943+
mock_user_profile_director: MagicMock,
944+
):
945+
mock_report = MagicMock(spec=ComplianceReport)
946+
mock_report.compliance_report_id = 999
947+
mock_report.compliance_report_group_uuid = "group-999"
948+
mock_report.version = 1
949+
mock_report.organization_id = 321
950+
mock_report.transaction = MagicMock()
951+
mock_report.is_non_assessment = False
952+
mock_report.compliance_period = MagicMock(description="2024")
953+
mock_report.transaction.compliance_units = -200
954+
mock_report.compliance_period = MagicMock(description="2024")
955+
956+
mock_summary = MagicMock()
957+
mock_summary.line_20_surplus_deficit_units = -300
958+
mock_summary.is_locked = True
959+
mock_report.summary = mock_summary
960+
961+
mock_repo.get_draft_report_by_group_uuid = AsyncMock(return_value=None)
962+
963+
compliance_report_update_service.org_service.calculate_available_balance.return_value = 500
964+
compliance_report_update_service.org_service.calculate_available_balance_for_period.return_value = 200
965+
966+
with patch(
967+
"lcfs.web.api.compliance_report.update_service.user_has_roles",
968+
return_value=True,
969+
):
970+
await compliance_report_update_service.handle_assessed_status(
971+
mock_report, mock_user_profile_director
972+
)
973+
974+
compliance_report_update_service.org_service.calculate_available_balance.assert_not_awaited()
975+
compliance_report_update_service.org_service.calculate_available_balance_for_period.assert_not_awaited()
976+
assert mock_report.transaction.transaction_action == TransactionActionEnum.Adjustment
977+
assert mock_report.transaction.compliance_units == -200
978+
assert (
979+
mock_report.transaction.update_user
980+
== mock_user_profile_director.keycloak_username
981+
)
982+
mock_repo.update_compliance_report.assert_called_once_with(mock_report)
983+
984+
1000985
@pytest.mark.anyio
1001986
async def test_handle_assessed_status_government_adjustment_no_transaction(
1002987
compliance_report_update_service: ComplianceReportUpdateService,
@@ -1021,6 +1006,7 @@ async def test_handle_assessed_status_government_adjustment_no_transaction(
10211006
mock_report.transaction = None # No existing transaction - the key bug case
10221007
# Set is_non_assessment to False to enter transaction logic
10231008
mock_report.is_non_assessment = False
1009+
mock_report.compliance_period = MagicMock(description="2024")
10241010

10251011
# Set up supplemental initiator to indicate it's a government adjustment
10261012
mock_report.supplemental_initiator = (
@@ -1042,7 +1028,7 @@ async def test_handle_assessed_status_government_adjustment_no_transaction(
10421028
# This is needed to simulate the transaction being created
10431029
def side_effect_create_transaction(credit_change, report):
10441030
report.transaction = mock_transaction
1045-
return mock_transaction
1031+
return credit_change
10461032

10471033
compliance_report_update_service._create_or_update_reserve_transaction.side_effect = (
10481034
side_effect_create_transaction
@@ -1259,6 +1245,8 @@ async def test_handle_assessed_status_calls_calculate_with_skip_check(
12591245
mock_report.version = 1
12601246
mock_report.transaction = MagicMock()
12611247
mock_report.is_non_assessment = False
1248+
mock_report.compliance_period = MagicMock(description="2024")
1249+
mock_report.transaction.compliance_units = 150
12621250

12631251
# Mock the summary that should already be locked
12641252
mock_summary = MagicMock(spec=ComplianceReportSummary)

0 commit comments

Comments
 (0)