From dcc2f413413c5cc25e7f99f4420fede7b8ca49d4 Mon Sep 17 00:00:00 2001 From: tarunnjoshi Date: Mon, 19 Oct 2026 12:43:26 +0530 Subject: [PATCH] Footfall & Contribution Details: count CSV imports, cancellations and other payment methods (main) Brings the camp and event contribution-total changes from dev (PR #1823) to main: - Camp and event totals and contributor counts now include CSV-imported contributions and come down when a contribution is cancelled or refunded; only Completed contributions are counted. - New "Total Other Monetary Contributions" total for camps and events: any payment method that is not Razorpay or Cash/Cheque. - Event online/cash totals are recalculated from all Completed contributions instead of being added to on every save. - Event material footfall counts the newest material donor. - New cli/backfill-collection-source-contribution-stats.php to correct existing camp records (dry run by default). Refs ColoredCow/goonj-crm#392 Co-Authored-By: Claude Opus 5.5 --- .../Civi/CollectionBaseService.php | 57 ++-- .../Civi/GoonjInitiatedEventsService.php | 109 +++++--- ...l-collection-source-contribution-stats.php | 261 ++++++++++++++++++ 3 files changed, 371 insertions(+), 56 deletions(-) create mode 100644 wp-content/civi-extensions/goonjcustom/cli/backfill-collection-source-contribution-stats.php diff --git a/wp-content/civi-extensions/goonjcustom/Civi/CollectionBaseService.php b/wp-content/civi-extensions/goonjcustom/Civi/CollectionBaseService.php index fe6ee607d..df3bba346 100644 --- a/wp-content/civi-extensions/goonjcustom/Civi/CollectionBaseService.php +++ b/wp-content/civi-extensions/goonjcustom/Civi/CollectionBaseService.php @@ -333,7 +333,7 @@ public static function handleMaterialContributionDelete($op, $objectName, $objec /** * */ - public static function updateUniqueContributorsCount(int $contributionId, string $activeFieldName, int $currentContactId) { + public static function updateUniqueContributorsCount(int $contributionId, string $activeFieldName, int $currentContactId, bool $countCurrentContact = TRUE) { $materialContactIds = []; if (!in_array($activeFieldName, ['Material_Contribution.Institution_Goonj_Activities', 'Material_Contribution.Goonj_Activities'], TRUE)) { @@ -368,7 +368,7 @@ public static function updateUniqueContributorsCount(int $contributionId, string $inMaterial = in_array($currentContactId, $materialContactIds, TRUE); $inMoney = in_array($currentContactId, $monetaryContactIds, TRUE); - if (!$inMaterial && !$inMoney) { + if ($countCurrentContact && !$inMaterial && !$inMoney) { $uniqueCount++; } @@ -474,15 +474,10 @@ public static function updateMonetaryContributorsCount(string $op, string $objec } $contributionId = $objectRef->id; - if (isset($processed[$contributionId])) { - return; - } - $processed[$contributionId] = TRUE; $contribution = Contribution::get(FALSE) - ->addSelect('contact_id', 'Contribution_Details.Source.id') + ->addSelect('contact_id', 'Contribution_Details.Source.id', 'contribution_status_id:name') ->addWhere('id', '=', $contributionId) - ->addWhere('contribution_status_id:name', '=', 'Completed') ->execute() ->first(); @@ -490,6 +485,15 @@ public static function updateMonetaryContributorsCount(string $op, string $objec return; } + // Process each status of a contribution once, not just the first save: + // a CSV import saves it as Pending and completes it in a second save, and + // a cancellation must bring the count back down. + $status = $contribution['contribution_status_id:name']; + if (isset($processed[$contributionId][$status])) { + return; + } + $processed[$contributionId][$status] = TRUE; + $contributionId = $contribution['Contribution_Details.Source.id']; $collectionCamps = EckEntity::get('Collection_Camp', FALSE) @@ -502,7 +506,7 @@ public static function updateMonetaryContributorsCount(string $op, string $objec if ($subtype) { $fieldName = "Material_Contribution.$subtype"; - self::updateUniqueContributorsCount($contributionId, $fieldName, $contribution['contact_id']); + self::updateUniqueContributorsCount($contributionId, $fieldName, $contribution['contact_id'], $status === 'Completed'); } $allContributions = Contribution::get(FALSE) @@ -614,7 +618,8 @@ public static function handleMonetaryContributionDelete(string $op, string $obje $existing = EckEntity::get('Collection_Camp') ->addSelect( 'Core_Contribution_Details.Total_online_monetary_contributions', - 'Core_Contribution_Details.Total_cash_cheque_monetary_contributions' + 'Core_Contribution_Details.Total_cash_cheque_monetary_contributions', + 'Core_Contribution_Details.Total_Other_Monetary_Contributions' ) ->addWhere('id', '=', $contributionId) ->execute() @@ -622,22 +627,27 @@ public static function handleMonetaryContributionDelete(string $op, string $obje $onlineCurrent = (float) ($existing['Core_Contribution_Details.Total_online_monetary_contributions'] ?? 0); $cashCurrent = (float) ($existing['Core_Contribution_Details.Total_cash_cheque_monetary_contributions'] ?? 0); + $otherCurrent = (float) ($existing['Core_Contribution_Details.Total_Other_Monetary_Contributions'] ?? 0); $onlineNew = $onlineCurrent; $cashNew = $cashCurrent; + $otherNew = $otherCurrent; if ($paymentMethod === 'Credit Card') { $onlineNew = max(0, $onlineCurrent - $amount); } - - if (in_array($paymentMethod, ['Cash', 'Check'], TRUE)) { + elseif (in_array($paymentMethod, ['Cash', 'Check'], TRUE)) { $cashNew = max(0, $cashCurrent - $amount); } + else { + $otherNew = max(0, $otherCurrent - $amount); + } $update = EckEntity::update('Collection_Camp') ->addWhere('id', '=', $contributionId) ->addValue('Core_Contribution_Details.Total_online_monetary_contributions', $onlineNew) - ->addValue('Core_Contribution_Details.Total_cash_cheque_monetary_contributions', $cashNew); + ->addValue('Core_Contribution_Details.Total_cash_cheque_monetary_contributions', $cashNew) + ->addValue('Core_Contribution_Details.Total_Other_Monetary_Contributions', $otherNew); if (!$currentContactStillExists) { $update->addValue('Core_Contribution_Details.Number_of_unique_monetary_contributors', $uniqueMonetaryCount); @@ -665,10 +675,6 @@ public static function updateMonetaryContributionTotalAmount(string $op, string } $contributionId = $objectRef->id; - if (isset($processed[$contributionId])) { - return; - } - $processed[$contributionId] = TRUE; $contribution = Contribution::get(FALSE) ->addSelect( @@ -678,7 +684,6 @@ public static function updateMonetaryContributionTotalAmount(string $op, string 'payment_instrument_id:name' ) ->addWhere('id', '=', $contributionId) - ->addWhere('contribution_status_id:name', '=', 'Completed') ->execute() ->first(); @@ -686,6 +691,15 @@ public static function updateMonetaryContributionTotalAmount(string $op, string return; } + // Process each status of a contribution once, not just the first save: + // a CSV import saves it as Pending and completes it in a second save, and + // a cancellation must bring the totals back down. + $status = $contribution['contribution_status_id:name']; + if (isset($processed[$contributionId][$status])) { + return; + } + $processed[$contributionId][$status] = TRUE; + $campId = (int) $contribution['Contribution_Details.Source.id']; $allCampContributions = Contribution::get(FALSE) @@ -696,6 +710,7 @@ public static function updateMonetaryContributionTotalAmount(string $op, string $onlineTotal = 0.0; $cashChequeTotal = 0.0; + $otherTotal = 0.0; foreach ($allCampContributions as $item) { $amount = (float) ($item['total_amount'] ?? 0); @@ -707,6 +722,11 @@ public static function updateMonetaryContributionTotalAmount(string $op, string elseif (in_array($method, ['Cash', 'Check'], TRUE)) { $cashChequeTotal += $amount; } + else { + // Any payment method that is not Razorpay or cash/cheque, including + // ones added in future (UPI, Wire Transfer, ...). + $otherTotal += $amount; + } } try { @@ -714,6 +734,7 @@ public static function updateMonetaryContributionTotalAmount(string $op, string ->addWhere('id', '=', $campId) ->addValue('Core_Contribution_Details.Total_online_monetary_contributions', $onlineTotal) ->addValue('Core_Contribution_Details.Total_cash_cheque_monetary_contributions', $cashChequeTotal) + ->addValue('Core_Contribution_Details.Total_Other_Monetary_Contributions', $otherTotal) ->execute(); } catch (\Exception $e) { diff --git a/wp-content/civi-extensions/goonjcustom/Civi/GoonjInitiatedEventsService.php b/wp-content/civi-extensions/goonjcustom/Civi/GoonjInitiatedEventsService.php index cb40c41d9..6154e779c 100644 --- a/wp-content/civi-extensions/goonjcustom/Civi/GoonjInitiatedEventsService.php +++ b/wp-content/civi-extensions/goonjcustom/Civi/GoonjInitiatedEventsService.php @@ -126,7 +126,8 @@ public static function handleEventMonetaryContributionDelete(string $op, string $existing = Event::get(FALSE) ->addSelect( 'Goonj_Events_Outcome.Online_Monetary_Contribution', - 'Goonj_Events_Outcome.Cash_Contribution' + 'Goonj_Events_Outcome.Cash_Contribution', + 'Goonj_Events_Outcome.Total_Other_Monetary_Contributions' ) ->addWhere('id', '=', $eventId) ->execute() @@ -134,6 +135,7 @@ public static function handleEventMonetaryContributionDelete(string $op, string $onlineCurrent = (float) ($existing['Goonj_Events_Outcome.Online_Monetary_Contribution'] ?? 0); $cashCurrent = (float) ($existing['Goonj_Events_Outcome.Cash_Contribution'] ?? 0); + $otherCurrent = (float) ($existing['Goonj_Events_Outcome.Total_Other_Monetary_Contributions'] ?? 0); $update = Event::update() ->addWhere('id', '=', $eventId); @@ -150,9 +152,12 @@ public static function handleEventMonetaryContributionDelete(string $op, string $update->addValue('Goonj_Events_Outcome.Online_Monetary_Contribution', max(0, $onlineCurrent - $amount)); } - if (in_array($paymentMethod, ['Cash', 'Check'], TRUE)) { + elseif (in_array($paymentMethod, ['Cash', 'Check'], TRUE)) { $update->addValue('Goonj_Events_Outcome.Cash_Contribution', max(0, $cashCurrent - $amount)); } + else { + $update->addValue('Goonj_Events_Outcome.Total_Other_Monetary_Contributions', max(0, $otherCurrent - $amount)); + } $update->execute(); @@ -240,7 +245,7 @@ public static function handleEventMaterialContributionDelete(string $op, string /** * */ - public static function updateUniqueContributorsCount(int $eventId) { + public static function updateUniqueContributorsCount(int $eventId, ?int $currentContactId = NULL) { $materialActivities = Activity::get(FALSE) ->addSelect('source_contact_id') ->addWhere('Material_Contribution.Event', '=', $eventId) @@ -253,6 +258,12 @@ public static function updateUniqueContributorsCount(int $eventId) { } } + // The contact whose material contribution is being saved right now; see + // updateEventMatrialContributorsCount. + if ($currentContactId) { + $materialContactIds[] = $currentContactId; + } + $monetaryContributions = Contribution::get(FALSE) ->addSelect('contact_id') ->addWhere('Contribution_Details.Events.id', '=', $eventId) @@ -320,9 +331,17 @@ public static function updateEventMatrialContributorsCount(string $op, string $o } } + // The form's submission is saved before its activity, so the material + // contribution being added is not in the list above yet; count its + // contact here (the camp hook does the same). + $currentContactId = (int) ($activityFields['source_contact_id'] ?? 0); + if ($currentContactId) { + $uniqueContactIds[$currentContactId] = TRUE; + } + $uniqueCount = count($uniqueContactIds); - self::updateUniqueContributorsCount($eventId); + self::updateUniqueContributorsCount($eventId, $currentContactId ?: NULL); try { Event::update() @@ -345,15 +364,10 @@ public static function updateEventMonetaryContributorsCount(string $op, string $ } $contributionId = $objectRef->id; - if (isset($processed[$contributionId])) { - return; - } - $processed[$contributionId] = TRUE; $contribution = Contribution::get(FALSE) - ->addSelect('contact_id', 'Contribution_Details.Events.id') + ->addSelect('contact_id', 'Contribution_Details.Events.id', 'contribution_status_id:name') ->addWhere('id', '=', $contributionId) - ->addWhere('contribution_status_id:name', '=', 'Completed') ->execute() ->first(); @@ -361,6 +375,15 @@ public static function updateEventMonetaryContributorsCount(string $op, string $ return; } + // Process each status of a contribution once, not just the first save: + // a CSV import saves it as Pending and completes it in a second save, and + // a cancellation must bring the count back down. + $status = $contribution['contribution_status_id:name']; + if (isset($processed[$contributionId][$status])) { + return; + } + $processed[$contributionId][$status] = TRUE; + $eventId = $contribution['Contribution_Details.Events.id']; if (!$eventId) { @@ -401,10 +424,6 @@ public static function updateEventMonetaryContributionTotalAmount(string $op, st } $contributionId = $objectRef->id; - if (isset($processed[$contributionId])) { - return; - } - $processed[$contributionId] = TRUE; $contribution = Contribution::get(FALSE) ->addSelect( @@ -414,7 +433,6 @@ public static function updateEventMonetaryContributionTotalAmount(string $op, st 'payment_instrument_id:name' ) ->addWhere('id', '=', $contributionId) - ->addWhere('contribution_status_id:name', '=', 'Completed') ->execute() ->first(); @@ -422,35 +440,50 @@ public static function updateEventMonetaryContributionTotalAmount(string $op, st return; } - $eventId = $contribution['Contribution_Details.Events.id']; - $totalAmount = (float) $contribution['total_amount']; - $paymentMethod = $contribution['payment_instrument_id:name']; + // Process each status of a contribution once, not just the first save: + // a CSV import saves it as Pending and completes it in a second save, and + // a cancellation must bring the totals back down. + $status = $contribution['contribution_status_id:name']; + if (isset($processed[$contributionId][$status])) { + return; + } + $processed[$contributionId][$status] = TRUE; - $existing = Event::get(FALSE) - ->addSelect( - 'Goonj_Events_Outcome.Online_Monetary_Contribution', - 'Goonj_Events_Outcome.Cash_Contribution' - ) - ->addWhere('id', '=', $eventId) - ->execute() - ->first(); + $eventId = (int) $contribution['Contribution_Details.Events.id']; - $onlineCurrent = (float) ($existing['Goonj_Events_Outcome.Online_Monetary_Contribution'] ?? 0); - $cashCurrent = (float) ($existing['Goonj_Events_Outcome.Cash_Contribution'] ?? 0); - $update = Event::update(FALSE) - ->addWhere('id', '=', $eventId); + $allEventContributions = Contribution::get(FALSE) + ->addSelect('total_amount', 'payment_instrument_id:name') + ->addWhere('Contribution_Details.Events.id', '=', $eventId) + ->addWhere('contribution_status_id:name', '=', 'Completed') + ->execute(); - if ($paymentMethod === 'Credit Card') { - $onlineNew = $onlineCurrent + $totalAmount; - $update->addValue('Goonj_Events_Outcome.Online_Monetary_Contribution', $onlineNew); - } + $onlineTotal = 0.0; + $cashChequeTotal = 0.0; + $otherTotal = 0.0; + + foreach ($allEventContributions as $item) { + $amount = (float) ($item['total_amount'] ?? 0); + $method = $item['payment_instrument_id:name'] ?? ''; - if (in_array($paymentMethod, ['Cash', 'Check'], TRUE)) { - $cashNew = $cashCurrent + $totalAmount; - $update->addValue('Goonj_Events_Outcome.Cash_Contribution', $cashNew); + if ($method === 'Credit Card') { + $onlineTotal += $amount; + } + elseif (in_array($method, ['Cash', 'Check'], TRUE)) { + $cashChequeTotal += $amount; + } + else { + // Any payment method that is not Razorpay or cash/cheque, including + // ones added in future (UPI, Wire Transfer, ...). + $otherTotal += $amount; + } } - $update->execute(); + Event::update(FALSE) + ->addWhere('id', '=', $eventId) + ->addValue('Goonj_Events_Outcome.Online_Monetary_Contribution', $onlineTotal) + ->addValue('Goonj_Events_Outcome.Cash_Contribution', $cashChequeTotal) + ->addValue('Goonj_Events_Outcome.Total_Other_Monetary_Contributions', $otherTotal) + ->execute(); } /** diff --git a/wp-content/civi-extensions/goonjcustom/cli/backfill-collection-source-contribution-stats.php b/wp-content/civi-extensions/goonjcustom/cli/backfill-collection-source-contribution-stats.php new file mode 100644 index 000000000..338035ba4 --- /dev/null +++ b/wp-content/civi-extensions/goonjcustom/cli/backfill-collection-source-contribution-stats.php @@ -0,0 +1,261 @@ + 'Material_Contribution.Collection_Camp', + 'Dropping_Center' => 'Material_Contribution.Dropping_Center', + 'Institution_Collection_Camp' => 'Material_Contribution.Institution_Collection_Camp', + 'Institution_Dropping_Center' => 'Material_Contribution.Institution_Dropping_Center', +]; + +// Outcome forms on which the Footfall & Contribution Details can be typed in by hand. +$handEntryForms = [ + 'afformAdminCampOutcomeForm', + 'afformAdminFormForEditingInstitutionCampOutcomes', + 'afformInstitutionActivityOutcomeForm', + 'afformInstitutionActivityOutcomeForm2', + 'afformInstitutionActivityOutcomeEdit', + 'afformUpdateCustom_Goonj_Activities_Outcome', +]; + +/** + * Returns the ids of collection sources that have a Footfall & Contribution + * Details value typed in on one of the given outcome forms. + */ +function getHandEnteredCollectionSourceIds(array $formNames): array { + $ids = []; + $submissions = AfformSubmission::get(FALSE) + ->addSelect('data') + ->addWhere('afform_name', 'IN', $formNames) + ->execute(); + + foreach ($submissions as $submission) { + foreach ((array) $submission['data'] as $entityRows) { + foreach ((array) $entityRows as $row) { + $rowFields = $row['fields'] ?? []; + if (empty($rowFields['id'])) { + continue; + } + foreach ($rowFields as $name => $value) { + if (str_starts_with($name, 'Core_Contribution_Details.') && $value !== NULL && $value !== '') { + $ids[(int) $rowFields['id']] = TRUE; + break; + } + } + } + } + } + + return $ids; +} + +/** + * Calculates the correct Footfall & Contribution Details of a collection source. + */ +function calculateContributionStats(int $collectionSourceId, ?string $materialField): array { + $materialContactIds = []; + if ($materialField) { + $materialActivities = Activity::get(FALSE) + ->addSelect('source_contact_id') + ->addWhere($materialField, '=', $collectionSourceId) + ->execute(); + + foreach ($materialActivities as $activity) { + if (!empty($activity['source_contact_id'])) { + $materialContactIds[(int) $activity['source_contact_id']] = TRUE; + } + } + } + + $monetaryContactIds = []; + $onlineTotal = 0.0; + $cashChequeTotal = 0.0; + $otherTotal = 0.0; + + $contributions = Contribution::get(FALSE) + ->addSelect('contact_id', 'total_amount', 'payment_instrument_id:name') + ->addWhere('Contribution_Details.Source.id', '=', $collectionSourceId) + ->addWhere('contribution_status_id:name', '=', 'Completed') + ->execute(); + + foreach ($contributions as $contribution) { + if (!empty($contribution['contact_id'])) { + $monetaryContactIds[(int) $contribution['contact_id']] = TRUE; + } + + $amount = (float) ($contribution['total_amount'] ?? 0); + $method = $contribution['payment_instrument_id:name'] ?? ''; + + if ($method === 'Credit Card') { + $onlineTotal += $amount; + } + elseif (in_array($method, ['Cash', 'Check'], TRUE)) { + $cashChequeTotal += $amount; + } + else { + $otherTotal += $amount; + } + } + + return [ + 'Core_Contribution_Details.Number_of_unique_contributors' => count($materialContactIds + $monetaryContactIds), + 'Core_Contribution_Details.Number_of_unique_material_contributors' => count($materialContactIds), + 'Core_Contribution_Details.Number_of_unique_monetary_contributors' => count($monetaryContactIds), + 'Core_Contribution_Details.Total_online_monetary_contributions' => $onlineTotal, + 'Core_Contribution_Details.Total_cash_cheque_monetary_contributions' => $cashChequeTotal, + 'Core_Contribution_Details.Total_Other_Monetary_Contributions' => $otherTotal, + ]; +} + +$apply = getenv('APPLY') === '1'; +$onlyIds = array_filter(array_map('intval', explode(',', (string) getenv('IDS')))); + +echo ($apply ? 'APPLY' : 'DRY RUN') . ': Footfall & Contribution Details' . ($onlyIds ? ' for ' . implode(', ', $onlyIds) : ' for all collection sources') . "\n"; + +$handEnteredIds = getHandEnteredCollectionSourceIds($handEntryForms); +echo 'Collection sources with hand-entered values (never changed): ' . count($handEnteredIds) . "\n"; + +$report = fopen(REPORT_PATH, 'w'); +$header = ['id', 'title', 'subtype', 'saved']; +foreach ($fields as $field) { + $shortName = str_replace('Core_Contribution_Details.', '', $field); + $header[] = "$shortName (stored)"; + $header[] = "$shortName (correct)"; +} +fputcsv($report, $header); + +$stats = ['checked' => 0, 'different' => 0, 'saved' => 0, 'skipped_hand_entered' => 0, 'errors' => 0]; +$lastId = 0; + +while (TRUE) { + $query = EckEntity::get('Collection_Camp', FALSE) + ->addSelect('id', 'title', 'subtype:name', ...$fields) + ->addWhere('id', '>', $lastId) + ->addOrderBy('id') + ->setLimit(BATCH_SIZE); + + if ($onlyIds) { + $query->addWhere('id', 'IN', $onlyIds); + } + + $collectionSources = $query->execute(); + if (!count($collectionSources)) { + break; + } + + foreach ($collectionSources as $collectionSource) { + $lastId = $collectionSource['id']; + $stats['checked']++; + + try { + $correct = calculateContributionStats($collectionSource['id'], $materialFieldBySubtype[$collectionSource['subtype:name']] ?? NULL); + + $isDifferent = FALSE; + foreach ($fields as $field) { + // Blank and 0 both show as empty on the View tab, so only a real + // difference in value counts. + if (abs((float) $collectionSource[$field] - (float) $correct[$field]) > 0.001) { + $isDifferent = TRUE; + break; + } + } + + if (!$isDifferent) { + continue; + } + + $stats['different']++; + + if (isset($handEnteredIds[$collectionSource['id']])) { + $stats['skipped_hand_entered']++; + $saved = 'skipped: hand-entered'; + } + elseif ($apply) { + EckEntity::update('Collection_Camp', FALSE) + ->addWhere('id', '=', $collectionSource['id']) + ->setValues($correct) + ->execute(); + $stats['saved']++; + $saved = 'yes'; + } + else { + $saved = 'no'; + } + + $row = [$collectionSource['id'], $collectionSource['title'], $collectionSource['subtype:name'], $saved]; + foreach ($fields as $field) { + $row[] = $collectionSource[$field]; + $row[] = $correct[$field]; + } + fputcsv($report, $row); + } + catch (\Exception $e) { + $stats['errors']++; + echo "Error on collection source {$collectionSource['id']}: {$e->getMessage()}\n"; + } + } +} + +fclose($report); + +echo "Checked: {$stats['checked']}\n"; +echo "Different from the correct values: {$stats['different']}\n"; +echo "Saved: {$stats['saved']}\n"; +echo "Skipped, hand-entered: {$stats['skipped_hand_entered']}\n"; +echo "Errors: {$stats['errors']}\n"; +echo "Report: " . REPORT_PATH . "\n";