From d0cb06918b40d6099616d0062bc8258f3739ecbd Mon Sep 17 00:00:00 2001 From: Richard van Oosterhout Date: Wed, 1 Jul 2026 20:28:04 +0200 Subject: [PATCH] Fix balance corruption when IPN completes a contribution with a partial payment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit completeTransaction() assumes the IPN represents the full, first payment against a contribution: it always marks it Completed and effectively treats the full original contribution amount as paid. That's wrong when the contribution already carries an outstanding balance smaller than the total — e.g. an earlier partial payment was recorded (status Partially paid), or the contribution was created pay-later (Pending) and is now being settled. In both cases the transaction ends up recorded for the full original amount rather than the amount the processor actually reports for this transaction, corrupting the payment ledger even though the contribution status itself ends up correct. This is confirmed by civicrm-core dev/financial#174 ("Partial payment records amount of full contribution disrupting balance"), reported in 2021 and still open: contribution 155, initial payment 55, remaining 100 paid via the user-dashboard Pay Now button -> contribution correctly shows Completed, but the payment transaction is recorded as the full 155 instead of 100. Fix: when the contribution is Partially paid or Pending at IPN time, route through Payment::create with the amount the processor actually reports (capped defensively at the outstanding balance) instead of completetransaction. Payment::create records only that amount and lets core flip the contribution to Completed once the balance reaches zero. The ordinary case (fresh, first, full payment) is unaffected -- it still goes through completetransaction exactly as before. Also adds CRM_Utils_System::civiExit() after $response->redirect() in doPayment(), so execution doesn't continue past a redirect that has already sent output. --- CRM/Core/Payment/OmnipayMultiProcessor.php | 44 +++++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/CRM/Core/Payment/OmnipayMultiProcessor.php b/CRM/Core/Payment/OmnipayMultiProcessor.php index 8b806b39..4327e09b 100644 --- a/CRM/Core/Payment/OmnipayMultiProcessor.php +++ b/CRM/Core/Payment/OmnipayMultiProcessor.php @@ -259,6 +259,7 @@ public function doPayment(&$params, $component = 'contribute'): array { CRM_Utils_System::redirect($url); } $response->redirect(); + CRM_Utils_System::civiExit(); } else { return $this->handleError('alert', 'failed processor transaction ' . $this->_paymentProcessor['payment_processor_type'], [$response->getCode() => $response->getMessage()]); @@ -870,11 +871,44 @@ public function processPaymentNotification($params) { if ($this->getLock() && $this->contribution['contribution_status_id:name'] !== 'Completed') { $this->gatewayConfirmContribution($response); $trxnReference = $response->getTransactionReference(); - civicrm_api3('contribution', 'completetransaction', [ - 'id' => $this->transaction_id, - 'trxn_id' => $trxnReference, - 'payment_processor_id' => $params['processor_id'], - ]); + $contributionStatus = $this->contribution['contribution_status_id:name'] ?? ''; + if (in_array($contributionStatus, ['Partially paid', 'Pending'], TRUE)) { + // The contribution already carries an outstanding balance (e.g. it received an + // earlier partial payment, or is a pay-later contribution being settled now). + // completetransaction() would record the FULL original contribution amount as + // paid rather than the amount the processor actually reports for this + // transaction, corrupting the balance (see dev/financial#174). Route through + // Payment::create instead, which records only the actual amount and lets core + // recompute the balance/status. + $balanceAmount = (float) (civicrm_api4('Contribution', 'get', [ + 'select' => ['balance_amount'], + 'where' => [['id', '=', $this->transaction_id]], + ])->first()['balance_amount'] ?? 0); + $paymentAmount = (float) $response->getAmount(); + // Guard against processors that don't report an amount here, and never + // register more than the outstanding balance. + if ($paymentAmount <= 0 || $paymentAmount > $balanceAmount) { + $paymentAmount = $balanceAmount; + } + if ($paymentAmount > 0) { + civicrm_api4('Payment', 'create', [ + 'values' => [ + 'contribution_id' => (int) $this->transaction_id, + 'total_amount' => $paymentAmount, + 'trxn_id' => $trxnReference, + 'payment_processor_id' => (int) ($params['processor_id'] ?? 0), + 'trxn_date' => date('Y-m-d H:i:s'), + ], + ]); + } + } + else { + civicrm_api3('contribution', 'completetransaction', [ + 'id' => $this->transaction_id, + 'trxn_id' => $trxnReference, + 'payment_processor_id' => $params['processor_id'], + ]); + } if (!empty($this->contribution['contribution_recur_id']) && $trxnReference) { $this->updatePaymentTokenWithAnyExtraData($trxnReference); }