Skip to content

Commit cea3b8b

Browse files
authored
Merge pull request #3984 from bcgov/372-supp-report-increases-emissions-past-deadline
Compliance/Automatic Emails - Supplementary Report Submitted after Deadline with increased emissions #372
2 parents 94a45a8 + d54f41e commit cea3b8b

6 files changed

Lines changed: 230 additions & 7 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Generated by Django 5.1.10 on 2025-10-07 21:57
2+
3+
from django.db import migrations
4+
5+
6+
def supplementary_report_submitted_after_deadline_template(apps, schema_editor):
7+
EmailNotificationTemplate = apps.get_model('common', 'EmailNotificationTemplate')
8+
EmailNotificationTemplate.objects.create(
9+
name='Supplementary Report Submitted after Deadline',
10+
subject='BCIERS Notification – Supplementary Report Submitted after Deadline',
11+
body='''
12+
<p style="text-align: center;">Province of British Columbia</p>
13+
<p style="text-align: center;">B.C. Industrial Emissions Reporting System (BCIERS)</p>
14+
<br/>
15+
<p>Dear {{operator_legal_name}},</p>
16+
<br/>
17+
<p>Thank you for submitting a supplementary report through the BC Industrial Emissions Reporting System (BCIERS).
18+
</p>
19+
<br/>
20+
<p>Because your supplementary report results in an increased amount of excess emissions, <b>you must meet an additional compliance obligation</b>. Based on the information submitted in <b>{{operation_name}}</b>’s supplementary report and the applicable compliance charge rate, an additional compliance obligation for excess emissions of {{tonnes_of_co2}} at an equivalent amount of {{outstanding_balance}} is now payable. </p>
21+
<br/>
22+
<p><b>Because the supplementary report was submitted after the compliance obligation deadline</b>, the additional compliance obligation is subject to interest under the Greenhouse Gas Emission Administrative Penalty and Appeals Regulation (GGEAPAR). Interest is calculated at the prime lending rate + 3%, compounded monthly from the compliance obligation deadline of November 30, {{year_due}} and applies until you resolve the overdue obligation.</p>
23+
<br/><p>To review your compliance obligation and how it was calculated, please log-into BCIERS at <a
24+
href="https://industrialemissions.gov.bc.ca/onboarding">https://industrialemissions.gov.bc.ca/onboarding</a>.</p><br/>
25+
<p>To meet your compliance obligation: </p>
26+
<ol>
27+
<li>Navigate to the Compliance module and click <em>My Compliance > Compliance Summaries</em>.</li>
28+
<li>Find your operation in the grid and click <em>Manage Obligation</em>.</li>
29+
</ol>
30+
<p>If you do not meet the additional compliance obligation <b>within 30 days</b>, the additional obligation will be subject to daily compounding penalties, as well as interest under the <i>Financial Administration Act</i> (FAA). We strongly encourage you to make your payment on time to avoid these additional charges. Please refer to the B.C. OBPS compliance module guidance document on our website for further information. </p><br/>
31+
<p><em>Please do not reply to this email. This email is auto-generated. If you have any questions, or need further assistance, please reach out to our support team at <a href="mailto:GHGRegulator@gov.bc.ca">GHGRegulator@gov.bc.ca</a></em></p>
32+
<br/>
33+
<p>Best Regards,</p>
34+
<p>Ministry of Energy and Climate Solutions</p>
35+
<p>Email: <a href="mailto:GHGRegulator@gov.bc.ca">GHGRegulator@gov.bc.ca</a></p>
36+
<p>Website: <a href="https://www2.gov.bc.ca/gov/content/environment/climate-change/industry/bc-output-based-pricing-system">BC
37+
Government Website Link</a>
38+
</p>
39+
''',
40+
)
41+
42+
43+
def reverse_supplementary_report_submitted_after_deadline_template(apps, schema_editor):
44+
EmailNotificationTemplate = apps.get_model('common', 'EmailNotificationTemplate')
45+
EmailNotificationTemplate.objects.filter(name='Supplementary Report Submitted after Deadline').delete()
46+
47+
48+
class Migration(migrations.Migration):
49+
dependencies = [
50+
('common', '0096_V4_4_0'),
51+
]
52+
53+
operations = [
54+
migrations.RunPython(
55+
supplementary_report_submitted_after_deadline_template,
56+
reverse_supplementary_report_submitted_after_deadline_template,
57+
elidable=True,
58+
)
59+
]

bc_obps/compliance/emails.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,8 @@ def send_notice_of_obligation_met_email(obligation_id: int) -> None:
209209
"""
210210
Sends an email to every operator's industry user when their obligation is met.
211211
212-
Args:
213-
compliance_report_version_id: The id of the compliance_report_version instance for which to send notification emails.
212+
Args:
213+
obligation_id: The id of the obligation instance for which to send notification emails.
214214
"""
215215
obligation = ComplianceObligation.objects.get(id=obligation_id)
216216
template = EmailNotificationTemplateService.get_template_by_name('Notice of Obligation Met')
@@ -239,3 +239,19 @@ def send_notice_of_penalty_accrual_email(obligation_id: int) -> None:
239239
template,
240240
email_context,
241241
)
242+
243+
244+
def send_supplementary_report_submitted_after_deadline(obligation_id: int) -> None:
245+
"""
246+
Sends an email to every operator's industry user when they submit a supplementary report after the deadline that increases their emissions and results in a new obligation.
247+
248+
Args:
249+
obligation_id: The id of the obligation instance for which to send notification emails.
250+
"""
251+
obligation = ComplianceObligation.objects.get(id=obligation_id)
252+
template = EmailNotificationTemplateService.get_template_by_name('Supplementary Report Submitted after Deadline')
253+
email_context = _prepare_obligation_context(obligation)
254+
255+
_send_email_to_operators_approved_users_or_raise(
256+
obligation.compliance_report_version.compliance_report.report.operator, template, email_context
257+
)

bc_obps/compliance/service/elicensing/elicensing_obligation_service.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import timedelta
1+
from datetime import timedelta, date
22
import logging
33
import uuid
44
from typing import Dict, Any
@@ -45,6 +45,13 @@ def _is_invoice_generation_date_today(cls, compliance_period: CompliancePeriod)
4545
current_date = timezone.now().astimezone(vancouver_timezone).date()
4646
return current_date == compliance_period.invoice_generation_date
4747

48+
@classmethod
49+
def _has_obligation_deadline_passed(cls, obligation_deadline: date) -> bool:
50+
# Convert current UTC time to Vancouver timezone before extracting date to ensure proper comparison
51+
vancouver_timezone = ZoneInfo("America/Vancouver")
52+
current_date = timezone.now().astimezone(vancouver_timezone).date()
53+
return current_date > obligation_deadline
54+
4855
@classmethod
4956
def handle_obligation_integration(cls, obligation_id: int, compliance_period: CompliancePeriod) -> None:
5057
"""
@@ -84,7 +91,10 @@ def process_obligation_integration(cls, obligation_id: int) -> None:
8491
requests.RequestException: If there's an API error
8592
"""
8693
from compliance.service.compliance_report_version_service import ComplianceReportVersionService
87-
from compliance.tasks import retryable_send_notice_of_obligation_due_email
94+
from compliance.tasks import (
95+
retryable_send_notice_of_obligation_due_email,
96+
retryable_notice_of_supplementary_report_post_deadline_increases_emissions,
97+
)
8898

8999
obligation = ComplianceObligation.objects.get(id=obligation_id)
90100
try:
@@ -129,7 +139,13 @@ def process_obligation_integration(cls, obligation_id: int) -> None:
129139

130140
# If successful, update the compliance status
131141
ComplianceReportVersionService.update_compliance_status(obligation.compliance_report_version)
132-
retryable_send_notice_of_obligation_due_email.execute(obligation.id)
142+
# If the new obligation was created by a supplementary report and the obligation deadline has passed, we send a specific email. Otherwise, we send the general one about the obligation being due.
143+
if obligation.compliance_report_version.is_supplementary and cls._has_obligation_deadline_passed(
144+
obligation.obligation_deadline
145+
):
146+
retryable_notice_of_supplementary_report_post_deadline_increases_emissions.execute(obligation.id)
147+
else:
148+
retryable_send_notice_of_obligation_due_email.execute(obligation.id)
133149

134150
except Exception:
135151
obligation.compliance_report_version.status = (

bc_obps/compliance/tasks.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
send_notice_of_penalty_accrual_email,
1212
send_reminder_of_obligation_due_email,
1313
send_notice_of_obligation_met_email,
14+
send_supplementary_report_submitted_after_deadline,
1415
)
1516
from task_scheduler.service.retry_task.factories import create_retryable
1617
from task_scheduler.service.scheduled_task.dataclass import ScheduledTaskConfig
@@ -89,6 +90,12 @@
8990
retry_delay_minutes=10,
9091
)
9192

93+
retryable_notice_of_supplementary_report_post_deadline_increases_emissions = create_retryable(
94+
func=send_supplementary_report_submitted_after_deadline,
95+
tag="past_deadline_increased_emissions_email_notifications",
96+
max_retries=5,
97+
retry_delay_minutes=10,
98+
)
9299

93100
###################
94101
# Scheduled tasks #

bc_obps/compliance/tests/service/elicensing/test_elicensing_obligation_service.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242

4343
OBLIGATION_DUE_EMAIL_PATH = 'compliance.tasks.retryable_send_notice_of_obligation_due_email'
4444

45+
PAST_DEADLINE_EMAIL_PATH = 'compliance.tasks.retryable_notice_of_supplementary_report_post_deadline_increases_emissions'
46+
4547
SYNC_WITH_ELICENSING_PATH = (
4648
f"{ELICENSING_OBLIGATION_SERVICE_PATH}.ElicensingOperatorService.sync_client_with_elicensing"
4749
)
@@ -133,6 +135,12 @@ def mock_retryable_obligation_due_email():
133135
yield mock
134136

135137

138+
@pytest.fixture
139+
def mock_retryable_notice_of_supplementary_report_post_deadline_increases_emissions():
140+
with patch(PAST_DEADLINE_EMAIL_PATH) as mock:
141+
yield mock
142+
143+
136144
@pytest.fixture
137145
def mock_update_status():
138146
with patch(UPDATE_COMPLIANCE_STATUS_PATH) as mock:
@@ -257,17 +265,19 @@ def test_map_obligation_to_invoice_data(self, mock_obligation: MagicMock) -> Non
257265
assert result["businessAreaCode"] == "OBPS"
258266
assert result["fees"] == [fee_id]
259267

260-
def test_process_obligation_integration_success(
268+
def test_process_obligation_integration_success_before_deadline(
261269
self,
262270
mock_update_status,
263271
mock_refresh_by_invoice,
264272
mock_create_invoice,
265273
mock_create_fees,
266274
mock_retryable_obligation_due_email,
275+
mock_retryable_notice_of_supplementary_report_post_deadline_increases_emissions,
276+
mock_timezone,
267277
) -> None:
268278
"""Test successful full obligation integration process"""
269279
# Setup mocks
270-
280+
self._set_vancouver_today(mock_timezone, date(2025, 11, 30))
271281
obligation = make_recipe('compliance.tests.utils.compliance_obligation')
272282
client_operator = make_recipe(
273283
'compliance.tests.utils.elicensing_client_operator',
@@ -301,6 +311,63 @@ def test_process_obligation_integration_success(
301311

302312
mock_update_status.assert_called_once_with(obligation.compliance_report_version)
303313
mock_retryable_obligation_due_email.execute.assert_called_once_with(obligation.pk)
314+
mock_retryable_notice_of_supplementary_report_post_deadline_increases_emissions.execute.assert_not_called()
315+
316+
def test_process_obligation_integration_success_after_deadline(
317+
self,
318+
mock_update_status,
319+
mock_refresh_by_invoice,
320+
mock_create_invoice,
321+
mock_create_fees,
322+
mock_retryable_obligation_due_email,
323+
mock_retryable_notice_of_supplementary_report_post_deadline_increases_emissions,
324+
mock_timezone,
325+
) -> None:
326+
"""Test successful full obligation integration process if a supplmentary report has been submitted after the deadline."""
327+
# Setup mocks
328+
self._set_vancouver_today(mock_timezone, date(2025, 12, 1))
329+
330+
obligation = make_recipe('compliance.tests.utils.compliance_obligation')
331+
compliance_report_version = obligation.compliance_report_version
332+
compliance_report_version.is_supplementary = True
333+
compliance_report_version.save()
334+
335+
client_operator = make_recipe(
336+
'compliance.tests.utils.elicensing_client_operator',
337+
operator_id=obligation.compliance_report_version.compliance_report.report.operator_id,
338+
)
339+
340+
# Setup API responses
341+
mock_fee_response = FeeResponse(
342+
clientObjectId=client_operator.client_object_id,
343+
clientGUID=client_operator.client_guid,
344+
fees=[FeeItem(feeGUID=str(uuid.uuid4()), feeObjectId="1")],
345+
)
346+
347+
mock_create_fees.return_value = mock_fee_response
348+
349+
mock_invoice_response = TestInvoiceResponse(invoiceNumber='inv-001')
350+
mock_create_invoice.return_value = mock_invoice_response
351+
352+
mock_refresh_by_invoice.return_value = None
353+
mock_update_status.return_value = None
354+
355+
invoice = make_recipe('compliance.tests.utils.elicensing_invoice', invoice_number='inv-001')
356+
357+
# Call the method
358+
ElicensingObligationService.process_obligation_integration(obligation.id)
359+
360+
obligation.refresh_from_db()
361+
362+
# Invoice has been assigned to obligation
363+
assert obligation.elicensing_invoice_id == invoice.id
364+
365+
mock_update_status.assert_called_once_with(obligation.compliance_report_version)
366+
# brianna
367+
mock_retryable_obligation_due_email.execute.assert_not_called()
368+
mock_retryable_notice_of_supplementary_report_post_deadline_increases_emissions.execute.assert_called_once_with(
369+
obligation.pk
370+
)
304371

305372
def test_process_obligation_integration_failure_sets_pending_status_and_does_not_email(
306373
self, mock_sync_client, mock_create_fees, mock_retryable_obligation_due_email

bc_obps/compliance/tests/test_emails.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
send_notice_of_no_obligation_no_credits_generated_email,
1818
send_notice_of_obligation_due_email,
1919
send_notice_of_obligation_generated_email,
20+
send_supplementary_report_submitted_after_deadline,
2021
send_reminder_of_obligation_due_email,
2122
send_notice_of_obligation_met_email,
2223
send_notice_of_penalty_accrual_email,
@@ -608,3 +609,60 @@ def test_penalty_accrual_email(
608609
template_instance,
609610
expected_context,
610611
)
612+
613+
@patch(SEND_EMAIL_TO_OPERATORS_USERS_PATH)
614+
@patch('compliance.emails._prepare_obligation_context')
615+
def test_supplementary_report_creates_obligation_email(
616+
self, mock_prepare_obligation_context, mock_send_email_to_operators_approved_users_or_raise
617+
):
618+
# admin user
619+
approved_user_operator = baker.make_recipe(
620+
'registration.tests.utils.approved_user_operator',
621+
)
622+
623+
# Create mock data
624+
report = baker.make_recipe('reporting.tests.utils.report', operator=approved_user_operator.operator)
625+
compliance_report = baker.make_recipe('compliance.tests.utils.compliance_report', report=report)
626+
report_version = baker.make_recipe('reporting.tests.utils.report_version', report=report)
627+
report_operation = baker.make_recipe('reporting.tests.utils.report_operation', report_version=report_version)
628+
report_compliance_summary = baker.make_recipe(
629+
'compliance.tests.utils.report_compliance_summary', report_version=report_version
630+
)
631+
compliance_report_version = baker.make_recipe(
632+
'compliance.tests.utils.compliance_report_version',
633+
compliance_report=compliance_report,
634+
report_compliance_summary=report_compliance_summary,
635+
)
636+
invoice = baker.make_recipe('compliance.tests.utils.elicensing_invoice')
637+
obligation = baker.make_recipe(
638+
'compliance.tests.utils.compliance_obligation',
639+
compliance_report_version=compliance_report_version,
640+
elicensing_invoice=invoice,
641+
)
642+
643+
template_instance = EmailNotificationTemplateService.get_template_by_name(
644+
'Supplementary Report Submitted after Deadline'
645+
)
646+
647+
report_version = obligation.compliance_report_version
648+
report = report_version.compliance_report.report
649+
report_operation = report_version.report_compliance_summary.report_version.report_operation
650+
reporting_year = report.reporting_year.reporting_year
651+
expected_context = {
652+
"operator_legal_name": report_operation.operator_legal_name,
653+
"operation_name": report_operation.operation_name,
654+
"compliance_period": reporting_year,
655+
# the following properties aren't used in the email, but we're reusing the helper function that includes them
656+
"year_due": reporting_year + 1,
657+
"tonnes_of_co2": '1,000.1234',
658+
"outstanding_balance": '$0.00',
659+
}
660+
mock_prepare_obligation_context.return_value = expected_context
661+
662+
# Call the function with the obligcation id
663+
send_supplementary_report_submitted_after_deadline(obligation.id)
664+
mock_send_email_to_operators_approved_users_or_raise.assert_called_once_with(
665+
approved_user_operator.operator,
666+
template_instance,
667+
expected_context,
668+
)

0 commit comments

Comments
 (0)