Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 116 additions & 1 deletion django-backend/fecfiler/transactions/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from decimal import Decimal
from django.db import models
from django.db.models import Q
from django.contrib.postgres.fields import ArrayField
Expand All @@ -15,6 +16,7 @@
)
from fecfiler.transactions.utils_aggregation_service import (
update_aggregates_for_affected_transactions,
calculate_loan_payment_to_date,
)
from fecfiler.transactions.schedule_a.models import ScheduleA
from fecfiler.transactions.schedule_b.models import ScheduleB
Expand Down Expand Up @@ -418,6 +420,107 @@ def _handle_debt_aggregation(self):

process_aggregation_for_debts(self)

def _handle_loan_aggregation_on_delete(self):
# Loan repayment delete must explicitly trigger reaggregation to keep
# loan_payment_to_date/loan_balance in sync with create/update behavior.
# This keeps delete-path behavior in parity with create/update service flow.
if self.transaction_type_identifier != "LOAN_REPAYMENT_MADE":
return

if self.loan_id is None or self.schedule_c_id is not None:
logger.warning(
"Skipped loan aggregation on repayment delete",
transaction_id=self.id,
loan_id=self.loan_id,
hook_executed=False,
skip_reason="not_loan_repayment",
)
return

if getattr(self, "schedule_b_id", None) is None:
logger.warning(
"Skipped loan aggregation on repayment delete",
transaction_id=self.id,
loan_id=self.loan_id,
hook_executed=False,
skip_reason="missing_schedule_b",
transaction_type_identifier=self.transaction_type_identifier,
)
return

updated_count = calculate_loan_payment_to_date(Transaction, self.loan_id)

# Keep normal-path cost low; only run invariant verification when no
# row was updated on delete.
expected_total = None
persisted_total = None
if updated_count == 0:
loan = Transaction.objects.filter(
id=self.loan_id,
deleted__isnull=True,
).only("id", "loan_payment_to_date").first()

if loan is None:
logger.warning(
"Skipped loan aggregation verification on repayment delete",
transaction_id=self.id,
loan_id=self.loan_id,
hook_executed=True,
skip_reason="loan_not_found",
updated_count=updated_count,
)
return

expected_total = (
Transaction.objects.filter(
loan_id=self.loan_id,
transaction_type_identifier="LOAN_REPAYMENT_MADE",
deleted__isnull=True,
schedule_b__isnull=False,
).aggregate(total=models.Sum("schedule_b__expenditure_amount"))["total"]
or Decimal("0")
)
persisted_total = loan.loan_payment_to_date or Decimal("0")

if persisted_total != expected_total:
logger.warning(
"Loan repayment delete aggregation invariant mismatch",
transaction_id=self.id,
loan_id=self.loan_id,
hook_executed=True,
updated_count=updated_count,
expected_total=str(expected_total),
persisted_total=str(persisted_total),
)
corrective_updated_count = calculate_loan_payment_to_date(
Transaction, self.loan_id
)
loan.refresh_from_db(fields=["loan_payment_to_date"])
persisted_total = loan.loan_payment_to_date or Decimal("0")
logger.info(
"Loan repayment delete aggregation corrective pass",
transaction_id=self.id,
loan_id=self.loan_id,
hook_executed=True,
corrective_updated_count=corrective_updated_count,
expected_total=str(expected_total),
persisted_total=str(persisted_total),
)

logger.info(
"Processed loan aggregation on repayment delete",
transaction_id=self.id,
loan_id=self.loan_id,
hook_executed=True,
updated_count=updated_count,
expected_total=str(expected_total)
if expected_total is not None
else None,
persisted_total=str(persisted_total)
if persisted_total is not None
else None,
)

def save(self, *args, **kwargs):
# Check if old_snapshot was passed from view (for Schedule A, B, F)
# This is needed because schedule is saved before transaction,
Expand Down Expand Up @@ -479,7 +582,7 @@ def save(self, *args, **kwargs):
try:
action = "create" if is_create else "update"
update_aggregates_for_affected_transactions(
Transaction, self, action
Transaction, self, action, old_snapshot
)
except Exception as e:
logger.error(
Expand Down Expand Up @@ -562,6 +665,18 @@ def delete(self):
error=str(e),
exc_info=True,
)

# Handle loan repayment aggregation after marking deleted
try:
self._handle_loan_aggregation_on_delete()
except Exception as e:
logger.error(
"Failed to process loan aggregation on delete",
transaction_id=self.id,
loan_id=self.loan_id,
error=str(e),
exc_info=True,
)
self.delete_children()
self.delete_debts()
self.delete_loans()
Expand Down
Loading