From 04a9d8ebcede755a48abdd8d549fc2f5f323f58f Mon Sep 17 00:00:00 2001 From: Erawat Chamanont Date: Wed, 14 Jan 2026 21:03:10 +0000 Subject: [PATCH 1/5] CIVIMM-455: Add WebhookHandlerAdapter pattern for duck-typed handlers - Add WebhookHandlerAdapter class to wrap duck-typed handlers - Update WebhookHandlerRegistry to use named adapter instead of anonymous class - Add is_object() check for PHPStan type safety - Update WebhookHandlerInterface docblock to document both implementation options - Update tests with proper return types and PHPDoc annotations - Regenerate PHPStan baseline to remove stale patterns from renamed tests --- .../Service/WebhookHandlerRegistry.php | 41 ++++++--- .../Webhook/WebhookHandlerAdapter.php | 45 ++++++++++ .../Webhook/WebhookHandlerInterface.php | 47 ++++++++++- phpstan-baseline.neon | 15 ---- .../Service/WebhookHandlerRegistryTest.php | 84 ++++++++++++++----- 5 files changed, 181 insertions(+), 51 deletions(-) create mode 100644 Civi/Paymentprocessingcore/Webhook/WebhookHandlerAdapter.php diff --git a/Civi/Paymentprocessingcore/Service/WebhookHandlerRegistry.php b/Civi/Paymentprocessingcore/Service/WebhookHandlerRegistry.php index 8d574c8..618ac2b 100644 --- a/Civi/Paymentprocessingcore/Service/WebhookHandlerRegistry.php +++ b/Civi/Paymentprocessingcore/Service/WebhookHandlerRegistry.php @@ -2,6 +2,7 @@ namespace Civi\Paymentprocessingcore\Service; +use Civi\Paymentprocessingcore\Webhook\WebhookHandlerAdapter; use Civi\Paymentprocessingcore\Webhook\WebhookHandlerInterface; /** @@ -43,14 +44,22 @@ public function registerHandler(string $processorType, string $eventType, string * Get the handler for a processor and event type. * * Retrieves the handler service from the container using the registered - * service ID. The handler must implement WebhookHandlerInterface. + * service ID. Validation follows the Liskov Substitution Principle: + * + * 1. Prefers instanceof WebhookHandlerInterface (proper OOP contract) + * 2. Falls back to duck typing (method_exists) for handlers that cannot + * implement the interface due to extension loading order constraints + * + * Note: Interface check happens at RUNTIME (here), not at class definition. + * This allows proper SOLID compliance while avoiding PHP autoload issues + * that occur when using `implements` in class declarations. * * @param string $processorType The processor type * @param string $eventType The event type * - * @return \Civi\Paymentprocessingcore\Webhook\WebhookHandlerInterface + * @return \Civi\Paymentprocessingcore\Webhook\WebhookHandlerInterface Handler instance * - * @throws \RuntimeException If no handler is registered for the combination + * @throws \RuntimeException If no handler is registered or handler is invalid */ public function getHandler(string $processorType, string $eventType): WebhookHandlerInterface { if (!isset($this->handlers[$processorType][$eventType])) { @@ -73,16 +82,26 @@ public function getHandler(string $processorType, string $eventType): WebhookHan $handler = \Civi::service($serviceId); } - if (!$handler instanceof WebhookHandlerInterface) { - throw new \RuntimeException( - sprintf( - "Handler service '%s' does not implement WebhookHandlerInterface", - $serviceId - ) - ); + // Runtime validation: check interface implementation (Liskov Substitution) + // This check happens at runtime when all extensions are loaded, + // avoiding autoload issues that occur at class definition time. + if ($handler instanceof WebhookHandlerInterface) { + return $handler; + } + + // Fallback: Duck typing for handlers that cannot implement interface + // due to extension loading order constraints. Still validates contract. + if (is_object($handler) && method_exists($handler, 'handle')) { + // Wrap in adapter to satisfy return type (Adapter Pattern) + return new WebhookHandlerAdapter($handler); } - return $handler; + throw new \RuntimeException( + sprintf( + "Handler service '%s' must implement WebhookHandlerInterface or have a handle() method", + $serviceId + ) + ); } /** diff --git a/Civi/Paymentprocessingcore/Webhook/WebhookHandlerAdapter.php b/Civi/Paymentprocessingcore/Webhook/WebhookHandlerAdapter.php new file mode 100644 index 0000000..162c410 --- /dev/null +++ b/Civi/Paymentprocessingcore/Webhook/WebhookHandlerAdapter.php @@ -0,0 +1,45 @@ +handler = $handler; + } + + /** + * {@inheritdoc} + * + * @param array $params Additional parameters. + */ + public function handle(int $webhookId, array $params): string { + /** @var callable(int, array): string $callback */ + $callback = [$this->handler, 'handle']; + return $callback($webhookId, $params); + } + +} \ No newline at end of file diff --git a/Civi/Paymentprocessingcore/Webhook/WebhookHandlerInterface.php b/Civi/Paymentprocessingcore/Webhook/WebhookHandlerInterface.php index d9d4840..bbf2122 100644 --- a/Civi/Paymentprocessingcore/Webhook/WebhookHandlerInterface.php +++ b/Civi/Paymentprocessingcore/Webhook/WebhookHandlerInterface.php @@ -5,10 +5,49 @@ /** * Interface for processor-specific webhook event handlers. * - * All payment processor extensions must implement this interface - * for their webhook event handlers. This enables the generic - * WebhookQueueRunnerService to delegate processing to the - * appropriate processor-specific handler. + * This interface defines the contract for webhook handlers following + * SOLID principles (Interface Segregation, Liskov Substitution). + * + * ## Design Pattern: Adapter with Runtime Validation + * + * The WebhookHandlerRegistry uses a two-tier validation approach: + * + * 1. **Preferred**: Handlers implementing this interface are returned directly + * 2. **Fallback**: Handlers with `handle()` method are wrapped in an Adapter + * + * This allows proper OOP (interface contracts) while supporting handlers that + * cannot use `implements` due to PHP autoload constraints. + * + * ## Why autoload is a problem + * + * When CiviCRM loads extension classes (during hook registration, container + * compilation), PHP autoloads any interfaces in `implements` clauses. + * If PaymentProcessingCore is not yet loaded, this causes fatal errors. + * + * ## Implementation Options + * + * **Option 1 (Preferred): Implement interface** - if your extension loads + * after PaymentProcessingCore: + * ```php + * class MyWebhookHandler implements WebhookHandlerInterface { + * public function handle(int $webhookId, array $params): string { + * return 'applied'; + * } + * } + * ``` + * + * **Option 2 (Fallback): Duck typing** - if autoload issues occur: + * ```php + * class MyWebhookHandler { + * public function handle(int $webhookId, array $params): string { + * return 'applied'; + * } + * } + * ``` + * + * Both approaches satisfy the Liskov Substitution Principle - the registry + * validates the contract at runtime and wraps duck-typed handlers in an + * Adapter that implements this interface. * * @package Civi\Paymentprocessingcore\Webhook */ diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b24d278..522fda8 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -935,21 +935,11 @@ parameters: count: 1 path: tests/phpunit/Civi/Paymentprocessingcore/Service/TestWebhookReceiverService.php - - - message: "#^Method Civi\\\\Paymentprocessingcore\\\\Service\\\\WebhookHandlerRegistryTest\\:\\:testGetHandlerReturnsHandlerInstanceViaServiceContainer\\(\\) has no return type specified\\.$#" - count: 1 - path: tests/phpunit/Civi/Paymentprocessingcore/Service/WebhookHandlerRegistryTest.php - - message: "#^Method Civi\\\\Paymentprocessingcore\\\\Service\\\\WebhookHandlerRegistryTest\\:\\:testGetHandlerThrowsExceptionForUnregisteredHandler\\(\\) has no return type specified\\.$#" count: 1 path: tests/phpunit/Civi/Paymentprocessingcore/Service/WebhookHandlerRegistryTest.php - - - message: "#^Method Civi\\\\Paymentprocessingcore\\\\Service\\\\WebhookHandlerRegistryTest\\:\\:testGetHandlerThrowsExceptionIfServiceDoesNotImplementInterface\\(\\) has no return type specified\\.$#" - count: 1 - path: tests/phpunit/Civi/Paymentprocessingcore/Service/WebhookHandlerRegistryTest.php - - message: "#^Method Civi\\\\Paymentprocessingcore\\\\Service\\\\WebhookHandlerRegistryTest\\:\\:testGetRegisteredEventTypesReturnsEmptyArrayForUnknownProcessor\\(\\) has no return type specified\\.$#" count: 1 @@ -990,11 +980,6 @@ parameters: count: 1 path: tests/phpunit/Civi/Paymentprocessingcore/Service/WebhookHandlerRegistryTest.php - - - message: "#^Method class@anonymous/tests/phpunit/Civi/Paymentprocessingcore/Service/WebhookHandlerRegistryTest\\.php\\:82\\:\\:handle\\(\\) has parameter \\$params with no value type specified in iterable type array\\.$#" - count: 1 - path: tests/phpunit/Civi/Paymentprocessingcore/Service/WebhookHandlerRegistryTest.php - - message: "#^Method Civi\\\\Paymentprocessingcore\\\\Service\\\\WebhookQueueServiceTest\\:\\:testAddTaskAddsMultipleTasksToQueue\\(\\) has no return type specified\\.$#" count: 1 diff --git a/tests/phpunit/Civi/Paymentprocessingcore/Service/WebhookHandlerRegistryTest.php b/tests/phpunit/Civi/Paymentprocessingcore/Service/WebhookHandlerRegistryTest.php index d2e5099..027842a 100644 --- a/tests/phpunit/Civi/Paymentprocessingcore/Service/WebhookHandlerRegistryTest.php +++ b/tests/phpunit/Civi/Paymentprocessingcore/Service/WebhookHandlerRegistryTest.php @@ -75,50 +75,92 @@ public function testGetHandlerThrowsExceptionForUnregisteredHandler() { } /** - * Test getHandler() returns handler instance via service container. + * Test getHandler() returns handler implementing interface directly. + * + * Handlers that implement WebhookHandlerInterface are returned as-is + * (preferred approach, Liskov Substitution Principle). */ - public function testGetHandlerReturnsHandlerInstanceViaServiceContainer() { - // Create a mock handler + public function testGetHandlerReturnsInterfaceImplementorDirectly(): void { + // Create handler that implements the interface $mockHandler = new class implements WebhookHandlerInterface { + /** + * {@inheritdoc} + * + * @param array $params Parameters. + */ public function handle(int $webhookId, array $params): string { return 'applied'; } }; - // Register it in Civi::$statics (simulates service container) - \Civi::$statics['test.mock_handler'] = $mockHandler; - - // Register handler in registry - $this->registry->registerHandler('test', 'payment.succeeded', 'test.mock_handler'); + \Civi::$statics['test.interface_handler'] = $mockHandler; + $this->registry->registerHandler('test', 'payment.succeeded', 'test.interface_handler'); - // Get handler $handler = $this->registry->getHandler('test', 'payment.succeeded'); + // Should return the exact same instance (no adapter needed) $this->assertInstanceOf(WebhookHandlerInterface::class, $handler); $this->assertSame($mockHandler, $handler); - // Cleanup - unset(\Civi::$statics['test.mock_handler']); + unset(\Civi::$statics['test.interface_handler']); } /** - * Test getHandler() throws exception if service doesn't implement interface. + * Test getHandler() wraps duck-typed handler in adapter. + * + * Handlers with handle() method but no interface implementation are + * wrapped in an adapter (Adapter Pattern) to satisfy the return type. + * This supports handlers that cannot use `implements` due to autoload. */ - public function testGetHandlerThrowsExceptionIfServiceDoesNotImplementInterface() { - // Register a non-handler service - \Civi::$statics['test.not_a_handler'] = new \stdClass(); + public function testGetHandlerWrapsDuckTypedHandlerInAdapter(): void { + // Create handler with handle() method but no interface (duck typing) + $mockHandler = new class { + + /** + * Handle webhook event. + * + * @param int $webhookId Webhook ID. + * @param array $params Parameters. + * + * @return string Result code. + */ + public function handle(int $webhookId, array $params): string { + return 'duck_typed'; + } + + }; - $this->registry->registerHandler('test', 'payment.succeeded', 'test.not_a_handler'); + \Civi::$statics['test.duck_handler'] = $mockHandler; + $this->registry->registerHandler('test', 'payment.succeeded', 'test.duck_handler'); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage("Handler service 'test.not_a_handler' does not implement WebhookHandlerInterface"); + $handler = $this->registry->getHandler('test', 'payment.succeeded'); + + // Should be wrapped in adapter implementing interface + $this->assertInstanceOf(WebhookHandlerInterface::class, $handler); + // Adapter should delegate to original handler + $this->assertEquals('duck_typed', $handler->handle(1, [])); + + unset(\Civi::$statics['test.duck_handler']); + } - $this->registry->getHandler('test', 'payment.succeeded'); + /** + * Test getHandler() throws exception if service has no handle() method. + */ + public function testGetHandlerThrowsExceptionIfServiceHasNoHandleMethod(): void { + \Civi::$statics['test.invalid'] = new \stdClass(); + $this->registry->registerHandler('test', 'payment.succeeded', 'test.invalid'); - // Cleanup - unset(\Civi::$statics['test.not_a_handler']); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage("must implement WebhookHandlerInterface or have a handle() method"); + + try { + $this->registry->getHandler('test', 'payment.succeeded'); + } + finally { + unset(\Civi::$statics['test.invalid']); + } } /** From c30a290c0b8269e6b75fddc15d6803bde42d47f4 Mon Sep 17 00:00:00 2001 From: Erawat Chamanont Date: Wed, 14 Jan 2026 21:03:24 +0000 Subject: [PATCH 2/5] CIVIMM-455: Update CI workflow to use new Docker image - Update Docker image from compucorp/civicrm-buildkit:1.3.1-php8.0 to compucorp/php-fpm.civicrm:8.1 - Add --user root container option - Remove workarounds (composer downgrade, php-bcmath install) - Update civibuild path to /opt/buildkit/bin/civibuild - Aligns with io.compuco.svixclient extension workflow --- .github/workflows/unit-test.yml | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 48f1dc4..58f0ac1 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -6,7 +6,9 @@ jobs: run-unit-tests: runs-on: ubuntu-latest - container: compucorp/civicrm-buildkit:1.3.1-php8.0 + container: + image: compucorp/php-fpm.civicrm:8.1 + options: --user root env: CIVICRM_EXTENSIONS_DIR: site/web/sites/all/modules/civicrm/tools/extensions @@ -18,24 +20,18 @@ jobs: env: MYSQL_ROOT_PASSWORD: root ports: - - 3306 + - 3306 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 steps: - name: Config mysql database as per CiviCRM requirement run: echo "SET GLOBAL sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''));" | mysql -u root --password=root --host=mysql - - name: Composer version downgrade 2.2.5 - run: composer self-update 2.2.5 - - - name: Install missing extension - run: sudo apt update && apt install -y php-bcmath - - name: Config amp - run : amp config:set --mysql_dsn=mysql://root:root@mysql:3306 + run: amp config:set --mysql_dsn=mysql://root:root@mysql:3306 - name: Build Drupal site - run: civibuild create drupal-clean --civi-ver 6.4.1 --cms-ver 7.100 --web-root $GITHUB_WORKSPACE/site + run: /opt/buildkit/bin/civibuild create drupal-clean --civi-ver 6.4.1 --cms-ver 7.100 --web-root $GITHUB_WORKSPACE/site - uses: actions/checkout@v2 with: From 8cb3943a0c56517dc3b6640b1db731b8d8dcaa09 Mon Sep 17 00:00:00 2001 From: Erawat Chamanont Date: Wed, 14 Jan 2026 21:05:08 +0000 Subject: [PATCH 3/5] CIVIMM-455: Update documentation for WebhookHandlerAdapter pattern - Update README.md with both handler implementation options (interface vs duck typing) - Update CLAUDE.md webhook system documentation with Adapter pattern details --- CLAUDE.md | 18 ++++++++++++++++-- README.md | 10 ++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 57a4e87..838b56e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -527,8 +527,8 @@ Webhook arrives → Verify signature → Save to DB → Queue → Process → Co ``` **Key Components:** -1. **WebhookHandlerInterface** - Contract all handlers must implement -2. **WebhookHandlerRegistry** - Central registry mapping event types to handlers +1. **WebhookHandlerInterface** - Contract for handlers (via interface or duck typing with Adapter pattern) +2. **WebhookHandlerRegistry** - Central registry mapping event types to handlers (uses Adapter pattern for duck-typed handlers) 3. **WebhookQueueService** - Per-processor SQL queues for webhook tasks 4. **WebhookQueueRunnerService** - Processes queued tasks with retry logic 5. **PaymentWebhook entity** - Generic webhook storage across all processors @@ -609,6 +609,9 @@ class WebhookReceiverService { } // 3. Create event handlers (processor-specific business logic) +// +// OPTION 1 (Preferred): Implement the interface directly +// Use this when your extension loads after PaymentProcessingCore namespace Civi\YourProcessor\Webhook; use Civi\Paymentprocessingcore\Webhook\WebhookHandlerInterface; @@ -656,6 +659,17 @@ class PaymentSuccessHandler implements WebhookHandlerInterface { } } +// OPTION 2 (Fallback): Duck typing - if autoload issues occur +// Use this when `implements WebhookHandlerInterface` causes "Interface not found" errors +// The WebhookHandlerRegistry will wrap this in an Adapter automatically +class PaymentSuccessHandler { + public function handle(int $webhookId, array $params): string { + // Same signature as interface - registry validates at runtime + // Your business logic here + return 'applied'; + } +} + // 4. Register services and handlers in ServiceContainer.php namespace Civi\YourProcessor\Hook\Container; diff --git a/README.md b/README.md index 95dd2af..9b49663 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,7 @@ class YourProcessorWebhookReceiverService { } // Implement handlers for specific event types: +// Option 1 (Preferred): Implement the interface directly class PaymentSuccessHandler implements WebhookHandlerInterface { public function handle(int $webhookId, array $params): string { $eventData = $params['event_data']; @@ -201,6 +202,15 @@ class PaymentSuccessHandler implements WebhookHandlerInterface { } } +// Option 2 (Fallback): Duck typing - if autoload issues occur +// The registry will wrap this in an Adapter automatically +class PaymentSuccessHandler { + public function handle(int $webhookId, array $params): string { + // Same signature as interface - registry validates at runtime + return 'applied'; + } +} + // Register handlers in ServiceContainer.php: private function registerWebhookHandlers(): void { if ($this->container->hasDefinition('paymentprocessingcore.webhook_handler_registry')) { From 765140c89885e81881e2faa2d3e4d0e74799e08c Mon Sep 17 00:00:00 2001 From: Erawat Chamanont Date: Wed, 14 Jan 2026 22:59:30 +0000 Subject: [PATCH 4/5] CIVIMM-455: Fix linter and PHPStan compatibility for adapter pattern - Use @phpstan-param annotations for generic array types (PHPStan reads, linter ignores) - Remove object type hint (not recognized by Drupal standards), use @var mixed - Add proper PHPDoc with @param array and @phpstan-param array --- .../Webhook/WebhookHandlerAdapter.php | 24 ++++++++++++------- .../Service/WebhookHandlerRegistryTest.php | 9 +++---- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/Civi/Paymentprocessingcore/Webhook/WebhookHandlerAdapter.php b/Civi/Paymentprocessingcore/Webhook/WebhookHandlerAdapter.php index 162c410..704aae9 100644 --- a/Civi/Paymentprocessingcore/Webhook/WebhookHandlerAdapter.php +++ b/Civi/Paymentprocessingcore/Webhook/WebhookHandlerAdapter.php @@ -17,29 +17,37 @@ class WebhookHandlerAdapter implements WebhookHandlerInterface { /** * The wrapped duck-typed handler. * - * @var object + * @var mixed */ - private object $handler; + private $handler; /** * Construct adapter with duck-typed handler. * - * @param object $handler + * @param mixed $handler * Handler object with handle(int, array): string method. */ - public function __construct(object $handler) { + public function __construct($handler) { $this->handler = $handler; } /** - * {@inheritdoc} + * Handle a webhook event by delegating to wrapped handler. * - * @param array $params Additional parameters. + * @param int $webhookId + * The webhook record ID. + * @param array $params + * Additional parameters including event data. + * + * @phpstan-param array $params + * + * @return string + * Result code: 'applied', 'noop', or 'ignored_out_of_order'. */ public function handle(int $webhookId, array $params): string { - /** @var callable(int, array): string $callback */ + /** @var callable $callback */ $callback = [$this->handler, 'handle']; return $callback($webhookId, $params); } -} \ No newline at end of file +} diff --git a/tests/phpunit/Civi/Paymentprocessingcore/Service/WebhookHandlerRegistryTest.php b/tests/phpunit/Civi/Paymentprocessingcore/Service/WebhookHandlerRegistryTest.php index 027842a..8ad2307 100644 --- a/tests/phpunit/Civi/Paymentprocessingcore/Service/WebhookHandlerRegistryTest.php +++ b/tests/phpunit/Civi/Paymentprocessingcore/Service/WebhookHandlerRegistryTest.php @@ -85,9 +85,9 @@ public function testGetHandlerReturnsInterfaceImplementorDirectly(): void { $mockHandler = new class implements WebhookHandlerInterface { /** - * {@inheritdoc} + * Handle webhook event. * - * @param array $params Parameters. + * @phpstan-param array $params */ public function handle(int $webhookId, array $params): string { return 'applied'; @@ -121,10 +121,7 @@ public function testGetHandlerWrapsDuckTypedHandlerInAdapter(): void { /** * Handle webhook event. * - * @param int $webhookId Webhook ID. - * @param array $params Parameters. - * - * @return string Result code. + * @phpstan-param array $params */ public function handle(int $webhookId, array $params): string { return 'duck_typed'; From f52c873ee1a1843ea10afe76d662833743cfb8d2 Mon Sep 17 00:00:00 2001 From: Erawat Chamanont Date: Thu, 15 Jan 2026 13:54:19 +0000 Subject: [PATCH 5/5] CIVIMM-455: Refactor CLAUDE.md and README.md to eliminate duplication - README.md is now single source of truth for project details - CLAUDE.md focuses on AI-specific development instructions - CLAUDE.md references README.md for project overview and usage - Reduced CLAUDE.md from 965 to 269 lines - Streamlined README.md from 305 to 236 lines --- CLAUDE.md | 1001 ++++++++--------------------------------------------- README.md | 211 ++++------- 2 files changed, 223 insertions(+), 989 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 838b56e..54ccfa1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,965 +1,268 @@ - +# Claude Code Development Guide -# 🧠 Claude Code Development Guide +This file defines how **Claude Code (Anthropic)** should assist with this project. -This file defines how **Claude Code (Anthropic)** should be used within this project. -It acts as both: -- a **developer onboarding guide**, and -- a **context reference** for Claude when assisting in coding tasks. +> **For project overview, installation, and usage examples, see [README.md](README.md).** -Claude Code can edit files, plan changes, and run commands — but must follow all internal development standards described here. +Claude Code can edit files, plan changes, and run commands — but must follow all development standards described here. --- -## 📦 Project Overview +## 1. Quick Reference -This is a **CiviCRM extension** that provides **generic payment processing infrastructure** for multiple payment processors (Stripe, GoCardless, ITAS, Deluxe, etc.). It centralizes common payment logic that was previously duplicated across processor-specific extensions. - -**Extension Key:** `io.compuco.paymentprocessingcore` -**Repository:** https://github.com/compucorp/io.compuco.paymentprocessingcore -**CiviCRM Version:** 6.64.1+ (CI), 6.4.1+ (Development) -**PHP Version:** 7.3+ (minimum), 8.0+ (recommended) - -**Core Purpose:** -- Provide generic payment attempt tracking (`civicrm_payment_attempt`) -- Provide generic webhook event deduplication (`civicrm_payment_webhooks`) -- Centralize contribution completion/failure/cancellation logic -- Define interfaces for payment processors to implement -- Reduce code duplication across payment processors - -**Dependencies:** -- CiviCRM Core 6.64.1+ -- PHP 8.1+ - -**Who Uses This Extension:** -This extension is used by payment processor extensions: -- `uk.co.compucorp.stripe` - Stripe Connect integration -- `uk.co.compuco.gocardless` - GoCardless integration (future) -- Other payment processors (future) - -**Installation:** -```bash -# Install PaymentProcessingCore -composer install -cv en paymentprocessingcore - -# OR with Drush: -drush cei paymentprocessingcore -``` - -**Important:** Payment processor extensions depend on this extension for generic payment processing infrastructure. - ---- - -## 0. Development Environment Setup - -### CiviCRM Core Reference - -For development and debugging, it's highly recommended to have CiviCRM core code available for reference. This helps with: -- Understanding CiviCRM API changes between versions -- Debugging core integration issues -- Verifying compatibility with core expectations -- Checking for known issues and patches - -**Setup:** -```bash -# Clone Compucorp's CiviCRM core fork (includes patches for 5.75.0) -# From the extension root directory: -git clone https://github.com/compucorp/civicrm-core.git -cd civicrm-core - -# Checkout the patches branch used by Compucorp -git checkout 6.4.1-patches -cd .. - -# The core will be at: ./civicrm-core/ -# (inside the extension directory: io.compuco.paymentprocessingcore/civicrm-core/) -``` - -**Note:** We use Compucorp's fork of CiviCRM core rather than the official repository because it includes necessary patches applied via the `compucorp/apply-patch` GitHub Action in CI workflows. - -**Important:** Add `civicrm-core/` to `.gitignore` if not already present, as this is a reference copy for development only. - -**Usage:** -- Reference core API implementations when debugging -- Check core changes when updating CiviCRM version -- Verify parameter requirements for API calls -- Look for patches that may be needed - -**Note for Claude Code:** When working on compatibility issues or API integration, always check the CiviCRM core code at `./civicrm-core/` if available. This will help identify breaking changes and required parameter updates. - ---- - -## 1. Pull Request Descriptions - -All PRs must use the standard template stored at `.github/PULL_REQUEST_TEMPLATE.md`. - -Claude can help generate PR descriptions but must follow this structure: - -**Required Sections:** -- **Overview**: Non-technical description of the change -- **Before**: Current status with screenshots/gifs where appropriate -- **After**: What changed with screenshots/gifs where appropriate -- **Technical Details**: Noteworthy technical changes, code snippets -- **Core overrides**: If any CiviCRM core files are patched (file, reason, changes) -- **Comments**: Any additional notes for reviewers - -**When drafting PRs:** -- Reference the ticket ID in the PR title, e.g. `CIVIMM-123: Add webhook event deduplication` -- Fill all required template sections -- Keep summaries factual — avoid assumptions -- Include before/after screenshots for UI changes - -**Example Claude prompt:** -> Summarize the following diff into a PR description using `.github/PULL_REQUEST_TEMPLATE.md`. -> Include the issue key `CIVIMM-123` and explain what changed, why, and how to test. - ---- - -## 1.5. Handling Pull Request Review Feedback - -When receiving PR review comments (from GitHub, Copilot, or human reviewers), **NEVER blindly implement feedback**. Always think critically and ask questions. - -**Required Process:** - -1. **Analyze Each Suggestion:** - - Does this suggestion make technical sense? - - What are the implications (database constraints, type safety, performance)? - - Could this break existing functionality? - - Is this consistent with the project's architecture? - -2. **Ask Clarifying Questions:** - - If unsure about the reasoning, ask the user: "Why is this change recommended?" - - If there are trade-offs, present them: "This suggestion would fix X but might break Y - which is preferred?" - - If the suggestion seems incorrect, explain why: "I think this might cause issues because..." - -3. **Explain Your Analysis:** - - For each change, explain WHY you're making it (or not making it) - - Present technical reasoning (e.g., "is_null() is more precise than empty() for integer IDs because...") - - Highlight potential issues (e.g., "Making contact_id NOT NULL would prevent ON DELETE SET NULL from working") - -4. **Get Approval Before Implementing:** - - Show the user what you plan to change - - Wait for explicit confirmation before committing - - Never batch commit multiple review changes without review - -**Important for Claude Code:** - -- ✅ Always explain your reasoning for accepting or rejecting feedback -- ✅ Present trade-offs clearly to the user -- ✅ Ask for clarification when suggestions seem wrong -- ⚠️ Never commit without user approval -- ⚠️ Don't assume reviewers are always correct - they can be wrong too -- ✅ Your job is to provide technical analysis, not blindly follow instructions +| Resource | Location | +|----------|----------| +| Project Overview | [README.md](README.md) | +| Usage Examples | [README.md](README.md#usage) | +| PR Template | `.github/PULL_REQUEST_TEMPLATE.md` | +| Linting Rules | `phpcs-ruleset.xml` | +| PHPStan Config | `phpstan.neon` | --- -## 2. Unit Testing - -Unit tests are **mandatory** for all new features and bug fixes. - -**Requirements:** -- Tests must be written using **PHPUnit** (CiviCRM extension standard) -- Store tests in `tests/phpunit/` directory, mirroring source structure -- Tests require full CiviCRM buildkit environment -- Never modify or skip tests just to make them pass. Fix the underlying code. - -**Running Tests Locally with Docker (Recommended):** +## 2. Development Environment -This project includes a flexible Docker-based test environment with configurable CiviCRM versions: - -**Configuration** (`scripts/env-config.sh`): -- **Default**: CiviCRM 6.4.1, Drupal 7.100 (current development target) +### Running Tests, Linter, PHPStan ```bash -# Setup with default CiviCRM version (6.4.1) +# Setup Docker environment (one-time) ./scripts/run.sh setup # Run all tests ./scripts/run.sh tests -# Run specific test file -./scripts/run.sh test tests/phpunit/Civi/PaymentProcessingCore/Service/PaymentAttemptServiceTest.php - -# Generate DAO files from XML schemas -./scripts/run.sh civix - -# Open shell in CiviCRM container -./scripts/run.sh shell - -# Run cv commands -./scripts/run.sh cv cli -./scripts/run.sh cv api Contact.get - -# Stop services (preserves data) -./scripts/run.sh stop - -# Clean up (removes all data including volumes) -./scripts/run.sh clean -``` +# Run linter on changed files +./scripts/lint.sh check -**Typical Workflow:** -```bash -# 1. Setup test environment and run tests (most common) -./scripts/run.sh setup -./scripts/run.sh tests +# Run PHPStan on changed files +./scripts/run.sh phpstan-changed -# 2. When schema changes, regenerate DAO files -# (Only needed when xml/schema/ files are modified) -./scripts/run.sh civix # Regenerates DAO files -./scripts/run.sh tests # Verify tests still pass +# Regenerate DAO files after schema changes +./scripts/run.sh civix ``` -**What the setup does:** -- Spins up MySQL 8.0 service -- Builds Drupal 7.100 site with specified CiviCRM version using civibuild -- Configures CiviCRM settings -- Creates test database -- Enables the extension - -**Important Notes:** -- The `civix` command uses rsync to properly sync generated files from container to host -- Always use `./scripts/run.sh clean` before switching CiviCRM versions -- SQL files (`sql/auto_install.sql`, `sql/auto_uninstall.sql`) are auto-generated by civix - do not edit manually +### CiviCRM Core Reference (Optional) -**Or trigger CI workflow locally using act:** -```bash -act -j run-unit-tests -P ubuntu-latest=compucorp/civicrm-buildkit:1.3.1-php8.0 -``` +For debugging CiviCRM API issues: -**Running Tests Without Docker:** ```bash -# Run all tests -vendor/bin/phpunit - -# Run specific test file -vendor/bin/phpunit tests/phpunit/Civi/PaymentProcessingCore/Service/PaymentAttemptServiceTest.php - -# Run specific test method -vendor/bin/phpunit --filter testCreatePaymentAttempt tests/phpunit/Civi/PaymentProcessingCore/Service/PaymentAttemptServiceTest.php +git clone https://github.com/compucorp/civicrm-core.git +cd civicrm-core && git checkout 6.4.1-patches ``` -**CI Workflow:** -Tests run automatically on PRs via `.github/workflows/unit-test.yml` which: -- Sets up MariaDB 10.5 service container -- Configures CiviCRM database requirements -- Builds Drupal 7.100 site with CiviCRM 6.4.1 using civibuild -- Installs required extensions and dependencies -- Runs PHPUnit tests with coverage - -**Test Patterns:** -- Extend `BaseHeadlessTest` for all test classes -- Use fabricators in `tests/phpunit/Fabricator/` to create test data -- Test positive, negative, and edge cases -- Mock external API calls when appropriate - -**Example Claude prompt:** -> Generate a PHPUnit test for `Civi\PaymentProcessingCore\Service\PaymentAttemptService::createAttempt()`. -> Cover success case, API error case, and missing parameter case using existing test patterns. - -**Important for Claude Code:** -- ⚠️ Cannot run tests directly without Docker/buildkit environment -- ✅ Can write test files following existing patterns -- ✅ Can review test output from CI workflows -- ✅ Suggest: "Push changes to trigger CI tests" or "Run tests via Docker" - -All tests must pass before commits are pushed or PRs are opened. - --- -## 3. Code Linting & Style - -Code must follow **CiviCRM Drupal coding standards** and pass all linting checks. - -**Ruleset:** Custom ruleset defined in `phpcs-ruleset.xml` (based on Drupal standards) -- Excludes `paymentprocessingcore.civix.php` (auto-generated file) - -**Running Linters Locally (Docker - Recommended):** -```bash -# Run linter on changed files (vs origin/master) -./scripts/lint.sh check - -# Auto-fix linting issues -./scripts/lint.sh fix +## 3. Pull Request Guidelines -# Run linter on all source files -./scripts/lint.sh check-all - -# Stop linter container -./scripts/lint.sh stop +### PR Title Format ``` - -**Running Linters Locally (Manual):** -```bash -# Install linter (if needed) -cd bin && ./install-php-linter - -# Run linter on changed files (used by CI) -git diff --diff-filter=d origin/master --name-only -- '*.php' | xargs -r ./bin/phpcs.phar --standard=phpcs-ruleset.xml - -# Or lint all PHP files -./bin/phpcs.phar --standard=phpcs-ruleset.xml CRM/ Civi/ api/ - -# Auto-fix fixable issues -./bin/phpcbf.phar --standard=phpcs-ruleset.xml CRM/ Civi/ api/ +CIVIMM-###: Short description ``` -**CI Workflow:** -Linting runs automatically via `.github/workflows/linters.yml` on all PHP files changed in the PR. - -**Important for Claude Code:** -- ✅ Can fix style issues based on linter output -- ✅ Can apply Drupal coding standards -- ⚠️ Always check formatting before commits -- ✅ Suggest: "Run linter to check code style" +### Required Sections (from template) +- **Overview**: Non-technical description +- **Before**: Current state +- **After**: What changed +- **Technical Details**: Code snippets, patterns used +- **Core overrides**: If any CiviCRM core files are patched -### File Newline Requirements +### Handling Review Feedback -**All files must end with a newline character** (POSIX standard compliance). +**NEVER blindly implement feedback.** Always: -**Why this matters:** -- Git diffs show "No newline at end of file" warnings for files without newlines -- Many Unix tools expect files to end with newlines -- POSIX defines a line as ending with a newline character -- Prevents potential issues with concatenation and shell scripts - -**Important for Claude Code:** -- ✅ Always ensure files end with newlines when creating or editing -- ✅ Can check for missing newlines before commits -- ⚠️ Editor settings should be configured to add trailing newlines automatically -- ✅ Verify with `git diff --check` before pushing +1. Analyze if the suggestion makes technical sense +2. Consider implications (database constraints, type safety, performance) +3. Ask clarifying questions if unsure +4. Explain your reasoning for accepting or rejecting +5. Get user approval before committing changes --- -## 3.5. Static Analysis (PHPStan) - -All code must pass **PHPStan level 9** static analysis, the strictest PHP type checking available. - -**Configuration:** `phpstan.neon` - Configured for Docker test environment -- **Level:** 9 (maximum strictness) -- **Baseline:** `phpstan-baseline.neon` - Contains ignored errors from existing code -- **Approach:** Baseline captures existing errors, enforces strict typing on all future code +## 4. Commit Message Convention -**What Gets Analyzed:** -- All source files in `Civi/` and `CRM/` directories -- Test files (important for quality!) -- New untracked files - -**What Gets Excluded (Auto-Generated):** -- `CRM/PaymentProcessingCore/DAO/*` - Generated by civix from XML schemas -- `paymentprocessingcore.civix.php` - Generated by civix -- `*.mgd.php` - CiviCRM managed entity files -- `tests/bootstrap.php` - Test bootstrap configuration - -**Running PHPStan Locally (Docker - Recommended):** -```bash -# Run PHPStan on changed files only (recommended - fast) -./scripts/run.sh phpstan-changed - -# Run PHPStan on entire codebase (slow - full analysis) -./scripts/run.sh phpstan ``` - -**Prerequisites:** -- Docker environment must be running: `./scripts/run.sh setup` -- PHPStan needs access to CiviCRM core for type information - -**CI Workflow:** -PHPStan runs automatically via `.github/workflows/phpstan.yml` on all changed PHP files in the PR. - -**Important for Claude Code:** -- ✅ Can read PHPStan errors and suggest fixes -- ✅ Can add proper type hints to fix errors -- ⚠️ Always run `./scripts/run.sh phpstan-changed` before pushing -- ⚠️ Never regenerate baseline to "fix" errors - fix the code instead -- ✅ Suggest: "Run PHPStan to check type safety" - ---- - -## 4. 🛡️ Critical Review Areas - -### 🔐 Security - -**Payment Processing Security:** -- Never log or expose sensitive payment data -- Validate all payment amounts and currency codes before processing -- Check for SQL injection in dynamic queries (use parameterized queries) -- Sanitize all user input before rendering (XSS prevention) -- Verify webhook signatures for payment processors -- Ensure proper authentication/authorization for API endpoints - -**Sensitive Data Handling:** -- Payment processor IDs, transaction IDs are sensitive -- All payment processor API calls should use proper error handling -- Payment processor credentials stored in `civicrm.settings.php` must never be committed - -### 🚀 Performance - -- Identify N+1 query issues in contribution/contact lookups -- Detect inefficient loops when processing bulk payments -- Avoid unnecessary API calls (use cached records) -- Review database queries in BAO classes for optimization - -### 🧼 Code Quality - -- Services should be focused and follow single responsibility principle -- Use meaningful names following CiviCRM conventions (`CRM_*` or `Civi\*`) -- Handle exceptions properly (use custom exception classes) -- All service methods should have proper return type declarations -- Use dependency injection for service dependencies - ---- - -## 5. Commit Message Convention - -All commits must start with the branch prefix (issue ID) followed by a short imperative description. - -**Format:** -``` -CIVIMM-123: Short description of change +CIVIMM-###: Short imperative description ``` **Rules:** -- Keep summaries under 72 characters +- Keep under 72 characters - Use present tense ("Add", "Fix", "Refactor") -- Claude must include the correct issue key when committing -- Be specific and descriptive -- **DO NOT add any AI attribution or co-authorship lines** (no "Generated with Claude Code", no "Co-Authored-By: Claude") +- **NO AI attribution** (no "Co-Authored-By: Claude") **Examples:** ``` CIVIMM-456: Add PaymentAttempt entity with multi-processor support -CIVIMM-789: Implement webhook event deduplication service -CIVIMM-101: Refactor ContributionCompletionService for idempotency +CIVIMM-789: Fix webhook deduplication race condition ``` -If Claude proposes commits automatically, it must use this exact format without any attribution footer. - ---- - -## 6. Continuous Integration (CI) - -All code must pass these workflows before merging: - -| Workflow | Purpose | Local Command | CI File | -|-----------|----------|---------------|---------| -| **unit-test.yml** | PHPUnit test execution | `./scripts/run.sh tests` | `.github/workflows/unit-test.yml` | -| **linters.yml** | Code style and lint checks (PHPCS) | `./scripts/lint.sh check` | `.github/workflows/linters.yml` | -| **phpstan.yml** | Static analysis (PHPStan level 9) | `./scripts/run.sh phpstan-changed` | `.github/workflows/phpstan.yml` | - -Claude must ensure that code: -- ✅ Passes **PHPUnit tests** (no test failures) -- ✅ Passes **linting** (CiviCRM Drupal standard compliance) -- ✅ Passes **PHPStan** (level 9 static analysis on changed files) - --- -## 7. Architecture - -### Code Organization - -The extension uses two primary namespaces: - -1. **`CRM_*` namespace** (CRM/ directory): Traditional CiviCRM architecture - - **DAO/**: Database Access Objects (auto-generated from XML schemas in `xml/schema/`) - - **BAO/**: Business Access Objects extending DAOs with business logic - - **Page/**: Base classes for webhook endpoints - -2. **`Civi\PaymentProcessingCore\*` namespace** (Civi/ directory): Modern service-oriented architecture - - **Service/**: Business logic services (payment attempts, webhook logging, completion, failure) - - **Utils/**: Utility classes for common operations - - **Interface/**: Contracts for payment processors to implement - - **Hook/**: Hook implementations (Container) - -### Core Purpose: Centralization - -This extension centralizes payment processing logic that was previously duplicated across payment processors: - -**What's Centralized (Generic across all processors):** -- **PaymentAttempt tracking** - Generic table for all processors (`civicrm_payment_attempt`) -- **Webhook event deduplication** - Generic table (`civicrm_payment_webhooks`) -- **ContributionCompletionService** - Generic contribution completion logic -- **ContributionFailureService** - Generic status transitions (Pending → Cancelled → Failed) -- **ContributionCancellationService** - Generic cancellation logic -- **WebhookEventLogService** - Generic webhook de-duplication -- **WebhookBase** - Abstract base class for webhook endpoints - -**What Stays in Processor Extensions (Processor-specific):** -- **Processor API calls** - Stripe Checkout, GoCardless Mandate, etc. -- **Webhook signature verification** - Processor-specific cryptography -- **Webhook event parsing** - Processor-specific schemas -- **Fee calculation** - Processor-specific fee structures - -### Key Entities - -The extension manages two custom database entities defined in `xml/schema/CRM/PaymentProcessingCore/`: +## 5. Code Quality Standards -- **PaymentAttempt**: Generic payment attempt tracking for all processors -- **PaymentWebhook**: Generic webhook event logging for deduplication +### Unit Testing +- **Mandatory** for all new features and bug fixes +- Extend `BaseHeadlessTest` for test classes +- Use fabricators in `tests/phpunit/Fabricator/` +- Never modify tests just to make them pass -### Service Layer Architecture +### Linting (PHPCS) +- Follow CiviCRM Drupal coding standards +- All files must end with a newline +- Run `./scripts/lint.sh fix` to auto-fix issues -Services are the primary business logic layer, registered via dependency injection in `Civi\PaymentProcessingCore\Hook\Container\ServiceContainer`. - -**Core Services:** -- **PaymentAttemptService**: CRUD operations for payment attempts -- **WebhookEventLogService**: Webhook event deduplication -- **ContributionCompletionService**: Generic contribution completion -- **ContributionFailureService**: Generic contribution failure handling -- **ContributionCancellationService**: Generic contribution cancellation -- **PaymentStatusMapper**: Maps processor statuses to CiviCRM statuses - -### Interfaces - -The extension defines interfaces that payment processors must implement: - -- **WebhookHandlerInterface**: Contract for webhook event handlers (processor-specific) -- **PaymentAttemptInterface**: Contract for attempt handling -- **PaymentSessionInterface**: Contract for session handling - -### Webhook Processing System - -The extension provides a queue-based webhook processing system with automatic retry and recovery: - -**Architecture:** -``` -Webhook arrives → Verify signature → Save to DB → Queue → Process → Complete/Retry -``` - -**Key Components:** -1. **WebhookHandlerInterface** - Contract for handlers (via interface or duck typing with Adapter pattern) -2. **WebhookHandlerRegistry** - Central registry mapping event types to handlers (uses Adapter pattern for duck-typed handlers) -3. **WebhookQueueService** - Per-processor SQL queues for webhook tasks -4. **WebhookQueueRunnerService** - Processes queued tasks with retry logic -5. **PaymentWebhook entity** - Generic webhook storage across all processors - -**How Processor Extensions Integrate:** +### Static Analysis (PHPStan Level 9) +- Strictest PHP type checking +- Never regenerate baseline to "fix" errors - fix the code +- Use `@phpstan-param` for generic types that linter doesn't support +### Linter + PHPStan Compatibility Pattern ```php -// 1. Create webhook endpoint page (processor-specific) -class CRM_YourProcessor_Page_Webhook extends CRM_Core_Page { - public function run(): void { - $payload = file_get_contents('php://input'); - $signature = $_SERVER['HTTP_YOUR_PROCESSOR_SIGNATURE'] ?? ''; - - /** @var \Civi\YourProcessor\Service\WebhookReceiverService $receiver */ - $receiver = \Civi::service('yourprocessor.webhook_receiver'); - $receiver->handleRequest($payload, $signature); - - CRM_Utils_System::civiExit(); - } -} +/** + * @param array $params // Linter sees this + * @phpstan-param array $params // PHPStan sees this + */ +``` -// 2. Create webhook receiver service (processor-specific) -namespace Civi\YourProcessor\Service; +--- -use Civi\Paymentprocessingcore\Service\WebhookQueueService; +## 6. CI Requirements -class WebhookReceiverService { - private WebhookQueueService $queueService; +All code must pass before merging: - public function __construct(WebhookQueueService $queueService) { - $this->queueService = $queueService; - } +| Check | Command | CI Workflow | +|-------|---------|-------------| +| Tests | `./scripts/run.sh tests` | `unit-test.yml` | +| Linting | `./scripts/lint.sh check` | `linters.yml` | +| PHPStan | `./scripts/run.sh phpstan-changed` | `phpstan.yml` | - public function handleRequest(string $payload, string $signature): void { - // 1. Verify signature (processor-specific cryptography) - $event = $this->verifyAndParseEvent($payload, $signature); - - // 2. Check if event type should be processed - if (!in_array($event->type, self::ALLOWED_EVENTS, TRUE)) { - http_response_code(200); - return; - } - - // 3. Save to generic webhook table (atomic insert to prevent duplicates) - $webhookId = $this->saveWebhookEventAtomic($event); - - if ($webhookId === NULL) { - // Duplicate - already processed - http_response_code(200); - return; - } - - // 4. Add to queue (generic queue service) - $this->queueService->addTask( - 'yourprocessor', // processor type - $webhookId, - ['event_data' => $event->toArray()] - ); - - http_response_code(200); - } +--- - private function saveWebhookEventAtomic($event): ?int { - // Use INSERT IGNORE for atomic duplicate prevention - $sql = "INSERT IGNORE INTO civicrm_payment_webhook - (event_id, processor_type, event_type, status, attempts, created_date) - VALUES (%1, %2, %3, 'new', 0, NOW())"; +## 7. Architecture Overview - \CRM_Core_DAO::executeQuery($sql, [ - 1 => [$event->id, 'String'], - 2 => ['yourprocessor', 'String'], - 3 => [$event->type, 'String'], - ]); +### Namespaces - $affectedRows = \CRM_Core_DAO::singleValueQuery("SELECT ROW_COUNT()"); - return ($affectedRows > 0) ? (int) \CRM_Core_DAO::singleValueQuery("SELECT LAST_INSERT_ID()") : NULL; - } -} +| Namespace | Directory | Purpose | +|-----------|-----------|---------| +| `CRM_*` | `CRM/` | Traditional CiviCRM (DAO, BAO) | +| `Civi\Paymentprocessingcore\*` | `Civi/` | Modern services | -// 3. Create event handlers (processor-specific business logic) -// -// OPTION 1 (Preferred): Implement the interface directly -// Use this when your extension loads after PaymentProcessingCore -namespace Civi\YourProcessor\Webhook; +### Key Services -use Civi\Paymentprocessingcore\Webhook\WebhookHandlerInterface; -use Civi\Paymentprocessingcore\Service\ContributionCompletionService; +| Service | Purpose | +|---------|---------| +| `ContributionCompletionService` | Complete pending contributions | +| `ContributionFailureService` | Handle failed contributions | +| `WebhookQueueService` | Queue webhook events | +| `WebhookHandlerRegistry` | Map events to handlers | +| `PaymentProcessorCustomerService` | Manage customer IDs | -class PaymentSuccessHandler implements WebhookHandlerInterface { - private ContributionCompletionService $completionService; +### Webhook Handler Pattern - public function __construct(ContributionCompletionService $completionService) { - $this->completionService = $completionService; - } +Handlers can implement `WebhookHandlerInterface` directly (preferred) or use duck typing (fallback). The registry uses the Adapter pattern for duck-typed handlers. +```php +// Option 1: Implement interface (preferred) +class MyHandler implements WebhookHandlerInterface { public function handle(int $webhookId, array $params): string { - $eventData = $params['event_data']; - $payment = $eventData['data']['object']; - - // Find payment attempt - $attempt = \Civi\Api4\PaymentAttempt::get(FALSE) - ->addWhere('processor_payment_id', '=', $payment['id']) - ->addWhere('processor_type', '=', 'yourprocessor') - ->execute() - ->first(); - - if (!$attempt) { - return 'noop'; - } - - // Complete contribution - try { - $result = $this->completionService->complete( - $attempt['contribution_id'], - $payment['id'], - $payment['fee'] ?? NULL - ); - - return $result['already_completed'] ? 'noop' : 'applied'; - } - catch (\Exception $e) { - \Civi::log()->error('Payment completion failed', [ - 'webhook_id' => $webhookId, - 'error' => $e->getMessage(), - ]); - throw $e; - } + return 'applied'; } } -// OPTION 2 (Fallback): Duck typing - if autoload issues occur -// Use this when `implements WebhookHandlerInterface` causes "Interface not found" errors -// The WebhookHandlerRegistry will wrap this in an Adapter automatically -class PaymentSuccessHandler { +// Option 2: Duck typing (fallback - wrapped in Adapter) +class MyHandler { public function handle(int $webhookId, array $params): string { - // Same signature as interface - registry validates at runtime - // Your business logic here return 'applied'; } } - -// 4. Register services and handlers in ServiceContainer.php -namespace Civi\YourProcessor\Hook\Container; - -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Definition; -use Symfony\Component\DependencyInjection\Reference; - -class ServiceContainer { - public function register(): void { - // Check if PaymentProcessingCore is available (defensive) - if (!$this->container->has('paymentprocessingcore.webhook_queue')) { - return; - } - - // Register webhook receiver service - $this->container->setDefinition('yourprocessor.webhook_receiver', new Definition( - \Civi\YourProcessor\Service\WebhookReceiverService::class, - [new Reference('paymentprocessingcore.webhook_queue')] - ))->setPublic(TRUE); - - // Register event handlers - $this->container->setDefinition('yourprocessor.handler.payment_success', new Definition( - \Civi\YourProcessor\Webhook\PaymentSuccessHandler::class - ))->setAutowired(TRUE)->setPublic(TRUE); - - $this->container->setDefinition('yourprocessor.handler.payment_failed', new Definition( - \Civi\YourProcessor\Webhook\PaymentFailedHandler::class - ))->setAutowired(TRUE)->setPublic(TRUE); - - // Register handlers with PaymentProcessingCore registry (compile-time) - $this->registerWebhookHandlers(); - } - - private function registerWebhookHandlers(): void { - if (!$this->container->hasDefinition('paymentprocessingcore.webhook_handler_registry')) { - return; - } - - $registry = $this->container->getDefinition('paymentprocessingcore.webhook_handler_registry'); - - // Register handler for payment.success event - $registry->addMethodCall('registerHandler', [ - 'yourprocessor', // processor type - 'payment.success', // event type - 'yourprocessor.handler.payment_success', // handler service ID - ]); - - // Register handler for payment.failed event - $registry->addMethodCall('registerHandler', [ - 'yourprocessor', - 'payment.failed', - 'yourprocessor.handler.payment_failed', - ]); - } -} ``` -**How Auto-Discovery Works:** - -1. Container compilation phase: - - PaymentProcessingCore creates empty WebhookHandlerRegistry - - Each processor extension calls `registry->addMethodCall('registerHandler', ...)` - - Registry is populated before any code runs - -2. Scheduled job runs (processor_type='all'): - - Calls `WebhookQueueRunnerService->runAllQueues()` - - Registry returns all processor types: `['stripe', 'yourprocessor', 'gocardless']` - - Automatically processes webhooks from ALL registered processors - -3. Adding a new processor: - - Install extension - - Container rebuilds - - New processor auto-appears in processing queue - - **No configuration changes needed!** - -**Retry & Recovery:** -- Exponential backoff: 5min → 15min → 45min -- Max 3 attempts before marking as permanent_error -- Stuck webhook recovery (resets webhooks stuck in "processing" > 30 minutes) -- Batch processing to prevent job timeouts (250 events per processor per run) - --- ## 8. Workflow with Claude Code -Claude Code operates in **Plan Mode** and **Execution Mode**. - -**Recommended Flow:** -1. **Explain** – Ask Claude to describe the issue in its own words -2. **Plan** – Enable Plan Mode (`Shift + Tab` twice) and ask for a clear step-by-step fix plan -3. **Review** – Verify and edit Claude's plan before implementation -4. **Implement** – Disable Plan Mode and let Claude apply changes -5. **Verify** – Run linting and tests to confirm all checks pass - -**Safe Commands:** -```bash -# Check git status and diff -git status -git diff - -# Run linting -./scripts/lint.sh check - -# Run tests (requires Docker/buildkit) -./scripts/run.sh tests +### Recommended Flow +1. **Explain** – Describe the issue +2. **Plan** – Use Plan Mode for complex tasks +3. **Review** – Verify plan before implementation +4. **Implement** – Apply changes +5. **Verify** – Run tests and linting -# Commit changes -git commit -m "CIVIMM-###: ..." -``` - -**Request Confirmation Before:** +### Request Confirmation Before - Deleting or overwriting files -- Running migrations or database changes -- Modifying auto-generated files (`paymentprocessingcore.civix.php`, DAO files) -- Making changes to `xml/schema/` files (require regeneration) +- Database migrations +- Modifying auto-generated files +- Changes to `xml/schema/` files --- -## 9. Review & Validation - -After Claude proposes code: - -1. Review the diff manually -2. Run linting and tests -3. Ensure commit message format is correct (CIVIMM-###: ...) -4. Push the branch and open a PR using the PR template -5. Verify CI passes (unit-test.yml, linters.yml, phpstan.yml) - -If Claude generates documentation or summaries, review for accuracy before committing. - ---- - -## 10. Developer Prompts (Examples) - -| Task | Example Prompt | -|------|----------------| -| Generate tests | "Create PHPUnit tests for `PaymentAttemptService::createAttempt()` covering success, API error, and invalid parameter cases." | -| Summarize PR | "Summarize the last 3 commits into a PR description using `.github/PULL_REQUEST_TEMPLATE.md` for issue CIVIMM-123." | -| Fix linting | "PHPCS reports style violations in `ContributionCompletionService.php`. Fix all issues according to `phpcs-ruleset.xml`." | -| Refactor | "Refactor `WebhookEventLogService` to improve testability, preserving all logic and tests." | -| Add service | "Create a new service `RefundService` following the existing service patterns with dependency injection." | -| Update docs | "Add PHPDoc blocks to all public methods in `PaymentAttemptService` with proper type hints." | +## 9. Safety Rules ---- - -## 11. Common Patterns & Best Practices - -**Service Registration:** -Services registered in `Civi\PaymentProcessingCore\Hook\Container\ServiceContainer` using Symfony DI container. - -**Exception Handling:** -Use domain-specific exceptions from `Civi\PaymentProcessingCore\Exception/`: -- `PaymentAttemptException`: Payment attempt errors -- `WebhookException`: Webhook processing errors - -**Logging:** -Always use `Civi\PaymentProcessingCore\Utils\Logger` for consistent logging: -```php -$logger = Civi::service('service.logger'); -$logger->log('Payment attempt created', ['attempt_id' => $attemptId]); -``` - -**Database Schema Changes:** -When modifying entities in `xml/schema/`, regenerate DAO files: +### CRITICAL: Run Tests Before Committing ```bash -./scripts/run.sh civix +./scripts/run.sh tests ``` ---- - -## 12. Safety & Best Practices - -**CRITICAL: Always Run Tests Before Committing Code Changes** - -- **MANDATORY**: When modifying source code (`.php` files), run tests BEFORE committing: - ```bash - ./scripts/run.sh tests - ``` -- **MANDATORY**: When modifying error messages, verify affected tests expect the new message -- Tests catch issues that code review might miss (changed behavior, broken assertions, etc.) -- Pushing failing code wastes reviewer time and blocks CI - -**Other Requirements:** -- Never commit code without running **tests** and **linting** -- Never remove or weaken tests to make them pass -- Always review Claude's suggestions before execution -- Always prefix commits with the issue ID (CIVIMM-###) -- Claude must never push commits automatically without human review -- Never commit `civicrm.settings.php` or any file containing credentials -- Never modify auto-generated files (`paymentprocessingcore.civix.php`, DAO classes) manually -- If unsure, stop and consult a senior developer - -**Sensitive Files (Never Commit):** -- `civicrm.settings.php` (contains credentials) +### Never Commit +- `civicrm.settings.php` (credentials) - `.env` files -- Any files with credentials or secrets +- Any secrets or API keys -**Auto-Generated Files (Do Not Edit Manually):** -- `paymentprocessingcore.civix.php` (regenerate with `civix`) -- `CRM/PaymentProcessingCore/DAO/*.php` (regenerate from XML schemas) -- Files in `xml/schema/CRM/PaymentProcessingCore/*.entityType.php` (auto-generated) +### Never Edit Manually +- `paymentprocessingcore.civix.php` +- `CRM/PaymentProcessingCore/DAO/*.php` +- Files in `xml/schema/*.entityType.php` ---- - -## 13. Deployment & Release Process - -**Pre-Deployment Checklist:** -- ✅ All tests pass (unit-test.yml) -- ✅ Linting passes (linters.yml) -- ✅ PHPStan passes (phpstan.yml) -- ✅ Code reviewed and PR approved -- ✅ Version bumped in `info.xml` if needed -- ✅ CHANGELOG updated (if applicable) - -**Release Process:** -1. Merge PR to target branch (e.g., `master`) -2. GitHub Actions automatically creates `{branch}-test` branch with vendor dependencies -3. For production releases, use the built release from GitHub releases page -4. Production deployments MUST use built releases (includes `vendor/` directory) - -**Important Notes:** -- Repository does NOT include `vendor/` directory in source code -- Test branches include dependencies for testing purposes +### Other Rules +- Never push without running tests and linting +- Never remove tests to make them pass +- Always prefix commits with issue ID +- Never push automatically without human review --- -## 14. Pre-Merge Validation Checklist +## 10. Pre-Merge Checklist | Check | Requirement | -|--------|-------------| -| ✅ Tests pass | PHPUnit tests all green in CI | -| ✅ Linting passes | PHPCS reports no violations | -| ✅ PHPStan passes | Level 9 static analysis clean on changed files | -| ✅ Commit prefix | Uses CIVIMM-### format | -| ✅ PR Template used | `.github/PULL_REQUEST_TEMPLATE.md` completed | -| ✅ No sensitive data | No credentials in code | -| ✅ Code reviewed | At least one approval from team member | +|-------|-------------| +| ✅ Tests pass | PHPUnit all green | +| ✅ Linting passes | PHPCS no violations | +| ✅ PHPStan passes | Level 9 clean | +| ✅ Commit prefix | `CIVIMM-###` format | +| ✅ PR template used | All sections filled | +| ✅ No sensitive data | No credentials | +| ✅ Code reviewed | At least one approval | --- -## 15. CiviCRM Extension Specifics - -**Extension Structure:** -- `info.xml`: Extension metadata, dependencies, version -- `paymentprocessingcore.php`: Hook implementations entry point -- `paymentprocessingcore.civix.php`: Auto-generated CiviX boilerplate (DO NOT EDIT) -- `xml/schema/`: Entity schema definitions -- `sql/`: Database schema and upgrade scripts -- `composer.json`: PHP dependencies +## 11. Common Commands -**CiviCRM Commands:** ```bash -# Enable extension -cv en paymentprocessingcore - -# Disable extension -cv dis paymentprocessingcore +# Git +git status && git diff -# Uninstall extension -cv ext:uninstall paymentprocessingcore +# Testing +./scripts/run.sh setup # One-time Docker setup +./scripts/run.sh tests # Run all tests +./scripts/run.sh test path/to/Test.php # Run specific test -# Upgrade extension -cv api Extension.upgrade +# Code Quality +./scripts/lint.sh check # Check linting +./scripts/lint.sh fix # Auto-fix linting +./scripts/run.sh phpstan-changed # PHPStan on changed files -# Clear cache -cv flush -``` - -**Database Schema Changes:** -When modifying entities in `xml/schema/`, regenerate DAO files: - -```bash -# Using the Docker test environment (RECOMMENDED - Claude Code can run this) -./scripts/run.sh setup # One-time setup +# CiviCRM ./scripts/run.sh civix # Regenerate DAO files - -# OR if working in a full CiviCRM dev environment -cd /path/to/civicrm/sites/default -civix generate:entity-boilerplate -x /path/to/extension +./scripts/run.sh cv api Contact.get # Run cv commands +./scripts/run.sh shell # Shell into container ``` -**Important Notes for Claude Code:** -- ✅ **Can run civix via Docker test environment** - use `./scripts/run.sh civix` -- ⚠️ Requires Docker to be running and test environment to be set up -- ✅ DAO files are **automatically regenerated** during extension installation/upgrade -- 📝 Always regenerate DAO files after modifying XML schemas -- 🔧 Test environment replicates exact CI setup (MariaDB, full CiviCRM) - --- -By following this file, **Claude Code** can act as a reliable assistant within our workflow — improving speed, not replacing review or standards. +## 12. Example Prompts -**Happy coding with Claude Code 🚀** +| Task | Prompt | +|------|--------| +| Generate tests | "Create PHPUnit tests for `PaymentAttemptService::createAttempt()` covering success and error cases." | +| Summarize PR | "Summarize commits into PR description using template for CIVIMM-123." | +| Fix linting | "Fix PHPCS violations in `ContributionCompletionService.php`." | +| Add service | "Create `RefundService` following existing service patterns." | diff --git a/README.md b/README.md index 9b49663..b011c06 100644 --- a/README.md +++ b/README.md @@ -3,18 +3,18 @@ ![Build Status](https://github.com/compucorp/io.compuco.paymentprocessingcore/workflows/Tests/badge.svg) ![License](https://img.shields.io/badge/license-AGPL--3.0-blue.svg) -Generic payment processing infrastructure for CiviCRM - provides foundational entities for payment attempt tracking and webhook event management across multiple payment processors. +Generic payment processing infrastructure for CiviCRM - provides foundational services for payment attempt tracking and webhook event management across multiple payment processors. ## Overview -Payment Processing Core is a foundational CiviCRM extension that provides reusable database entities and patterns for payment processor extensions. +Payment Processing Core centralizes payment processing logic that was previously duplicated across payment processor extensions (Stripe, GoCardless, Deluxe, etc.). **What it provides:** - - Payment attempt tracking entity -- Webhook event logging entity -- Multi-processor support -- API4 integration +- Webhook event logging and deduplication +- Contribution completion/failure/cancellation services +- Queue-based webhook processing with retry logic +- Multi-processor support with auto-discovery ## Requirements @@ -24,42 +24,30 @@ Payment Processing Core is a foundational CiviCRM extension that provides reusab ## Installation -### Via Command Line - ```bash # Download and enable cv ext:download io.compuco.paymentprocessingcore cv ext:enable paymentprocessingcore ``` -### For Developers - +For development: ```bash -# Clone repository git clone https://github.com/compucorp/io.compuco.paymentprocessingcore.git cd io.compuco.paymentprocessingcore - -# Install dependencies composer install - -# Enable extension cv en paymentprocessingcore ``` ## Usage -This extension provides infrastructure for payment processor extensions. See the [developer documentation](CLAUDE.md) for detailed usage information. +### Entities (API4) -### For Payment Processor Developers - -The extension provides three main entities via API4: +The extension provides three entities: - `PaymentAttempt` - Track payment sessions and attempts - `PaymentWebhook` - Log and deduplicate webhook events - `PaymentProcessorCustomer` - Store customer IDs across processors -#### Example: Payment Attempts - ```php use Civi\Api4\PaymentAttempt; @@ -73,178 +61,118 @@ $attempt = PaymentAttempt::create(FALSE) ->first(); ``` -#### Example: Contribution Completion Service +### ContributionCompletionService + +Complete pending contributions with idempotency: ```php -// Get service from container $service = \Civi::service('paymentprocessingcore.contribution_completion'); -// Complete a pending contribution -try { - $result = $service->complete( - $contributionId, // CiviCRM contribution ID - $transactionId, // Payment processor transaction ID (e.g., ch_123) - $feeAmount, // Optional: Fee amount (e.g., 2.50) - $sendReceipt // Optional: TRUE/FALSE/NULL (NULL = auto-detect from contribution page) - ); - - if ($result['success']) { - // Contribution completed successfully - } -} -catch (\Civi\Paymentprocessingcore\Exception\ContributionCompletionException $e) { - // Handle error - $context = $e->getContext(); +$result = $service->complete( + $contributionId, // CiviCRM contribution ID + $transactionId, // Payment processor transaction ID + $feeAmount, // Optional: Fee amount + $sendReceipt // Optional: TRUE/FALSE/NULL (auto-detect) +); + +if ($result['success']) { + // Contribution completed } ``` -**Features:** -- ✅ Idempotent (safe to call multiple times) -- ✅ Automatically handles accounting entries via `Contribution.completetransaction` API -- ✅ Auto-detects receipt settings from contribution page -- ✅ Records payment processor fees -- ✅ Detailed error messages with context +**Features:** Idempotent, handles accounting entries, auto-detects receipt settings, records fees. -#### Example: Customer ID Management +### PaymentProcessorCustomerService + +Manage customer IDs across processors: ```php -// Get service from container $customerService = \Civi::service('paymentprocessingcore.payment_processor_customer'); -// Get or create customer ID -try { - $customerId = $customerService->getOrCreateCustomerId( - $contactId, - $paymentProcessorId, - function() use ($stripeClient, $email, $name) { - // This callback only runs if customer doesn't exist - $customer = $stripeClient->customers->create([ - 'email' => $email, - 'name' => $name, - ]); - return $customer->id; - } - ); - - // Use $customerId in payment flow -} -catch (\Civi\Paymentprocessingcore\Exception\PaymentProcessorCustomerException $e) { - // Handle error - $context = $e->getContext(); -} +$customerId = $customerService->getOrCreateCustomerId( + $contactId, + $paymentProcessorId, + function() use ($stripeClient, $email) { + // Only runs if customer doesn't exist + $customer = $stripeClient->customers->create(['email' => $email]); + return $customer->id; + } +); ``` -**Features:** -- ✅ Prevents duplicate customers across payment processors -- ✅ Reuses existing customers (reduces API calls) -- ✅ Works with Stripe, GoCardless, ITAS, Deluxe, etc. -- ✅ Simple callback pattern for customer creation - -#### Example: Webhook Processing System +**Features:** Prevents duplicates, reuses existing customers, works with any processor. -The extension provides a queue-based webhook processing system that automatically handles events from all registered payment processors. +### Webhook Processing System -**How It Works:** +Queue-based webhook processing with automatic retry: -1. Payment processor receives webhook from external service (Stripe, GoCardless, etc.) -2. Processor extension verifies signature and saves event to `civicrm_payment_webhook` -3. Event is added to processor-specific queue -4. Scheduled job processes queued events with retry logic -5. Handlers execute processor-specific business logic +``` +Webhook → Verify signature → Save to DB → Queue → Process → Complete/Retry +``` **For Payment Processor Developers:** +1. Create webhook endpoint: ```php -// In your processor extension's webhook endpoint (CRM_YourProcessor_Page_Webhook): class CRM_YourProcessor_Page_Webhook extends CRM_Core_Page { public function run(): void { $payload = file_get_contents('php://input'); $signature = $_SERVER['HTTP_YOUR_PROCESSOR_SIGNATURE'] ?? ''; - // Get webhook receiver service $receiver = \Civi::service('yourprocessor.webhook_receiver'); $receiver->handleRequest($payload, $signature); CRM_Utils_System::civiExit(); } } +``` -// In your webhook receiver service: -class YourProcessorWebhookReceiverService { - private WebhookQueueService $queueService; - - public function handleRequest(string $payload, string $signature): void { - // 1. Verify signature (processor-specific) - $event = $this->verifyAndParseEvent($payload, $signature); - - // 2. Save to generic webhook table - $webhookId = $this->saveWebhookEvent($event); - - // 3. Queue for processing - $this->queueService->addTask( - 'yourprocessor', // processor type - $webhookId, - ['event_data' => $event->toArray()] - ); - } -} +2. Implement webhook handlers: +```php +// Option 1 (Preferred): Implement interface +use Civi\Paymentprocessingcore\Webhook\WebhookHandlerInterface; -// Implement handlers for specific event types: -// Option 1 (Preferred): Implement the interface directly class PaymentSuccessHandler implements WebhookHandlerInterface { public function handle(int $webhookId, array $params): string { - $eventData = $params['event_data']; - - // Your business logic here - // Use ContributionCompletionService to complete contributions - + // Your business logic return 'applied'; // or 'noop', 'ignored_out_of_order' } } // Option 2 (Fallback): Duck typing - if autoload issues occur -// The registry will wrap this in an Adapter automatically +// Registry wraps in Adapter automatically class PaymentSuccessHandler { public function handle(int $webhookId, array $params): string { - // Same signature as interface - registry validates at runtime return 'applied'; } } - -// Register handlers in ServiceContainer.php: -private function registerWebhookHandlers(): void { - if ($this->container->hasDefinition('paymentprocessingcore.webhook_handler_registry')) { - $registry = $this->container->getDefinition('paymentprocessingcore.webhook_handler_registry'); - - $registry->addMethodCall('registerHandler', [ - 'yourprocessor', // processor type - 'payment.succeeded', // event type - 'yourprocessor.handler.payment_success', // handler service ID - ]); - } -} ``` -**Scheduled Job:** - -The extension automatically creates a scheduled job "Process Payment Webhooks" that runs continuously. It automatically discovers and processes webhooks from all registered payment processors. +3. Register handlers in ServiceContainer: +```php +$registry = $this->container->getDefinition('paymentprocessingcore.webhook_handler_registry'); +$registry->addMethodCall('registerHandler', [ + 'yourprocessor', // processor type + 'payment.succeeded', // event type + 'yourprocessor.handler.payment_success', // handler service ID +]); +``` **Features:** -- ✅ Automatic retry with exponential backoff (5min, 15min, 45min) -- ✅ Stuck webhook recovery (resets webhooks stuck in "processing" > 30 minutes) -- ✅ Batch processing to prevent job timeouts (250 events per processor per run) -- ✅ Idempotent processing (safe to call multiple times) -- ✅ Multi-processor support (Stripe, GoCardless, Deluxe auto-discovered) +- Automatic retry with exponential backoff (5min → 15min → 45min) +- Stuck webhook recovery (resets after 30 minutes) +- Batch processing (250 events per processor per run) +- Multi-processor auto-discovery ### For CiviCRM Administrators -This extension provides infrastructure used by payment processors. After installation: +This extension provides infrastructure for payment processors. After installation: 1. Install payment processor extensions (Stripe, GoCardless, etc.) 2. Configure payment processors as normal 3. PaymentProcessingCore works automatically in the background -No additional configuration is required. +No additional configuration required. ## Development @@ -281,23 +209,26 @@ No additional configuration is required. ./scripts/lint.sh check ./scripts/lint.sh fix -# Static analysis +# Static analysis (PHPStan level 9) ./scripts/run.sh phpstan-changed ``` -### Contributing +## Contributing -See [CLAUDE.md](CLAUDE.md) for detailed development guidelines. +See [CLAUDE.md](CLAUDE.md) for development guidelines including: +- PR and commit conventions +- Code quality standards +- CI requirements +- Architecture overview ## Support - **Issues:** [GitHub Issues](https://github.com/compucorp/io.compuco.paymentprocessingcore/issues) - **Email:** hello@compuco.io -- **Documentation:** [Developer Guide](CLAUDE.md) ## License -This extension is licensed under [AGPL-3.0](LICENSE.txt). +[AGPL-3.0](LICENSE.txt) ## Credits