Skip to content

Commit 06166c1

Browse files
Merge pull request #4940 from bcgov/4759-penalty-and-faa-calculation-if-obligation-decreases-from-supp-report
4759 Add back-dating logic for decreased compliance obligations
2 parents 6459940 + 226b863 commit 06166c1

9 files changed

Lines changed: 389 additions & 32 deletions

File tree

bc_obps/compliance/dataclass.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,13 +182,13 @@ class AdjustmentStrategy:
182182
Immutable, normalized contract consumed by `DecreasedObligationHandler._process_adjustment_after_commit(...)`
183183
184184
- invoices: invoice-level adjustments to apply
185-
- should_record_manual_handling: flag to create manual-handling record
185+
- manual_handling_context: the manual-handling context to record, or None if manual handling is not required
186186
- earned_credits_tonnes: earned credits to create on the NEW CRV (tonnes)
187187
- should_create_earned_credits: whether to create an earned-credits record
188188
"""
189189

190190
invoices: List["InvoiceAdjustment"] = field(default_factory=list)
191-
should_record_manual_handling: bool = False
191+
manual_handling_context: Optional[str] = None
192192

193193
earned_credits_tonnes: Decimal = Decimal("0")
194194
should_create_earned_credits: bool = False
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Generated by Django 5.1.15 on 2026-07-14 23:49
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('compliance', '0057_V5_16_0'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='compliancereportversionmanualhandling',
15+
name='context',
16+
field=models.CharField(
17+
choices=[
18+
(
19+
'obligation_refund_pool_cash',
20+
'Obligation is fully paid and the refund pool contains refundable cash.',
21+
),
22+
('earned_credits_previously_approved', 'Earned credits have been previously approved.'),
23+
(
24+
'credits_over_allowed_percentage',
25+
'A supplementary decrease pushed the applied compliance units over the allowed percentage.',
26+
),
27+
],
28+
db_comment='Reason for requiring manual handling',
29+
max_length=100,
30+
),
31+
),
32+
]

bc_obps/compliance/models/compliance_report_version_manual_handling.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ class Context(models.TextChoices):
2929
"earned_credits_previously_approved",
3030
"Earned credits have been previously approved.",
3131
)
32+
CREDITS_OVER_ALLOWED_PERCENTAGE = (
33+
"credits_over_allowed_percentage",
34+
"A supplementary decrease pushed the applied compliance units over the allowed percentage.",
35+
)
3236

3337
compliance_report_version = models.OneToOneField(
3438
ComplianceReportVersion,

bc_obps/compliance/service/bc_carbon_registry/apply_compliance_units_service.py

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from typing import Dict, List, Optional, Any
33
from common.exceptions import UserError
44
from compliance.dataclass import ComplianceUnitsPageData, BCCRUnit, TransferComplianceUnitsPayload, MixedUnit
5+
from compliance.models.compliance_obligation import ComplianceObligation
56
from compliance.models.compliance_report_version import ComplianceReportVersion
67
from compliance.service.bc_carbon_registry.account_service import BCCarbonRegistryAccountService
78
from compliance.service.compliance_charge_rate_service import ComplianceChargeRateService
@@ -43,21 +44,32 @@ def _get_total_adjustments_for_report_version_by_reason(
4344
return total_adjustments
4445

4546
@classmethod
46-
def _calculate_apply_units_cap(cls, compliance_report_version_id: int) -> Decimal:
47+
def _calculate_apply_units_cap(
48+
cls, compliance_report_version_id: int, pending_supplementary_adjustment: Decimal = Decimal("0")
49+
) -> Decimal:
4750
"""
4851
Retrieves the obligation for the given compliance report version ID and calculates the maximum
4952
allowable value for applied compliance units based on the max_credit_usage_percentage
5053
configured on the associated CompliancePeriod.
5154
55+
Args:
56+
compliance_report_version_id: The ID of the compliance report version for which the credit usage cap is being checked
57+
pending_supplementary_adjustment: A supplementary decrease that has not yet been posted to
58+
eLicensing (signed, negative reduces the obligation). Lets callers compute the cap that
59+
will apply *after* the decrease, before its adjustment row exists.
60+
5261
Raises:
5362
UserError: If the obligation is missing or the fee_amount_dollars is not available.
5463
"""
5564
obligation = ComplianceObligationService.get_obligation_for_report_version(compliance_report_version_id)
5665
if not obligation or not obligation.fee_amount_dollars:
5766
raise UserError("Unable to calculate unit cap: missing obligation or fee amount.")
58-
total_supplementary_report_adjustments = cls._get_total_adjustments_for_report_version_by_reason(
59-
compliance_report_version_id=compliance_report_version_id,
60-
reason=ElicensingAdjustment.Reason.SUPPLEMENTARY_REPORT_ADJUSTMENT,
67+
total_supplementary_report_adjustments = (
68+
cls._get_total_adjustments_for_report_version_by_reason(
69+
compliance_report_version_id=compliance_report_version_id,
70+
reason=ElicensingAdjustment.Reason.SUPPLEMENTARY_REPORT_ADJUSTMENT,
71+
)
72+
+ pending_supplementary_adjustment
6173
)
6274
crv = ComplianceReportVersion.objects.select_related('compliance_report__compliance_period').get(
6375
id=compliance_report_version_id
@@ -87,6 +99,37 @@ def _compute_compliance_unit_caps(cls, compliance_report_version_id: int) -> tup
8799

88100
return compliance_unit_cap_limit, compliance_unit_cap_remaining
89101

102+
@classmethod
103+
def is_credit_usage_over_cap(
104+
cls, compliance_report_version_id: int, pending_supplementary_adjustment: Decimal = Decimal("0")
105+
) -> bool:
106+
"""
107+
Returns True if the compliance units already applied to this report version now exceed the
108+
allowed cap. This happens when a supplementary report decreases the obligation, shrinking the
109+
cap (max_credit_usage_percentage of the reduced obligation) below the units already applied.
110+
111+
Args:
112+
compliance_report_version_id: The ID of the compliance report version for which the credit usage cap is being checked
113+
pending_supplementary_adjustment: A not-yet-posted supplementary decrease (signed, negative
114+
reduces the obligation) to fold into the cap, so the check reflects the reduced obligation
115+
before the adjustment row exists.
116+
117+
Raises:
118+
UserError: If the obligation is missing or the fee_amount_dollars is not available.
119+
"""
120+
obligation = ComplianceObligation.objects.filter(
121+
compliance_report_version_id=compliance_report_version_id
122+
).first()
123+
if not obligation or not obligation.fee_amount_dollars:
124+
raise UserError("Unable to calculate unit cap: missing obligation or fee amount.")
125+
126+
cap_limit = cls._calculate_apply_units_cap(compliance_report_version_id, pending_supplementary_adjustment)
127+
# Negative value (applications reduce the balance)
128+
total_compliance_unit_adjustments = cls._get_total_adjustments_for_report_version_by_reason(
129+
compliance_report_version_id=compliance_report_version_id, reason='Compliance Units Applied'
130+
)
131+
return (cap_limit + total_compliance_unit_adjustments) < Decimal("0")
132+
90133
@classmethod
91134
def can_apply_compliance_units(cls, compliance_report_version_id: int) -> bool:
92135
"""

bc_obps/compliance/service/compliance_adjustment_service.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def create_adjustment_for_target_version(
5353
adjustment_total: Decimal,
5454
supplementary_compliance_report_version_id: int,
5555
reason: Optional[ElicensingAdjustment.Reason] = None,
56+
date_adjustment_to_fee_date: bool = False,
5657
) -> None:
5758
"""
5859
Creates fee adjustment for a target compliance report version when triggered by a supplementary version.
@@ -62,6 +63,9 @@ def create_adjustment_for_target_version(
6263
adjustment_total: Total amount of the adjustment to be applied
6364
supplementary_compliance_report_version_id: ID of the supplementary compliance report version that triggered this adjustment
6465
reason: Reason for the adjustment
66+
date_adjustment_to_fee_date: When True, date the adjustment to the target fee's date instead of
67+
today, so a supplementary decrease applies from the start of the fee's accrual window (and
68+
eLicensing accepts it, since the adjustment is never dated before the fee it adjusts).
6569
"""
6670
from compliance.tasks import retryable_create_adjustment
6771

@@ -71,6 +75,7 @@ def create_adjustment_for_target_version(
7175
adjustment_total=adjustment_total,
7276
supplementary_compliance_report_version_id=supplementary_compliance_report_version_id,
7377
reason=reason,
78+
date_adjustment_to_fee_date=date_adjustment_to_fee_date,
7479
)
7580
)
7681

@@ -81,6 +86,7 @@ def create_adjustment(
8186
adjustment_total: Decimal,
8287
supplementary_compliance_report_version_id: int | None = None,
8388
reason: Optional[ElicensingAdjustment.Reason] = None,
89+
date_adjustment_to_fee_date: bool = False,
8490
) -> None:
8591
"""
8692
Creates fee adjustment connecting with elicensing service.
@@ -90,6 +96,7 @@ def create_adjustment(
9096
adjustment_total: Total amount of the adjustment to be applied
9197
supplementary_compliance_report_version_id: ID of the supplementary compliance report version that triggered this adjustment
9298
reason: Reason for the adjustment
99+
date_adjustment_to_fee_date: When True, date the adjustment to the fee's date instead of today.
93100
"""
94101

95102
# Get the compliance obligation from the compliance report version
@@ -123,7 +130,11 @@ def create_adjustment(
123130
"feeObjectId": elicensing_line_item.object_id,
124131
"adjustmentGUID": str(uuid.uuid4()),
125132
"adjustmentTotal": float(adjustment_total),
126-
"date": timezone.now().strftime("%Y-%m-%d"),
133+
"date": (
134+
elicensing_line_item.fee_date.strftime("%Y-%m-%d")
135+
if date_adjustment_to_fee_date
136+
else timezone.now().strftime("%Y-%m-%d")
137+
),
127138
"reason": reason,
128139
"type": "Adjustment",
129140
}

bc_obps/compliance/service/supplementary_version_service/decreased_obligation_handler.py

Lines changed: 61 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ def handle(
8888
# 3) Build a normalized strategy dict
8989
# The strategy object returned by the determination functions is the single contract used _process_adjustment_after_commit
9090
# invoices: list of InvoiceAdjustment entries, each with `version_id`, `applied`, `net_outstanding_after`, `mark_fully_met`, `should_void_invoice`.
91-
# should_record_manual_handling: boolean flag.
91+
# manual_handling_context: the manual-handling context to record, or None.
9292
# earned_credits_tonnes: earned credits to create on the NEW CRV
9393
# should_create_earned_credits: whether to create an earned-credits record
9494

@@ -134,37 +134,43 @@ def _process_adjustment_after_commit(
134134
"""
135135
Execute the "strategy" produced in handle():
136136
137-
For each invoice in strategy["invoices"]:
138-
- Post a signed negative adjustment (reduces outstanding).
137+
For each invoice in strategy.invoices:
138+
- Post a signed negative adjustment (reduces outstanding), dated to the fee's date so the
139+
reduction applies from the start of the fee's accrual window (works for regular and
140+
supplementary obligations, and eLicensing accepts it since it is never dated before the fee).
139141
- If net outstanding hits zero, mark that previous CRV as FULLY_MET.
140142
- If fully met AND no prior CASH payments, void the invoice.
141-
- If fully met AND no prior CASH payments AND credited emissions, create earned credits
142143
144+
Then act on the single outcome of the strategy already decided for the new supplementary CRV:
145+
record manual handling (with the strategy's context) or create earned credits.
143146
"""
144-
for entry in strategy.invoices:
145-
applied = entry.applied
147+
for invoice in strategy.invoices:
148+
applied = invoice.applied
146149
if applied != ZERO_DECIMAL:
147150
reason = (
148151
ElicensingAdjustment.Reason.SUPPLEMENTARY_REPORT_ADJUSTMENT_TO_VOID_INVOICE
149-
if entry.should_void_invoice
152+
if invoice.should_void_invoice
150153
else ElicensingAdjustment.Reason.SUPPLEMENTARY_REPORT_ADJUSTMENT
151154
)
152155
ComplianceAdjustmentService.create_adjustment_for_target_version(
153-
target_compliance_report_version_id=entry.version_id,
156+
target_compliance_report_version_id=invoice.version_id,
154157
adjustment_total=applied,
155158
supplementary_compliance_report_version_id=compliance_report_version_id,
156159
reason=reason,
160+
date_adjustment_to_fee_date=True,
157161
)
158162

159-
if entry.mark_fully_met:
160-
DecreasedObligationHandler._mark_previous_version_fully_met(entry.version_id)
161-
if entry.should_void_invoice:
162-
DecreasedObligationHandler._void_unpaid_invoices(entry.version_id)
163+
if invoice.mark_fully_met:
164+
DecreasedObligationHandler._mark_previous_version_fully_met(invoice.version_id)
165+
if invoice.should_void_invoice:
166+
DecreasedObligationHandler._void_unpaid_invoices(invoice.version_id)
163167

164-
if strategy.should_record_manual_handling:
165-
DecreasedObligationHandler._record_manual_handling(compliance_report_version_id)
166-
167-
if strategy.should_create_earned_credits and strategy.earned_credits_tonnes > ZERO_DECIMAL:
168+
# Act on the single outcome decided by the strategy builder.
169+
if strategy.manual_handling_context:
170+
DecreasedObligationHandler._record_manual_handling(
171+
compliance_report_version_id, context=strategy.manual_handling_context
172+
)
173+
elif strategy.should_create_earned_credits and strategy.earned_credits_tonnes > ZERO_DECIMAL:
168174
DecreasedObligationHandler._create_earned_credits(
169175
compliance_report_version_id,
170176
strategy.earned_credits_tonnes,
@@ -271,18 +277,17 @@ def _build_adjustment_strategy(
271277
total_refund_vs_anchor, invoices, anchor_crv_id
272278
)
273279

274-
per_invoice, refund_pool = DecreasedObligationHandler._adjust_refund_to_invoices(invoices, refund_pool)
280+
invoice_adjustments, refund_pool = DecreasedObligationHandler._adjust_refund_to_invoices(invoices, refund_pool)
275281

276282
# --- Manual handling decision -------------------------------------------
277283
# Fully paid if no invoices OR all tracked invoices end at $0.
278-
fully_paid_obligation = (len(per_invoice) == 0) or all(
279-
e.net_outstanding_after == ZERO_DECIMAL for e in per_invoice
284+
fully_paid_obligation = (len(invoice_adjustments) == 0) or all(
285+
invoice_adjustment.net_outstanding_after == ZERO_DECIMAL for invoice_adjustment in invoice_adjustments
280286
)
281287

282288
has_cash = DecreasedObligationHandler._determine_if_has_cash(
283289
fully_paid_obligation, refund_pool, over_corrected_tonnes, invoices, anchor_crv_id
284290
)
285-
should_record_manual_handling = has_cash
286291

287292
# --- Earned credits decision -------------------------------------------
288293
# If the decrease over-corrects past zero AND there were no cash payments,
@@ -295,9 +300,22 @@ def _build_adjustment_strategy(
295300
should_create_earned_credits = True
296301
earned_credits_tonnes = over_corrected_tonnes
297302

303+
# --- Manual handling decision -------------------------------------------
304+
# Two situations require manual handling, differing only by the context:
305+
# - a refundable cash pool, or
306+
# - a decrease that pushed applied credits over the cap
307+
# An over-correction into earned credits takes precedence over the over-cap case.
308+
manual_handling_context: Optional[str] = None
309+
if has_cash:
310+
manual_handling_context = ComplianceReportVersionManualHandling.Context.OBLIGATION_REFUND_POOL_CASH
311+
elif not should_create_earned_credits and DecreasedObligationHandler._has_credit_usage_over_cap(
312+
invoice_adjustments
313+
):
314+
manual_handling_context = ComplianceReportVersionManualHandling.Context.CREDITS_OVER_ALLOWED_PERCENTAGE
315+
298316
return AdjustmentStrategy(
299-
invoices=per_invoice,
300-
should_record_manual_handling=should_record_manual_handling,
317+
invoices=invoice_adjustments,
318+
manual_handling_context=manual_handling_context,
301319
should_create_earned_credits=should_create_earned_credits,
302320
earned_credits_tonnes=earned_credits_tonnes,
303321
)
@@ -653,6 +671,7 @@ def _mark_previous_version_fully_met(previous_version_id: int) -> None:
653671
@staticmethod
654672
def _record_manual_handling(
655673
compliance_report_version_id: int,
674+
context: str = ComplianceReportVersionManualHandling.Context.OBLIGATION_REFUND_POOL_CASH,
656675
) -> None:
657676
"""
658677
Create a related ComplianceReportVersionManualHandling record and set the
@@ -664,12 +683,31 @@ def _record_manual_handling(
664683
ComplianceReportVersionManualHandling.objects.create(
665684
compliance_report_version=crv,
666685
handling_type=ComplianceReportVersionManualHandling.HandlingType.OBLIGATION,
667-
context=ComplianceReportVersionManualHandling.Context.OBLIGATION_REFUND_POOL_CASH,
686+
context=context,
668687
)
669688

670689
crv.status = ComplianceReportVersion.ComplianceStatus.REQUIRES_MANUAL_HANDLING
671690
crv.save(update_fields=['status'])
672691

692+
@staticmethod
693+
def _has_credit_usage_over_cap(invoice_adjustments: List[InvoiceAdjustment]) -> bool:
694+
"""
695+
Return True if the decrease pushes the compliance units already applied to any target obligation
696+
over the allowed cap (the decrease shrinks the cap). The invoice_adjustments decrease amount is passed as
697+
the pending supplementary adjustment, so the cap reflects the decrease before it is posted
698+
"""
699+
from compliance.service.bc_carbon_registry.apply_compliance_units_service import ApplyComplianceUnitsService
700+
701+
return any(
702+
ApplyComplianceUnitsService.is_credit_usage_over_cap(
703+
invoice_adjustment.version_id,
704+
pending_supplementary_adjustment=(
705+
ZERO_DECIMAL if invoice_adjustment.should_void_invoice else invoice_adjustment.applied
706+
),
707+
)
708+
for invoice_adjustment in invoice_adjustments
709+
)
710+
673711
@staticmethod
674712
def _create_earned_credits(compliance_report_version_id: int, tonnes: Decimal) -> None:
675713
"""

0 commit comments

Comments
 (0)