diff --git a/django-backend/fecfiler/transactions/models.py b/django-backend/fecfiler/transactions/models.py index 0210a14f6..103f54f98 100644 --- a/django-backend/fecfiler/transactions/models.py +++ b/django-backend/fecfiler/transactions/models.py @@ -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 @@ -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 @@ -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, @@ -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( @@ -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() diff --git a/django-backend/fecfiler/transactions/tests/test_models.py b/django-backend/fecfiler/transactions/tests/test_models.py index 979e64f39..74e4f792c 100644 --- a/django-backend/fecfiler/transactions/tests/test_models.py +++ b/django-backend/fecfiler/transactions/tests/test_models.py @@ -146,6 +146,38 @@ def setUp(self): self.committee.id, ) + def _refresh_all(self, *transactions): + for transaction in transactions: + transaction.refresh_from_db() + + def _assert_deleted_state(self, *transactions, deleted: bool): + self._refresh_all(*transactions) + for transaction in transactions: + if deleted: + self.assertIsNotNone(transaction.deleted) + else: + self.assertIsNone(transaction.deleted) + + def _undelete_all(self, *transactions): + for transaction in transactions: + undelete(transaction) + + def _assert_loan_balance_state(self, loan, payment_to_date: str, balance: str): + loan.refresh_from_db() + self.assertEqual(loan.loan_payment_to_date, Decimal(payment_to_date)) + self.assertEqual( + Transaction.objects.get(pk=loan.id).loan_balance, + Decimal(balance), + ) + + def _delete_repayment_and_assert_restored_parent_loan( + self, loan, repayment, restored_payment_to_date: str, restored_balance: str + ): + repayment.delete() + repayment.refresh_from_db() + self.assertIsNotNone(repayment.deleted) + self._assert_loan_balance_state(loan, restored_payment_to_date, restored_balance) + def test_delete_transaction(self): partnership_receipt = Transaction.objects.filter( id=self.partnership_receipt.id @@ -264,45 +296,173 @@ def test_delete_debt_transactions(self): second_repayment.save() original_debt.delete() - original_debt.refresh_from_db() - carried_forward_debt.refresh_from_db() - first_repayment.refresh_from_db() - second_repayment.refresh_from_db() - self.assertIsNotNone(original_debt.deleted) - self.assertIsNotNone(carried_forward_debt.deleted) - self.assertIsNotNone(first_repayment.deleted) - self.assertIsNotNone(second_repayment.deleted) - - undelete(original_debt) - undelete(carried_forward_debt) - undelete(first_repayment) - undelete(second_repayment) + self._assert_deleted_state( + original_debt, + carried_forward_debt, + first_repayment, + second_repayment, + deleted=True, + ) + + self._undelete_all( + original_debt, + carried_forward_debt, + first_repayment, + second_repayment, + ) def test_delete_loan_by_committee(self): - self.assertIsNone(self.loan.deleted) - self.assertIsNone(self.loan_made.deleted) - self.assertIsNone(self.carried_forward_loan.deleted) - self.assertIsNone(self.payment_1.deleted) - self.assertIsNone(self.payment_2.deleted) + self._assert_deleted_state( + self.loan, + self.loan_made, + self.carried_forward_loan, + self.payment_1, + self.payment_2, + deleted=False, + ) self.loan.delete() - self.loan.refresh_from_db() - self.loan_made.refresh_from_db() + self._assert_deleted_state( + self.loan, + self.loan_made, + self.carried_forward_loan, + self.payment_1, + self.payment_2, + deleted=True, + ) + + self._undelete_all( + self.loan, + self.loan_made, + self.carried_forward_loan, + self.payment_1, + self.payment_2, + ) + + def test_delete_loan_repayment_recalculates_parent_loan_balance(self): + report = create_form3x(self.committee, "2024-04-01", "2024-04-30", {}) + loan = create_loan( + self.committee, + self.contact_1, + "6000.00", + "2024-12-31", + "6%", + loan_incurred_date="2024-04-01", + report=report, + ) + repayment = create_schedule_b( + "LOAN_REPAYMENT_MADE", + self.committee, + self.contact_1, + "2024-04-10", + "1000.00", + loan_id=loan.id, + report=report, + ) + + self._assert_loan_balance_state(loan, "1000.00", "5000.00") + self._delete_repayment_and_assert_restored_parent_loan( + loan, + repayment, + "0.00", + "6000.00", + ) + + def test_delete_bank_loan_repayment_recalculates_parent_loan_balance(self): + report = create_form3x(self.committee, "2024-04-01", "2024-04-30", {}) + organization = create_test_organization_contact( + "Test Bank Contact", + self.committee.id, + ) + loan, _, _, _ = create_loan_from_bank( + self.committee, + organization, + "6000.00", + "2024-12-31", + "6%", + False, + "2024-04-01", + report=report, + ) + repayment = create_schedule_b( + "LOAN_REPAYMENT_MADE", + self.committee, + organization, + "2024-04-10", + "1000.00", + loan_id=loan.id, + report=report, + ) + + self._assert_loan_balance_state(loan, "1000.00", "5000.00") + self._delete_repayment_and_assert_restored_parent_loan( + loan, + repayment, + "0.00", + "6000.00", + ) + + def test_delete_malformed_loan_repayment_shape_skips_without_crashing(self): + report = create_form3x(self.committee, "2024-04-01", "2024-04-30", {}) + loan = create_loan( + self.committee, + self.contact_1, + "6000.00", + "2024-12-31", + "6%", + loan_incurred_date="2024-04-01", + report=report, + ) + create_schedule_b( + "LOAN_REPAYMENT_MADE", + self.committee, + self.contact_1, + "2024-04-10", + "1000.00", + loan_id=loan.id, + report=report, + ) + malformed = Transaction.objects.create( + committee_account=self.committee, + loan=loan, + transaction_type_identifier="LOAN_REPAYMENT_MADE", + _form_type="SB", + ) + + loan.refresh_from_db() + self.assertEqual(loan.loan_payment_to_date, Decimal("1000.00")) + + malformed.delete() + malformed.refresh_from_db() + loan.refresh_from_db() + + self.assertIsNotNone(malformed.deleted) + self.assertEqual(loan.loan_payment_to_date, Decimal("1000.00")) + + def test_delete_carried_forward_loan_repayment_recalculates_chain_balance(self): self.carried_forward_loan.refresh_from_db() - self.payment_1.refresh_from_db() + self.assertEqual( + self.carried_forward_loan.loan_payment_to_date, + Decimal("1600.00"), + ) + self.assertEqual( + Transaction.objects.get(pk=self.carried_forward_loan.id).loan_balance, + Decimal("3400.00"), + ) + + self.payment_2.delete() self.payment_2.refresh_from_db() + self.carried_forward_loan.refresh_from_db() - self.assertIsNotNone(self.loan.deleted) - self.assertIsNotNone(self.loan_made.deleted) - self.assertIsNotNone(self.carried_forward_loan.deleted) - self.assertIsNotNone(self.payment_1.deleted) self.assertIsNotNone(self.payment_2.deleted) - - undelete(self.loan) - undelete(self.loan_made) - undelete(self.carried_forward_loan) - undelete(self.payment_1) - undelete(self.payment_2) + self.assertEqual( + self.carried_forward_loan.loan_payment_to_date, + Decimal("1000.00"), + ) + self.assertEqual( + Transaction.objects.get(pk=self.carried_forward_loan.id).loan_balance, + Decimal("4000.00"), + ) def test_delete_loan_received_from_bank(self): loan, loan_receipt, loan_aggreement, guarantor = create_loan_from_bank( @@ -900,10 +1060,10 @@ def test_can_delete_debt(self): m3_report = create_form3x(self.committee, "2024-03-01", "2024-04-01", {}) carry_forward_debt(original_debt, m3_report) - # m2_report.upload_submission = UploadSubmission.objects.initiate_submission( - # m2_report.id + # m2_report.upload_submission = ( # NOSONAR(S125) + # UploadSubmission.objects.initiate_submission(m2_report.id) # ) - # m2_report.save() + # m2_report.save() # NOSONAR(S125) m3_report.upload_submission = UploadSubmission.objects.initiate_submission( m3_report.id ) @@ -1188,6 +1348,48 @@ def test_itemization_with_parent_child_disbursement(self): self.assertFalse(tier1.itemized) self.assertFalse(tier2.itemized) + def test_force_unitemized_receipt_cascades_to_memo_child(self): + parent_contact = create_test_individual_contact( + "parent_ln", "parent_fn", self.committee.id + ) + child_contact = create_test_individual_contact( + "child_ln", "child_fn", self.committee.id + ) + + parent = create_schedule_a( + "INDIVIDUAL_RECEIPT", + self.committee, + parent_contact, + "2024-01-01", + "250.00", + report=self.q1_report, + ) + child = create_schedule_a( + "INDIVIDUAL_RECEIPT", + self.committee, + child_contact, + "2024-01-02", + "250.00", + memo_code=True, + report=self.q1_report, + parent_id=parent.id, + ) + + parent.refresh_from_db() + child.refresh_from_db() + + self.assertTrue(parent.itemized) + self.assertTrue(child.itemized) + + parent.force_itemized = False + parent.save() + + parent.refresh_from_db() + child.refresh_from_db() + + self.assertFalse(parent.itemized) + self.assertFalse(child.itemized) + def test_unitemization_cascades_to_children_in_other_chain(self): parent_contact = create_test_individual_contact( "parent_ln", "parent_fn", self.committee.id diff --git a/django-backend/fecfiler/transactions/tests/test_transaction_dependencies.py b/django-backend/fecfiler/transactions/tests/test_transaction_dependencies.py index fdbbff927..c6335288f 100644 --- a/django-backend/fecfiler/transactions/tests/test_transaction_dependencies.py +++ b/django-backend/fecfiler/transactions/tests/test_transaction_dependencies.py @@ -202,7 +202,7 @@ def test_partnership_receipt(self): None, None, None, - "(Partnership attributions do not meet itemization threshold)", + "Partnership attributions do not meet itemization threshold", ) partnership_attribution = create_schedule_a( "PARTNERSHIP_ATTRIBUTION", @@ -221,7 +221,7 @@ def test_partnership_receipt(self): parent.refresh_from_db() self.assertEqual( parent.schedule_a.contribution_purpose_descrip, - "(See Partnership Attribution(s) below)", + "See Partnership Attribution(s) below", ) partnership_attribution.delete() @@ -230,5 +230,5 @@ def test_partnership_receipt(self): self.assertEqual( parent.schedule_a.contribution_purpose_descrip, - "(Partnership attributions do not meet itemization threshold)", + "Partnership attributions do not meet itemization threshold", ) diff --git a/django-backend/fecfiler/transactions/tests/test_views.py b/django-backend/fecfiler/transactions/tests/test_views.py index 3f232000e..62ae8efef 100644 --- a/django-backend/fecfiler/transactions/tests/test_views.py +++ b/django-backend/fecfiler/transactions/tests/test_views.py @@ -20,6 +20,7 @@ create_schedule_a, create_schedule_b, create_loan, + create_loan_from_bank, create_debt, create_ie, create_schedule_f, @@ -197,6 +198,97 @@ def get_request(self, path="api/v1/transactions", params={}): request.data = {} return request + def _build_repayment_payload( + self, + payload_key: str, + relation_key: str, + transaction: Transaction, + report: Report, + repayment_date: str, + repayment_amount, + ): + transaction_representation = self.transaction_serializer.to_representation( + transaction + ) + repayment_payload = deepcopy(self.payloads[payload_key]) + repayment_payload["contact_1"] = transaction_representation["contact_1"] + repayment_payload["contact_1_id"] = transaction_representation["contact_1_id"] + repayment_payload[relation_key] = transaction_representation + repayment_payload[f"{relation_key}_id"] = transaction_representation["id"] + repayment_payload["report_ids"] = [str(report.id)] + repayment_payload["expenditure_date"] = repayment_date + repayment_payload["expenditure_amount"] = repayment_amount + return repayment_payload + + def _create_debt_with_aggregation(self, report: Report, contact, amount="1000.00"): + debt = create_debt(self.committee, contact, amount, report=report) + process_aggregation_for_debts(debt) + return debt + + def _assert_destroy_repayment_restores_parent_loan_balance( + self, + loan: Transaction, + repayment: Transaction, + expected_payment_before="1000.00", + expected_balance_before="5000.00", + expected_payment_after="0.00", + expected_balance_after="6000.00", + ): + loan.refresh_from_db() + self.assertEqual( + loan.loan_payment_to_date, + Decimal(expected_payment_before), + ) + self.assertEqual( + Transaction.objects.get(pk=loan.id).loan_balance, + Decimal(expected_balance_before), + ) + + response = self.send_viewset_delete_request( + f"api/v1/transactions/{repayment.id}/", + TransactionViewSet, + "destroy", + pk=repayment.id, + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(Transaction.objects.filter(pk=repayment.pk).exists()) + + loan_response = self.send_viewset_get_request( + f"api/v1/transactions/{loan.id}/", + TransactionViewSet, + "retrieve", + pk=loan.id, + ) + self.assertEqual(loan_response.status_code, status.HTTP_200_OK) + self.assertEqual( + Decimal(str(loan_response.data["loan_payment_to_date"])), + Decimal(expected_payment_after), + ) + self.assertEqual( + Decimal(str(loan_response.data["loan_balance"])), + Decimal(expected_balance_after), + ) + + def _create_schedule_f_debt_repayment( + self, + debt: Transaction, + report: Report, + repayment_date: str, + repayment_amount: int, + ): + payload = self.create_schedule_f_debt_repayment_payload( + debt, + report, + repayment_date, + repayment_amount, + ) + response = self.view.create(self.post_request(payload)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + return Transaction.objects.get( + debt_id=debt.id, + schedule_f__isnull=False, + ) + def test_save_transaction_pair(self): request = self.post_request(self.payloads["IN_KIND"]) transaction = TransactionViewSet().save_transaction(request.data, request) @@ -847,6 +939,62 @@ def test_destroy(self): self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertFalse(Transaction.objects.filter(pk=self.transaction.pk).exists()) + def test_destroy_loan_repayment_recalculates_parent_loan_balance(self): + report = create_form3x(self.committee, "2025-01-01", "2025-03-31", {}) + loan = create_loan( + self.committee, + self.test_ind_contact, + "6000.00", + "2025-12-31", + "6%", + form_type="SC/10", + loan_incurred_date="2025-01-01", + report=report, + ) + repayment = create_schedule_b( + "LOAN_REPAYMENT_MADE", + self.committee, + self.test_ind_contact, + "2025-01-05", + "1000.00", + loan_id=loan.id, + report=report, + ) + self._assert_destroy_repayment_restores_parent_loan_balance( + loan, + repayment, + ) + + def test_destroy_bank_loan_repayment_recalculates_parent_loan_balance(self): + report = create_form3x(self.committee, "2025-01-01", "2025-03-31", {}) + organization = create_test_organization_contact( + "Test Bank Contact", + self.committee.id, + ) + loan, _, _, _ = create_loan_from_bank( + self.committee, + organization, + "6000.00", + "2025-12-31", + "6%", + False, + "2025-01-01", + report=report, + ) + repayment = create_schedule_b( + "LOAN_REPAYMENT_MADE", + self.committee, + organization, + "2025-01-05", + "1000.00", + loan_id=loan.id, + report=report, + ) + self._assert_destroy_repayment_restores_parent_loan_balance( + loan, + repayment, + ) + def test_destroy_with_dependent_parent(self): jf_transfer = create_schedule_a( "JOINT_FUNDRAISING_TRANSFER", @@ -1295,30 +1443,66 @@ def test_debt_incurred_prior_aggregation(self): self.assertEqual(q3_debt.schedule_d.incurred_amount, 0.00) self.assertEqual(q3_debt.schedule_d.balance_at_close, 1500.00) + def test_update_itemization_aggregation_unitemizes_receipt_and_memo_child(self): + parent = create_schedule_a( + "INDIVIDUAL_RECEIPT", + self.committee, + self.contact_1, + "2024-01-01", + "250.00", + report=self.q1_report, + ) + child = create_schedule_a( + "INDIVIDUAL_RECEIPT", + self.committee, + self.contact_3, + "2024-01-02", + "250.00", + memo_code=True, + report=self.q1_report, + parent_id=parent.id, + ) + + parent.refresh_from_db() + child.refresh_from_db() + self.assertTrue(parent.itemized) + self.assertTrue(child.itemized) + + response = self.send_viewset_put_request( + f"/api/v1/transactions/{parent.id}/update-itemization-aggregation/", + json.dumps( + { + "force_itemized": False, + "schedule_id": "A", + "schema_name": "INDIVIDUAL_RECEIPT", + } + ), + TransactionViewSet, + "update_itemization_aggregation", + pk=parent.id, + ) + + self.assertEqual(response.status_code, 200) + + parent.refresh_from_db() + child.refresh_from_db() + self.assertFalse(parent.itemized) + self.assertFalse(child.itemized) + def test_create_schedule_f_debt_repayment(self): """Making a schedule f debt repayment should reduce the balance accordingly""" - # create q1 and associated debt test_q1_report_2025 = create_form3x( self.committee, "2025-01-01", "2025-03-31", {} ) - test_debt = create_debt( - self.committee, - self.test_org_contact, - "1000.00", - report=test_q1_report_2025, + test_debt = self._create_debt_with_aggregation( + test_q1_report_2025, self.test_org_contact ) - process_aggregation_for_debts(test_debt) - - # pay off debt on q2 and confirm q3 carry foward debt deleted - test_schedule_f_debt_repayment = self.create_schedule_f_debt_repayment_payload( + self._create_schedule_f_debt_repayment( test_debt, test_q1_report_2025, "2025-04-03", 150.00, ) - - response = self.view.create(self.post_request(test_schedule_f_debt_repayment)) - self.assertEqual(response.status_code, 200) self.assertEqual( Transaction.objects.get(pk=test_debt.id).balance_at_close, 850.00, @@ -1326,35 +1510,25 @@ def test_create_schedule_f_debt_repayment(self): def test_delete_schedule_f_debt_repayment(self): """Making a schedule f debt repayment should reduce the balance accordingly""" - # create q1 and associated debt test_q1_report_2025 = create_form3x( self.committee, "2025-01-01", "2025-03-31", {} ) - test_debt = create_debt( - self.committee, - self.test_org_contact, - "1000.00", - report=test_q1_report_2025, + test_debt = self._create_debt_with_aggregation( + test_q1_report_2025, self.test_org_contact ) - process_aggregation_for_debts(test_debt) - - test_schedule_f_debt_repayment = self.create_schedule_f_debt_repayment_payload( + repayment = self._create_schedule_f_debt_repayment( test_debt, test_q1_report_2025, "2025-04-03", 350.00, ) - - response = self.view.create(self.post_request(test_schedule_f_debt_repayment)) - self.assertEqual(response.status_code, 200) self.assertEqual( Transaction.objects.get(pk=test_debt.id).balance_at_close, 650.00, ) - repayment = Transaction.objects.filter(debt_id=test_debt.id).first() self.send_viewset_delete_request( - f"api/v1/transactions/{self.transaction.id}/", + f"api/v1/transactions/{repayment.id}/", TransactionViewSet, "destroy", pk=repayment.id, @@ -1369,16 +1543,14 @@ def create_loan_repayment_payload( repayment_date: str, repayment_amount: int, ): - loan_representation = self.transaction_serializer.to_representation(loan) - loan_repayment_payload = deepcopy(self.payloads["LOAN_REPAYMENT"]) - loan_repayment_payload["contact_1"] = loan_representation["contact_1"] - loan_repayment_payload["contact_1_id"] = loan_representation["contact_1_id"] - loan_repayment_payload["loan"] = loan_representation - loan_repayment_payload["loan_id"] = loan_representation["id"] - loan_repayment_payload["report_ids"] = [str(report.id)] - loan_repayment_payload["expenditure_date"] = repayment_date - loan_repayment_payload["expenditure_amount"] = repayment_amount - return loan_repayment_payload + return self._build_repayment_payload( + "LOAN_REPAYMENT", + "loan", + loan, + report, + repayment_date, + repayment_amount, + ) def create_debt_repayment_payload( self, @@ -1387,16 +1559,14 @@ def create_debt_repayment_payload( repayment_date: str, repayment_amount: int, ): - debt_representation = self.transaction_serializer.to_representation(debt) - debt_repayment_payload = deepcopy(self.payloads["DEBT_REPAYMENT"]) - debt_repayment_payload["contact_1"] = debt_representation["contact_1"] - debt_repayment_payload["contact_1_id"] = debt_representation["contact_1_id"] - debt_repayment_payload["debt"] = debt_representation - debt_repayment_payload["debt_id"] = debt_representation["id"] - debt_repayment_payload["report_ids"] = [str(report.id)] - debt_repayment_payload["expenditure_date"] = repayment_date - debt_repayment_payload["expenditure_amount"] = repayment_amount - return debt_repayment_payload + return self._build_repayment_payload( + "DEBT_REPAYMENT", + "debt", + debt, + report, + repayment_date, + repayment_amount, + ) def create_schedule_f_debt_repayment_payload( self, @@ -1405,20 +1575,14 @@ def create_schedule_f_debt_repayment_payload( repayment_date: str, repayment_amount: int, ): - debt_representation = self.transaction_serializer.to_representation(debt) - schedule_f_debt_repayment_payload = deepcopy( - self.payloads["COORDINATED_PARTY_EXPENDITURE"] + return self._build_repayment_payload( + "COORDINATED_PARTY_EXPENDITURE", + "debt", + debt, + report, + repayment_date, + repayment_amount, ) - schedule_f_debt_repayment_payload["contact_1"] = debt_representation["contact_1"] - schedule_f_debt_repayment_payload["contact_1_id"] = debt_representation[ - "contact_1_id" - ] - schedule_f_debt_repayment_payload["debt"] = debt_representation - schedule_f_debt_repayment_payload["debt_id"] = debt_representation["id"] - schedule_f_debt_repayment_payload["report_ids"] = [str(report.id)] - schedule_f_debt_repayment_payload["expenditure_date"] = repayment_date - schedule_f_debt_repayment_payload["expenditure_amount"] = repayment_amount - return schedule_f_debt_repayment_payload def test_schedule_f_aggregation(self): report = create_form3x( diff --git a/django-backend/fecfiler/transactions/transaction_dependencies.py b/django-backend/fecfiler/transactions/transaction_dependencies.py index b8d09930f..aa30f8f4c 100644 --- a/django-backend/fecfiler/transactions/transaction_dependencies.py +++ b/django-backend/fecfiler/transactions/transaction_dependencies.py @@ -61,9 +61,15 @@ def update_dependent_parent(transaction: Transaction): prefix = grandparent_dependencies.get("prefix", "") committee_name = getattr(getattr(grandparent, "contact_1", None), "name", "") - _, _, partnership_no_children_update, partnership_with_children_update = ( - get_jf_transfer_descriptions(prefix, committee_name) - ) + if parent.transaction_type_identifier == "PARTNERSHIP_RECEIPT": + partnership_no_children_update = ( + "Partnership attributions do not meet itemization threshold" + ) + partnership_with_children_update = "See Partnership Attribution(s) below" + else: + _, _, partnership_no_children_update, partnership_with_children_update = ( + get_jf_transfer_descriptions(prefix, committee_name) + ) ScheduleA.objects.filter(transaction__id=parent.id).update( contribution_purpose_descrip=Subquery( ScheduleA.objects.filter(id=OuterRef("id")) diff --git a/django-backend/fecfiler/transactions/utils_aggregation_prep.py b/django-backend/fecfiler/transactions/utils_aggregation_prep.py index e7872126b..3f31cfa09 100644 --- a/django-backend/fecfiler/transactions/utils_aggregation_prep.py +++ b/django-backend/fecfiler/transactions/utils_aggregation_prep.py @@ -20,6 +20,7 @@ def create_old_snapshot(transaction, effective_amount): "date": transaction.get_date(), "created": transaction.created, "effective_amount": effective_amount, + "itemized": transaction.itemized, "force_itemized": transaction.force_itemized, "force_unaggregated": transaction.force_unaggregated, "memo_code": transaction.memo_code, diff --git a/django-backend/fecfiler/transactions/utils_aggregation_service.py b/django-backend/fecfiler/transactions/utils_aggregation_service.py index 7bbbb2e78..9beb56161 100644 --- a/django-backend/fecfiler/transactions/utils_aggregation_service.py +++ b/django-backend/fecfiler/transactions/utils_aggregation_service.py @@ -870,10 +870,19 @@ def _update_parent_itemization_service(instance) -> None: cascade_unitemization_to_children(parent) +def _get_previous_itemized_service(created: bool, old_snapshot, instance) -> bool: + if created: + return False + if old_snapshot and "itemized" in old_snapshot: + return old_snapshot["itemized"] + return instance.itemized + + def update_aggregates_for_affected_transactions( transaction_model, instance, action: str, + old_snapshot=None, ) -> None: """ Entry point to update aggregates and itemization. @@ -910,8 +919,11 @@ def update_aggregates_for_affected_transactions( in (schedule_a_over_two_hundred_types + schedule_b_over_two_hundred_types) ) - # Track previous itemization state; on create, treat as False to enable cascade - previous_itemized = False if created else instance.itemized + # Track previous itemization state before the current save so explicit + # unitemization can still cascade to child transactions. + previous_itemized = _get_previous_itemized_service( + created, old_snapshot, instance + ) # For newly created transactions, ensure the transient state mirrors signals if created: @@ -933,10 +945,10 @@ def update_aggregates_for_affected_transactions( if itemization_changed: instance.save(update_fields=["itemized"]) - if instance.itemized and not previous_itemized: - cascade_itemization_to_parents(instance) - elif not instance.itemized and previous_itemized: - cascade_unitemization_to_children(instance) + if instance.itemized and not previous_itemized: + cascade_itemization_to_parents(instance) + elif not instance.itemized and previous_itemized: + cascade_unitemization_to_children(instance) # Update parent itemization when a child changes if instance.parent_transaction_id: diff --git a/django-backend/fecfiler/transactions/views.py b/django-backend/fecfiler/transactions/views.py index ed6346308..880812adb 100644 --- a/django-backend/fecfiler/transactions/views.py +++ b/django-backend/fecfiler/transactions/views.py @@ -205,7 +205,10 @@ def save_reatt_redes_transactions(self, request): def update_itemization_aggregation(self, request, pk=None): transaction: Transaction = self.get_object() - transaction_data = self.get_serializer(transaction).data + transaction_data = self.get_serializer( + transaction, context={"request": request, "no_depth": True} + ).data + transaction_data.pop("children", None) transaction_data["report_ids"] = [ str(rep_id) for rep_id in transaction.reports.values_list("id", flat=True) ]