Skip to content

Commit ff419d6

Browse files
authored
Merge pull request #3908 from bcgov/369-obligation-reminder
Compliance/Automatic Emails - Reminder of Outstanding Obligation #369
2 parents b9811ef + 9e34270 commit ff419d6

6 files changed

Lines changed: 396 additions & 10 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Generated by Django 5.1.10 on 2025-10-07 21:57
2+
3+
from django.db import migrations
4+
5+
6+
def create_obligation_reminder_template(apps, schema_editor):
7+
EmailNotificationTemplate = apps.get_model('common', 'EmailNotificationTemplate')
8+
EmailNotificationTemplate.objects.create(
9+
name='Reminder of Compliance Obligation Due',
10+
subject='BCIERS Reminder – Compliance Obligation Due on November 30th',
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>This is a reminder to meet your compliance obligation by <b>November 30, {{year_due}}</b>.
18+
</p>
19+
<br/>
20+
<p>Based on our records, <b>{{operation_name}}</b> has an outstanding compliance obligation of {{tonnes_of_co2}} in the amount of {{outstanding_balance}} that must be met using the BC Industrial Emissions Reporting System (BCIERS).
21+
</p>
22+
<br/>
23+
<p>If you have recently submitted payment and/or compliance units, please check that the details provided in your Payment Notification are accurate and that you have paid in full. It may take up to five business days for payments to be processed and reconciled. </p><br/>
24+
<p>If the details in your Payment Notification are correct and you have met your compliance obligation, you may disregard this email. </p><br/>
25+
<p>If the payment information does not match the details provided in your Payment Notification email as required in the Payment Instructions available in BCIERS, we cannot process your payment.</p>
26+
<br/>
27+
28+
29+
30+
<p>If you need to correct the details in your Payment Notification, please contact us at <a href="mailto:OBPSPayments@gov.bc.ca">OBPSPayments@gov.bc.ca</a>.
31+
</p>
32+
<br/ >
33+
<p>To review your compliance obligation, including how it was calculated, log-into BCIERS at <a
34+
href="https://industrialemissions.gov.bc.ca/onboarding">https://industrialemissions.gov.bc.ca/onboarding</a>.</p><br/>
35+
<p>To meet your compliance obligation: </p>
36+
<ol>
37+
<li>Navigate to the Compliance module and click <em>My Compliance > Compliance Summaries</em>.</li>
38+
<li>Find your operation in the grid and click <em>Manage Obligation</em>.</li>
39+
</ol>
40+
41+
<p>
42+
<b>Please note:</b> Unmet obligations after the payment deadline of November 30 will be subject to daily compounding penalties, as well as interest under the <em>Financial Administration Act</em> (FAA). </p><br/>
43+
<p><b>It may take up to five business days for payments to be processed and reconciled.</b> Once payment is received, BCIERS updates the obligation status accordingly. The Ministry uses the payment date to determine compliance with the deadline and whether a penalty exists. For clarity, the payment date is the date when payment is deposited to the B.C. OBPS bank account. If a payment date is on or before the deadline and processed after the deadline, BCIERS may temporarily show that a penalty exists until the payment is processed and the system refreshes the information. Please refer to the B.C. OBPS compliance module guidance document on our website for further information. </p><br/ >
44+
<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>
45+
<br/>
46+
<p>Best Regards,</p>
47+
<p>Ministry of Energy and Climate Solutions</p>
48+
<p>Email: <a href="mailto:GHGRegulator@gov.bc.ca">GHGRegulator@gov.bc.ca</a></p>
49+
<p>Website: <a href="https://www2.gov.bc.ca/gov/content/environment/climate-change/industry/bc-output-based-pricing-system">BC
50+
Government Website Link</a>
51+
</p>
52+
''',
53+
)
54+
55+
56+
def reverse_obligation_reminder_template(apps, schema_editor):
57+
EmailNotificationTemplate = apps.get_model('common', 'EmailNotificationTemplate')
58+
EmailNotificationTemplate.objects.filter(name='Reminder of Compliance Obligation Due').delete()
59+
60+
61+
class Migration(migrations.Migration):
62+
dependencies = [
63+
('common', '0090_V4_2_0'),
64+
]
65+
66+
operations = [
67+
migrations.RunPython(
68+
create_obligation_reminder_template,
69+
reverse_obligation_reminder_template,
70+
elidable=True,
71+
)
72+
]

bc_obps/compliance/emails.py

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -144,18 +144,9 @@ def send_notice_of_credits_requested_generated_email(compliance_earned_credit_id
144144
_send_email_or_raise(template, email_context, recipient_emails)
145145

146146

147-
def send_notice_of_obligation_due_email(obligation_id: int) -> None:
148-
"""
149-
Sends an email to every operator's industry user when compliance obligation invoices are generated, notifying that they can now pay their obligation.
150-
151-
Args:
152-
obligation_id: The id of the obligation instance for which to send notification emails.
153-
"""
147+
def _prepare_obligation_context(obligation: ComplianceObligation) -> Dict:
154148
from compliance.service.compliance_report_version_service import ComplianceReportVersionService
155149

156-
obligation = ComplianceObligation.objects.get(id=obligation_id)
157-
template = EmailNotificationTemplateService.get_template_by_name('Notice of Compliance Obligation Due')
158-
159150
report_version = obligation.compliance_report_version
160151
report = report_version.compliance_report.report
161152
report_operation = report_version.report_compliance_summary.report_version.report_operation
@@ -169,6 +160,39 @@ def send_notice_of_obligation_due_email(obligation_id: int) -> None:
169160
"tonnes_of_co2": f"{ComplianceReportVersionService.calculate_outstanding_balance_tco2e(report_version):,.4f}",
170161
"outstanding_balance": f"${obligation.elicensing_invoice.outstanding_balance:,.2f}", # type: ignore[union-attr]
171162
}
163+
return email_context
164+
165+
166+
def send_notice_of_obligation_due_email(obligation_id: int) -> None:
167+
"""
168+
Sends an email to every operator's industry user when compliance obligation invoices are generated, notifying that they can now pay their obligation.
169+
170+
Args:
171+
obligation_id: The id of the obligation instance for which to send notification emails.
172+
"""
173+
174+
obligation = ComplianceObligation.objects.get(id=obligation_id)
175+
template = EmailNotificationTemplateService.get_template_by_name('Notice of Compliance Obligation Due')
176+
177+
email_context = _prepare_obligation_context(obligation)
178+
179+
_send_email_to_operators_approved_users_or_raise(
180+
obligation.compliance_report_version.compliance_report.report.operator, template, email_context
181+
)
182+
183+
184+
def send_reminder_of_obligation_due_email(obligation_id: int) -> None:
185+
"""
186+
Sends an email to every operator's industry user if an operation hasn't paid its obligation, reminding everyone when the obligation is due.
187+
188+
Args:
189+
obligation_id: The id of the obligation instance for which to send notification emails.
190+
"""
191+
192+
obligation = ComplianceObligation.objects.get(id=obligation_id)
193+
template = EmailNotificationTemplateService.get_template_by_name('Reminder of Compliance Obligation Due')
194+
195+
email_context = _prepare_obligation_context(obligation)
172196

173197
_send_email_to_operators_approved_users_or_raise(
174198
obligation.compliance_report_version.compliance_report.report.operator, template, email_context

bc_obps/compliance/service/elicensing/elicensing_obligation_service.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,3 +226,54 @@ def _get_obligations_for_invoice_generation(
226226
'compliance_report_version__compliance_report__compliance_period', 'compliance_report_version'
227227
)
228228
)
229+
230+
@classmethod
231+
def _get_obligations_for_reminders(cls, compliance_period: CompliancePeriod) -> QuerySet[ComplianceObligation]:
232+
"""
233+
Get obligations that need reminders for the given compliance period.
234+
235+
Returns obligations that:
236+
1. Belong to the specified compliance period
237+
2. Haven't already been paid (outstanding balance > 0)
238+
3. Have a status of unmet obligation
239+
3. Don't have a void invoice
240+
"""
241+
return ComplianceObligation.objects.filter(
242+
compliance_report_version__compliance_report__compliance_period=compliance_period,
243+
compliance_report_version__status=ComplianceReportVersion.ComplianceStatus.OBLIGATION_NOT_MET,
244+
elicensing_invoice__isnull=False,
245+
elicensing_invoice__outstanding_balance__gt=0,
246+
elicensing_invoice__is_void=False,
247+
).select_related('compliance_report_version__compliance_report__compliance_period', 'compliance_report_version')
248+
249+
@classmethod
250+
def send_reminders_for_current_period(cls) -> None:
251+
"""
252+
Sends reminders for obligations in the current compliance period
253+
that haven't been paid.
254+
255+
This method:
256+
1. Gets the current reporting year
257+
2. Finds the corresponding compliance period
258+
3. Sends a reminder email for all obligations for that period that have an outstanding balance > 0
259+
"""
260+
261+
from service.reporting_year_service import ReportingYearService
262+
from compliance.tasks import retryable_send_reminder_of_obligation_due_email
263+
264+
current_reporting_year = ReportingYearService.get_current_reporting_year()
265+
266+
try:
267+
compliance_period = CompliancePeriod.objects.get(reporting_year=current_reporting_year)
268+
except CompliancePeriod.DoesNotExist:
269+
logger.warning(f"No compliance period found for reporting year {current_reporting_year.reporting_year}")
270+
return
271+
272+
obligations = cls._get_obligations_for_reminders(compliance_period)
273+
274+
if not obligations.exists():
275+
logger.info("No obligations found that need reminders")
276+
return
277+
278+
for obligation in obligations:
279+
retryable_send_reminder_of_obligation_due_email.execute(obligation.id)

bc_obps/compliance/tasks.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
send_notice_of_no_obligation_no_credits_generated_email,
88
send_notice_of_obligation_due_email,
99
send_notice_of_obligation_generated_email,
10+
send_reminder_of_obligation_due_email,
1011
)
1112
from task_scheduler.service.retry_task.factories import create_retryable
1213
from task_scheduler.service.scheduled_task.dataclass import ScheduledTaskConfig
@@ -59,6 +60,12 @@
5960
max_retries=5,
6061
retry_delay_minutes=10,
6162
)
63+
retryable_send_reminder_of_obligation_due_email = create_retryable(
64+
func=send_reminder_of_obligation_due_email,
65+
tag="obligation_reminder_email_notifications",
66+
max_retries=5,
67+
retry_delay_minutes=10,
68+
)
6269

6370
###################
6471
# Scheduled tasks #
@@ -88,4 +95,13 @@
8895
schedule_minute=0,
8996
tag="invoice_generation",
9097
),
98+
ScheduledTaskConfig(
99+
func=ElicensingObligationService.send_reminders_for_current_period,
100+
schedule_type="yearly",
101+
schedule_day_of_month=15,
102+
schedule_month=11,
103+
schedule_hour=4,
104+
schedule_minute=0,
105+
tag="obligation_reminder",
106+
),
91107
]

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

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,3 +570,167 @@ def test_generate_invoices_for_current_period__single_obligation(
570570

571571
assert mock_handle_integration.call_count == 1
572572
mock_handle_integration.assert_called_once_with(999, cp)
573+
574+
@patch("service.reporting_year_service.ReportingYearService.get_current_reporting_year")
575+
@patch(
576+
"compliance.service.elicensing.elicensing_obligation_service.ElicensingObligationService._get_obligations_for_reminders"
577+
)
578+
@patch('compliance.tasks.retryable_send_reminder_of_obligation_due_email.execute')
579+
def test_send_reminders_for_current_period__happy_path(
580+
self,
581+
mock_retryable_send_reminder_of_obligation_email,
582+
mock_get_obligations_for_reminders,
583+
mock_get_current_reporting_year,
584+
):
585+
586+
ry = ReportingYear.objects.get(reporting_year=2024)
587+
cp, created = CompliancePeriod.objects.get_or_create(
588+
reporting_year=ry,
589+
defaults={"invoice_generation_date": date(2024, 11, 1)},
590+
)
591+
obligations = _FauxQueryset([MagicMock(id=101), MagicMock(id=202)])
592+
mock_get_current_reporting_year.return_value = ry
593+
mock_get_obligations_for_reminders.return_value = obligations
594+
595+
# Act
596+
ElicensingObligationService.send_reminders_for_current_period()
597+
598+
# Assert
599+
mock_get_current_reporting_year.assert_called_once()
600+
mock_get_obligations_for_reminders.assert_called_once_with(cp)
601+
602+
assert mock_retryable_send_reminder_of_obligation_email.call_count == 2
603+
mock_retryable_send_reminder_of_obligation_email.assert_any_call(101)
604+
mock_retryable_send_reminder_of_obligation_email.assert_any_call(202)
605+
606+
@patch("service.reporting_year_service.ReportingYearService.get_current_reporting_year")
607+
@patch(
608+
"compliance.service.elicensing.elicensing_obligation_service.ElicensingObligationService._get_obligations_for_reminders"
609+
)
610+
@patch('compliance.tasks.retryable_send_reminder_of_obligation_due_email.execute')
611+
def test_send_reminders_for_current_period__no_compliance_period_found(
612+
self,
613+
mock_retryable_send_reminder_of_obligation_email,
614+
mock_get_obligations_for_reminders,
615+
mock_get_current_reporting_year,
616+
):
617+
"""
618+
Test case: No compliance period exists for the current reporting year
619+
- Should return early
620+
- Should not call subsequent methods
621+
"""
622+
# Arrange
623+
ry = ReportingYear.objects.get(reporting_year=2024)
624+
mock_get_year.return_value = ry
625+
626+
# Ensure no compliance period exists for this reporting year
627+
CompliancePeriod.objects.filter(reporting_year=ry).delete()
628+
629+
obligations = _FauxQueryset([MagicMock(id=101), MagicMock(id=202)])
630+
mock_get_current_reporting_year.return_value = ry
631+
mock_get_obligations_for_reminders.return_value = obligations
632+
633+
# Act
634+
635+
ElicensingObligationService.send_reminders_for_current_period()
636+
637+
# Assert
638+
mock_get_current_reporting_year.assert_called_once()
639+
mock_get_obligations_for_reminders.assert_not_called()
640+
mock_retryable_send_reminder_of_obligation_email.assert_not_called()
641+
642+
@patch("service.reporting_year_service.ReportingYearService.get_current_reporting_year")
643+
@patch(
644+
"compliance.service.elicensing.elicensing_obligation_service.ElicensingObligationService._get_obligations_for_reminders"
645+
)
646+
@patch('compliance.tasks.retryable_send_reminder_of_obligation_due_email.execute')
647+
def test_send_reminders_for_current_period__no_obligations_found(
648+
self,
649+
mock_retryable_send_reminder_of_obligation_email,
650+
mock_get_obligations_for_reminders,
651+
mock_get_current_reporting_year,
652+
):
653+
"""
654+
Test case: No obligations found that need reminders
655+
- Should return early
656+
- Should not send emails
657+
"""
658+
# Arrange
659+
ry = ReportingYear.objects.get(reporting_year=2024)
660+
cp, created = CompliancePeriod.objects.get_or_create(
661+
reporting_year=ry,
662+
defaults={"invoice_generation_date": date(2024, 11, 1)},
663+
)
664+
665+
empty_obligations = _FauxQueryset([]) # Empty queryset
666+
667+
mock_get_current_reporting_year.return_value = ry
668+
mock_get_obligations_for_reminders.return_value = empty_obligations
669+
670+
# Act
671+
672+
ElicensingObligationService.send_reminders_for_current_period()
673+
674+
# Assert
675+
mock_get_current_reporting_year.assert_called_once()
676+
mock_get_obligations_for_reminders.assert_called_once_with(cp)
677+
678+
# Assert
679+
mock_get_current_reporting_year.assert_called_once()
680+
mock_get_obligations_for_reminders.assert_called_once_with(cp)
681+
mock_retryable_send_reminder_of_obligation_email.assert_not_called()
682+
683+
def test_get_obligations_for_reminders(self):
684+
ry_2024 = ReportingYear.objects.get(reporting_year=2024)
685+
cp_2024, created = CompliancePeriod.objects.get_or_create(
686+
reporting_year=ry_2024,
687+
defaults={"invoice_generation_date": date(2024, 11, 1)},
688+
)
689+
ry_2025 = ReportingYear.objects.get(reporting_year=2025)
690+
cp_2025 = make_recipe(
691+
'compliance.tests.utils.compliance_period',
692+
start_date="2025-01-01",
693+
end_date="2025-12-31",
694+
compliance_deadline="2026-06-30",
695+
invoice_generation_date="2026-11-01",
696+
reporting_year=ry_2025,
697+
)
698+
699+
# 2024 obligations with outstanding balances - should all be returned
700+
for i in range(1, 5):
701+
cr = make_recipe('compliance.tests.utils.compliance_report', compliance_period=cp_2024)
702+
crv = make_recipe('compliance.tests.utils.compliance_report_version', compliance_report=cr)
703+
invoice = make_recipe('compliance.tests.utils.elicensing_invoice', outstanding_balance=Decimal(i + 100))
704+
make_recipe(
705+
'compliance.tests.utils.compliance_obligation',
706+
compliance_report_version=crv,
707+
elicensing_invoice=invoice,
708+
)
709+
710+
# obligation with 0 outstanding balance - should not be returned
711+
cr_0 = make_recipe('compliance.tests.utils.compliance_report', compliance_period=cp_2024)
712+
crv_0 = make_recipe('compliance.tests.utils.compliance_report_version', compliance_report=cr_0)
713+
invoice_0 = make_recipe('compliance.tests.utils.elicensing_invoice', outstanding_balance=Decimal(0))
714+
make_recipe(
715+
'compliance.tests.utils.compliance_obligation',
716+
compliance_report_version=crv_0,
717+
elicensing_invoice=invoice_0,
718+
)
719+
720+
# obligation with no invoice - should not be returned
721+
cr_none = make_recipe('compliance.tests.utils.compliance_report', compliance_period=cp_2024)
722+
crv_none = make_recipe('compliance.tests.utils.compliance_report_version', compliance_report=cr_none)
723+
make_recipe('compliance.tests.utils.compliance_obligation', compliance_report_version=crv_none)
724+
725+
# obligation in a different compliance period - should not be returned
726+
cr_2025 = make_recipe('compliance.tests.utils.compliance_report', compliance_period=cp_2025)
727+
crv_2025 = make_recipe('compliance.tests.utils.compliance_report_version', compliance_report=cr_2025)
728+
invoice_2025 = make_recipe('compliance.tests.utils.elicensing_invoice', outstanding_balance=Decimal(0))
729+
make_recipe(
730+
'compliance.tests.utils.compliance_obligation',
731+
compliance_report_version=crv_2025,
732+
elicensing_invoice=invoice_2025,
733+
)
734+
735+
obligations = ElicensingObligationService._get_obligations_for_reminders(cp_2024)
736+
assert obligations.count() == 4

0 commit comments

Comments
 (0)