Skip to content

Fix balance corruption when IPN completes a partially paid contribution - #302

Open
magnolia61 wants to merge 1 commit into
eileenmcnaughton:masterfrom
magnolia61:fix/partial-payment-ipn-balance
Open

Fix balance corruption when IPN completes a partially paid contribution#302
magnolia61 wants to merge 1 commit into
eileenmcnaughton:masterfrom
magnolia61:fix/partial-payment-ipn-balance

Conversation

@magnolia61

Copy link
Copy Markdown
Contributor

Problem

When the IPN handler's handlePaymentNotification() receives a successful response for a contribution that isn't yet Completed, it unconditionally calls completetransaction, which assumes this IPN represents the first and only payment against the contribution. That's correct for the common case (fresh registration, paid in full, first attempt). It's wrong whenever the contribution already carries an outstanding balance smaller than its total — e.g.:

  • an earlier partial payment was already recorded (status Partially paid), and this IPN is for the remaining balance;
  • the contribution was created pay-later (status Pending) and this IPN settles it later.

In both cases completetransaction records the transaction as being for the full original contribution amount, not the amount the processor actually reports for this transaction. The contribution ends up Completed, but the payment ledger shows more money received than was actually charged — the balance is corrupted.

This is a confirmed, still-open core issue: dev/financial#174 — "Partial payment records amount of full contribution disrupting balance", reported 2021, reproduced with exactly this scenario (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 the €100 actually paid). Checked current master: the blind completetransaction call is still present, unchanged, 5+ years later.

This affects any site where a contribution can receive more than one payment through this extension — event registrations with instalments, membership balance top-ups, pledges, or any pay-later flow settled online later.

Fix

Before completing the transaction, check the contribution's current status. If it is Partially paid or Pending with an outstanding balance, route through Payment::create with the amount the processor actually reports ($response->getAmount()), capped defensively at the outstanding balance. Payment::create records only that amount, adjusts the balance correctly, and lets core flip the contribution to Completed once the balance reaches zero.

For the ordinary case (no prior payment), behaviour is unchanged — completetransaction still runs exactly as before. Risk of regression for the vast majority of installs (single, full, first-time payments) should be minimal.

Also bundles a small, unrelated defensive fix: CRM_Utils_System::civiExit() after $response->redirect() in doPayment(), so execution doesn't continue past a redirect that has already sent output. Scoped only to the doPayment() redirect path; the separate doPreApproval() redirect flow is untouched.

Test plan

Verified against a live CiviCRM 6.15 instance via Payment::create directly (the same call this PR routes the IPN handler to):

  • Pending contribution, single payment for less than the total → Partially paid, balance reduced by exactly the paid amount (not the full total).
  • Pending contribution, two payments summing to the total → Completed, balance 0, no over-recording at any point.

These match the reproduction case described in dev/financial#174.

Related reports

  • dev/financial#174 (open) — the exact bug this fixes; I reported this in 2021 against the core dashboard Pay Now flow, still unresolved. This PR fixes the same failure mode in this extension's IPN handler specifically.
  • No email when partially paid contribution is fulfilled #58 "No email when partially paid contribution is fulfilled" (open, 2018) — related but different: a missing confirmation email on completion, not the amount-recording bug this PR addresses.
  • Fix SagePay to correctly confirm the transaction. #182 (merged) — touches the same doPayment()/redirect area but a different concern (trxn-reference persistence for transparent-redirect processors); no overlap.

🤖 Generated with Claude Code

…al payment

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.
@magnolia61

Copy link
Copy Markdown
Contributor Author

Opened #303 with the reproduction scenario for anyone who wants context outside the diff.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant