Skip to content

MAE-1384: Record the payment processor when completing a contribution - #32

Merged
erawat merged 1 commit into
masterfrom
MAE-1384-payment-processor-id-on-completion
Sep 30, 2026
Merged

erawat merged 1 commit into
masterfrom
MAE-1384-payment-processor-id-on-completion

Conversation

@erawat

@erawat erawat commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Overview

Staff could not refund a Stripe Checkout payment from CiviCRM — the Refund action was never offered on the contribution, so there was no way to start a refund at all.

Finance Extras decides whether to offer a refund by reading the payment processor off the payment. Payments completed by this extension were recorded without one, so every one of them looked unrefundable. This PR records the processor when the payment is completed, and repairs the payments already recorded without it.

Jira: https://compucorp.atlassian.net/browse/MAE-1384 (P0)
Test write-up: https://compucorp.atlassian.net/wiki/spaces/CN/pages/6574833665

Before

No Refund action on a Completed Stripe Checkout payment — the menu goes straight from "Duplicate As New Pending Contribution" to "Add Credit Note".

00-before-no-refund-action

After

"Submit Credit Card Refund" is offered on the same payment.

01-refund-action-offered

And it opens the refund form against that payment, with the Stripe charge and the amount available to refund.

02-refund-form

Payments taken before this fix are repaired by the upgrade step, so they become refundable too — same contribution as the "Before" shot, after running it.

03-after-backfill-refund-action

Technical Details

Why the processor went missing

Contribution.completetransaction already accepts payment_processor_id and passes it all the way through (completeOrder() sets $contributionParams['payment_processor'], which recordFinancialAccounts() writes to the financial transaction, and unsets when it is empty). ContributionCompletionService::completeTransaction() simply never sent it, so core dropped the column.

The fix

ContributionCompletionService::complete() takes an optional $paymentProcessorId. When a caller does not pass one it is resolved from:

  1. the contribution's payment attempt — every processor using this service records one, and it carries payment_processor_id;
  2. failing that, the contribution's recurring contribution.

Resolution never blocks completion: if it cannot find a processor the contribution still completes, just without one recorded, which is what back office payments do today.

This means no caller had to change — the Stripe extension's five call sites all run behind a payment attempt — and GoCardless gets the same fix for free.

Backfill

upgrade_1001 runs CRM_Paymentprocessingcore_Upgrader_PaymentProcessorBackfill, which takes the processor from the payment attempt alongside each payment. It only touches payment transactions (is_payment = 1) that have no processor, so it is safe to run again — a second run reports 0.

It sits under CRM/Paymentprocessingcore/Upgrader/ rather than in Civi/.../Service/: it runs once, from one caller, so it is migration code rather than something the extension uses at runtime. Keeping it in its own class under the upgrader leaves upgrade_1001() thin and lets the repair be tested on its own.

A note on the payment attempt table

contribution_id is unique on civicrm_payment_attempt, so a contribution only ever has one attempt. The backfill joins straight to it, and the constant carries a comment saying the join depends on that index — if it is ever dropped, this needs a deterministic way to choose between a contribution's attempts.

Core overrides

None.

Comments

Side effect worth a look. Once a processor is passed, core also sets the contribution's payment_instrument_id from that processor when it is empty (CRM_Contribute_BAO_Contribution::completeOrder()). For Stripe that is Credit Card either way, but it now applies to every processor using this service. testContributionTakesItsPaymentInstrumentFromTheProcessor pins the behaviour so a future change in core is noticed rather than silently absorbed.

Verified on a real site (compuclient dev, CiviCRM 6.4.1, Finance Extras installed):

Check Result
New Checkout payment processor recorded on the payment, isEligibleForRefund() true
upgrade_1001 125 payment transactions repaired, 0 left
Contributions 1001 / 881 / 877 isEligibleForRefund() false → true
Second run of the backfill 0, nothing changed

Tests: 338 pass, 23 of them new (14 on the completion service, 7 on the backfill, 2 on the upgrade step wiring). phpcs clean. PHPStan level 9 adds no new errors.

The diff adds EntityFinancialTrxn to stubs/CiviApi4.stub.php. The entity is real — its class lives in the civi_contribute core extension — but phpstan.neon only scans civicrm/Civi, civicrm/CRM and civicrm/api, so nothing under ext/ is visible to it. That is the same reason Contribution, ContributionRecur and PaymentToken are already stubbed there.

Companion PR with the end-to-end test that produced the screenshots above: https://github.com/compucorp/uk.co.compucorp.stripe/pull/300

Release timing. This extension releases on its own cycle, so that is what gates when clients actually get the fix — worth agreeing given the ticket is P0.

@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a critical issue where Stripe Checkout payments completed through this extension were not refundable from CiviCRM due to the absence of a recorded payment processor ID. The core problem stemmed from the ContributionCompletionService not passing this crucial ID during transaction completion. The solution involves modifying the completion service to correctly record the payment processor ID, either by explicit provision or by resolving it from associated payment attempts or recurring contributions. Additionally, a robust backfill mechanism is implemented via an upgrade step to repair historical payment records, ensuring that all past and future payments are correctly linked to their processors, thereby enabling proper refund functionality.

Highlights

  • Payment Processor Recording: The ContributionCompletionService now correctly records the payment_processor_id when completing a contribution, ensuring that payment transactions are associated with their respective processors.
  • Automatic Processor Resolution: If a payment processor ID is not explicitly provided during completion, the service attempts to resolve it from the contribution's payment attempts or its recurring contribution, maintaining backward compatibility and robustness.
  • Historical Data Backfill: An upgrade step (upgrade_1001) has been introduced to backfill existing payment transactions that were previously recorded without a payment_processor_id, making them refundable via extensions like Finance Extras.
  • Enhanced Refund Functionality: This change enables extensions like Finance Extras to properly offer refund actions for Stripe Checkout payments by ensuring the necessary payment processor information is present on completed contributions.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Generative AI Prohibited Use Policy, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a mechanism to resolve and record the payment processor ID on financial transactions when completing contributions, including a new PaymentProcessorBackfillService and an upgrade step to retroactively backfill missing processor IDs on historical transactions. Feedback on these changes highlights three key areas for improvement: adding an is_array() guard on the APIv4 first() result to prevent PHP warnings when no payment attempt is found, simplifying the backfill SQL query to avoid an expensive subquery and GROUP BY anti-pattern, and lowering the log level from warning to info when a processor cannot be determined to prevent log spam during normal back-office payment flows.

Comment thread Civi/Paymentprocessingcore/Service/ContributionCompletionService.php Outdated
Comment thread Civi/Paymentprocessingcore/Service/PaymentProcessorBackfillService.php Outdated
Comment thread Civi/Paymentprocessingcore/Service/ContributionCompletionService.php Outdated
@erawat
erawat force-pushed the MAE-1384-payment-processor-id-on-completion branch from f305904 to d424194 Compare September 25, 2026 09:07
Payments completed through this extension were recorded without the payment processor that
took them, because ContributionCompletionService did not pass payment_processor_id to
Contribution.completetransaction. Core then left the column empty on the financial transaction
it created, and Finance Extras, which reads the processor back off that transaction to decide
whether a refund is possible, offered no refund action at all. Staff could not refund a Stripe
Checkout payment from CiviCRM.

Callers can now pass the processor. When they do not, it is resolved from the contribution's
payment attempt, and failing that from its recurring contribution, so no caller has to change
and every processor using this service is fixed.

Upgrade step 1001 repairs the payments already recorded without a processor, taking it from the
payment attempt alongside each one. It only touches payment transactions that have no processor,
so it is safe to run again.
@erawat
erawat force-pushed the MAE-1384-payment-processor-id-on-completion branch from d424194 to 61dace0 Compare September 25, 2026 09:24
@erawat
erawat merged commit dfab176 into master Sep 30, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants