Skip to content

Conversation

@erawat
Copy link
Member

@erawat erawat commented Jan 27, 2026

Overview

Adds a scheduled job that creates Pending contribution records for each due In Progress recurring contribution that is not linked to a membership. This is the "invoice generation" step — no payment processor calls are made.

Also introduces PHPStan stub files infrastructure for CiviCRM's dynamically-generated Api4 entity classes, reducing the baseline by 29 entries (393 → 364).

Before

  • No mechanism to automatically generate instalment contributions for due recurring contributions. Instalments had to be created manually or by processor-specific logic.
  • PHPStan baseline contained "unknown class" errors for every Api4 entity usage (Civi\Api4\Contribution, etc.) because these classes are generated at runtime.

After

A new scheduled job (InstalmentGenerator.Run) runs periodically and:

  • Queries all In Progress recurring contributions whose next_sched_contribution_date is due (using < nextDay 00:00:00 precision)
  • Filters by payment processor type (default: Stripe, configurable via DEFAULT_PROCESSOR_TYPE constant)
  • Excludes membership-linked recurring contributions (via SQL JOIN, not per-record)
  • Checks idempotency — skips if a contribution already exists for the same recur + date with status Pending/Completed/Failed/Cancelled
  • Creates a Pending contribution via API4 Contribution.create using a template-based approach (copies key fields from most recent Completed contribution)
  • Wraps create + advance in a CRM_Core_Transaction for atomicity
  • Advances next_sched_contribution_date by frequency_unit × frequency_interval
  • Accepts an injectable referenceDate parameter for testability
  • Returns a human-readable summary with counts and error details

PHPStan infrastructure improvements:

  • Added stubs/CiviApi4.stub.php with stub declarations for 7 Api4 entity classes
  • Configured stubFiles in phpstan.neon and CI workflow
  • Added psr/log as dev dependency (matching CiviCRM's ^1.1 constraint)
  • Fixed pre-existing PaymentProcessorCustomer BAO return type annotations
  • Reduced baseline from 393 to 364 errors
  • Updated CLAUDE.md with PHPStan stub and baseline management instructions

Technical Details

New files:

  • Civi/Paymentprocessingcore/Service/InstalmentGenerationService.php — Core service with DEFAULT_PROCESSOR_TYPE constant and methods: generateInstalments(), getDueRecurringContributions(), instalmentExists(), createInstalment(), advanceScheduleDate()
  • api/v3/InstalmentGenerator/Run.php — API3 endpoint with processor_type and batch_size parameters
  • managed/Job_InstalmentGenerator.mgd.php — Managed scheduled job definition
  • tests/phpunit/.../InstalmentGenerationServiceTest.php — Tests covering all public methods
  • stubs/CiviApi4.stub.php — PHPStan stubs for dynamically-generated Api4 entity classes

Modified files:

  • Civi/Paymentprocessingcore/Hook/Container/ServiceContainer.php — Registers paymentprocessingcore.instalment_generation service
  • CRM/Paymentprocessingcore/BAO/PaymentProcessorCustomer.php — Fixed return type annotations with @var for PHPStan
  • phpstan.neon — Added stubFiles and reportUnmatchedIgnoredErrors: false
  • .github/workflows/phpstan.yml — Added stubFiles and reportUnmatchedIgnoredErrors to CI config
  • phpstan-baseline.neon — Regenerated (393 → 364 entries)
  • composer.json / composer.lock — Added psr/log ^1.1 to require-dev
  • CLAUDE.md — Added PHPStan stub files and baseline management documentation

Design decisions:

  • API4 Contribution.create with template-based field copying instead of repeattransaction API3 — avoids side effects (receipt emails, unpredictable hook triggers, tax bugs)
  • CRM_Core_Transaction wraps instalment creation + schedule advance for atomicity
  • Fail-fast validation on malformed recurring contribution records instead of defensive defaults
  • Date precision: < nextDay 00:00:00 instead of <= 23:59:59 to avoid edge cases
  • PHPStan stubs without extends AbstractEntity — CI and local resolve different classes from scan directories, and extending causes "unknown parent class" errors in CI
  • reportUnmatchedIgnoredErrors: false — necessary because local/CI scan different vendor paths, so some baseline patterns only match in one environment

Core overrides

None.

Comments

  • The job defaults to processor_type=Stripe and batch_size=500 but both are configurable via the scheduled job parameters
  • No payment processor communication occurs — this job only creates "invoice" records
  • PHPStan stub pattern is documented in CLAUDE.md for future Api4 entity additions

@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 significant automation feature to CiviCRM by implementing a scheduled job that generates instalment contributions for recurring donations. This job streamlines the process of creating "invoice" records for non-membership recurring contributions that are due, ensuring timely record creation without manual intervention or reliance on processor-specific logic. It focuses solely on record generation, explicitly avoiding any payment processor communication, and includes robust checks for idempotency and membership exclusion.

Highlights

  • Automated Instalment Generation: Introduced a new scheduled job (InstalmentGenerator.Run) to automatically create 'Pending' contribution records for due 'In Progress' recurring contributions.
  • Membership Exclusion: Recurring contributions linked to memberships are explicitly excluded from this automated generation process.
  • Idempotency Ensured: The system checks for existing 'Pending', 'Completed', 'Failed', or 'Cancelled' contributions for the same recurring contribution and date to prevent duplicate instalment creation.
  • API3 Integration: Utilizes the Contribution.repeattransaction API3 for creating new instalments, ensuring that line items, custom fields, and soft credits are copied from the template contribution.
  • Configurable Parameters: The scheduled job and its underlying API endpoint (InstalmentGenerator.Run) allow configuration of processor_type (defaulting to "Stripe") and batch_size.
  • UI Enhancement: The 'Memberships' tab is now hidden on contribution page administration settings, as the platform does not support membership integration on these pages.
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.

@erawat erawat changed the title CIVIMM-462: Add instalment generation scheduled job for recurring contributions [WIP] CIVIMM-462: Add instalment generation scheduled job for recurring contributions Jan 27, 2026
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 scheduled job to generate instalments for recurring contributions, which is a valuable addition. The implementation is well-structured, featuring a dedicated service, an API endpoint, and a managed job definition. The inclusion of comprehensive unit tests is particularly commendable.

I have identified two high-severity issues. The first is a logic bug in how due contributions are queried, which could lead to instalments being processed a day late. The second concerns overly defensive code that can mask data problems and lead to incorrect behavior when checking for existing instalments. I've provided specific suggestions to address these points. With these changes, this will be a very solid feature.

Comment on lines 55 to 72
if (!is_array($recur)) {
continue;
}
$recurId = is_numeric($recur['id']) ? (int) $recur['id'] : 0;
$receiveDate = is_string($recur['next_sched_contribution_date']) ? $recur['next_sched_contribution_date'] : '';
$frequencyUnit = is_string($recur['frequency_unit:name']) ? $recur['frequency_unit:name'] : 'month';
$frequencyInterval = is_numeric($recur['frequency_interval']) ? (int) $recur['frequency_interval'] : 1;

Choose a reason for hiding this comment

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

high

The variable extraction in this block is overly defensive and can mask underlying data issues. Specifically, if next_sched_contribution_date is null, $receiveDate defaults to an empty string. This causes instalmentExists() to check for contributions on 1970-01-01 because strtotime('') evaluates to false. This is incorrect and could lead to unexpected behavior, such as skipping the creation of a valid instalment.

It's safer to fail fast if critical data is missing. The existing try/catch block will handle these exceptions gracefully by logging the error and moving to the next record. This change makes the code more robust and easier to debug.

if (!is_array($recur) || empty($recur['id']) || empty($recur['next_sched_contribution_date'])) {
  throw new \InvalidArgumentException('Malformed recurring contribution record from query.');
}
$recurId = (int) $recur['id'];
$receiveDate = $recur['next_sched_contribution_date'];
$frequencyUnit = $recur['frequency_unit:name'] ?? 'month';
$frequencyInterval = (int) ($recur['frequency_interval'] ?? 1);

Copy link
Member Author

Choose a reason for hiding this comment

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

Good catch — the empty-string fallback for next_sched_contribution_date would silently check against 1970-01-01 via strtotime(''). Applied the fail-fast approach as suggested. The outer try/catch handles it gracefully.

['id', '=', 'm.contribution_recur_id']
)
->addWhere('contribution_status_id:name', '=', 'In Progress')
->addWhere('next_sched_contribution_date', '<=', date('Y-m-d'))

Choose a reason for hiding this comment

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

high

The query for due contributions uses date('Y-m-d'), which effectively compares next_sched_contribution_date against the start of the day (e.g., YYYY-MM-DD 00:00:00). Since next_sched_contribution_date is a DATETIME field, any contributions scheduled for later in the current day will be excluded by this query and only processed on the following day. This could cause a delay in generating instalments.

To ensure all due contributions are processed in a timely manner, it's better to compare against the current time. APIv4 supports relative date formats, so using 'now' is a clean and effective way to achieve this.

      ->addWhere('next_sched_contribution_date', '<=', 'now')

Copy link
Member Author

Choose a reason for hiding this comment

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

This has already been addressed — the query now uses < nextDay 00:00:00 instead of <= date('Y-m-d'), which includes the entire current day.

@erawat erawat force-pushed the CIVIMM-462-instalment-generation-job branch 3 times, most recently from 1f6f405 to 52922aa Compare January 28, 2026 08:15
@erawat erawat force-pushed the CIVIMM-462-instalment-generation-job branch 4 times, most recently from 04461d8 to e4000ea Compare January 28, 2026 09:01
@erawat erawat force-pushed the CIVIMM-462-instalment-generation-job branch from e4000ea to 4fdc210 Compare January 28, 2026 09:09
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