Skip to content

CIVIMM-474: Add payment attempt reconciliation with core post-reconciliation pipeline#15

Merged
erawat merged 1 commit intomasterfrom
CIVIMM-474-payment-attempt-reconcile
Feb 17, 2026
Merged

CIVIMM-474: Add payment attempt reconciliation with core post-reconciliation pipeline#15
erawat merged 1 commit intomasterfrom
CIVIMM-474-payment-attempt-reconcile

Conversation

@erawat
Copy link
Member

@erawat erawat commented Feb 16, 2026

Overview

Adds a scheduled job to reconcile stuck payment attempts, now with a core post-reconciliation pipeline that handles contribution completion, failure counting, and status transitions. Handlers only need to check their processor API and report status — core handles everything else.

Before

  • No mechanism exists to recover PaymentAttempt records stuck in processing status
  • If a webhook is missed or delayed, the attempt remains stuck indefinitely
  • Each handler (Stripe, GoCardless, future processors) would need to reimplement contribution completion, failure counting, and marking contributions as Failed

After

A daily scheduled job (PaymentAttemptReconcile.Run) finds stuck attempts, dispatches events for each processor type, collects results, and runs a standard post-reconciliation pipeline:

Handler result Core action
CompletedReconcileResult (subclass) Update PaymentAttempt → completed. Complete contribution via ContributionCompletionService
ReconcileAttemptResult('completed') (base) Update PaymentAttempt → completed only. Handler completed contribution itself (opt-out)
failed Update PaymentAttempt → failed. Increment failure_count. If threshold exceeded AND NOT pay-later → mark contribution Failed
cancelled Update PaymentAttempt → cancelled. If Pending AND NOT pay-later → mark contribution Failed
unchanged No action
No result (unhandled) No action

Default parameters:

  • processor_parameters=[Stripe,2],[GoCardless,6] — processor type and stuck threshold in days
  • batch_size=100 — max attempts to process per run (shared across all processors)
  • max_retry_count=3 — max failures before marking recurring contribution as Failed

Technical Details

Subclass polymorphism

CompletedReconcileResult extends ReconcileAttemptResult — overrides getCompletionData() to return a CompletionData VO (transactionId + optional feeAmount). The base class returns NULL.

Core calls the polymorphic method:

$completionData = $result->getCompletionData();
if ($completionData !== NULL) {
  $completionService->complete($contributionId, $completionData->transactionId, $completionData->feeAmount);
}

Handler intent via type choice:

  • new CompletedReconcileResult($action, $transactionId, $feeAmount) → core completes contribution
  • new ReconcileAttemptResult('completed', $action) → handler completed it itself (opt-out)

Batch pre-fetching

After collecting results from handlers, the service batch-fetches all required Contribution and ContributionRecur records in 2 queries (not N+1) before processing results.

maxRetryCount on the event

maxRetryCount is passed to the event even though core handles all failure threshold logic. This is intentional for:

  1. Consistency with ChargeInstalmentBatchEvent which follows the same pattern
  2. Future flexibility — a handler may want to adjust its behaviour based on retry limits (e.g., different API retry strategies)
  3. Observability — handlers can log/inspect the threshold for debugging

Two integration patterns

Pattern A — PaymentAttempt-based (Stripe, Deluxe, future processors):
Handler checks API, maps status, returns result via setAttemptResult(). Core handles everything else. Handler does NOT need to complete contributions, increment failure counts, or mark contributions Failed.

Pattern B — Trigger-only (GoCardless):
Uses event as trigger + config source only. Never calls setAttemptResult(). Manages its own payment_in_progress flow internally.

How to implement a new processor handler

  1. Subscribe to ReconcilePaymentAttemptBatchEvent::NAME
  2. Check your API for each attempt in $event->getAttempts()
  3. Return results:
    • new CompletedReconcileResult($action, $transactionId, $feeAmount) — core completes contribution
    • new ReconcileAttemptResult('failed', $action) — core handles failure pipeline
    • new ReconcileAttemptResult('cancelled', $action) — core handles cancellation
    • new ReconcileAttemptResult('unchanged', $action) — no action
  4. Call $event->setAttemptResult($attemptId, $result)
  5. Done — zero changes needed in core

Comments

  • This is the core extension part only. Stripe and GoCardless handler PRs are separate.
  • The event is dispatched even with 0 stuck PaymentAttempt records — so GoCardless (Pattern B) still gets triggered.
  • Tests use the built-in CiviCRM Dummy processor type — no external API dependencies.
  • New tests cover: completed with/without completion data, failed with threshold, cancelled, pay-later exceptions.

@gemini-code-assist
Copy link

Summary of Changes

Hello @erawat, 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 introduces a robust and extensible system to automatically reconcile payment attempts that become stuck in a 'processing' status. By establishing a scheduled job and an event-driven service, the system can now proactively detect and resolve stalled transactions, significantly enhancing payment processing reliability. The architecture allows individual payment processor extensions to integrate their unique reconciliation logic, ensuring adaptability and ease of maintenance.

Highlights

  • New Scheduled Job for Reconciliation: Introduced a new daily scheduled job (PaymentAttemptReconcile.Run) to automatically identify and reconcile PaymentAttempt records that are stuck in a 'processing' state.
  • Extensible Reconciliation Service: Implemented PaymentAttemptReconcileService which orchestrates the reconciliation process by querying stuck attempts, dispatching a new Symfony event (ReconcilePaymentAttemptBatchEvent), and processing results from processor-specific handlers.
  • Standardized Reconciliation Result DTO: Defined ReconcileAttemptResult Data Transfer Object (DTO) to provide a standardized, immutable way for processor-specific handlers to report the outcome of a single payment attempt reconciliation.
  • New API Endpoint: Added a new API endpoint (PaymentAttemptReconcile.Run) that allows for manual triggering and configuration of the reconciliation job, including processor-specific thresholds and batch sizes.
  • Comprehensive Unit Tests: Included extensive unit tests for the new DTO, event, and reconciliation service to ensure the reliability and correctness of the new reconciliation mechanism.
Changelog
  • Civi/Paymentprocessingcore/DTO/ReconcileAttemptResult.php
    • Added ReconcileAttemptResult DTO to encapsulate reconciliation outcomes.
  • Civi/Paymentprocessingcore/Event/ReconcilePaymentAttemptBatchEvent.php
    • Added ReconcilePaymentAttemptBatchEvent for batch reconciliation of payment attempts.
  • Civi/Paymentprocessingcore/Hook/Container/ServiceContainer.php
    • Registered PaymentAttemptReconcileService in the service container.
  • Civi/Paymentprocessingcore/Service/PaymentAttemptReconcileService.php
    • Added PaymentAttemptReconcileService to manage the reconciliation logic.
  • api/v3/PaymentAttemptReconcile/Run.php
    • Added PaymentAttemptReconcile.Run API endpoint for triggering the reconciliation job.
  • managed/Job_PaymentAttemptReconcile.mgd.php
    • Added a managed scheduled job definition for daily payment attempt reconciliation.
  • tests/phpunit/Civi/Paymentprocessingcore/Event/ReconcilePaymentAttemptBatchEventTest.php
    • Added unit tests for ReconcilePaymentAttemptBatchEvent and ReconcileAttemptResult.
  • tests/phpunit/Civi/Paymentprocessingcore/Service/PaymentAttemptReconcileServiceTest.php
    • Added unit tests for PaymentAttemptReconcileService.
Activity
  • No specific human activity (comments, reviews, or progress updates) has been recorded for this pull request yet.
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 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 counter productive. 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.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

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

Copy link

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

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 robust and well-designed scheduled job for reconciling payment attempts that are stuck in a 'processing' state. The implementation is clean, leveraging services, DTOs, and Symfony events to create a flexible and extensible system. The code is accompanied by a comprehensive suite of unit tests, ensuring its reliability. My review found the code to be of high quality. I have one suggestion to simplify the code in the PaymentAttemptReconcileService by making better use of the APIv4 result object, which will improve conciseness and maintainability.

@erawat erawat force-pushed the CIVIMM-474-payment-attempt-reconcile branch 2 times, most recently from 8b57253 to e056c01 Compare February 16, 2026 20:47
@erawat erawat changed the title CIVIMM-474: Add payment attempt reconciliation scheduled job CIVIMM-474: Add payment attempt reconciliation with core post-reconciliation pipeline Feb 16, 2026
@erawat erawat force-pushed the CIVIMM-474-payment-attempt-reconcile branch 4 times, most recently from 50738c3 to fcdd2b0 Compare February 17, 2026 07:46
Add core post-reconciliation pipeline that handles contribution
completion, failure counting, and status transitions for handlers
that report results via setAttemptResult().
@erawat erawat force-pushed the CIVIMM-474-payment-attempt-reconcile branch from fcdd2b0 to 5c2c2a9 Compare February 17, 2026 07:56
@erawat
Copy link
Member Author

erawat commented Feb 17, 2026

/gemini review

Copy link

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

Choose a reason for hiding this comment

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

Code Review

This is an excellent pull request that introduces a robust and extensible reconciliation pipeline for stuck payment attempts. The code is very well-structured, leveraging DTOs, Symfony events, and services to create a clean separation of concerns. The implementation is of high quality, with careful attention to performance (avoiding N+1 queries with batch pre-fetching), error handling, and edge cases like pay-later contributions. The addition of a comprehensive test suite covering the new service, DTOs, and events is commendable and ensures the reliability of this new core feature. I have reviewed the changes thoroughly and could not find any issues of medium or higher severity. The design is solid and the implementation is clean. Great work!

@erawat erawat merged commit d4d4ad0 into master Feb 17, 2026
3 checks passed
@erawat erawat deleted the CIVIMM-474-payment-attempt-reconcile branch February 17, 2026 08:52
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