Skip to content

Commit 4b2bf93

Browse files
authored
Merge pull request #3755 from bcgov/release
Hotfix: 2026 Reporting Fix
2 parents 56df3e8 + f3484a0 commit 4b2bf93

File tree

11 files changed

+311
-53
lines changed

11 files changed

+311
-53
lines changed

backend/lcfs/db/seeders/staging/test_compliance_report_seeder.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -127,30 +127,30 @@ async def seed_test_compliance_reports(session):
127127
"reporting_frequency": "ANNUAL",
128128
"nickname": "Original Report",
129129
},
130-
# LCFS5 (org 5, user 11): Submitted 2025, reserved tx 106
130+
# LCFS5 (org 5, user 11): Submitted 2025, reserved tx 105
131131
{
132132
"compliance_report_id": 105,
133133
"organization_id": 5,
134134
"compliance_period_id": 16, # 2025
135135
"current_status_id": 2, # Submitted
136-
"transaction_id": 106,
137-
# Supplemental off existing org 5 baseline (id 6) chain
138-
"compliance_report_group_uuid": "1122a80e-99a3-447b-a62e-4c758dd83700",
139-
"version": 1,
136+
"transaction_id": 105,
137+
# New 2025 report chain for org 5 (separate from 2024 report id 6)
138+
"compliance_report_group_uuid": "11111111-1111-1111-1111-111111111105",
139+
"version": 0,
140140
"reporting_frequency": "ANNUAL",
141-
"nickname": "Supplemental Report 1",
141+
"nickname": "Original Report",
142142
},
143-
# LCFS6 (org 6, user 12): Submitted 2025, adjustment tx 105
143+
# LCFS6 (org 6, user 12): Submitted 2025, adjustment tx 106
144144
{
145145
"compliance_report_id": 106,
146146
"organization_id": 6,
147147
"compliance_period_id": 16,
148148
"current_status_id": 2, # Submitted
149-
"transaction_id": 105,
149+
"transaction_id": 106,
150150
"compliance_report_group_uuid": "11111111-1111-1111-1111-111111111106",
151-
"version": 1,
151+
"version": 0,
152152
"reporting_frequency": "ANNUAL",
153-
"nickname": "Supplemental Report 1",
153+
"nickname": "Original Report",
154154
},
155155
# LCFS7 (org 7, user 13): Submitted 2025, reserved tx 107
156156
{
@@ -160,9 +160,9 @@ async def seed_test_compliance_reports(session):
160160
"current_status_id": 2, # Submitted
161161
"transaction_id": 107,
162162
"compliance_report_group_uuid": "11111111-1111-1111-1111-111111111107",
163-
"version": 1,
163+
"version": 0,
164164
"reporting_frequency": "ANNUAL",
165-
"nickname": "Supplemental Report 1",
165+
"nickname": "Original Report",
166166
},
167167
# LCFS8 (org 8, user 14): Submitted quarterly 2025
168168
{
@@ -196,7 +196,7 @@ async def seed_test_compliance_reports(session):
196196
"compliance_report_group_uuid": "11111111-1111-1111-1111-111111111110",
197197
"version": 0,
198198
"reporting_frequency": "ANNUAL",
199-
"nickname": "Supplemental Report 1",
199+
"nickname": "Original Report",
200200
},
201201
# New chain: Org 2, versions 0-2 in 2025 (all submitted for summary testing)
202202
{

backend/lcfs/db/seeders/staging/test_compliance_report_summary_seeder.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ async def seed_test_compliance_report_summaries(session):
392392
# 105 Analyst Adjustment 2025 (locked)
393393
# CR 105 locked analyst adj: fuel 10 + notional 8, retention 1, deferral 0.5 => line10/20 = 17.5
394394
{"summary_id": 105, "compliance_report_id": 105, "is_locked": True, "line_1_fossil_derived_base_fuel_gasoline": 40.0, "line_2_eligible_renewable_fuel_supplied_gasoline": 10.0, "line_3_total_tracked_fuel_supplied_gasoline": 50.0, "line_4_eligible_renewable_fuel_required_gasoline": 2.0, "line_5_net_notionally_transferred_gasoline": 8.0, "line_6_renewable_fuel_retained_gasoline": 1.0, "line_7_previously_retained_gasoline": 0.0, "line_8_obligation_deferred_gasoline": 0.5, "line_9_obligation_added_gasoline": 0.0, "line_10_net_renewable_fuel_supplied_gasoline": 17.5, "line_20_surplus_deficit_units": 17.5},
395-
# 106 Supplier Supplemental 2025 (draft) - uses previous retention (line 7)
395+
# 106 Original 2025 report (draft) - org 6's first 2025 report
396396
{
397397
"summary_id": 106,
398398
"compliance_report_id": 106,
@@ -403,13 +403,13 @@ async def seed_test_compliance_report_summaries(session):
403403
"line_4_eligible_renewable_fuel_required_gasoline": 2250000.0,
404404
"line_5_net_notionally_transferred_gasoline": -900000.0,
405405
"line_6_renewable_fuel_retained_gasoline": 0.0,
406-
"line_7_previously_retained_gasoline": 750000.0,
406+
"line_7_previously_retained_gasoline": 0.0,
407407
"line_8_obligation_deferred_gasoline": 0.0,
408408
"line_9_obligation_added_gasoline": 0.0,
409-
"line_10_net_renewable_fuel_supplied_gasoline": 8850000.0,
410-
"line_20_surplus_deficit_units": 8850000.0,
409+
"line_10_net_renewable_fuel_supplied_gasoline": 8100000.0,
410+
"line_20_surplus_deficit_units": 8100000.0,
411411
},
412-
# 107 Gov Supplemental 2025 (draft) - uses previous retention (line 7) and retains more (line 6)
412+
# 107 Original 2025 report (draft) - org 7's first 2025 report with retention
413413
{
414414
"summary_id": 107,
415415
"compliance_report_id": 107,
@@ -432,19 +432,19 @@ async def seed_test_compliance_report_summaries(session):
432432
"line_6_renewable_fuel_retained_gasoline": 100000.0,
433433
"line_6_renewable_fuel_retained_diesel": 50000.0,
434434
"line_6_renewable_fuel_retained_jet_fuel": 25000.0,
435-
"line_7_previously_retained_gasoline": 200000.0,
436-
"line_7_previously_retained_diesel": 100000.0,
435+
"line_7_previously_retained_gasoline": 0.0,
436+
"line_7_previously_retained_diesel": 0.0,
437437
"line_7_previously_retained_jet_fuel": 0.0,
438438
"line_8_obligation_deferred_gasoline": 0.0,
439439
"line_8_obligation_deferred_diesel": 0.0,
440440
"line_8_obligation_deferred_jet_fuel": 0.0,
441441
"line_9_obligation_added_gasoline": 0.0,
442442
"line_9_obligation_added_diesel": 0.0,
443443
"line_9_obligation_added_jet_fuel": 0.0,
444-
"line_10_net_renewable_fuel_supplied_gasoline": 4450000.0,
445-
"line_10_net_renewable_fuel_supplied_diesel": 3050000.0,
444+
"line_10_net_renewable_fuel_supplied_gasoline": 4250000.0,
445+
"line_10_net_renewable_fuel_supplied_diesel": 2950000.0,
446446
"line_10_net_renewable_fuel_supplied_jet_fuel": 1975000.0,
447-
"line_20_surplus_deficit_units": 9475000.0,
447+
"line_20_surplus_deficit_units": 9175000.0,
448448
},
449449
# 108 Early issuance quarterly draft 2025 - defers obligation (line 8)
450450
{

backend/lcfs/settings.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,18 @@ class Settings(BaseSettings):
9797
# Feature flags
9898
feature_credit_market_notifications: bool = True
9999
feature_fuel_code_expiry_email: bool = True
100+
# TEMPORARY SOLUTION - Issue #3730
101+
# This is a temporary approach to gate compliance year access.
102+
# A more robust long-term solution should be implemented to support future years
103+
# dynamically (e.g., database-driven configuration per compliance period).
104+
#
105+
# Current behavior:
106+
# - 2025: Enabled by default (backend allows it), frontend flag controls UI visibility
107+
# - 2026: ALWAYS blocked unless org has early issuance enabled for 2026
108+
#
109+
# Set LCFS_FEATURE_REPORTING_2025_ENABLED=false to disable 2025 reporting on backend.
110+
# Frontend has corresponding flag: reporting2025Enabled in config.ts
111+
feature_reporting_2025_enabled: bool = True
100112

101113
def __init__(self, **kwargs):
102114
# Map APP_ENVIRONMENT to environment if present

backend/lcfs/tests/organization/test_organization_validation.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,18 +71,18 @@ async def test_update_transfer_success(organization_validation, mock_transaction
7171

7272
@pytest.mark.anyio
7373
async def test_create_compliance_report_success(
74-
organization_validation, mock_report_repo, set_mock_user, fastapi_app
74+
organization_validation, mock_report_repo
7575
):
76-
# Mock user setup
77-
set_mock_user(fastapi_app, [RoleEnum.SUPPLIER])
78-
7976
# Mock the request object and its attributes
8077
mock_request = MagicMock()
8178
mock_request.user.organization.organization_id = 1
8279
organization_validation.request = mock_request
8380

84-
# Mock the report repository methods
85-
mock_report_repo.get_compliance_period.return_value = True
81+
# Mock the compliance period with proper attributes
82+
# The validation checks period.description for 2025/2026 restrictions
83+
mock_period = MagicMock()
84+
mock_period.description = "2024"
85+
mock_report_repo.get_compliance_period.return_value = mock_period
8686
mock_report_repo.get_compliance_report_by_period.return_value = False
8787

8888
# Call the method under test

backend/lcfs/web/api/compliance_report/validation.py

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,36 @@
44
from lcfs.db.models.user.Role import RoleEnum
55
from lcfs.db.models.compliance.ComplianceReportStatus import ComplianceReportStatusEnum
66
from lcfs.web.api.compliance_report.repo import ComplianceReportRepository
7+
from lcfs.web.api.organizations.repo import OrganizationsRepository
78
from fastapi import status
89
from lcfs.web.api.role.schema import user_has_roles
10+
from lcfs.settings import settings
911

1012

1113
class ComplianceReportValidation:
1214
def __init__(
1315
self,
1416
request: Request = None,
1517
repo: ComplianceReportRepository = Depends(ComplianceReportRepository),
18+
org_repo: OrganizationsRepository = Depends(OrganizationsRepository),
1619
) -> None:
1720
self.request = request
1821
self.repo = repo
22+
self.org_repo = org_repo
1923

2024
async def validate_organization_access(self, compliance_report_id: int):
25+
"""
26+
Validates that the user has access to the specified compliance report.
27+
28+
TEMPORARY SOLUTION - Issue #3730
29+
This method includes temporary year-based access checks for 2025/2026.
30+
A more robust long-term solution should be implemented to support future years
31+
dynamically (e.g., database-driven configuration per compliance period).
32+
33+
Compliance year access rules (also enforced in organization/validation.py):
34+
- 2025: Blocked when feature_reporting_2025_enabled is False
35+
- 2026: ALWAYS requires early issuance, regardless of 2025 flag status
36+
"""
2137
compliance_report = await self.repo.get_compliance_report_schema_by_id(
2238
compliance_report_id
2339
)
@@ -34,15 +50,42 @@ async def validate_organization_access(self, compliance_report_id: int):
3450
else None
3551
)
3652

37-
if (
38-
not user_has_roles(self.request.user, [RoleEnum.GOVERNMENT])
39-
and organization_id != user_organization_id
40-
):
53+
is_government = user_has_roles(self.request.user, [RoleEnum.GOVERNMENT])
54+
55+
if not is_government and organization_id != user_organization_id:
4156
raise HTTPException(
4257
status_code=status.HTTP_403_FORBIDDEN,
4358
detail="User does not have access to this compliance report.",
4459
)
4560

61+
# For non-government users, validate access to 2025/2026 compliance periods
62+
# Government users can always access all reports for oversight
63+
if not is_government and user_organization_id:
64+
compliance_period = compliance_report.compliance_period
65+
period_desc = (
66+
compliance_period.description
67+
if hasattr(compliance_period, "description")
68+
else str(compliance_period)
69+
)
70+
71+
# 2025: Blocked when feature_reporting_2025_enabled is False
72+
if period_desc == "2025" and not settings.feature_reporting_2025_enabled:
73+
raise HTTPException(
74+
status_code=status.HTTP_403_FORBIDDEN,
75+
detail="2025 reporting is not yet available.",
76+
)
77+
78+
# 2026: ALWAYS requires early issuance, regardless of 2025 flag status
79+
if period_desc == "2026":
80+
early_issuance = await self.org_repo.get_early_issuance_by_year(
81+
user_organization_id, "2026"
82+
)
83+
if not early_issuance or not early_issuance.has_early_issuance:
84+
raise HTTPException(
85+
status_code=status.HTTP_403_FORBIDDEN,
86+
detail="2026 reporting is only available to early issuance suppliers.",
87+
)
88+
4689
return compliance_report
4790

4891
async def validate_compliance_report_access(

backend/lcfs/web/api/organization/validation.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from lcfs.web.api.compliance_report.schema import ComplianceReportCreateSchema
99
from lcfs.web.api.compliance_report.repo import ComplianceReportRepository
1010
from lcfs.utils.constants import LCFS_Constants
11+
from lcfs.settings import settings
1112

1213

1314
class OrganizationValidation:
@@ -110,6 +111,38 @@ async def create_compliance_report(
110111
)
111112
if not period:
112113
raise HTTPException(status_code=404, detail="Compliance period not found")
114+
115+
# TEMPORARY SOLUTION - Issue #3730
116+
# This is a temporary approach to gate compliance year access.
117+
# A more robust long-term solution should be implemented to support future years
118+
# dynamically (e.g., database-driven configuration per compliance period).
119+
#
120+
# Compliance year access rules (also enforced in compliance_report/validation.py):
121+
# - 2025: Blocked when feature_reporting_2025_enabled is False
122+
# - 2026: ALWAYS requires early issuance, regardless of 2025 flag status
123+
124+
# Validate access to 2025/2026 compliance periods
125+
# 2025: Blocked when feature_reporting_2025_enabled is False
126+
if (
127+
period.description == "2025"
128+
and not settings.feature_reporting_2025_enabled
129+
):
130+
raise HTTPException(
131+
status_code=status.HTTP_403_FORBIDDEN,
132+
detail="2025 reporting is not yet available.",
133+
)
134+
135+
# 2026: ALWAYS requires early issuance, regardless of 2025 flag status
136+
if period.description == "2026":
137+
early_issuance = await self.org_repo.get_early_issuance_by_year(
138+
organization_id, "2026"
139+
)
140+
if not early_issuance or not early_issuance.has_early_issuance:
141+
raise HTTPException(
142+
status_code=status.HTTP_403_FORBIDDEN,
143+
detail="2026 reporting is only available to early issuance suppliers.",
144+
)
145+
113146
is_report_present = await self.report_repo.get_compliance_report_by_period(
114147
organization_id, report_data.compliance_period
115148
)

backend/lcfs/web/api/organizations/services.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,24 @@ async def get_organization(self, organization_id: int):
568568

569569
return organization
570570

571+
@service_handler
572+
async def get_early_issuance_for_year(
573+
self, organization_id: int, compliance_year: str
574+
) -> bool:
575+
"""
576+
Check if an organization has early issuance enabled for a specific compliance year.
577+
Used to determine if an organization can create compliance reports for future years.
578+
579+
TEMPORARY SOLUTION - Issue #3730
580+
This method is part of a temporary approach to gate 2026 compliance year access.
581+
A more robust long-term solution should be implemented to support future years
582+
dynamically (e.g., database-driven configuration per compliance period).
583+
"""
584+
early_issuance = await self.repo.get_early_issuance_by_year(
585+
organization_id, compliance_year
586+
)
587+
return early_issuance.has_early_issuance if early_issuance else False
588+
571589
@service_handler
572590
async def get_penalty_analytics(
573591
self, organization_id: int

backend/lcfs/web/api/organizations/views.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,39 @@ async def get_balances(request: Request, service: OrganizationsService = Depends
426426
)
427427

428428

429+
@router.get(
430+
"/current/early-issuance/{compliance_year}",
431+
response_model=Dict[str, bool],
432+
status_code=status.HTTP_200_OK,
433+
)
434+
@view_handler([RoleEnum.SUPPLIER])
435+
async def get_current_org_early_issuance(
436+
request: Request,
437+
compliance_year: str,
438+
service: OrganizationsService = Depends(),
439+
):
440+
"""
441+
Check if the current user's organization has early issuance enabled for a specific compliance year.
442+
Used to determine if an organization can create compliance reports for future years (e.g., 2026).
443+
444+
TEMPORARY SOLUTION - Issue #3730
445+
This endpoint is part of a temporary approach to gate 2026 compliance year access.
446+
A more robust long-term solution should be implemented to support future years
447+
dynamically (e.g., database-driven configuration per compliance period).
448+
"""
449+
org_id = request.user.organization_id
450+
if not org_id:
451+
raise HTTPException(
452+
status_code=status.HTTP_400_BAD_REQUEST,
453+
detail="User is not associated with an organization",
454+
)
455+
456+
has_early_issuance = await service.get_early_issuance_for_year(
457+
org_id, compliance_year
458+
)
459+
return {"hasEarlyIssuance": has_early_issuance}
460+
461+
429462
@router.put(
430463
"/current/credit-market",
431464
response_model=OrganizationResponseSchema,

frontend/src/hooks/useOrganization.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,45 @@ export const useGetOrgComplianceReportReportedYears = (orgID, options = {}) => {
300300
})
301301
}
302302

303+
/**
304+
* Hook to check if the current organization has early issuance enabled for a specific compliance year.
305+
* Used to determine if an organization can create compliance reports for future years (e.g., 2026).
306+
*
307+
* TEMPORARY SOLUTION - Issue #3730
308+
* This hook is part of a temporary approach to gate 2026 compliance year access.
309+
* A more robust long-term solution should be implemented to support future years
310+
* dynamically (e.g., backend-driven configuration per compliance period).
311+
*/
312+
export const useOrgEarlyIssuance = (complianceYear, options = {}) => {
313+
const client = useApiService()
314+
315+
const {
316+
staleTime = DEFAULT_STALE_TIME,
317+
cacheTime = DEFAULT_CACHE_TIME,
318+
enabled = true,
319+
...restOptions
320+
} = options
321+
322+
return useQuery({
323+
queryKey: ['org-early-issuance', complianceYear],
324+
queryFn: async () => {
325+
if (!complianceYear) {
326+
throw new Error('Compliance year is required')
327+
}
328+
const response = await client.get(
329+
`/organizations/current/early-issuance/${complianceYear}`
330+
)
331+
return response.data
332+
},
333+
enabled: enabled && !!complianceYear,
334+
staleTime,
335+
cacheTime,
336+
retry: 3,
337+
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
338+
...restOptions
339+
})
340+
}
341+
303342
// Mutation hooks for updating organization data
304343
export const useUpdateOrganization = (orgID, options = {}) => {
305344
const client = useApiService()

0 commit comments

Comments
 (0)