diff --git a/backend/lcfs/db/migrations/versions/2026-01-25-12-00_f3b1b9f03c9a.py b/backend/lcfs/db/migrations/versions/2026-01-25-12-00_f3b1b9f03c9a.py new file mode 100644 index 000000000..b1031b2e4 --- /dev/null +++ b/backend/lcfs/db/migrations/versions/2026-01-25-12-00_f3b1b9f03c9a.py @@ -0,0 +1,135 @@ +"""Create report_opening configuration table + +Revision ID: f3b1b9f03c9a +Revises: a1b2c3d4e5f1 +Create Date: 2026-01-25 12:00:00.000000 + +""" + +from datetime import datetime + +import sqlalchemy as sa +from enum import Enum +from alembic import op + +# revision identifiers, used by Alembic. +revision = "f3b1b9f03c9a" +down_revision = "a1b2c3d4e5f1" +branch_labels = None +depends_on = None + + +class SupplementalReportAccessRoleEnum(str, Enum): + BCeID = "BCeID" + IDIR = "IDIR" + + +enum_for_column = sa.Enum( + SupplementalReportAccessRoleEnum, + name="supplemental_report_access_role_enum", + create_type=False, +) + + +def upgrade() -> None: + op.execute("DROP TYPE IF EXISTS supplemental_report_access_role_enum") + + op.create_table( + "report_opening", + sa.Column( + "report_opening_id", + sa.Integer(), + autoincrement=True, + nullable=False, + comment="Unique identifier for the report opening row", + ), + sa.Column( + "compliance_year", + sa.Integer(), + nullable=False, + comment="Compliance year that this configuration applies to", + ), + sa.Column( + "compliance_reporting_enabled", + sa.Boolean(), + nullable=False, + server_default=sa.text("true"), + comment="If TRUE, suppliers can create compliance reports for this year", + ), + sa.Column( + "early_issuance_enabled", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + comment="Indicates whether early issuance is enabled for this year", + ), + sa.Column( + "supplemental_report_role", + enum_for_column, + nullable=False, + server_default=sa.text("'BCeID'"), + comment="Which role may create supplemental reports for the year", + ), + sa.Column( + "create_date", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=True, + comment="Date/time when the record was created", + ), + sa.Column( + "update_date", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=True, + comment="Date/time when the record was last updated", + ), + sa.Column( + "create_user", + sa.String(), + nullable=True, + comment="User who created the record", + ), + sa.Column( + "update_user", + sa.String(), + nullable=True, + comment="User who last updated the record", + ), + sa.PrimaryKeyConstraint( + "report_opening_id", + name=op.f("pk_report_opening"), + ), + sa.UniqueConstraint( + "compliance_year", + name="uq_report_opening_compliance_year", + ), + comment="Stores per-year configuration for compliance reporting availability and permissions.", + ) + op.execute('commit;') + report_opening_table = sa.table( + "report_opening", + sa.column("compliance_year", sa.Integer()), + sa.column("compliance_reporting_enabled", sa.Boolean()), + sa.column("early_issuance_enabled", sa.Boolean()), + sa.column("supplemental_report_role", enum_for_column), + ) + + current_year = datetime.utcnow().year + op.bulk_insert( + report_opening_table, + [ + { + "compliance_year": year, + "compliance_reporting_enabled": year == 2025, + "early_issuance_enabled": False, + "supplemental_report_role": SupplementalReportAccessRoleEnum.BCeID if year >= current_year else SupplementalReportAccessRoleEnum.IDIR, + } + for year in range(2019, 2031) + ], + ) + + +def downgrade() -> None: + op.drop_table("report_opening") + op.execute("DROP TYPE IF EXISTS supplemental_report_access_role_enum") diff --git a/backend/lcfs/db/models/compliance/ReportOpening.py b/backend/lcfs/db/models/compliance/ReportOpening.py new file mode 100644 index 000000000..2dfe99bce --- /dev/null +++ b/backend/lcfs/db/models/compliance/ReportOpening.py @@ -0,0 +1,66 @@ +from enum import Enum as PyEnum + +from sqlalchemy import Boolean, Column, Enum, Integer, UniqueConstraint, text + +from lcfs.db.base import Auditable, BaseModel + + +class SupplementalReportAccessRole(str, PyEnum): + """Enumerates which user role may create supplemental reports for a year.""" + + BCeID = "BCeID" + IDIR = "IDIR" + + +class ReportOpening(BaseModel, Auditable): + __tablename__ = "report_opening" + __table_args__ = ( + UniqueConstraint( + "compliance_year", name="uq_report_opening_compliance_year" + ), + { + "comment": "Stores per-year configuration for compliance reporting availability and permissions.", + }, + ) + + report_opening_id = Column( + Integer, + primary_key=True, + autoincrement=True, + comment="Unique identifier for the report opening row", + ) + compliance_year = Column( + Integer, + nullable=False, + comment="Compliance year that this configuration applies to", + ) + compliance_reporting_enabled = Column( + Boolean, + nullable=False, + server_default=text("true"), + default=True, + comment="If True, suppliers can create compliance reports for this year", + ) + early_issuance_enabled = Column( + Boolean, + nullable=False, + server_default=text("false"), + default=False, + comment="Indicates whether early issuance is enabled for this year", + ) + supplemental_report_role = Column( + Enum( + SupplementalReportAccessRole, + name="supplemental_report_access_role_enum", + ), + nullable=False, + default=SupplementalReportAccessRole.BCeID, + server_default=text("'BCeID'"), + comment="Which role (BCeID or IDIR) may create supplemental reports for the year", + ) + + def __repr__(self) -> str: # pragma: no cover - simple repr + return ( + f"" + ) diff --git a/backend/lcfs/db/models/compliance/__init__.py b/backend/lcfs/db/models/compliance/__init__.py index a183b7e9e..b3bcbbf2d 100644 --- a/backend/lcfs/db/models/compliance/__init__.py +++ b/backend/lcfs/db/models/compliance/__init__.py @@ -21,6 +21,7 @@ from .ChargingPowerOutput import ChargingPowerOutput from .NotionalTransfer import NotionalTransfer from .OtherUses import OtherUses +from .ReportOpening import ReportOpening, SupplementalReportAccessRole __all__ = [ "AllocationAgreement", @@ -46,4 +47,6 @@ "NotionalTransfer", "OtherUses", "EndUserType", + "ReportOpening", + "SupplementalReportAccessRole", ] diff --git a/backend/lcfs/services/jobs/jobs.py b/backend/lcfs/services/jobs/jobs.py index 70fd6f673..26b13be49 100644 --- a/backend/lcfs/services/jobs/jobs.py +++ b/backend/lcfs/services/jobs/jobs.py @@ -63,6 +63,7 @@ async def submit_supplemental_report(report_id: int, app: FastAPI): internal_comment_service = InternalCommentService(session) compliance_report_services = ComplianceReportServices( + request=None, repo=compliance_report_repo, org_repo=org_repo, snapshot_services=snapshot_service, diff --git a/backend/lcfs/services/rabbitmq/report_consumer.py b/backend/lcfs/services/rabbitmq/report_consumer.py index c4a0f527b..2ef5c55b6 100644 --- a/backend/lcfs/services/rabbitmq/report_consumer.py +++ b/backend/lcfs/services/rabbitmq/report_consumer.py @@ -156,6 +156,7 @@ async def handle_message( db=session, fuel_supply_repo=fuel_supply_repo ) compliance_report_service = ComplianceReportServices( + request=None, repo=compliance_report_repo ) user = await UserRepository(db=session).get_user_by_id(user_id) diff --git a/backend/lcfs/settings.py b/backend/lcfs/settings.py index 0691e6bbd..f77e8dc86 100644 --- a/backend/lcfs/settings.py +++ b/backend/lcfs/settings.py @@ -97,18 +97,6 @@ class Settings(BaseSettings): # Feature flags feature_credit_market_notifications: bool = True feature_fuel_code_expiry_email: bool = True - # TEMPORARY SOLUTION - Issue #3730 - # This is a temporary approach to gate compliance year access. - # A more robust long-term solution should be implemented to support future years - # dynamically (e.g., database-driven configuration per compliance period). - # - # Current behavior: - # - 2025: Enabled by default (backend allows it), frontend flag controls UI visibility - # - 2026: ALWAYS blocked unless org has early issuance enabled for 2026 - # - # Set LCFS_FEATURE_REPORTING_2025_ENABLED=false to disable 2025 reporting on backend. - # Frontend has corresponding flag: reporting2025Enabled in config.ts - feature_reporting_2025_enabled: bool = True def __init__(self, **kwargs): # Map APP_ENVIRONMENT to environment if present diff --git a/backend/lcfs/tests/allocation_agreement/test_allocation_agreement_services.py b/backend/lcfs/tests/allocation_agreement/test_allocation_agreement_services.py index a4cbcfe8b..480e3d66b 100644 --- a/backend/lcfs/tests/allocation_agreement/test_allocation_agreement_services.py +++ b/backend/lcfs/tests/allocation_agreement/test_allocation_agreement_services.py @@ -73,6 +73,7 @@ def service(mock_repo_full, mock_fuel_repo_full): @pytest.fixture def compliance_service(mock_compliance_repo, mock_snapshot_services): return ComplianceReportServices( + request=None, repo=mock_compliance_repo, snapshot_services=mock_snapshot_services, ) diff --git a/backend/lcfs/tests/compliance_report/conftest.py b/backend/lcfs/tests/compliance_report/conftest.py index 98287fb27..06dfaba47 100644 --- a/backend/lcfs/tests/compliance_report/conftest.py +++ b/backend/lcfs/tests/compliance_report/conftest.py @@ -33,6 +33,7 @@ from lcfs.web.api.final_supply_equipment.services import FinalSupplyEquipmentServices from lcfs.web.api.organizations.repo import OrganizationsRepository from lcfs.web.api.transaction.repo import TransactionRepository +from lcfs.web.api.report_opening.repo import ReportOpeningRepository from lcfs.services.s3.client import DocumentService from lcfs.db.models.user.Role import RoleEnum from lcfs.db.models.compliance.ComplianceReportStatus import ComplianceReportStatusEnum @@ -133,6 +134,15 @@ def mock_org_repo(): return repo +@pytest.fixture +def mock_report_opening_repo(): + repo = AsyncMock(spec=ReportOpeningRepository) + repo.sync_configured_years = AsyncMock(return_value=[]) + repo.ensure_year = AsyncMock() + repo.upsert_year = AsyncMock() + return repo + + @pytest.fixture def mock_snapshot_service(): return AsyncMock(spec=OrganizationSnapshotService) @@ -332,8 +342,10 @@ def compliance_report_service( mock_document_service, mock_internal_comment_service, mock_trxn_repo, + mock_report_opening_repo, ): service = ComplianceReportServices( + request=None, repo=mock_repo, org_repo=mock_org_repo, snapshot_services=mock_snapshot_service, @@ -341,6 +353,7 @@ def compliance_report_service( document_service=mock_document_service, transaction_repo=mock_trxn_repo, internal_comment_service=mock_internal_comment_service, + report_opening_repo=mock_report_opening_repo, ) service.request = MagicMock() service.request.user = mock_user_profile diff --git a/backend/lcfs/tests/compliance_report/test_compliance_report_services.py b/backend/lcfs/tests/compliance_report/test_compliance_report_services.py index 0eb6bbb11..6bea67e8d 100644 --- a/backend/lcfs/tests/compliance_report/test_compliance_report_services.py +++ b/backend/lcfs/tests/compliance_report/test_compliance_report_services.py @@ -1,5 +1,6 @@ from datetime import datetime import copy +from types import SimpleNamespace from lcfs.db.base import ActionTypeEnum from lcfs.db.models.compliance.AllocationAgreement import AllocationAgreement from lcfs.db.models.compliance.FuelSupply import FuelSupply @@ -39,12 +40,30 @@ # get_all_compliance_periods @pytest.mark.anyio -async def test_get_all_compliance_periods_success(compliance_report_service, mock_repo): +async def test_get_all_compliance_periods_success( + compliance_report_service, mock_repo, mock_report_opening_repo +): mock_periods = [ {"compliance_period_id": 1, "description": "2024 Compliance Period"}, {"compliance_period_id": 2, "description": "2025 Compliance Period"}, ] mock_repo.get_all_compliance_periods.return_value = mock_periods + mock_report_opening_repo.sync_configured_years.return_value = [ + SimpleNamespace( + compliance_year=2024, + compliance_reporting_enabled=True, + early_issuance_enabled=False, + supplemental_report_role="BCeID", + report_opening_id=1, + ), + SimpleNamespace( + compliance_year=2025, + compliance_reporting_enabled=True, + early_issuance_enabled=False, + supplemental_report_role="BCeID", + report_opening_id=2, + ), + ] result = await compliance_report_service.get_all_compliance_periods() @@ -52,6 +71,39 @@ async def test_get_all_compliance_periods_success(compliance_report_service, moc assert result[0].compliance_period_id == 1 assert result[0].description == "2024 Compliance Period" mock_repo.get_all_compliance_periods.assert_called_once() + mock_report_opening_repo.sync_configured_years.assert_called() + + +@pytest.mark.anyio +async def test_get_all_compliance_periods_filters_disabled_years( + compliance_report_service, mock_repo, mock_report_opening_repo +): + mock_periods = [ + {"compliance_period_id": 1, "description": "2024"}, + {"compliance_period_id": 2, "description": "2025"}, + ] + mock_repo.get_all_compliance_periods.return_value = mock_periods + mock_report_opening_repo.sync_configured_years.return_value = [ + SimpleNamespace( + compliance_year=2024, + compliance_reporting_enabled=True, + early_issuance_enabled=False, + supplemental_report_role="BCeID", + report_opening_id=1, + ), + SimpleNamespace( + compliance_year=2025, + compliance_reporting_enabled=False, + early_issuance_enabled=False, + supplemental_report_role="BCeID", + report_opening_id=2, + ), + ] + + result = await compliance_report_service.get_all_compliance_periods() + + assert len(result) == 1 + assert result[0].description == "2024" @pytest.mark.anyio @@ -1945,6 +1997,7 @@ def service(self) -> ComplianceReportServices: # The _mask_report_status_for_history method is pure Python logic on its inputs, # so it doesn't need real dependencies for these tests. return ComplianceReportServices( + request=None, repo=MagicMock(), org_repo=MagicMock(), snapshot_services=MagicMock(), @@ -2382,6 +2435,7 @@ class TestIsSupplementalRequestedByGovUser: def service(self) -> ComplianceReportServices: """Provides a simple instance of ComplianceReportServices for testing this method.""" return ComplianceReportServices( + request=None, repo=MagicMock(), org_repo=MagicMock(), snapshot_services=MagicMock(), diff --git a/backend/lcfs/tests/organization/conftest.py b/backend/lcfs/tests/organization/conftest.py index cfe562047..c53998565 100644 --- a/backend/lcfs/tests/organization/conftest.py +++ b/backend/lcfs/tests/organization/conftest.py @@ -1,4 +1,5 @@ import pytest +from types import SimpleNamespace from unittest.mock import MagicMock, AsyncMock from lcfs.web.api.organization.services import OrganizationService from lcfs.web.api.organization.validation import OrganizationValidation @@ -62,6 +63,20 @@ def mock_report_repo(): return AsyncMock(spec=ComplianceReportRepository) +@pytest.fixture +def mock_report_opening_repo(): + repo = AsyncMock() + repo.ensure_year = AsyncMock( + return_value=SimpleNamespace( + compliance_year=2024, + compliance_reporting_enabled=True, + early_issuance_enabled=False, + supplemental_report_role="BCeID", + ) + ) + return repo + + @pytest.fixture def mock_request(mock_user_profile): request = MagicMock() @@ -79,11 +94,16 @@ def organization_service(mock_user_repo, mock_transaction_repo): @pytest.fixture def organization_validation( - mock_orgs_repo, mock_transaction_repo, mock_report_repo, mock_request + mock_orgs_repo, + mock_transaction_repo, + mock_report_repo, + mock_request, + mock_report_opening_repo, ): validation = OrganizationValidation() validation.org_repo = mock_orgs_repo validation.transaction_repo = mock_transaction_repo validation.report_repo = mock_report_repo validation.request = mock_request + validation.report_opening_repo = mock_report_opening_repo return validation diff --git a/backend/lcfs/tests/organization/test_organization_validation.py b/backend/lcfs/tests/organization/test_organization_validation.py index c2da63a4b..5f2a44e71 100644 --- a/backend/lcfs/tests/organization/test_organization_validation.py +++ b/backend/lcfs/tests/organization/test_organization_validation.py @@ -1,5 +1,8 @@ import pytest from unittest.mock import MagicMock, AsyncMock +from fastapi import HTTPException +from types import SimpleNamespace +from datetime import datetime from lcfs.db.models.transfer.TransferStatus import TransferStatusEnum from lcfs.db.models.user.Role import RoleEnum @@ -96,3 +99,100 @@ async def test_create_compliance_report_success( # Assertions mock_report_repo.get_compliance_period.assert_called_once_with("2024") mock_report_repo.get_compliance_report_by_period.assert_called_once_with(1, "2024") + organization_validation.report_opening_repo.ensure_year.assert_awaited_once_with(2024) + + +@pytest.mark.anyio +async def test_create_compliance_report_disabled_year(organization_validation, mock_report_repo): + mock_request = MagicMock() + mock_request.user.organization.organization_id = 1 + organization_validation.request = mock_request + mock_period = MagicMock() + mock_period.description = "2025" + mock_report_repo.get_compliance_period.return_value = mock_period + mock_report_repo.get_compliance_report_by_period.return_value = False + + disabled_config = MagicMock() + disabled_config.compliance_reporting_enabled = False + disabled_config.early_issuance_enabled = False + organization_validation.report_opening_repo.ensure_year.return_value = disabled_config + + with pytest.raises(HTTPException) as exc: + await organization_validation.create_compliance_report( + 1, + ComplianceReportCreateSchema( + compliance_period="2025", organization_id=1, status="Draft" + ), + ) + + assert exc.value.status_code == 403 + + +@pytest.mark.anyio +async def test_create_compliance_report_requires_early_issuance_current_year( + organization_validation, mock_report_repo +): + mock_request = MagicMock() + mock_request.user.organization.organization_id = 1 + organization_validation.request = mock_request + + current_year = datetime.now().year + mock_period = MagicMock() + mock_period.description = str(current_year) + mock_report_repo.get_compliance_period.return_value = mock_period + mock_report_repo.get_compliance_report_by_period.return_value = False + + disabled_config = MagicMock() + disabled_config.compliance_reporting_enabled = False + disabled_config.early_issuance_enabled = True + organization_validation.report_opening_repo.ensure_year.return_value = disabled_config + organization_validation.org_repo.get_early_issuance_by_year = AsyncMock( + return_value=None + ) + + with pytest.raises(HTTPException) as exc: + await organization_validation.create_compliance_report( + 1, + ComplianceReportCreateSchema( + compliance_period=str(current_year), organization_id=1, status="Draft" + ), + ) + + assert exc.value.status_code == 403 + organization_validation.org_repo.get_early_issuance_by_year.assert_awaited_once_with( + 1, str(current_year) + ) + + +@pytest.mark.anyio +async def test_create_compliance_report_current_year_with_early_issuance_allowed( + organization_validation, mock_report_repo +): + mock_request = MagicMock() + mock_request.user.organization.organization_id = 1 + organization_validation.request = mock_request + + current_year = datetime.now().year + mock_period = MagicMock() + mock_period.description = str(current_year) + mock_report_repo.get_compliance_period.return_value = mock_period + mock_report_repo.get_compliance_report_by_period.return_value = False + + disabled_config = MagicMock() + disabled_config.compliance_reporting_enabled = False + disabled_config.early_issuance_enabled = True + organization_validation.report_opening_repo.ensure_year.return_value = disabled_config + organization_validation.org_repo.get_early_issuance_by_year = AsyncMock( + return_value=SimpleNamespace(has_early_issuance=True) + ) + + await organization_validation.create_compliance_report( + 1, + ComplianceReportCreateSchema( + compliance_period=str(current_year), organization_id=1, status="Draft" + ), + ) + + organization_validation.org_repo.get_early_issuance_by_year.assert_awaited_once_with( + 1, str(current_year) + ) diff --git a/backend/lcfs/tests/report_opening/test_report_opening_service.py b/backend/lcfs/tests/report_opening/test_report_opening_service.py new file mode 100644 index 000000000..d208ae2f9 --- /dev/null +++ b/backend/lcfs/tests/report_opening/test_report_opening_service.py @@ -0,0 +1,99 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from lcfs.db.models.compliance.ReportOpening import SupplementalReportAccessRole +from lcfs.web.api.report_opening.schema import ( + ReportOpeningUpdateRequest, + ReportOpeningUpdateSchema, +) +from lcfs.web.api.report_opening.services import ReportOpeningService + + +@pytest.fixture +def mock_report_opening_repo(): + repo = AsyncMock() + repo.sync_configured_years = AsyncMock( + return_value=[ + SimpleNamespace( + report_opening_id=1, + compliance_year=2019, + compliance_reporting_enabled=True, + early_issuance_enabled=False, + supplemental_report_role="BCeID", + ), + SimpleNamespace( + report_opening_id=2, + compliance_year=2020, + compliance_reporting_enabled=False, + early_issuance_enabled=False, + supplemental_report_role="BCeID", + ), + ] + ) + repo.upsert_year = AsyncMock() + return repo + + +@pytest.mark.anyio +async def test_get_report_openings_returns_all_years(mock_report_opening_repo, monkeypatch): + monkeypatch.setattr( + "lcfs.web.api.report_opening.services.configured_years", + lambda: [2019, 2020], + ) + service = ReportOpeningService(repo=mock_report_opening_repo) + + results = await service.get_report_openings() + + assert len(results) == 2 + assert results[0].compliance_year == 2019 + mock_report_opening_repo.sync_configured_years.assert_awaited() + + +@pytest.mark.anyio +async def test_update_report_openings_updates_each_year(mock_report_opening_repo, monkeypatch): + monkeypatch.setattr( + "lcfs.web.api.report_opening.services.configured_years", + lambda: [2019, 2020], + ) + service = ReportOpeningService(repo=mock_report_opening_repo) + payload = ReportOpeningUpdateRequest( + report_openings=[ + ReportOpeningUpdateSchema( + compliance_year=2019, + compliance_reporting_enabled=False, + early_issuance_enabled=True, + supplemental_report_role="IDIR", + ) + ] + ) + + results = await service.update_report_openings(payload) + + mock_report_opening_repo.upsert_year.assert_awaited_once_with( + 2019, + compliance_reporting_enabled=False, + early_issuance_enabled=True, + supplemental_report_role=SupplementalReportAccessRole.IDIR, + ) + assert any(result.compliance_year == 2019 for result in results) + + +@pytest.mark.anyio +async def test_update_report_openings_rejects_invalid_year(mock_report_opening_repo, monkeypatch): + monkeypatch.setattr( + "lcfs.web.api.report_opening.services.configured_years", + lambda: [2019, 2020], + ) + service = ReportOpeningService(repo=mock_report_opening_repo) + payload = ReportOpeningUpdateRequest( + report_openings=[ + ReportOpeningUpdateSchema( + compliance_year=2035, compliance_reporting_enabled=True + ) + ] + ) + + with pytest.raises(ValueError): + await service.update_report_openings(payload) diff --git a/backend/lcfs/web/api/compliance_report/services.py b/backend/lcfs/web/api/compliance_report/services.py index bad9ca959..b89160a1f 100644 --- a/backend/lcfs/web/api/compliance_report/services.py +++ b/backend/lcfs/web/api/compliance_report/services.py @@ -6,9 +6,8 @@ from lcfs.db.base import ActionTypeEnum import structlog import uuid -from fastapi import Depends -from typing import List, Literal -from typing import Union +from fastapi import Depends, Request +from typing import List, Literal, Union from lcfs.db.models.compliance.AllocationAgreement import AllocationAgreement from lcfs.db.models.compliance.ComplianceReport import ( @@ -52,6 +51,7 @@ from lcfs.web.core.decorators import service_handler from lcfs.web.exception.exceptions import DataNotFoundException, ServiceException from lcfs.services.s3.client import DocumentService +from lcfs.web.api.report_opening.repo import ReportOpeningRepository logger = structlog.get_logger(__name__) @@ -59,6 +59,7 @@ class ComplianceReportServices: def __init__( self, + request: Request, repo: ComplianceReportRepository = Depends(), org_repo: OrganizationsRepository = Depends(), snapshot_services: OrganizationSnapshotService = Depends(), @@ -66,7 +67,9 @@ def __init__( document_service: DocumentService = Depends(), transaction_repo: TransactionRepository = Depends(), internal_comment_service: InternalCommentService = Depends(), + report_opening_repo: ReportOpeningRepository = Depends(), ) -> None: + self.request = request self.fse_service = fse_service self.org_repo = org_repo self.repo = repo @@ -74,6 +77,17 @@ def __init__( self.document_service = document_service self.transaction_repo = transaction_repo self.internal_comment_service = internal_comment_service + self.report_opening_repo = report_opening_repo + + def _extract_compliance_year(self, description: str) -> int | None: + if not description: + return None + + try: + return int(description) + except (TypeError, ValueError): + digits = "".join(filter(str.isdigit, str(description))) + return int(digits) if digits else None async def _validate_analyst_eligibility(self, assigned_analyst_id: int) -> None: """ @@ -104,7 +118,40 @@ async def _validate_analyst_eligibility(self, assigned_analyst_id: int) -> None: async def get_all_compliance_periods(self) -> List[CompliancePeriodBaseSchema]: """Fetches all compliance periods and converts them to Pydantic models.""" periods = await self.repo.get_all_compliance_periods() - return [CompliancePeriodBaseSchema.model_validate(period) for period in periods] + configs = await self.report_opening_repo.sync_configured_years() + early_issuance = None + current_year = datetime.now().year + request_user = getattr(self.request, "user", None) + if request_user and not getattr(request_user, "is_government", False): + early_issuance = await self.org_repo.get_early_issuance_by_year( + request_user.organization_id, str(current_year) + ) + enabled_years = { + config.compliance_year + for config in configs + if config.compliance_reporting_enabled + or ( + early_issuance + and early_issuance.has_early_issuance + and config.compliance_year == int(current_year) + ) + } + + visible_periods = [] + for period in periods: + description = ( + period["description"] + if isinstance(period, dict) + else period.description + ) + year = self._extract_compliance_year(description) + if year is None or year in enabled_years: + visible_periods.append(period) + + return [ + CompliancePeriodBaseSchema.model_validate(period) + for period in visible_periods + ] @service_handler async def create_compliance_report( @@ -431,7 +478,7 @@ async def create_government_initiated_supplemental_report( # 5. Copy summary data from the current submitted report, focusing on user-input lines 6-9 if current_report.summary: # Handle both SQLAlchemy model and schema objects - if hasattr(current_report.summary, '__table__'): + if hasattr(current_report.summary, "__table__"): # SQLAlchemy model - use table columns summary_data = { column: getattr(current_report.summary, column) @@ -442,22 +489,32 @@ async def create_government_initiated_supplemental_report( # Pydantic schema or other object - copy specific line fields manually summary_data = {} for line_num in range(6, 10): - for fuel_type in ['gasoline', 'diesel', 'jet_fuel']: + for fuel_type in ["gasoline", "diesel", "jet_fuel"]: if line_num == 6: - field_name = f"line_{line_num}_renewable_fuel_retained_{fuel_type}" + field_name = ( + f"line_{line_num}_renewable_fuel_retained_{fuel_type}" + ) elif line_num == 7: - field_name = f"line_{line_num}_previously_retained_{fuel_type}" + field_name = ( + f"line_{line_num}_previously_retained_{fuel_type}" + ) elif line_num == 8: - field_name = f"line_{line_num}_obligation_deferred_{fuel_type}" + field_name = ( + f"line_{line_num}_obligation_deferred_{fuel_type}" + ) elif line_num == 9: field_name = f"line_{line_num}_obligation_added_{fuel_type}" if hasattr(current_report.summary, field_name): - summary_data[field_name] = getattr(current_report.summary, field_name) + summary_data[field_name] = getattr( + current_report.summary, field_name + ) new_summary = ComplianceReportSummary(**summary_data) else: - new_summary = ComplianceReportSummary() # Fallback to empty if no summary exists + new_summary = ( + ComplianceReportSummary() + ) # Fallback to empty if no summary exists # Create the new supplemental report object new_report = ComplianceReport( @@ -1085,6 +1142,7 @@ def make_deep_copy(obj): diff = [] for key, value in data_copy.__dict__.items(): prev_value = getattr(prev, key, None) + # Handle object comparisons, especially relations def get_comparable_value(val): if val is None: @@ -1092,20 +1150,27 @@ def get_comparable_value(val): if isinstance(val, (str, int, float, bool)): return val # For relations, try to get the ID - if hasattr(val, 'id'): + if hasattr(val, "id"): return val.id # Try foreign key pattern (e.g., fuel_type_id) - if hasattr(val, f'{key}_id'): - return getattr(val, f'{key}_id') + if hasattr(val, f"{key}_id"): + return getattr(val, f"{key}_id") return str(val) - - if get_comparable_value(prev_value) != get_comparable_value(value): + + if get_comparable_value(prev_value) != get_comparable_value( + value + ): camel_case_key = key.split("_")[0] + "".join( x.capitalize() for x in key.split("_")[1:] ) diff.append(camel_case_key) # if the diff contains q1Quantity, q2Quantity, q3Quantity, or q4Quantity, ensure totalQuantity is also included - quantity_fields = {"q1Quantity", "q2Quantity", "q3Quantity", "q4Quantity"} + quantity_fields = { + "q1Quantity", + "q2Quantity", + "q3Quantity", + "q4Quantity", + } if any(field in diff for field in quantity_fields): diff.append("totalQuantity") prev.diff = diff diff --git a/backend/lcfs/web/api/compliance_report/validation.py b/backend/lcfs/web/api/compliance_report/validation.py index 0936e0c58..de800ac44 100644 --- a/backend/lcfs/web/api/compliance_report/validation.py +++ b/backend/lcfs/web/api/compliance_report/validation.py @@ -7,7 +7,6 @@ from lcfs.web.api.organizations.repo import OrganizationsRepository from fastapi import status from lcfs.web.api.role.schema import user_has_roles -from lcfs.settings import settings class ComplianceReportValidation: @@ -25,14 +24,6 @@ async def validate_organization_access(self, compliance_report_id: int): """ Validates that the user has access to the specified compliance report. - TEMPORARY SOLUTION - Issue #3730 - This method includes temporary year-based access checks for 2025/2026. - A more robust long-term solution should be implemented to support future years - dynamically (e.g., database-driven configuration per compliance period). - - Compliance year access rules (also enforced in organization/validation.py): - - 2025: Blocked when feature_reporting_2025_enabled is False - - 2026: ALWAYS requires early issuance, regardless of 2025 flag status """ compliance_report = await self.repo.get_compliance_report_schema_by_id( compliance_report_id @@ -58,34 +49,6 @@ async def validate_organization_access(self, compliance_report_id: int): detail="User does not have access to this compliance report.", ) - # For non-government users, validate access to 2025/2026 compliance periods - # Government users can always access all reports for oversight - if not is_government and user_organization_id: - compliance_period = compliance_report.compliance_period - period_desc = ( - compliance_period.description - if hasattr(compliance_period, "description") - else str(compliance_period) - ) - - # 2025: Blocked when feature_reporting_2025_enabled is False - if period_desc == "2025" and not settings.feature_reporting_2025_enabled: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="2025 reporting is not yet available.", - ) - - # 2026: ALWAYS requires early issuance, regardless of 2025 flag status - if period_desc == "2026": - early_issuance = await self.org_repo.get_early_issuance_by_year( - user_organization_id, "2026" - ) - if not early_issuance or not early_issuance.has_early_issuance: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="2026 reporting is only available to early issuance suppliers.", - ) - return compliance_report async def validate_compliance_report_access( diff --git a/backend/lcfs/web/api/organization/validation.py b/backend/lcfs/web/api/organization/validation.py index 3f8b6472e..73f62ce36 100644 --- a/backend/lcfs/web/api/organization/validation.py +++ b/backend/lcfs/web/api/organization/validation.py @@ -1,3 +1,4 @@ +import datetime from fastapi import Depends, HTTPException, Request from starlette import status @@ -7,8 +8,8 @@ from lcfs.web.api.transfer.schema import TransferCreateSchema from lcfs.web.api.compliance_report.schema import ComplianceReportCreateSchema from lcfs.web.api.compliance_report.repo import ComplianceReportRepository +from lcfs.web.api.report_opening.repo import ReportOpeningRepository from lcfs.utils.constants import LCFS_Constants -from lcfs.settings import settings class OrganizationValidation: @@ -18,11 +19,23 @@ def __init__( org_repo: OrganizationsRepository = Depends(OrganizationsRepository), transaction_repo: TransactionRepository = Depends(TransactionRepository), report_repo: ComplianceReportRepository = Depends(ComplianceReportRepository), + report_opening_repo: ReportOpeningRepository = Depends(ReportOpeningRepository), ): self.org_repo = org_repo self.request = request self.transaction_repo = transaction_repo self.report_repo = report_repo + self.report_opening_repo = report_opening_repo + + def _extract_compliance_year(self, description: str) -> int | None: + if not description: + return None + + try: + return int(description) + except (TypeError, ValueError): + digits = "".join(filter(str.isdigit, str(description))) + return int(digits) if digits else None async def check_available_balance(self, organization_id, quantity): available_balance = await self.transaction_repo.calculate_available_balance( @@ -33,22 +46,21 @@ async def check_available_balance(self, organization_id, quantity): "adjusted": True, "available_balance": available_balance, "original_quantity": quantity, - "adjusted_quantity": available_balance + "adjusted_quantity": available_balance, } return { "adjusted": False, "available_balance": available_balance, "original_quantity": quantity, - "adjusted_quantity": quantity + "adjusted_quantity": quantity, } async def create_transfer( self, organization_id, transfer_create: TransferCreateSchema ): balance_check = await self.check_available_balance( - organization_id, - transfer_create.quantity + organization_id, transfer_create.quantity ) if balance_check["adjusted"]: @@ -112,35 +124,28 @@ async def create_compliance_report( if not period: raise HTTPException(status_code=404, detail="Compliance period not found") - # TEMPORARY SOLUTION - Issue #3730 - # This is a temporary approach to gate compliance year access. - # A more robust long-term solution should be implemented to support future years - # dynamically (e.g., database-driven configuration per compliance period). - # - # Compliance year access rules (also enforced in compliance_report/validation.py): - # - 2025: Blocked when feature_reporting_2025_enabled is False - # - 2026: ALWAYS requires early issuance, regardless of 2025 flag status - - # Validate access to 2025/2026 compliance periods - # 2025: Blocked when feature_reporting_2025_enabled is False - if ( - period.description == "2025" - and not settings.feature_reporting_2025_enabled - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="2025 reporting is not yet available.", - ) - - # 2026: ALWAYS requires early issuance, regardless of 2025 flag status - if period.description == "2026": - early_issuance = await self.org_repo.get_early_issuance_by_year( - organization_id, "2026" - ) - if not early_issuance or not early_issuance.has_early_issuance: + compliance_year = self._extract_compliance_year(period.description) + if compliance_year is not None: + year_config = await self.report_opening_repo.ensure_year(compliance_year) + # Check for early issuance eligibility if the reporting window is not open + if ( + compliance_year == datetime.datetime.now().year + and not year_config.compliance_reporting_enabled + and year_config.early_issuance_enabled + ): + early_issuance = await self.org_repo.get_early_issuance_by_year( + organization_id, str(compliance_year) + ) + if not early_issuance or not early_issuance.has_early_issuance: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"{compliance_year} reporting is only available to early issuance suppliers.", + ) + return + if not year_config.compliance_reporting_enabled: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="2026 reporting is only available to early issuance suppliers.", + detail=f"{compliance_year} reporting is not currently available.", ) is_report_present = await self.report_repo.get_compliance_report_by_period( @@ -152,12 +157,3 @@ async def create_compliance_report( detail="Duplicate report for the compliance period", ) return - - # async def save_final_supply_equipment_rows( - # self, organization_id, report_id, fse_list - # ): - # report = await self.report_repo.get_compliance_report_by_id(report_id) - # if not report: - # raise HTTPException(status_code=404, detail="Report not found") - # # TODO: validate each row data - # return diff --git a/backend/lcfs/web/api/report_opening/__init__.py b/backend/lcfs/web/api/report_opening/__init__.py new file mode 100644 index 000000000..a71d75808 --- /dev/null +++ b/backend/lcfs/web/api/report_opening/__init__.py @@ -0,0 +1,3 @@ +from lcfs.web.api.report_opening.views import router + +__all__ = ["router"] diff --git a/backend/lcfs/web/api/report_opening/constants.py b/backend/lcfs/web/api/report_opening/constants.py new file mode 100644 index 000000000..c6f4b5c29 --- /dev/null +++ b/backend/lcfs/web/api/report_opening/constants.py @@ -0,0 +1,8 @@ +START_YEAR = 2019 +END_YEAR = 2030 + + +def configured_years() -> list[int]: + """Return the list of compliance years that can be configured.""" + + return list(range(START_YEAR, END_YEAR + 1)) diff --git a/backend/lcfs/web/api/report_opening/repo.py b/backend/lcfs/web/api/report_opening/repo.py new file mode 100644 index 000000000..a05be21c7 --- /dev/null +++ b/backend/lcfs/web/api/report_opening/repo.py @@ -0,0 +1,100 @@ +from datetime import datetime +from typing import List, Sequence + +from fastapi import Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from lcfs.db.dependencies import get_async_db_session +from lcfs.db.models.compliance.ReportOpening import ( + ReportOpening, + SupplementalReportAccessRole, +) +from lcfs.web.api.report_opening.constants import configured_years +from lcfs.web.core.decorators import repo_handler + + +class ReportOpeningRepository: + def __init__(self, db: AsyncSession = Depends(get_async_db_session)) -> None: + self.db = db + + def _build_default_record(self, year: int) -> ReportOpening: + current_year = datetime.utcnow().year + return ReportOpening( + compliance_year=year, + compliance_reporting_enabled=year <= current_year, + early_issuance_enabled=False, + supplemental_report_role=SupplementalReportAccessRole.BCEID, + ) + + @repo_handler + async def list_all(self) -> Sequence[ReportOpening]: + result = await self.db.execute( + select(ReportOpening).order_by(ReportOpening.compliance_year) + ) + return result.scalars().all() + + @repo_handler + async def ensure_year(self, year: int) -> ReportOpening: + result = await self.db.execute( + select(ReportOpening).where(ReportOpening.compliance_year == year) + ) + record = result.scalars().first() + if record: + return record + + record = self._build_default_record(year) + self.db.add(record) + await self.db.flush() + return record + + @repo_handler + async def sync_configured_years(self) -> List[ReportOpening]: + years = configured_years() + if not years: + return [] + + result = await self.db.execute( + select(ReportOpening).where(ReportOpening.compliance_year.in_(years)) + ) + records = result.scalars().all() + existing_years = {record.compliance_year for record in records} + missing_years = [year for year in years if year not in existing_years] + + if missing_years: + for year in missing_years: + new_record = self._build_default_record(year) + self.db.add(new_record) + records.append(new_record) + await self.db.flush() + + return sorted(records, key=lambda record: record.compliance_year) + + @repo_handler + async def upsert_year( + self, + year: int, + *, + compliance_reporting_enabled: bool | None = None, + early_issuance_enabled: bool | None = None, + supplemental_report_role: SupplementalReportAccessRole | None = None, + ) -> ReportOpening: + result = await self.db.execute( + select(ReportOpening).where(ReportOpening.compliance_year == year) + ) + record = result.scalars().first() + if not record: + record = self._build_default_record(year) + self.db.add(record) + + if compliance_reporting_enabled is not None: + record.compliance_reporting_enabled = compliance_reporting_enabled + + if early_issuance_enabled is not None: + record.early_issuance_enabled = early_issuance_enabled + + if supplemental_report_role is not None: + record.supplemental_report_role = supplemental_report_role + + await self.db.flush() + return record diff --git a/backend/lcfs/web/api/report_opening/schema.py b/backend/lcfs/web/api/report_opening/schema.py new file mode 100644 index 000000000..5f6e3cff0 --- /dev/null +++ b/backend/lcfs/web/api/report_opening/schema.py @@ -0,0 +1,34 @@ +from typing import List + +from pydantic import Field + +from lcfs.db.models.compliance.ReportOpening import ( + ReportOpening, + SupplementalReportAccessRole, +) +from lcfs.web.api.base import BaseSchema + + +class ReportOpeningSchema(BaseSchema): + report_opening_id: int + compliance_year: int + compliance_reporting_enabled: bool + early_issuance_enabled: bool + supplemental_report_role: SupplementalReportAccessRole + + +class ReportOpeningUpdateSchema(BaseSchema): + compliance_year: int + compliance_reporting_enabled: bool | None = None + early_issuance_enabled: bool | None = None + supplemental_report_role: SupplementalReportAccessRole | None = None + + +class ReportOpeningUpdateRequest(BaseSchema): + report_openings: List[ReportOpeningUpdateSchema] = Field( + default_factory=list, alias="reportOpenings" + ) + + +def model_to_schema(record: ReportOpening) -> ReportOpeningSchema: + return ReportOpeningSchema.model_validate(record) diff --git a/backend/lcfs/web/api/report_opening/services.py b/backend/lcfs/web/api/report_opening/services.py new file mode 100644 index 000000000..02e4be8a9 --- /dev/null +++ b/backend/lcfs/web/api/report_opening/services.py @@ -0,0 +1,52 @@ +from typing import List + +from fastapi import Depends + +from lcfs.web.api.report_opening.constants import configured_years +from lcfs.web.api.report_opening.repo import ReportOpeningRepository +from lcfs.web.api.report_opening.schema import ( + ReportOpeningSchema, + ReportOpeningUpdateRequest, + model_to_schema, +) +from lcfs.web.core.decorators import service_handler + + +class ReportOpeningService: + def __init__(self, repo: ReportOpeningRepository = Depends()) -> None: + self.repo = repo + + def _validate_years(self, years: List[int]) -> None: + allowed = set(configured_years()) + invalid = [year for year in years if year not in allowed] + if invalid: + invalid_str = ", ".join(str(year) for year in sorted(invalid)) + raise ValueError( + f"Configuration for years {invalid_str} is not supported." + ) + + @service_handler + async def get_report_openings(self) -> List[ReportOpeningSchema]: + records = await self.repo.sync_configured_years() + return [model_to_schema(record) for record in records] + + @service_handler + async def update_report_openings( + self, payload: ReportOpeningUpdateRequest + ) -> List[ReportOpeningSchema]: + years = [entry.compliance_year for entry in payload.report_openings] + self._validate_years(years) + + # Ensure defaults exist before applying updates so the caller gets a full list back. + await self.repo.sync_configured_years() + + for update in payload.report_openings: + await self.repo.upsert_year( + update.compliance_year, + compliance_reporting_enabled=update.compliance_reporting_enabled, + early_issuance_enabled=update.early_issuance_enabled, + supplemental_report_role=update.supplemental_report_role, + ) + + records = await self.repo.sync_configured_years() + return [model_to_schema(record) for record in records] diff --git a/backend/lcfs/web/api/report_opening/views.py b/backend/lcfs/web/api/report_opening/views.py new file mode 100644 index 000000000..044e3e522 --- /dev/null +++ b/backend/lcfs/web/api/report_opening/views.py @@ -0,0 +1,40 @@ +from typing import List + +from fastapi import APIRouter, Depends, Request, status + +from lcfs.db.models.user.Role import RoleEnum +from lcfs.web.api.report_opening.schema import ( + ReportOpeningSchema, + ReportOpeningUpdateRequest, +) +from lcfs.web.api.report_opening.services import ReportOpeningService +from lcfs.web.core.decorators import view_handler + +router = APIRouter() + + +@router.get( + "", + response_model=List[ReportOpeningSchema], + status_code=status.HTTP_200_OK, +) +@view_handler([RoleEnum.ADMINISTRATOR, RoleEnum.SUPPLIER]) +async def list_report_openings( + request: Request, + service: ReportOpeningService = Depends(), +) -> List[ReportOpeningSchema]: + return await service.get_report_openings() + + +@router.put( + "", + response_model=List[ReportOpeningSchema], + status_code=status.HTTP_200_OK, +) +@view_handler([RoleEnum.ADMINISTRATOR]) +async def update_report_openings( + request: Request, + payload: ReportOpeningUpdateRequest, + service: ReportOpeningService = Depends(), +) -> List[ReportOpeningSchema]: + return await service.update_report_openings(payload) diff --git a/backend/lcfs/web/api/router.py b/backend/lcfs/web/api/router.py index 4cd3b1e1a..11dbd1624 100644 --- a/backend/lcfs/web/api/router.py +++ b/backend/lcfs/web/api/router.py @@ -21,6 +21,7 @@ admin_adjustment, initiative_agreement, compliance_report, + report_opening, notional_transfer, other_uses, final_supply_equipment, @@ -85,6 +86,9 @@ api_router.include_router( compliance_report.router, prefix="/reports", tags=["compliance_reports"] ) +api_router.include_router( + report_opening.router, prefix="/report-openings", tags=["report_openings"] +) api_router.include_router( notional_transfer.router, prefix="/notional-transfers", tags=["notional_transfers"] ) diff --git a/frontend/src/assets/locales/en/reports.json b/frontend/src/assets/locales/en/reports.json index 0c8b36f16..de77a13fa 100644 --- a/frontend/src/assets/locales/en/reports.json +++ b/frontend/src/assets/locales/en/reports.json @@ -304,12 +304,28 @@ "notSubjectToAssessmentHistoryMessage": "This report is not subject to assessment under the Low Carbon Fuels Act. No action will be taken on the contents of this report, and the summary result will not impact the organization's compliance unit balance.", "assignAnalyst": "Assign Analyst", "unassign": "Unassign", + "reportOpenings": { + "title": "Report openings", + "year": "Year", + "complianceReporting": "Compliance reporting", + "complianceReportingToggle": "Toggle compliance reporting availability for {{year}}", + "earlyIssuance": "Early issuance", + "earlyIssuanceToggle": "Toggle early issuance for {{year}}", + "createSupplemental": "Create supplemental", + "bceid": "BCeID", + "idir": "IDIR", + "save": "Save", + "saveSuccess": "Report availability updated", + "saveError": "Unable to update report availability. Please try again.", + "loading": "Loading report openings..." + }, "tabs": { "complianceReporting": "Compliance reporting", "manageChargingSites": "Manage charging sites", "manageFSE": "Manage FSE", "fseMap": "FSE map", "chargingSites": "Charging sites", - "fseIndex": "FSE index" + "fseIndex": "FSE index", + "reportOpenings": "Report openings" } } diff --git a/frontend/src/constants/routes/apiRoutes.js b/frontend/src/constants/routes/apiRoutes.js index d890047ec..e2df8aaf0 100644 --- a/frontend/src/constants/routes/apiRoutes.js +++ b/frontend/src/constants/routes/apiRoutes.js @@ -88,6 +88,7 @@ export const apiRoutes = { getChangelog: '/reports/:complianceReportGroupUuid/changelog/:dataType', getAvailableAnalysts: '/reports/analysts', assignAnalyst: '/reports/:reportId/assign', + reportOpenings: '/report-openings', // notional-transfers notionalTransferOptions: '/notional-transfers/table-options', diff --git a/frontend/src/hooks/useComplianceReports.js b/frontend/src/hooks/useComplianceReports.js index a18fd0363..aa133b633 100644 --- a/frontend/src/hooks/useComplianceReports.js +++ b/frontend/src/hooks/useComplianceReports.js @@ -15,8 +15,8 @@ export const useCompliancePeriod = (options = {}) => { const client = useApiService() const { - staleTime = STATIC_DATA_STALE_TIME, - cacheTime = STATIC_DATA_STALE_TIME, + staleTime = 1 * 60 * 1000, // 1 minute + cacheTime = DEFAULT_CACHE_TIME, enabled = true, ...restOptions } = options diff --git a/frontend/src/hooks/useReportOpenings.js b/frontend/src/hooks/useReportOpenings.js new file mode 100644 index 000000000..653d53306 --- /dev/null +++ b/frontend/src/hooks/useReportOpenings.js @@ -0,0 +1,40 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useApiService } from '@/services/useApiService' +import { apiRoutes } from '@/constants/routes' + +const QUERY_KEY = ['report-openings'] + +export const useReportOpenings = (options = {}) => { + const client = useApiService() + const { enabled = true, staleTime = 5 * 60 * 1000, cacheTime = 5 * 60 * 1000, ...rest } = options + + return useQuery({ + queryKey: QUERY_KEY, + queryFn: async () => { + const { data } = await client.get(apiRoutes.reportOpenings) + return data + }, + enabled, + staleTime, + cacheTime, + ...rest + }) +} + +export const useUpdateReportOpenings = (options = {}) => { + const client = useApiService() + const queryClient = useQueryClient() + const { onSuccess, ...rest } = options + + return useMutation({ + mutationFn: async (payload) => { + const { data } = await client.put(apiRoutes.reportOpenings, payload) + return data + }, + onSuccess: (data, variables, context) => { + queryClient.setQueryData(QUERY_KEY, data) + onSuccess?.(data, variables, context) + }, + ...rest + }) +} diff --git a/frontend/src/routes/routeConfig/reportRoutes.tsx b/frontend/src/routes/routeConfig/reportRoutes.tsx index 5c4d995dc..f1cff326d 100644 --- a/frontend/src/routes/routeConfig/reportRoutes.tsx +++ b/frontend/src/routes/routeConfig/reportRoutes.tsx @@ -16,6 +16,7 @@ import { ViewChargingSite } from '@/views/ChargingSite/ViewChargingSite' import { FinalSupplyEquipmentReporting } from '@/views/FinalSupplyEquipments/FinalSupplyEquipmentReporting' import { FSEProcessing } from '@/views/FSEProcessing' import FSEFullMap from '@/views/FinalSupplyEquipments/FSEFullMap' +import { ReportOpenings } from '@/views/ComplianceReports/ReportOpenings' import { AppRouteObject } from '../types' export const reportRoutes: AppRouteObject[] = [ @@ -67,9 +68,15 @@ export const reportRoutes: AppRouteObject[] = [ }, { path: 'fse-map', - element: , + element: , handle: { title: 'FSE map' } } + , + { + path: 'report-openings', + element: , + handle: { title: 'Report openings' } + } ] }, { diff --git a/frontend/src/routes/routes.ts b/frontend/src/routes/routes.ts index 8e7ef43db..9e594e1e9 100644 --- a/frontend/src/routes/routes.ts +++ b/frontend/src/routes/routes.ts @@ -70,6 +70,7 @@ export const ROUTES = { LIST: '/compliance-reporting', VIEW: '/compliance-reporting/:compliancePeriod/:complianceReportId', COMPARE: '/compare-reporting', + REPORT_OPENINGS: '/compliance-reporting/report-openings', CHARGING_SITE: { INDEX: '/compliance-reporting/charging-sites', VIEW: '/compliance-reporting/charging-sites/:siteId', diff --git a/frontend/src/tests/utils/handlers.jsx b/frontend/src/tests/utils/handlers.jsx index 9227b48ee..7c0482365 100644 --- a/frontend/src/tests/utils/handlers.jsx +++ b/frontend/src/tests/utils/handlers.jsx @@ -3,6 +3,22 @@ import { apiRoutes } from '@/constants/routes' import { http, HttpResponse } from 'msw' const api = 'http://localhost:8000/api' +const reportOpenings = [ + { + reportOpeningId: 1, + complianceYear: 2019, + complianceReportingEnabled: true, + earlyIssuanceEnabled: false, + supplementalReportRole: 'BCeID' + }, + { + reportOpeningId: 2, + complianceYear: 2020, + complianceReportingEnabled: false, + earlyIssuanceEnabled: false, + supplementalReportRole: 'BCeID' + } +] export const httpOverwrite = (method, endpoint, cb, once) => { return testServer.use(http[method](api + endpoint, cb, { once })) @@ -48,6 +64,31 @@ export const handlers = [ http.get(api + apiRoutes.getCompliancePeriods, () => HttpResponse.json([]) ), + http.get(api + apiRoutes.reportOpenings, () => + HttpResponse.json(reportOpenings) + ), + http.put(api + apiRoutes.reportOpenings, async ({ request }) => { + const body = await request.json() + const updatedYears = body?.reportOpenings ?? [] + const updated = reportOpenings.map((entry) => { + const override = updatedYears.find( + (item) => item.complianceYear === entry.complianceYear + ) + if (!override) { + return entry + } + return { + ...entry, + complianceReportingEnabled: + override.complianceReportingEnabled ?? entry.complianceReportingEnabled, + earlyIssuanceEnabled: + override.earlyIssuanceEnabled ?? entry.earlyIssuanceEnabled, + supplementalReportRole: + override.supplementalReportRole ?? entry.supplementalReportRole + } + }) + return HttpResponse.json(updated) + }), // Address geocoder API for AddressAutocomplete http.get('https://geocoder.api.gov.bc.ca/addresses.json', ({ request }) => { diff --git a/frontend/src/views/ComplianceReports/ReportOpenings/ReportOpenings.jsx b/frontend/src/views/ComplianceReports/ReportOpenings/ReportOpenings.jsx new file mode 100644 index 000000000..170649790 --- /dev/null +++ b/frontend/src/views/ComplianceReports/ReportOpenings/ReportOpenings.jsx @@ -0,0 +1,324 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + Box, + Checkbox, + Radio, + RadioGroup, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow +} from '@mui/material' +import BCTypography from '@/components/BCTypography' +import BCButton from '@/components/BCButton' +import Loading from '@/components/Loading' +import { useTranslation } from 'react-i18next' +import { + useReportOpenings, + useUpdateReportOpenings +} from '@/hooks/useReportOpenings' +import { FloatingAlert } from '@/components/BCAlert' + +const buildRowState = (records = []) => ({ + rows: records + .slice() + .sort((a, b) => a.complianceYear - b.complianceYear) + .map((record) => ({ + complianceYear: record.complianceYear, + complianceReportingEnabled: record.complianceReportingEnabled, + earlyIssuanceEnabled: record.earlyIssuanceEnabled, + supplementalReportRole: record.supplementalReportRole + })), + lookup: records.reduce((acc, record) => { + acc[record.complianceYear] = record + return acc + }, {}) +}) + +const CustomTableCell = ({ children, ...props }) => { + return ( + + {children} + + ) +} + +export const ReportOpenings = () => { + const { t } = useTranslation(['reports']) + const alertRef = useRef(null) + const { data, isLoading } = useReportOpenings() + const updateMutation = useUpdateReportOpenings() + const [rowState, setRowState] = useState({ rows: [], lookup: {} }) + const currentYear = new Date().getFullYear() + + useEffect(() => { + if (Array.isArray(data)) { + setRowState(buildRowState(data)) + } + }, [data]) + + const rows = rowState.rows + const originalLookup = rowState.lookup + + const dirtyRows = useMemo(() => { + return rows.filter((row) => { + const original = originalLookup[row.complianceYear] + if (!original) { + return false + } + + return ( + original.complianceReportingEnabled !== + row.complianceReportingEnabled || + original.earlyIssuanceEnabled !== row.earlyIssuanceEnabled || + original.supplementalReportRole !== row.supplementalReportRole + ) + }) + }, [rows, originalLookup]) + + const hasChanges = dirtyRows.length > 0 + const saving = updateMutation.isPending + + const handleComplianceToggle = (year) => { + setRowState((prev) => ({ + ...prev, + rows: prev.rows.map((row) => + row.complianceYear === year + ? { + ...row, + complianceReportingEnabled: !row.complianceReportingEnabled + } + : row + ) + })) + } + + const handleEarlyIssuanceToggle = (year) => { + if (year !== currentYear) { + return + } + + setRowState((prev) => ({ + ...prev, + rows: prev.rows.map((row) => + row.complianceYear === year + ? { + ...row, + earlyIssuanceEnabled: !row.earlyIssuanceEnabled + } + : row + ) + })) + } + + const handleSupplementalChange = (year, role) => { + setRowState((prev) => ({ + ...prev, + rows: prev.rows.map((row) => + row.complianceYear === year + ? { + ...row, + supplementalReportRole: role + } + : row + ) + })) + } + + const handleSave = () => { + if (!hasChanges) { + return + } + + updateMutation.mutate( + { + reportOpenings: dirtyRows.map((row) => ({ + complianceYear: row.complianceYear, + complianceReportingEnabled: row.complianceReportingEnabled, + earlyIssuanceEnabled: row.earlyIssuanceEnabled, + supplementalReportRole: row.supplementalReportRole + })) + }, + { + onSuccess: (response) => { + setRowState(buildRowState(response)) + alertRef.current?.triggerAlert({ + message: t('reportOpenings.saveSuccess'), + severity: 'success' + }) + }, + onError: () => { + alertRef.current?.triggerAlert({ + message: t('reportOpenings.saveError'), + severity: 'error' + }) + } + } + ) + } + + if (isLoading && rows.length === 0) { + return + } + + return ( + <> + + + + {t('reportOpenings.title')} + + + + + + + + + + {t('reportOpenings.createSupplemental')} + + + + + + {t('reportOpenings.complianceReporting')} + + + {t('reportOpenings.earlyIssuance')} + + + {t('reportOpenings.bceid')} + + + {t('reportOpenings.idir')} + + + + + {rows.map((row) => ( + + + {row.complianceYear} + + + handleComplianceToggle(row.complianceYear)} + inputProps={{ + 'aria-label': t( + 'reportOpenings.complianceReportingToggle', + { + year: row.complianceYear + } + ) + }} + /> + + + + handleEarlyIssuanceToggle(row.complianceYear) + } + inputProps={{ + 'aria-label': t('reportOpenings.earlyIssuanceToggle', { + year: row.complianceYear + }) + }} + /> + + + + handleSupplementalChange( + row.complianceYear, + event.target.value + ) + } + sx={{ + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + gap: 10, + '& .MuiRadio-root': { + p: 0 + } + }} + > + + + + + + ))} + +
+ + + {t('reportOpenings.save')} + + +
+
+ + ) +} diff --git a/frontend/src/views/ComplianceReports/ReportOpenings/index.js b/frontend/src/views/ComplianceReports/ReportOpenings/index.js new file mode 100644 index 000000000..a3d86069c --- /dev/null +++ b/frontend/src/views/ComplianceReports/ReportOpenings/index.js @@ -0,0 +1 @@ +export { ReportOpenings } from './ReportOpenings' diff --git a/frontend/src/views/ComplianceReports/ReportsMenu.jsx b/frontend/src/views/ComplianceReports/ReportsMenu.jsx index 199a85ff1..0cd2db866 100644 --- a/frontend/src/views/ComplianceReports/ReportsMenu.jsx +++ b/frontend/src/views/ComplianceReports/ReportsMenu.jsx @@ -25,11 +25,12 @@ export function ReportsMenu() { const [tabsOrientation, setTabsOrientation] = useState('horizontal') const navigate = useNavigate() const location = useLocation() - const { hasAnyRole } = useCurrentUser() + const { hasAnyRole, hasRoles } = useCurrentUser() const isIDIR = hasAnyRole(...govRoles) const canAccessChargingSitesTab = isIDIR || isFeatureEnabled(FEATURE_FLAGS.MANAGE_CHARGING_SITES) const canAccessFseTab = isIDIR || isFeatureEnabled(FEATURE_FLAGS.MANAGE_FSE) + const isAdministrator = hasRoles(roles.administrator) const tabs = useMemo(() => { const baseTabs = [ @@ -61,8 +62,16 @@ export function ReportsMenu() { }) } + if (isAdministrator) { + baseTabs.push({ + key: 'reportOpenings', + label: t('tabs.reportOpenings'), + path: ROUTES.REPORTS.REPORT_OPENINGS + }) + } + return baseTabs - }, [canAccessChargingSitesTab, canAccessFseTab, isIDIR, t]) + }, [canAccessChargingSitesTab, canAccessFseTab, isAdministrator, isIDIR, t]) const tabIndex = useMemo(() => { // Only select tab when on the exact index route, not on detail/nested pages @@ -112,6 +121,14 @@ export function ReportsMenu() { ) } + if (location.pathname.includes('/report-openings')) { + return ( + + + + ) + } + // Default to compliance reports return } @@ -123,7 +140,7 @@ export function ReportsMenu() { ({ + useReportOpenings: vi.fn(), + useUpdateReportOpenings: vi.fn() +})) + +vi.mock('@/components/BCAlert', () => ({ + FloatingAlert: forwardRef((props, ref) => ( +
+ )) +})) + +const useReportOpeningsMock = useReportOpenings +const useUpdateReportOpeningsMock = useUpdateReportOpenings + +describe('ReportOpenings', () => { + const mockData = [ + { + complianceYear: 2019, + complianceReportingEnabled: false, + earlyIssuanceEnabled: false, + supplementalReportRole: 'BCeID' + }, + { + complianceYear: 2020, + complianceReportingEnabled: true, + earlyIssuanceEnabled: false, + supplementalReportRole: 'IDIR' + } + ] + + beforeEach(() => { + vi.clearAllMocks() + useReportOpeningsMock.mockReturnValue({ + data: mockData, + isLoading: false + }) + useUpdateReportOpeningsMock.mockReturnValue({ + isPending: false, + mutate: vi.fn() + }) + }) + + it('renders years from the API and enables save on change', async () => { + render(, { wrapper }) + + await waitFor(() => { + expect(screen.getByText('2019')).toBeInTheDocument() + expect(screen.getByText('2020')).toBeInTheDocument() + }) + + const saveButton = screen.getByRole('button', { name: /Save/i }) + + expect(saveButton).toBeDisabled() + + const complianceToggle2020 = screen.getByLabelText(/compliance reporting availability.*2020/i) + await userEvent.click(complianceToggle2020) + + expect(saveButton).not.toBeDisabled() + + const idirRadios = screen.getAllByRole('radio', { name: /IDIR/i }) + await userEvent.click(idirRadios[0]) + + expect(saveButton).not.toBeDisabled() + }) +}) diff --git a/frontend/src/views/ComplianceReports/components/NewComplianceReportButton.jsx b/frontend/src/views/ComplianceReports/components/NewComplianceReportButton.jsx index 201b51561..0c4c5626f 100644 --- a/frontend/src/views/ComplianceReports/components/NewComplianceReportButton.jsx +++ b/frontend/src/views/ComplianceReports/components/NewComplianceReportButton.jsx @@ -1,4 +1,4 @@ -import { useState, forwardRef } from 'react' +import { useMemo, useState, forwardRef } from 'react' import { useTranslation } from 'react-i18next' import BCButton from '@/components/BCButton' import BCTypography from '@/components/BCTypography' @@ -11,31 +11,35 @@ import { faInfoCircle } from '@fortawesome/free-solid-svg-icons' import { useCompliancePeriod } from '@/hooks/useComplianceReports' -import { - useGetOrgComplianceReportReportedYears, - useOrgEarlyIssuance -} from '@/hooks/useOrganization' -import { isFeatureEnabled, FEATURE_FLAGS } from '@/constants/config' +import { useGetOrgComplianceReportReportedYears } from '@/hooks/useOrganization' -/** - * TEMPORARY SOLUTION - Issue #3730 - * This component uses hardcoded year checks for 2025/2026 compliance periods. - * A more robust long-term solution should be implemented to support future years - * dynamically (e.g., backend-driven configuration per compliance period). - * - * Current behavior: - * - 2025: Disabled when REPORTING_2025_ENABLED feature flag is false - * - 2026: ALWAYS disabled unless org has early issuance enabled for 2026 - */ export const NewComplianceReportButton = forwardRef((props, ref) => { - const { handleNewReport, isButtonLoading, setIsButtonLoading } = props + const { + handleNewReport, + isButtonLoading, + setIsButtonLoading = () => {} + } = props const { data: periods, isLoading, isFetched } = useCompliancePeriod() const { data: reportedPeriods } = useGetOrgComplianceReportReportedYears() - // Check if org has early issuance for 2026 - always required for 2026 reporting - const { data: earlyIssuance2026 } = useOrgEarlyIssuance('2026') const { t } = useTranslation(['common', 'report']) const reportedPeriodIDs = reportedPeriods?.map((p) => p.compliancePeriodId) + const availablePeriods = useMemo(() => { + const periodsArray = periods?.data || periods || [] + + return periodsArray.filter((period) => { + if (!period?.effectiveDate) { + return false + } + + const effectiveYear = new Date(period.effectiveDate).getFullYear() + if (Number.isNaN(effectiveYear)) { + return false + } + + return true + }) + }, [periods]) const [anchorEl, setAnchorEl] = useState(null) @@ -55,17 +59,6 @@ export const NewComplianceReportButton = forwardRef((props, ref) => { const isMenuOpen = Boolean(anchorEl) - const filteredDates = () => { - const currentYear = new Date().getFullYear() - - // Handle both possible data structures safely - const periodsArray = periods?.data || periods || [] - return periodsArray.filter((item) => { - const effectiveYear = new Date(item.effectiveDate).getFullYear() - return effectiveYear <= currentYear && effectiveYear >= 2024 - }) - } - return (
{ } }} > - {filteredDates().map((period) => { - // Determine if this period should be disabled + {availablePeriods.map((period) => { + const effectiveYear = new Date(period.effectiveDate).getFullYear() const isAlreadyReported = reportedPeriodIDs?.includes( period.compliancePeriodId ) - // 2025: Blocked when feature flag is disabled - const is2025Blocked = - period.description === '2025' && - !isFeatureEnabled(FEATURE_FLAGS.REPORTING_2025_ENABLED) - // 2026: ALWAYS requires early issuance, regardless of 2025 flag - const is2026Blocked = - period.description === '2026' && - !earlyIssuance2026?.hasEarlyIssuance return ( handleComplianceOptionClick(period)} - disabled={isAlreadyReported || is2025Blocked || is2026Blocked} - className={`compliance-period-${period.description}`} + disabled={isAlreadyReported} + className={`compliance-period-${effectiveYear}`} > - {period.description} + {effectiveYear} ) })} - {/* Show info message only when 2025 reporting is disabled */} - {!isFeatureEnabled(FEATURE_FLAGS.REPORTING_2025_ENABLED) && ( + {availablePeriods.length === 0 && ( { }} /> - 2025 reporting is temporarily unavailable due to regulatory updates + {t('report:noReportsFound')} diff --git a/frontend/src/views/ComplianceReports/components/__tests__/NewComplianceReportButton.test.jsx b/frontend/src/views/ComplianceReports/components/__tests__/NewComplianceReportButton.test.jsx index b36f22459..dacbb8138 100644 --- a/frontend/src/views/ComplianceReports/components/__tests__/NewComplianceReportButton.test.jsx +++ b/frontend/src/views/ComplianceReports/components/__tests__/NewComplianceReportButton.test.jsx @@ -1,530 +1,141 @@ import React, { createRef } from 'react' import { describe, it, expect, vi, beforeEach } from 'vitest' -import { - render, - screen, - fireEvent, - waitFor, - within, - act -} from '@testing-library/react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { NewComplianceReportButton } from '../NewComplianceReportButton' import { wrapper } from '@/tests/utils/wrapper.jsx' import { useCompliancePeriod } from '@/hooks/useComplianceReports' -import { - useGetOrgComplianceReportReportedYears, - useOrgEarlyIssuance -} from '@/hooks/useOrganization' +import { useGetOrgComplianceReportReportedYears } from '@/hooks/useOrganization' -// Mock translation hook vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key) => key }) })) -// Mock hooks vi.mock('@/hooks/useComplianceReports', () => ({ useCompliancePeriod: vi.fn() })) + vi.mock('@/hooks/useOrganization', () => ({ - useGetOrgComplianceReportReportedYears: vi.fn(), - useOrgEarlyIssuance: vi.fn() + useGetOrgComplianceReportReportedYears: vi.fn() })) const useCompliancePeriodMock = useCompliancePeriod const useGetOrgComplianceReportReportedYearsMock = useGetOrgComplianceReportReportedYears -const useOrgEarlyIssuanceMock = useOrgEarlyIssuance describe('NewComplianceReportButton', () => { - let handleNewReportMock, setIsButtonLoadingMock - - const currentYear = new Date().getFullYear() - const mockPeriodsData = [ + const basePeriods = [ { compliancePeriodId: 1, description: '2024', effectiveDate: '2024-01-01T00:00:00Z' - }, - { - compliancePeriodId: 2, - description: '2025', - effectiveDate: '2025-01-01T00:00:00Z' - }, - { - compliancePeriodId: 3, - description: '2026', - effectiveDate: '2026-01-01T00:00:00Z' } ] - const setupMocks = (overrides = {}) => { - const defaults = { - periods: { data: mockPeriodsData, isLoading: false, isFetched: true }, - reportedPeriods: [], - // By default, early issuance is disabled (2026 should be blocked) - earlyIssuance2026: { hasEarlyIssuance: false } - } - - const config = { ...defaults, ...overrides } - + const mockHooks = ({ + periods = { data: basePeriods, isLoading: false, isFetched: true }, + reportedPeriods = [] + } = {}) => { useCompliancePeriodMock.mockReturnValue({ - data: config.periods.data, - isLoading: config.periods.isLoading, - isFetched: config.periods.isFetched + data: periods.data, + isLoading: periods.isLoading, + isFetched: periods.isFetched }) useGetOrgComplianceReportReportedYearsMock.mockReturnValue({ - data: config.reportedPeriods - }) - - useOrgEarlyIssuanceMock.mockReturnValue({ - data: config.earlyIssuance2026 + data: reportedPeriods }) } + let handleNewReportMock + let setIsButtonLoadingMock + beforeEach(() => { vi.clearAllMocks() handleNewReportMock = vi.fn() setIsButtonLoadingMock = vi.fn() - setupMocks() + mockHooks() }) - const defaultProps = { + const getDefaultProps = () => ({ handleNewReport: handleNewReportMock, isButtonLoading: false, setIsButtonLoading: setIsButtonLoadingMock - } - - describe('Component Rendering', () => { - it('renders button with correct initial state', () => { - render(, { wrapper }) - - expect(screen.getByRole('button')).toBeInTheDocument() - expect(screen.getByText('report:newReportBtn')).toBeInTheDocument() - }) - - it('shows loading state when isLoading is true', () => { - setupMocks({ - periods: { data: mockPeriodsData, isLoading: true, isFetched: false } - }) - - render(, { wrapper }) - - expect(screen.getByRole('progressbar')).toBeInTheDocument() - }) - - it('shows loading state when isButtonLoading is true', () => { - render( - , - { wrapper } - ) - - expect(screen.getByRole('progressbar')).toBeInTheDocument() - }) - - it('forwards ref correctly', () => { - const ref = createRef() - - render(, { - wrapper - }) - - expect(ref.current).toBeTruthy() - }) }) - describe('Menu Functionality', () => { - it('opens menu when button is clicked', async () => { - render(, { wrapper }) - - const button = screen.getByRole('button') - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument() - }) - }) - - it('shows menu when button is clicked', async () => { - render(, { wrapper }) - - const button = screen.getByRole('button') - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument() - }) - }) - - it('does not render menu when not fetched', () => { - setupMocks({ - periods: { data: mockPeriodsData, isLoading: false, isFetched: false } - }) - - render(, { wrapper }) - - const button = screen.getByRole('button') - fireEvent.click(button) + it('renders the primary button and forwards refs', () => { + const ref = createRef() - expect(screen.queryByRole('menu')).not.toBeInTheDocument() + render(, { + wrapper }) - it('does not render menu when button is loading', () => { - render( - , - { wrapper } - ) - - const button = screen.getByRole('button') - fireEvent.click(button) - - expect(screen.queryByRole('menu')).not.toBeInTheDocument() - }) + expect(screen.getByRole('button')).toHaveTextContent('report:newReportBtn') + expect(ref.current).not.toBeNull() }) - describe('filteredDates Function', () => { - it('filters periods by current year and 2024+ constraint', async () => { - // Use relative years based on currentYear to ensure test works across year boundaries - const testPeriods = [ - { - compliancePeriodId: 1, - description: '2024', - effectiveDate: '2024-01-01T08:00:00Z' // 2024 is always >= 2024 - }, - { - compliancePeriodId: 2, - description: `${currentYear}`, - effectiveDate: `${currentYear}-01-01T08:00:00Z` // currentYear should show - }, - { - compliancePeriodId: 3, - description: `${currentYear + 1}`, - effectiveDate: `${currentYear + 1}-01-01T08:00:00Z` // future year, filtered out - }, - { - compliancePeriodId: 4, - description: `${currentYear + 2}`, - effectiveDate: `${currentYear + 2}-01-01T08:00:00Z` // future year, filtered out - } - ] - - setupMocks({ - periods: { data: testPeriods, isLoading: false, isFetched: true } - }) - - render(, { wrapper }) - - const button = screen.getByRole('button') - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument() - }) + it('opens the menu and lists available years that are ready', async () => { + render(, { wrapper }) - // Should show periods where effectiveYear is <= currentYear and >= 2024 - expect(screen.getByText('2024')).toBeInTheDocument() // 2024 is always valid - expect(screen.getByText(`${currentYear}`)).toBeInTheDocument() // currentYear should show - expect(screen.queryByText(`${currentYear + 1}`)).not.toBeInTheDocument() // future year, filtered out - expect(screen.queryByText(`${currentYear + 2}`)).not.toBeInTheDocument() // future year, filtered out - }) - - it('handles periods data structure safely - nested data', async () => { - useCompliancePeriodMock.mockReturnValue({ - data: { data: mockPeriodsData }, - isLoading: false, - isFetched: true - }) - - render(, { wrapper }) - - const button = screen.getByRole('button') - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument() - }) - - expect(screen.getByText('2025')).toBeInTheDocument() - }) - - it('handles periods data structure safely - null data', async () => { - setupMocks({ - periods: { data: null, isLoading: false, isFetched: true } - }) - - render(, { wrapper }) - - const button = screen.getByRole('button') - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument() - }) - }) + fireEvent.click(screen.getByRole('button')) - it('handles periods as direct array', async () => { - useCompliancePeriodMock.mockReturnValue({ - data: mockPeriodsData, - isLoading: false, - isFetched: true - }) - - render(, { wrapper }) - - const button = screen.getByRole('button') - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument() - }) - - expect(screen.getByText('2025')).toBeInTheDocument() - }) + const menu = await screen.findByRole('menu') + expect(menu).toBeInTheDocument() + expect(screen.getByText('2024')).toBeInTheDocument() + expect(screen.queryByText('2025')).not.toBeInTheDocument() }) - describe('Menu Item States', () => { - it('disables menu items for reported periods', async () => { - const reportedPeriods = [{ compliancePeriodId: 2 }] // 2025 period - setupMocks({ reportedPeriods }) - - render(, { wrapper }) - - const button = screen.getByRole('button') - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument() - }) - - const menuItems = within(screen.getByRole('menu')).getAllByRole( - 'menuitem' - ) - const reportedItem = menuItems.find((item) => item.textContent === '2025') - expect(reportedItem).toHaveAttribute('aria-disabled', 'true') - }) - - it('disables 2025 menu item when feature flag is off', async () => { - render(, { wrapper }) - - const button = screen.getByRole('button') - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument() - }) - - const menuItems = within(screen.getByRole('menu')).getAllByRole( - 'menuitem' - ) - const item2025 = menuItems.find((item) => item.textContent === '2025') - expect(item2025).toHaveAttribute('aria-disabled', 'true') - }) - - it('disables 2026 menu item when org does not have early issuance', async () => { - // Early issuance is false by default in setupMocks - setupMocks({ - earlyIssuance2026: { hasEarlyIssuance: false } - }) - - render(, { wrapper }) - - const button = screen.getByRole('button') - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument() - }) - - const menuItems = within(screen.getByRole('menu')).getAllByRole( - 'menuitem' - ) - const item2026 = menuItems.find((item) => item.textContent === '2026') - expect(item2026).toHaveAttribute('aria-disabled', 'true') - }) - - it('enables 2026 menu item when org has early issuance', async () => { - setupMocks({ - earlyIssuance2026: { hasEarlyIssuance: true } - }) - - render(, { wrapper }) - - const button = screen.getByRole('button') - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument() - }) - - const menuItems = within(screen.getByRole('menu')).getAllByRole( - 'menuitem' - ) - const item2026 = menuItems.find((item) => item.textContent === '2026') - expect(item2026).not.toHaveAttribute('aria-disabled') + it('disables periods that have already been reported', async () => { + mockHooks({ + reportedPeriods: [{ compliancePeriodId: 1 }] }) - it('enables available periods', async () => { - // Use explicit mock data to ensure consistent behavior across environments - const deterministicPeriods = [ - { - compliancePeriodId: 1, - description: '2024', - effectiveDate: '2024-06-01T12:00:00Z' // Will be 2024 in any reasonable timezone - }, - { - compliancePeriodId: 2, - description: '2025', - effectiveDate: '2025-06-01T12:00:00Z' // Will be 2025 in any reasonable timezone (disabled by business logic) - } - ] - - setupMocks({ - periods: { data: deterministicPeriods, isLoading: false, isFetched: true }, - reportedPeriods: [] - }) + render(, { wrapper }) + fireEvent.click(screen.getByRole('button')) - render(, { wrapper }) - - const button = screen.getByRole('button') - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument() - }) - - const menuItems = within(screen.getByRole('menu')).getAllByRole( - 'menuitem' - ) - - // 2024 should be available (not disabled), 2025 should be disabled by business logic - const item2024 = menuItems.find(item => item.textContent === '2024') - if (item2024) { - expect(item2024).not.toHaveAttribute('aria-disabled') - } else { - // If 2024 is not present due to current year filtering, just verify menu exists - expect(screen.getByRole('menu')).toBeInTheDocument() - } - }) + const menuItem = await screen.findByText('2024') + expect(menuItem).toHaveAttribute('aria-disabled', 'true') }) - describe('Menu Item Selection', () => { - it('renders clickable menu items', async () => { - // Use explicit mock data to ensure consistent behavior across environments - const deterministicPeriods = [ - { - compliancePeriodId: 1, - description: '2024', - effectiveDate: '2024-06-01T12:00:00Z' // Will be 2024 in any reasonable timezone - }, - { - compliancePeriodId: 2, - description: '2025', - effectiveDate: '2025-06-01T12:00:00Z' // Will be 2025 in any reasonable timezone (disabled by business logic) - } - ] - - setupMocks({ - periods: { data: deterministicPeriods, isLoading: false, isFetched: true }, - reportedPeriods: [] - }) - - render(, { wrapper }) + it('calls handlers when a period is selected', async () => { + render(, { wrapper }) + fireEvent.click(screen.getByRole('button')) - const button = screen.getByRole('button') - fireEvent.click(button) + const option = await screen.findByText('2024') + fireEvent.click(option) - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument() - }) + expect(setIsButtonLoadingMock).toHaveBeenCalledWith(true) + expect(handleNewReportMock).toHaveBeenCalledWith( + expect.objectContaining({ compliancePeriodId: 1 }) + ) + }) - const menuItems = within(screen.getByRole('menu')).getAllByRole( - 'menuitem' - ) - - // 2024 should be clickable (not disabled and not reported) - const item2024 = menuItems.find(item => item.textContent === '2024') - - if (item2024) { - expect(item2024).toBeInTheDocument() - expect(item2024).not.toHaveAttribute('aria-disabled') - } else { - // If 2024 is not present due to current year filtering, just verify menu exists - expect(screen.getByRole('menu')).toBeInTheDocument() + it('shows an informational message when no periods are available', async () => { + mockHooks({ + periods: { + data: [], + isLoading: false, + isFetched: true } }) - }) - - describe('Information Bulletin', () => { - it('renders information bulletin box when 2025 feature flag is off', async () => { - render(, { wrapper }) - const button = screen.getByRole('button') - fireEvent.click(button) + render(, { wrapper }) + fireEvent.click(screen.getByRole('button')) - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument() - }) - - expect( - screen.getByText( - '2025 reporting is temporarily unavailable due to regulatory updates' - ) - ).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByText('report:noReportsFound')).toBeInTheDocument() }) }) - describe('Edge Cases', () => { - it('handles empty reportedPeriods safely', async () => { - setupMocks({ reportedPeriods: null }) - - render(, { wrapper }) - - const button = screen.getByRole('button') - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument() - }) + it('does not render the menu when data has not finished loading', () => { + mockHooks({ + periods: { data: basePeriods, isLoading: false, isFetched: false } }) - it('handles undefined reportedPeriods safely', async () => { - useGetOrgComplianceReportReportedYearsMock.mockReturnValue({ - data: undefined - }) - - render(, { wrapper }) - - const button = screen.getByRole('button') - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument() - }) - }) - - it('handles empty periods array', async () => { - setupMocks({ - periods: { data: [], isLoading: false, isFetched: true } - }) - - render(, { wrapper }) + render(, { wrapper }) + fireEvent.click(screen.getByRole('button')) - const button = screen.getByRole('button') - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByRole('menu')).toBeInTheDocument() - }) - }) - }) - - describe('Translation Integration', () => { - it('uses translation key for button text', () => { - render(, { wrapper }) - - expect(screen.getByText('report:newReportBtn')).toBeInTheDocument() - }) + expect(screen.queryByRole('menu')).not.toBeInTheDocument() }) }) diff --git a/frontend/src/views/ComplianceReports/index.js b/frontend/src/views/ComplianceReports/index.js index 726a4e6e1..3eb7dc6da 100644 --- a/frontend/src/views/ComplianceReports/index.js +++ b/frontend/src/views/ComplianceReports/index.js @@ -1,3 +1,4 @@ export { ComplianceReports } from './ComplianceReports' export { CreditCalculator } from './CreditCalculator' +export { ReportOpenings } from './ReportOpenings' export { CalculatorMenu } from './CalculatorMenu'