diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..227f85e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.idea +vendor/ +civicrm-core/ +.phpunit.result.cache +bin/civicrm/ +bin/phpcbf.phar +bin/phpcs.phar \ No newline at end of file diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml new file mode 100644 index 0000000..8ddbd81 --- /dev/null +++ b/.github/workflows/linters.yml @@ -0,0 +1,23 @@ +name: Linters + +on: pull_request + +env: + GITHUB_BASE_REF: ${{ github.base_ref }} + +jobs: + run-linters: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Install phpcs + run: cd bin && ./install-php-linter + + - name: Fetch target branch + run: git fetch -n origin ${GITHUB_BASE_REF} + + - name: Run phpcs linter + run: git diff --diff-filter=d origin/${GITHUB_BASE_REF} --name-only -- '*.php' | xargs -r ./bin/phpcs.phar --standard=phpcs-ruleset.xml diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml new file mode 100644 index 0000000..2a9c6d7 --- /dev/null +++ b/.github/workflows/phpstan.yml @@ -0,0 +1,96 @@ +name: PHPStan + +on: pull_request + +env: + GITHUB_BASE_REF: ${{ github.base_ref }} + +jobs: + run-phpstan: + + runs-on: ubuntu-latest + container: compucorp/civicrm-buildkit:1.3.1-php8.0 + + env: + CIVICRM_EXTENSIONS_DIR: site/web/sites/all/modules/civicrm/tools/extensions + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: root + ports: + - 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 + + - name: Build Drupal site + run: civibuild create drupal-clean --civi-ver 6.4.1 --cms-ver 7.100 --web-root $GITHUB_WORKSPACE/site + + - uses: actions/checkout@v2 + with: + path: ${{ env.CIVICRM_EXTENSIONS_DIR }}/io.compuco.paymentprocessingcore + fetch-depth: 0 + + - name: Enable Payment Processing Core extension + working-directory: ${{ env.CIVICRM_EXTENSIONS_DIR }} + run: cv en paymentprocessingcore + + - name: Install extension dependencies + working-directory: ${{ env.CIVICRM_EXTENSIONS_DIR }}/io.compuco.paymentprocessingcore + run: composer install + + - name: Fetch target branch + working-directory: ${{ env.CIVICRM_EXTENSIONS_DIR }}/io.compuco.paymentprocessingcore + run: git fetch -n origin ${GITHUB_BASE_REF} + + - name: Download PHPStan + run: | + curl -sL https://github.com/phpstan/phpstan/releases/download/1.12.10/phpstan.phar -o /tmp/phpstan.phar + chmod +x /tmp/phpstan.phar + + - name: Run PHPStan + working-directory: ${{ env.CIVICRM_EXTENSIONS_DIR }}/io.compuco.paymentprocessingcore + run: | + # Create phpstan.neon with CI-specific paths + cat > phpstan-ci.neon <> "$TMP_FILE" + if [ "$line" = "> "$TMP_FILE" + fi + done < "$FILE_PATH" + mv "$TMP_FILE" "$FILE_PATH" + echo "File modified successfully." + + - name: Run phpunit tests + working-directory: ${{ env.CIVICRM_EXTENSIONS_DIR }}/io.compuco.paymentprocessingcore + run: phpunit9 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc7e45d --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +/vendor/ +.env +civicrm-core/ +.idea +.claude/ +.phpunit.result.cache +bin/civicrm/ +bin/phpcbf.phar +bin/phpcs.phar +phpstan_errors.log +phpstan_output.log +*test_output.log +lint_output.log \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ebeffc9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,731 @@ + + +# ๐Ÿง  Claude Code Development Guide + +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. + +Claude Code can edit files, plan changes, and run commands โ€” but must follow all internal development standards described here. + +--- + +## ๐Ÿ“ฆ Project Overview + +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 + +--- + +## 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):** + +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) + +```bash +# Setup with default CiviCRM version (6.4.1) +./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 +``` + +**Typical Workflow:** +```bash +# 1. Setup test environment and run tests (most common) +./scripts/run.sh setup +./scripts/run.sh tests + +# 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 +``` + +**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 + +**Or trigger CI workflow locally using act:** +```bash +act -j run-unit-tests -P ubuntu-latest=compucorp/civicrm-buildkit:1.3.1-php8.0 +``` + +**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 +``` + +**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 + +# Run linter on all source files +./scripts/lint.sh check-all + +# Stop linter container +./scripts/lint.sh stop +``` + +**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/ +``` + +**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" + +### File Newline Requirements + +**All files must end with a newline character** (POSIX standard compliance). + +**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 + +--- + +## 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 + +**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 +``` + +**Rules:** +- Keep summaries 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") + +**Examples:** +``` +CIVIMM-456: Add PaymentAttempt entity with multi-processor support +CIVIMM-789: Implement webhook event deduplication service +CIVIMM-101: Refactor ContributionCompletionService for idempotency +``` + +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/`: + +- **PaymentAttempt**: Generic payment attempt tracking for all processors +- **PaymentWebhook**: Generic webhook event logging for deduplication + +### Service Layer Architecture + +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: + +- **PaymentProcessorWebhookInterface**: Contract for webhook handlers +- **PaymentAttemptInterface**: Contract for attempt handling +- **PaymentSessionInterface**: Contract for session handling + +--- + +## 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 + +# Commit changes +git commit -m "CIVIMM-###: ..." +``` + +**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) + +--- + +## 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." | + +--- + +## 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: +```bash +./scripts/run.sh civix +``` + +--- + +## 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) +- `.env` files +- Any files with credentials or secrets + +**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) + +--- + +## 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 + +--- + +## 14. Pre-Merge Validation 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 | + +--- + +## 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 + +**CiviCRM Commands:** +```bash +# Enable extension +cv en paymentprocessingcore + +# Disable extension +cv dis paymentprocessingcore + +# Uninstall extension +cv ext:uninstall paymentprocessingcore + +# Upgrade extension +cv api Extension.upgrade + +# 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 +./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 +``` + +**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. + +**Happy coding with Claude Code ๐Ÿš€** diff --git a/CRM/Paymentprocessingcore/BAO/PaymentAttempt.php b/CRM/Paymentprocessingcore/BAO/PaymentAttempt.php new file mode 100644 index 0000000..cd11748 --- /dev/null +++ b/CRM/Paymentprocessingcore/BAO/PaymentAttempt.php @@ -0,0 +1,120 @@ +copyValues($params); + $instance->save(); + CRM_Utils_Hook::post($hook, $entityName, (int) $instance->id, $instance); + + return $instance; + } + + /** + * Find a PaymentAttempt record by processor session ID + * + * @param string $sessionId Processor session ID (cs_... for Stripe, mandate_... for GoCardless) + * @param string $processorType Processor type ('stripe', 'gocardless', etc.) + * @return array|null Array of attempt data or NULL if not found + */ + public static function findBySessionId($sessionId, $processorType = 'stripe') { + if (empty($sessionId)) { + return NULL; + } + + $attempt = new self(); + $attempt->processor_session_id = $sessionId; + $attempt->processor_type = $processorType; + + /** @var CRM_Paymentprocessingcore_DAO_PaymentAttempt $attempt */ + if ($attempt->find(TRUE)) { + /** @var array */ + return $attempt->toArray(); + } + + return NULL; + } + + /** + * Find a PaymentAttempt record by processor payment ID + * + * @param string $paymentId Processor payment ID (pi_... for Stripe, payment_... for GoCardless) + * @param string $processorType Processor type ('stripe', 'gocardless', etc.) + * @return array|null Array of attempt data or NULL if not found + */ + public static function findByPaymentId($paymentId, $processorType = 'stripe') { + if (empty($paymentId)) { + return NULL; + } + + $attempt = new self(); + $attempt->processor_payment_id = $paymentId; + $attempt->processor_type = $processorType; + + /** @var CRM_Paymentprocessingcore_DAO_PaymentAttempt $attempt */ + if ($attempt->find(TRUE)) { + /** @var array */ + return $attempt->toArray(); + } + + return NULL; + } + + /** + * Find a PaymentAttempt record by Contribution ID + * + * @param int $contributionId CiviCRM Contribution ID + * @return array|null Array of attempt data or NULL if not found + */ + public static function findByContributionId($contributionId) { + if (empty($contributionId)) { + return NULL; + } + + $attempt = new self(); + $attempt->contribution_id = $contributionId; + + /** @var CRM_Paymentprocessingcore_DAO_PaymentAttempt $attempt */ + if ($attempt->find(TRUE)) { + /** @var array */ + return $attempt->toArray(); + } + + return NULL; + } + + /** + * Get available statuses for PaymentAttempt + * + * @return array Status options + */ + public static function getStatuses() { + return [ + 'pending' => E::ts('Pending'), + 'completed' => E::ts('Completed'), + 'failed' => E::ts('Failed'), + 'cancelled' => E::ts('Cancelled'), + ]; + } + +} diff --git a/CRM/Paymentprocessingcore/BAO/PaymentWebhook.php b/CRM/Paymentprocessingcore/BAO/PaymentWebhook.php new file mode 100644 index 0000000..ea20583 --- /dev/null +++ b/CRM/Paymentprocessingcore/BAO/PaymentWebhook.php @@ -0,0 +1,78 @@ +copyValues($params); + $instance->save(); + CRM_Utils_Hook::post($hook, $entityName, $instance->id, $instance); + + return $instance; + } + + /** + * Find a PaymentWebhook record by event ID + * + * @param string $eventId Processor event ID (evt_... for Stripe) + * @return array|null Array of webhook data or NULL if not found + */ + public static function findByEventId($eventId) { + if (empty($eventId)) { + return NULL; + } + + $webhook = new self(); + $webhook->event_id = $eventId; + + if ($webhook->find(TRUE)) { + return $webhook->toArray(); + } + + return NULL; + } + + /** + * Check if an event has already been processed (for idempotency) + * + * @param string $eventId Processor event ID + * @return bool TRUE if event has been processed, FALSE otherwise + */ + public static function isProcessed($eventId) { + $webhook = self::findByEventId($eventId); + return !empty($webhook) && in_array($webhook['status'], ['processed', 'processing']); + } + + /** + * Get available statuses for PaymentWebhook + * + * @return array Status options + */ + public static function getStatuses() { + return [ + 'new' => E::ts('New'), + 'processing' => E::ts('Processing'), + 'processed' => E::ts('Processed'), + 'error' => E::ts('Error'), + ]; + } + +} diff --git a/CRM/Paymentprocessingcore/DAO/PaymentAttempt.php b/CRM/Paymentprocessingcore/DAO/PaymentAttempt.php new file mode 100644 index 0000000..4284036 --- /dev/null +++ b/CRM/Paymentprocessingcore/DAO/PaymentAttempt.php @@ -0,0 +1,473 @@ +__table = 'civicrm_payment_attempt'; + parent::__construct(); + } + + /** + * Returns localized title of this entity. + * + * @param bool $plural + * Whether to return the plural version of the title. + */ + public static function getEntityTitle($plural = FALSE) { + return $plural ? E::ts('Payment Attempts') : E::ts('Payment Attempt'); + } + + /** + * Returns all the column names of this table + * + * @return array + */ + public static function &fields() { + if (!isset(Civi::$statics[__CLASS__]['fields'])) { + Civi::$statics[__CLASS__]['fields'] = [ + 'id' => [ + 'name' => 'id', + 'type' => CRM_Utils_Type::T_INT, + 'title' => E::ts('ID'), + 'description' => E::ts('Unique ID'), + 'required' => TRUE, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_payment_attempt.id', + 'table_name' => 'civicrm_payment_attempt', + 'entity' => 'PaymentAttempt', + 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', + 'localizable' => 0, + 'html' => [ + 'type' => 'Number', + ], + 'readonly' => TRUE, + 'add' => NULL, + ], + 'contribution_id' => [ + 'name' => 'contribution_id', + 'type' => CRM_Utils_Type::T_INT, + 'title' => E::ts('Contribution ID'), + 'description' => E::ts('FK to Contribution'), + 'required' => TRUE, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_payment_attempt.contribution_id', + 'table_name' => 'civicrm_payment_attempt', + 'entity' => 'PaymentAttempt', + 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', + 'localizable' => 0, + 'FKClassName' => 'CRM_Contribute_DAO_Contribution', + 'html' => [ + 'type' => 'EntityRef', + 'label' => E::ts("Contribution"), + ], + 'add' => NULL, + ], + 'contact_id' => [ + 'name' => 'contact_id', + 'type' => CRM_Utils_Type::T_INT, + 'title' => E::ts('Contact ID'), + 'description' => E::ts('FK to Contact (donor)'), + 'required' => FALSE, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_payment_attempt.contact_id', + 'table_name' => 'civicrm_payment_attempt', + 'entity' => 'PaymentAttempt', + 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', + 'localizable' => 0, + 'FKClassName' => 'CRM_Contact_DAO_Contact', + 'html' => [ + 'type' => 'EntityRef', + 'label' => E::ts("Contact"), + ], + 'add' => NULL, + ], + 'payment_processor_id' => [ + 'name' => 'payment_processor_id', + 'type' => CRM_Utils_Type::T_INT, + 'title' => E::ts('Payment Processor ID'), + 'description' => E::ts('FK to Payment Processor'), + 'required' => FALSE, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_payment_attempt.payment_processor_id', + 'table_name' => 'civicrm_payment_attempt', + 'entity' => 'PaymentAttempt', + 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', + 'localizable' => 0, + 'FKClassName' => 'CRM_Financial_DAO_PaymentProcessor', + 'html' => [ + 'type' => 'Select', + 'label' => E::ts("Payment Processor"), + ], + 'add' => NULL, + ], + 'processor_type' => [ + 'name' => 'processor_type', + 'type' => CRM_Utils_Type::T_STRING, + 'title' => E::ts('Processor Type'), + 'description' => E::ts('Processor type: \'stripe\', \'gocardless\', \'itas\', etc.'), + 'required' => TRUE, + 'maxlength' => 50, + 'size' => CRM_Utils_Type::BIG, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_payment_attempt.processor_type', + 'table_name' => 'civicrm_payment_attempt', + 'entity' => 'PaymentAttempt', + 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', + 'localizable' => 0, + 'html' => [ + 'type' => 'Text', + 'label' => E::ts("Processor Type"), + ], + 'add' => NULL, + ], + 'processor_session_id' => [ + 'name' => 'processor_session_id', + 'type' => CRM_Utils_Type::T_STRING, + 'title' => E::ts('Processor Session ID'), + 'description' => E::ts('Processor session ID (cs_... for Stripe, mandate_... for GoCardless)'), + 'maxlength' => 255, + 'size' => CRM_Utils_Type::HUGE, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_payment_attempt.processor_session_id', + 'table_name' => 'civicrm_payment_attempt', + 'entity' => 'PaymentAttempt', + 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', + 'localizable' => 0, + 'html' => [ + 'type' => 'Text', + 'label' => E::ts("Processor Session ID"), + ], + 'add' => NULL, + ], + 'processor_payment_id' => [ + 'name' => 'processor_payment_id', + 'type' => CRM_Utils_Type::T_STRING, + 'title' => E::ts('Processor Payment ID'), + 'description' => E::ts('Processor payment ID (pi_... for Stripe, payment_... for GoCardless)'), + 'maxlength' => 255, + 'size' => CRM_Utils_Type::HUGE, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_payment_attempt.processor_payment_id', + 'table_name' => 'civicrm_payment_attempt', + 'entity' => 'PaymentAttempt', + 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', + 'localizable' => 0, + 'html' => [ + 'type' => 'Text', + 'label' => E::ts("Processor Payment ID"), + ], + 'add' => NULL, + ], + 'status' => [ + 'name' => 'status', + 'type' => CRM_Utils_Type::T_STRING, + 'title' => E::ts('Status'), + 'description' => E::ts('Attempt status: pending, completed, failed, cancelled'), + 'required' => TRUE, + 'maxlength' => 25, + 'size' => CRM_Utils_Type::MEDIUM, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_payment_attempt.status', + 'default' => 'pending', + 'table_name' => 'civicrm_payment_attempt', + 'entity' => 'PaymentAttempt', + 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', + 'localizable' => 0, + 'html' => [ + 'type' => 'Select', + 'label' => E::ts("Status"), + ], + 'pseudoconstant' => [ + 'callback' => 'CRM_Paymentprocessingcore_BAO_PaymentAttempt::getStatuses', + ], + 'add' => NULL, + ], + 'created_date' => [ + 'name' => 'created_date', + 'type' => CRM_Utils_Type::T_TIMESTAMP, + 'title' => E::ts('Created Date'), + 'description' => E::ts('When attempt was created'), + 'required' => TRUE, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_payment_attempt.created_date', + 'default' => 'CURRENT_TIMESTAMP', + 'table_name' => 'civicrm_payment_attempt', + 'entity' => 'PaymentAttempt', + 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', + 'localizable' => 0, + 'html' => [ + 'type' => 'Select Date', + 'label' => E::ts("Created Date"), + ], + 'add' => NULL, + ], + 'updated_date' => [ + 'name' => 'updated_date', + 'type' => CRM_Utils_Type::T_TIMESTAMP, + 'title' => E::ts('Updated Date'), + 'description' => E::ts('Last updated'), + 'required' => TRUE, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_payment_attempt.updated_date', + 'default' => 'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', + 'table_name' => 'civicrm_payment_attempt', + 'entity' => 'PaymentAttempt', + 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', + 'localizable' => 0, + 'html' => [ + 'type' => 'Select Date', + 'label' => E::ts("Updated Date"), + ], + 'add' => NULL, + ], + ]; + CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'fields_callback', Civi::$statics[__CLASS__]['fields']); + } + return Civi::$statics[__CLASS__]['fields']; + } + + /** + * Returns the list of fields that can be imported + * + * @param bool $prefix + * + * @return array + */ + public static function &import($prefix = FALSE) { + $r = CRM_Core_DAO_AllCoreTables::getImports(__CLASS__, 'payment_attempt', $prefix, []); + return $r; + } + + /** + * Returns the list of fields that can be exported + * + * @param bool $prefix + * + * @return array + */ + public static function &export($prefix = FALSE) { + $r = CRM_Core_DAO_AllCoreTables::getExports(__CLASS__, 'payment_attempt', $prefix, []); + return $r; + } + + /** + * Returns the list of indices + * + * @param bool $localize + * + * @return array + */ + public static function indices($localize = TRUE) { + $indices = [ + 'index_contribution_id' => [ + 'name' => 'index_contribution_id', + 'field' => [ + 0 => 'contribution_id', + ], + 'localizable' => FALSE, + 'unique' => TRUE, + 'sig' => 'civicrm_payment_attempt::1::contribution_id', + ], + 'index_processor_type' => [ + 'name' => 'index_processor_type', + 'field' => [ + 0 => 'processor_type', + ], + 'localizable' => FALSE, + 'sig' => 'civicrm_payment_attempt::0::processor_type', + ], + 'index_processor_session' => [ + 'name' => 'index_processor_session', + 'field' => [ + 0 => 'processor_session_id', + 1 => 'processor_type', + ], + 'localizable' => FALSE, + 'sig' => 'civicrm_payment_attempt::0::processor_session_id::processor_type', + ], + 'index_processor_payment' => [ + 'name' => 'index_processor_payment', + 'field' => [ + 0 => 'processor_payment_id', + 1 => 'processor_type', + ], + 'localizable' => FALSE, + 'sig' => 'civicrm_payment_attempt::0::processor_payment_id::processor_type', + ], + ]; + return ($localize && !empty($indices)) ? CRM_Core_DAO_AllCoreTables::multilingualize(__CLASS__, $indices) : $indices; + } + +} diff --git a/CRM/Paymentprocessingcore/DAO/PaymentWebhook.php b/CRM/Paymentprocessingcore/DAO/PaymentWebhook.php new file mode 100644 index 0000000..e949f0e --- /dev/null +++ b/CRM/Paymentprocessingcore/DAO/PaymentWebhook.php @@ -0,0 +1,454 @@ +__table = 'civicrm_payment_webhook'; + parent::__construct(); + } + + /** + * Returns localized title of this entity. + * + * @param bool $plural + * Whether to return the plural version of the title. + */ + public static function getEntityTitle($plural = FALSE) { + return $plural ? E::ts('Payment Webhooks') : E::ts('Payment Webhook'); + } + + /** + * Returns all the column names of this table + * + * @return array + */ + public static function &fields() { + if (!isset(Civi::$statics[__CLASS__]['fields'])) { + Civi::$statics[__CLASS__]['fields'] = [ + 'id' => [ + 'name' => 'id', + 'type' => CRM_Utils_Type::T_INT, + 'title' => E::ts('ID'), + 'description' => E::ts('Unique ID'), + 'required' => TRUE, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_payment_webhook.id', + 'table_name' => 'civicrm_payment_webhook', + 'entity' => 'PaymentWebhook', + 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', + 'localizable' => 0, + 'html' => [ + 'type' => 'Number', + ], + 'readonly' => TRUE, + 'add' => NULL, + ], + 'event_id' => [ + 'name' => 'event_id', + 'type' => CRM_Utils_Type::T_STRING, + 'title' => E::ts('Event ID'), + 'description' => E::ts('Processor event ID (evt_... for Stripe, evt_... for GoCardless)'), + 'required' => TRUE, + 'maxlength' => 255, + 'size' => CRM_Utils_Type::HUGE, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_payment_webhook.event_id', + 'table_name' => 'civicrm_payment_webhook', + 'entity' => 'PaymentWebhook', + 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', + 'localizable' => 0, + 'html' => [ + 'type' => 'Text', + 'label' => E::ts("Event ID"), + ], + 'add' => NULL, + ], + 'processor_type' => [ + 'name' => 'processor_type', + 'type' => CRM_Utils_Type::T_STRING, + 'title' => E::ts('Processor Type'), + 'description' => E::ts('Processor type: \'stripe\', \'gocardless\', \'itas\', etc.'), + 'required' => TRUE, + 'maxlength' => 50, + 'size' => CRM_Utils_Type::BIG, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_payment_webhook.processor_type', + 'table_name' => 'civicrm_payment_webhook', + 'entity' => 'PaymentWebhook', + 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', + 'localizable' => 0, + 'html' => [ + 'type' => 'Text', + 'label' => E::ts("Processor Type"), + ], + 'add' => NULL, + ], + 'event_type' => [ + 'name' => 'event_type', + 'type' => CRM_Utils_Type::T_STRING, + 'title' => E::ts('Event Type'), + 'description' => E::ts('Event type (e.g. checkout.session.completed, payment_intent.succeeded)'), + 'required' => TRUE, + 'maxlength' => 100, + 'size' => CRM_Utils_Type::HUGE, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_payment_webhook.event_type', + 'table_name' => 'civicrm_payment_webhook', + 'entity' => 'PaymentWebhook', + 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', + 'localizable' => 0, + 'html' => [ + 'type' => 'Text', + 'label' => E::ts("Event Type"), + ], + 'add' => NULL, + ], + 'payment_attempt_id' => [ + 'name' => 'payment_attempt_id', + 'type' => CRM_Utils_Type::T_INT, + 'title' => E::ts('Payment Attempt ID'), + 'description' => E::ts('FK to Payment Attempt'), + 'required' => FALSE, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_payment_webhook.payment_attempt_id', + 'table_name' => 'civicrm_payment_webhook', + 'entity' => 'PaymentWebhook', + 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', + 'localizable' => 0, + 'FKClassName' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', + 'html' => [ + 'type' => 'EntityRef', + 'label' => E::ts("Payment Attempt"), + ], + 'add' => NULL, + ], + 'status' => [ + 'name' => 'status', + 'type' => CRM_Utils_Type::T_STRING, + 'title' => E::ts('Status'), + 'description' => E::ts('Processing status: new, processing, processed, error'), + 'required' => TRUE, + 'maxlength' => 25, + 'size' => CRM_Utils_Type::MEDIUM, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_payment_webhook.status', + 'default' => 'new', + 'table_name' => 'civicrm_payment_webhook', + 'entity' => 'PaymentWebhook', + 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', + 'localizable' => 0, + 'html' => [ + 'type' => 'Select', + 'label' => E::ts("Status"), + ], + 'pseudoconstant' => [ + 'callback' => 'CRM_Paymentprocessingcore_BAO_PaymentWebhook::getStatuses', + ], + 'add' => NULL, + ], + 'result' => [ + 'name' => 'result', + 'type' => CRM_Utils_Type::T_STRING, + 'title' => E::ts('Result'), + 'description' => E::ts('Processing result: applied, noop, ignored_out_of_order, error'), + 'maxlength' => 50, + 'size' => CRM_Utils_Type::BIG, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_payment_webhook.result', + 'table_name' => 'civicrm_payment_webhook', + 'entity' => 'PaymentWebhook', + 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', + 'localizable' => 0, + 'html' => [ + 'type' => 'Text', + 'label' => E::ts("Result"), + ], + 'add' => NULL, + ], + 'error_log' => [ + 'name' => 'error_log', + 'type' => CRM_Utils_Type::T_TEXT, + 'title' => E::ts('Error Log'), + 'description' => E::ts('Error details if processing failed'), + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_payment_webhook.error_log', + 'table_name' => 'civicrm_payment_webhook', + 'entity' => 'PaymentWebhook', + 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', + 'localizable' => 0, + 'html' => [ + 'type' => 'TextArea', + 'label' => E::ts("Error Log"), + ], + 'add' => NULL, + ], + 'processed_at' => [ + 'name' => 'processed_at', + 'type' => CRM_Utils_Type::T_TIMESTAMP, + 'title' => E::ts('Processed At'), + 'description' => E::ts('When event was processed'), + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_payment_webhook.processed_at', + 'table_name' => 'civicrm_payment_webhook', + 'entity' => 'PaymentWebhook', + 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', + 'localizable' => 0, + 'html' => [ + 'type' => 'Select Date', + 'label' => E::ts("Processed At"), + ], + 'add' => NULL, + ], + 'created_date' => [ + 'name' => 'created_date', + 'type' => CRM_Utils_Type::T_TIMESTAMP, + 'title' => E::ts('Created Date'), + 'description' => E::ts('When webhook was received'), + 'required' => TRUE, + 'usage' => [ + 'import' => FALSE, + 'export' => FALSE, + 'duplicate_matching' => FALSE, + 'token' => FALSE, + ], + 'where' => 'civicrm_payment_webhook.created_date', + 'default' => 'CURRENT_TIMESTAMP', + 'table_name' => 'civicrm_payment_webhook', + 'entity' => 'PaymentWebhook', + 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', + 'localizable' => 0, + 'html' => [ + 'type' => 'Select Date', + 'label' => E::ts("Created Date"), + ], + 'add' => NULL, + ], + ]; + CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'fields_callback', Civi::$statics[__CLASS__]['fields']); + } + return Civi::$statics[__CLASS__]['fields']; + } + + /** + * Returns the list of fields that can be imported + * + * @param bool $prefix + * + * @return array + */ + public static function &import($prefix = FALSE) { + $r = CRM_Core_DAO_AllCoreTables::getImports(__CLASS__, 'payment_webhook', $prefix, []); + return $r; + } + + /** + * Returns the list of fields that can be exported + * + * @param bool $prefix + * + * @return array + */ + public static function &export($prefix = FALSE) { + $r = CRM_Core_DAO_AllCoreTables::getExports(__CLASS__, 'payment_webhook', $prefix, []); + return $r; + } + + /** + * Returns the list of indices + * + * @param bool $localize + * + * @return array + */ + public static function indices($localize = TRUE) { + $indices = [ + 'UI_event_processor' => [ + 'name' => 'UI_event_processor', + 'field' => [ + 0 => 'event_id', + 1 => 'processor_type', + ], + 'localizable' => FALSE, + 'unique' => TRUE, + 'sig' => 'civicrm_payment_webhook::1::event_id::processor_type', + ], + 'index_event_type' => [ + 'name' => 'index_event_type', + 'field' => [ + 0 => 'event_type', + ], + 'localizable' => FALSE, + 'sig' => 'civicrm_payment_webhook::0::event_type', + ], + ]; + return ($localize && !empty($indices)) ? CRM_Core_DAO_AllCoreTables::multilingualize(__CLASS__, $indices) : $indices; + } + +} diff --git a/CRM/Paymentprocessingcore/Upgrader.php b/CRM/Paymentprocessingcore/Upgrader.php new file mode 100644 index 0000000..88b9fde --- /dev/null +++ b/CRM/Paymentprocessingcore/Upgrader.php @@ -0,0 +1,135 @@ +executeSqlFile('sql/my_install.sql'); + // } + + /** + * Example: Work with entities usually not available during the install step. + * + * This method can be used for any post-install tasks. For example, if a step + * of your installation depends on accessing an entity that is itself + * created during the installation (e.g., a setting or a managed entity), do + * so here to avoid order of operation problems. + */ + // public function postInstall(): void { + // $customFieldId = civicrm_api3('CustomField', 'getvalue', array( + // 'return' => array("id"), + // 'name' => "customFieldCreatedViaManagedHook", + // )); + // civicrm_api3('Setting', 'create', array( + // 'myWeirdFieldSetting' => array('id' => $customFieldId, 'weirdness' => 1), + // )); + // } + + /** + * Example: Run an external SQL script when the module is uninstalled. + * + * Note that if a file is present sql\auto_uninstall that will run regardless of this hook. + */ + // public function uninstall(): void { + // $this->executeSqlFile('sql/my_uninstall.sql'); + // } + + /** + * Example: Run a simple query when a module is enabled. + */ + // public function enable(): void { + // CRM_Core_DAO::executeQuery('UPDATE foo SET is_active = 1 WHERE bar = "whiz"'); + // } + + /** + * Example: Run a simple query when a module is disabled. + */ + // public function disable(): void { + // CRM_Core_DAO::executeQuery('UPDATE foo SET is_active = 0 WHERE bar = "whiz"'); + // } + + /** + * Example: Run a couple simple queries. + * + * @return TRUE on success + */ + // public function upgrade_4200(): bool { + // $this->ctx->log->info('Applying update 4200'); + // CRM_Core_DAO::executeQuery('UPDATE foo SET bar = "whiz"'); + // CRM_Core_DAO::executeQuery('DELETE FROM bang WHERE willy = wonka(2)'); + // return TRUE; + // } + + /** + * Example: Run an external SQL script. + * + * @return TRUE on success + */ + // public function upgrade_4201(): bool { + // $this->ctx->log->info('Applying update 4201'); + // // this path is relative to the extension base dir + // $this->executeSqlFile('sql/upgrade_4201.sql'); + // return TRUE; + // } + + /** + * Example: Run a slow upgrade process by breaking it up into smaller chunk. + * + * @return TRUE on success + */ + // public function upgrade_4202(): bool { + // $this->ctx->log->info('Planning update 4202'); // PEAR Log interface + + // $this->addTask(E::ts('Process first step'), 'processPart1', $arg1, $arg2); + // $this->addTask(E::ts('Process second step'), 'processPart2', $arg3, $arg4); + // $this->addTask(E::ts('Process second step'), 'processPart3', $arg5); + // return TRUE; + // } + // public function processPart1($arg1, $arg2) { sleep(10); return TRUE; } + // public function processPart2($arg3, $arg4) { sleep(10); return TRUE; } + // public function processPart3($arg5) { sleep(10); return TRUE; } + + /** + * Example: Run an upgrade with a query that touches many (potentially + * millions) of records by breaking it up into smaller chunks. + * + * @return TRUE on success + */ + // public function upgrade_4203(): bool { + // $this->ctx->log->info('Planning update 4203'); // PEAR Log interface + + // $minId = CRM_Core_DAO::singleValueQuery('SELECT coalesce(min(id),0) FROM civicrm_contribution'); + // $maxId = CRM_Core_DAO::singleValueQuery('SELECT coalesce(max(id),0) FROM civicrm_contribution'); + // for ($startId = $minId; $startId <= $maxId; $startId += self::BATCH_SIZE) { + // $endId = $startId + self::BATCH_SIZE - 1; + // $title = E::ts('Upgrade Batch (%1 => %2)', array( + // 1 => $startId, + // 2 => $endId, + // )); + // $sql = ' + // UPDATE civicrm_contribution SET foobar = apple(banana()+durian) + // WHERE id BETWEEN %1 and %2 + // '; + // $params = array( + // 1 => array($startId, 'Integer'), + // 2 => array($endId, 'Integer'), + // ); + // $this->addTask($title, 'executeSql', $sql, $params); + // } + // return TRUE; + // } + +} diff --git a/Civi/Api4/PaymentAttempt.php b/Civi/Api4/PaymentAttempt.php new file mode 100644 index 0000000..c0a43dd --- /dev/null +++ b/Civi/Api4/PaymentAttempt.php @@ -0,0 +1,20 @@ + $params Must include: contributionPageID, qfKey, contactID + * @param array $additionalParams Processor-specific params (e.g., {CHECKOUT_SESSION_ID}) + * @return string Absolute URL to thank-you page with parameters + */ + public static function buildSuccessUrl(int $contributionId, array $params, array $additionalParams = []): string { + $queryParams = [ + 'id' => $params['contributionPageID'] ?? NULL, + '_qf_ThankYou_display' => 1, + 'qfKey' => $params['qfKey'] ?? NULL, + 'cid' => $params['contactID'] ?? NULL, + ]; + + // Add processor-specific parameters (e.g., session_id for Stripe) + foreach ($additionalParams as $key => $value) { + $queryParams[$key] = $value; + } + + return \CRM_Utils_System::url( + 'civicrm/contribute/transact', + $queryParams, + TRUE, + NULL, + FALSE + ); + } + + /** + * Build cancel URL for payment processor redirect + * + * Returns user to the contribution page main form with error/cancel message. + * Matches RequireActionHandler URL format for consistency. + * + * @param int $contributionId CiviCRM contribution ID + * @param array $params Must include: contributionPageID, qfKey, contactID + * @return string Absolute URL to contribution page with cancel flag + */ + public static function buildCancelUrl(int $contributionId, array $params): string { + $queryParams = [ + 'id' => $params['contributionPageID'] ?? NULL, + '_qf_Main_display' => 1, + 'qfKey' => $params['qfKey'] ?? NULL, + 'cancel' => 1, + 'cid' => $params['contactID'] ?? NULL, + // For logging/debugging + 'contribution_id' => $contributionId, + ]; + + return \CRM_Utils_System::url( + 'civicrm/contribute/transact', + $queryParams, + TRUE, + NULL, + FALSE + ); + } + + /** + * Build error URL for payment processor error redirects + * + * Returns user to the contribution page with error message. + * + * @param int $contributionId CiviCRM contribution ID + * @param array $params Must include: contributionPageID, qfKey + * @param string $errorMessage Optional error message to display + * @return string Absolute URL to contribution page with error flag + */ + public static function buildErrorUrl(int $contributionId, array $params, string $errorMessage = ''): string { + $queryParams = [ + 'id' => $params['contributionPageID'] ?? NULL, + '_qf_Main_display' => 1, + 'qfKey' => $params['qfKey'] ?? NULL, + 'error' => 1, + 'contribution_id' => $contributionId, + ]; + + if (!empty($errorMessage)) { + $queryParams['error_message'] = $errorMessage; + } + + return \CRM_Utils_System::url( + 'civicrm/contribute/transact', + $queryParams, + TRUE, + NULL, + FALSE + ); + } + + /** + * Build event registration success URL + * + * For processors used with event registration instead of contribution pages. + * + * @param int $participantId CiviCRM participant ID + * @param array $params Must include: eventID, qfKey + * @param array $additionalParams Processor-specific params + * @return string Absolute URL to event thank-you page + */ + public static function buildEventSuccessUrl(int $participantId, array $params, array $additionalParams = []): string { + $queryParams = [ + 'id' => $params['eventID'] ?? NULL, + '_qf_ThankYou_display' => 1, + 'qfKey' => $params['qfKey'] ?? NULL, + ]; + + foreach ($additionalParams as $key => $value) { + $queryParams[$key] = $value; + } + + return \CRM_Utils_System::url( + 'civicrm/event/register', + $queryParams, + TRUE, + NULL, + FALSE + ); + } + + /** + * Build event registration cancel URL + * + * @param int $participantId CiviCRM participant ID + * @param array $params Must include: eventID, qfKey + * @return string Absolute URL to event registration page with cancel flag + */ + public static function buildEventCancelUrl(int $participantId, array $params): string { + $queryParams = [ + 'id' => $params['eventID'] ?? NULL, + '_qf_Register_display' => 1, + 'qfKey' => $params['qfKey'] ?? NULL, + 'cancel' => 1, + 'participant_id' => $participantId, + ]; + + return \CRM_Utils_System::url( + 'civicrm/event/register', + $queryParams, + TRUE, + NULL, + FALSE + ); + } + +} diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..853c1cb --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,667 @@ +Package: io.compuco.paymentprocessingcore +Copyright (C) 2025, FIXME +Licensed under the GNU Affero Public License 3.0 (below). + +------------------------------------------------------------------------------- + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2db2a9e --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +# Payment Processing Core + +![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. + +## Overview + +Payment Processing Core is a foundational CiviCRM extension that provides reusable database entities and patterns for payment processor extensions. + +**What it provides:** + +- Payment attempt tracking entity +- Webhook event logging entity +- Multi-processor support +- API4 integration + +## Requirements + +- **PHP:** 8.1+ (7.4+ minimum) +- **CiviCRM:** 6.4.1+ +- **Database:** MySQL 5.7+ / MariaDB 10.3+ + +## Installation + +### Via Command Line + +```bash +# Download and enable +cv ext:download io.compuco.paymentprocessingcore +cv ext:enable paymentprocessingcore +``` + +### For Developers + +```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. + +### For Payment Processor Developers + +The extension provides two main entities via API4: + +- `PaymentAttempt` - Track payment sessions and attempts +- `PaymentWebhook` - Log and deduplicate webhook events + +Example usage: + +```php +use Civi\Api4\PaymentAttempt; + +// Create a payment attempt +$attempt = PaymentAttempt::create(FALSE) + ->addValue('contribution_id', $contributionId) + ->addValue('contact_id', $contactId) + ->addValue('processor_type', 'stripe') + ->addValue('status', 'pending') + ->execute() + ->first(); +``` + +### For CiviCRM Administrators + +This extension provides infrastructure used by 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. + +## Development + +### Setup + +```bash +# Setup Docker test environment +./scripts/run.sh setup + +# Run tests +./scripts/run.sh tests + +# Run linter +./scripts/lint.sh check + +# Run static analysis +./scripts/run.sh phpstan-changed +``` + +### Testing + +```bash +# Run all tests +./scripts/run.sh tests + +# Run specific test +./scripts/run.sh test tests/phpunit/Civi/Api4/PaymentAttemptTest.php +``` + +### Code Quality + +```bash +# Linting +./scripts/lint.sh check +./scripts/lint.sh fix + +# Static analysis +./scripts/run.sh phpstan-changed +``` + +### Contributing + +See [CLAUDE.md](CLAUDE.md) for detailed development guidelines. + +## 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). + +## Credits + +Developed and maintained by [Compuco](https://compuco.io). diff --git a/bin/install-php-linter b/bin/install-php-linter new file mode 100755 index 0000000..980f397 --- /dev/null +++ b/bin/install-php-linter @@ -0,0 +1,22 @@ +#!/bin/bash +set -e + +# Download PHPCS if it already does not exist +if [ ! -f phpcs.phar ]; then + curl -OL https://squizlabs.github.io/PHP_CodeSniffer/phpcs.phar +fi +# Give executable permission to PHPCS +chmod +x phpcs.phar + +# Download PHPCBF if it already does not exist +if [ ! -f phpcbf.phar ]; then + curl -OL https://squizlabs.github.io/PHP_CodeSniffer/phpcbf.phar +fi + +# Give executable permission to PHPCBF +chmod +x phpcbf.phar + +# Clone CiviCRM Coder repo +if [ ! -d civicrm/coder ]; then + git clone --depth 1 https://github.com/civicrm/coder.git civicrm/coder +fi \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..38a84c0 --- /dev/null +++ b/composer.json @@ -0,0 +1,18 @@ +{ + "name": "compucorp/paymentprocessingcore-extension", + "description": "Payment Processing Core Extension - Generic payment processing infrastructure for CiviCRM", + "authors": [ + { + "name": "Compucorp", + "email": "hello@compuco.io" + } + ], + "require": { + "php": ">=7.4" + }, + "require-dev": { + "phpstan/phpstan": "^1.12", + "phpstan/phpstan-phpunit": "^1.4", + "phpunit/phpunit": "^9.5" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..ddc1580 --- /dev/null +++ b/composer.lock @@ -0,0 +1,1923 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "5d031c74e57aa1b66b463856c07bfcea", + "packages": [], + "packages-dev": [ + { + "name": "doctrine/instantiator", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b", + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^11", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^0.16 || ^1", + "phpstan/phpstan": "^1.4", + "phpstan/phpstan-phpunit": "^1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "vimeo/psalm": "^4.30 || ^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/1.5.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2022-12-30T00:15:36+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.6.2", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "3a454ca033b9e06b63282ce19562e892747449bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb", + "reference": "3a454ca033b9e06b63282ce19562e892747449bb", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2" + }, + "time": "2025-10-21T19:32:17+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "1.12.32", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8", + "reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2025-09-30T10:16:31+00:00" + }, + { + "name": "phpstan/phpstan-phpunit", + "version": "1.4.2", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-phpunit.git", + "reference": "72a6721c9b64b3e4c9db55abbc38f790b318267e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/72a6721c9b64b3e4c9db55abbc38f790b318267e", + "reference": "72a6721c9b64b3e4c9db55abbc38f790b318267e", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "phpstan/phpstan": "^1.12" + }, + "conflict": { + "phpunit/phpunit": "<7.0" + }, + "require-dev": { + "nikic/php-parser": "^4.13.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-strict-rules": "^1.5.1", + "phpunit/phpunit": "^9.5" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPUnit extensions and rules for PHPStan", + "support": { + "issues": "https://github.com/phpstan/phpstan-phpunit/issues", + "source": "https://github.com/phpstan/phpstan-phpunit/tree/1.4.2" + }, + "time": "2024-12-17T17:20:49+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "9.2.32", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=7.3", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-text-template": "^2.0.4", + "sebastian/code-unit-reverse-lookup": "^2.0.3", + "sebastian/complexity": "^2.0.3", + "sebastian/environment": "^5.1.5", + "sebastian/lines-of-code": "^1.0.4", + "sebastian/version": "^3.0.2", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.2.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:23:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "3.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2021-12-02T12:48:52+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:58:55+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T05:33:50+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:16:10+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "9.6.29", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9ecfec57835a5581bc888ea7e13b51eb55ab9dd3", + "reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.5.0 || ^2", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=7.3", + "phpunit/php-code-coverage": "^9.2.32", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-invoker": "^3.1.1", + "phpunit/php-text-template": "^2.0.4", + "phpunit/php-timer": "^5.0.3", + "sebastian/cli-parser": "^1.0.2", + "sebastian/code-unit": "^1.0.8", + "sebastian/comparator": "^4.0.9", + "sebastian/diff": "^4.0.6", + "sebastian/environment": "^5.1.5", + "sebastian/exporter": "^4.0.8", + "sebastian/global-state": "^5.0.8", + "sebastian/object-enumerator": "^4.0.4", + "sebastian/resource-operations": "^3.0.4", + "sebastian/type": "^3.2.1", + "sebastian/version": "^3.0.2" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.6-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.29" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:29:11+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:27:43+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "1.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:08:54+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:30:19+00:00" + }, + { + "name": "sebastian/comparator", + "version": "4.0.9", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/67a2df3a62639eab2cc5906065e9805d4fd5dfc5", + "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/diff": "^4.0", + "sebastian/exporter": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.9" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2025-08-10T06:51:50+00:00" + }, + { + "name": "sebastian/complexity", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:19:30+00:00" + }, + { + "name": "sebastian/diff", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:30:58+00:00" + }, + { + "name": "sebastian/environment", + "version": "5.1.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:03:51+00:00" + }, + { + "name": "sebastian/exporter", + "version": "4.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:03:27+00:00" + }, + { + "name": "sebastian/global-state", + "version": "5.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2025-08-10T07:10:35+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "1.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:20:34+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:12:34+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:14:26+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-10T06:57:39+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "3.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "support": { + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-14T16:00:52+00:00" + }, + { + "name": "sebastian/type", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:13:03+00:00" + }, + { + "name": "sebastian/version", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c6c1022351a901512170118436c764e473f6de8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", + "reference": "c6c1022351a901512170118436c764e473f6de8c", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:39:44+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=7.4" + }, + "platform-dev": [], + "plugin-api-version": "2.2.0" +} diff --git a/docker-compose.lint.yml b/docker-compose.lint.yml new file mode 100644 index 0000000..4a82b06 --- /dev/null +++ b/docker-compose.lint.yml @@ -0,0 +1,7 @@ +services: + php-lint: + image: php:8.1-cli + volumes: + - .:/extension + working_dir: /extension + command: tail -f /dev/null # Keep container running diff --git a/docker-compose.phpstan.yml b/docker-compose.phpstan.yml new file mode 100644 index 0000000..1d107f6 --- /dev/null +++ b/docker-compose.phpstan.yml @@ -0,0 +1,15 @@ +services: + phpstan: + image: php:8.1-cli + volumes: + - .:/app + working_dir: /app + entrypoint: [] + command: > + sh -c " + if [ ! -f /tmp/phpstan.phar ]; then + curl -sL https://github.com/phpstan/phpstan/releases/download/1.12.10/phpstan.phar -o /tmp/phpstan.phar && + chmod +x /tmp/phpstan.phar + fi && + php /tmp/phpstan.phar analyse -c phpstan.neon --memory-limit=1G + " diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..b348161 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,36 @@ +services: + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_ROOT_HOST: "%" + command: --default-authentication-plugin=mysql_native_password + ports: + - "3308:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 3 + tmpfs: + - /var/lib/mysql:rw,noexec,nosuid,size=1024m + + civicrm: + image: compucorp/civicrm-buildkit:1.3.1-php8.0 + platform: linux/amd64 + working_dir: /extension + volumes: + - civicrm-site:/build + - .:/extension + depends_on: + mysql: + condition: service_healthy + environment: + CIVICRM_EXTENSIONS_DIR: /site/web/sites/all/modules/civicrm/tools/extensions + CIVICRM_SETTINGS_DIR: /site/web/sites/default + command: tail -f /dev/null + stdin_open: true + tty: true + +volumes: + civicrm-site: diff --git a/info.xml b/info.xml new file mode 100644 index 0000000..8fcb7e0 --- /dev/null +++ b/info.xml @@ -0,0 +1,40 @@ + + + paymentprocessingcore + Payment Processing Core + Generic payment processing infrastructure for CiviCRM - shared logic for payment attempts, webhooks, and contribution completion/failure handling across multiple payment processors + AGPL-3.0 + + Compuco + hello@compuco.io + + + https://github.com/compucorp/io.compuco.paymentprocessingcore + https://github.com/compucorp/io.compuco.paymentprocessingcore/blob/master/README.md + http://www.gnu.org/licenses/agpl-3.0.html + + 2025-11-20 + 1.0.0 + alpha + + 6.4 + + + Supported CiviCRM versions: + - CiviCRM 6.4.1+ + + + + + + CRM/Paymentprocessingcore + 23.02.1 + + + menu-xml@1.0.0 + mgd-php@1.0.0 + smarty-v2@1.0.1 + entity-types-php@1.0.0 + + CRM_Paymentprocessingcore_Upgrader + diff --git a/paymentprocessingcore.civix.php b/paymentprocessingcore.civix.php new file mode 100644 index 0000000..1d8860a --- /dev/null +++ b/paymentprocessingcore.civix.php @@ -0,0 +1,200 @@ +getUrl(self::LONG_NAME), '/'); + } + return CRM_Core_Resources::singleton()->getUrl(self::LONG_NAME, $file); + } + + /** + * Get the path of a resource file (in this extension). + * + * @param string|NULL $file + * Ex: NULL. + * Ex: 'css/foo.css'. + * @return string + * Ex: '/var/www/example.org/sites/default/ext/org.example.foo'. + * Ex: '/var/www/example.org/sites/default/ext/org.example.foo/css/foo.css'. + */ + public static function path($file = NULL) { + // return CRM_Core_Resources::singleton()->getPath(self::LONG_NAME, $file); + return __DIR__ . ($file === NULL ? '' : (DIRECTORY_SEPARATOR . $file)); + } + + /** + * Get the name of a class within this extension. + * + * @param string $suffix + * Ex: 'Page_HelloWorld' or 'Page\\HelloWorld'. + * @return string + * Ex: 'CRM_Foo_Page_HelloWorld'. + */ + public static function findClass($suffix) { + return self::CLASS_PREFIX . '_' . str_replace('\\', '_', $suffix); + } + +} + +use CRM_Paymentprocessingcore_ExtensionUtil as E; + +/** + * (Delegated) Implements hook_civicrm_config(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_config + */ +function _paymentprocessingcore_civix_civicrm_config($config = NULL) { + static $configured = FALSE; + if ($configured) { + return; + } + $configured = TRUE; + + $extRoot = __DIR__ . DIRECTORY_SEPARATOR; + $include_path = $extRoot . PATH_SEPARATOR . get_include_path(); + set_include_path($include_path); + // Based on , this does not currently require mixin/polyfill.php. +} + +/** + * Implements hook_civicrm_install(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_install + */ +function _paymentprocessingcore_civix_civicrm_install() { + _paymentprocessingcore_civix_civicrm_config(); + // Based on , this does not currently require mixin/polyfill.php. +} + +/** + * (Delegated) Implements hook_civicrm_enable(). + * + * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_enable + */ +function _paymentprocessingcore_civix_civicrm_enable(): void { + _paymentprocessingcore_civix_civicrm_config(); + // Based on , this does not currently require mixin/polyfill.php. +} + +/** + * Inserts a navigation menu item at a given place in the hierarchy. + * + * @param array $menu - menu hierarchy + * @param string $path - path to parent of this item, e.g. 'my_extension/submenu' + * 'Mailing', or 'Administer/System Settings' + * @param array $item - the item to insert (parent/child attributes will be + * filled for you) + * + * @return bool + */ +function _paymentprocessingcore_civix_insert_navigation_menu(&$menu, $path, $item) { + // If we are done going down the path, insert menu + if (empty($path)) { + $menu[] = [ + 'attributes' => array_merge([ + 'label' => $item['name'] ?? NULL, + 'active' => 1, + ], $item), + ]; + return TRUE; + } + else { + // Find an recurse into the next level down + $found = FALSE; + $path = explode('/', $path); + $first = array_shift($path); + foreach ($menu as $key => &$entry) { + if ($entry['attributes']['name'] == $first) { + if (!isset($entry['child'])) { + $entry['child'] = []; + } + $found = _paymentprocessingcore_civix_insert_navigation_menu($entry['child'], implode('/', $path), $item); + } + } + return $found; + } +} + +/** + * (Delegated) Implements hook_civicrm_navigationMenu(). + */ +function _paymentprocessingcore_civix_navigationMenu(&$nodes) { + if (!is_callable(['CRM_Core_BAO_Navigation', 'fixNavigationMenu'])) { + _paymentprocessingcore_civix_fixNavigationMenu($nodes); + } +} + +/** + * Given a navigation menu, generate navIDs for any items which are + * missing them. + */ +function _paymentprocessingcore_civix_fixNavigationMenu(&$nodes) { + $maxNavID = 1; + array_walk_recursive($nodes, function($item, $key) use (&$maxNavID) { + if ($key === 'navID') { + $maxNavID = max($maxNavID, $item); + } + }); + _paymentprocessingcore_civix_fixNavigationMenuItems($nodes, $maxNavID, NULL); +} + +function _paymentprocessingcore_civix_fixNavigationMenuItems(&$nodes, &$maxNavID, $parentID) { + $origKeys = array_keys($nodes); + foreach ($origKeys as $origKey) { + if (!isset($nodes[$origKey]['attributes']['parentID']) && $parentID !== NULL) { + $nodes[$origKey]['attributes']['parentID'] = $parentID; + } + // If no navID, then assign navID and fix key. + if (!isset($nodes[$origKey]['attributes']['navID'])) { + $newKey = ++$maxNavID; + $nodes[$origKey]['attributes']['navID'] = $newKey; + $nodes[$newKey] = $nodes[$origKey]; + unset($nodes[$origKey]); + $origKey = $newKey; + } + if (isset($nodes[$origKey]['child']) && is_array($nodes[$origKey]['child'])) { + _paymentprocessingcore_civix_fixNavigationMenuItems($nodes[$origKey]['child'], $maxNavID, $nodes[$origKey]['attributes']['navID']); + } + } +} diff --git a/paymentprocessingcore.php b/paymentprocessingcore.php new file mode 100644 index 0000000..964ce9d --- /dev/null +++ b/paymentprocessingcore.php @@ -0,0 +1,61 @@ + E::ts('New subliminal message'), +// 'name' => 'mailing_subliminal_message', +// 'url' => 'civicrm/mailing/subliminal', +// 'permission' => 'access CiviMail', +// 'operator' => 'OR', +// 'separator' => 0, +// ]); +// _paymentprocessingcore_civix_navigationMenu($menu); +//} diff --git a/phpcs-ruleset.xml b/phpcs-ruleset.xml new file mode 100644 index 0000000..70ada44 --- /dev/null +++ b/phpcs-ruleset.xml @@ -0,0 +1,7 @@ + + + CiviCRM Coder Ruleset + + paymentprocessingcore.civix.php + + diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..933b037 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,621 @@ +parameters: + ignoreErrors: + - + message: "#^Call to an undefined method CRM_Paymentprocessingcore_DAO_PaymentAttempt\\:\\:find\\(\\)\\.$#" + count: 3 + path: CRM/Paymentprocessingcore/BAO/PaymentAttempt.php + + - + message: "#^Call to an undefined method CRM_Paymentprocessingcore_DAO_PaymentAttempt\\:\\:toArray\\(\\)\\.$#" + count: 3 + path: CRM/Paymentprocessingcore/BAO/PaymentAttempt.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentAttempt\\:\\:create\\(\\) has parameter \\$params with no value type specified in iterable type array\\.$#" + count: 1 + path: CRM/Paymentprocessingcore/BAO/PaymentAttempt.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentAttempt\\:\\:getStatuses\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: CRM/Paymentprocessingcore/BAO/PaymentAttempt.php + + - + message: "#^Call to an undefined method CRM_Paymentprocessingcore_BAO_PaymentWebhook\\:\\:find\\(\\)\\.$#" + count: 1 + path: CRM/Paymentprocessingcore/BAO/PaymentWebhook.php + + - + message: "#^Call to an undefined method CRM_Paymentprocessingcore_BAO_PaymentWebhook\\:\\:toArray\\(\\)\\.$#" + count: 1 + path: CRM/Paymentprocessingcore/BAO/PaymentWebhook.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentWebhook\\:\\:create\\(\\) has parameter \\$params with no value type specified in iterable type array\\.$#" + count: 1 + path: CRM/Paymentprocessingcore/BAO/PaymentWebhook.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentWebhook\\:\\:findByEventId\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: CRM/Paymentprocessingcore/BAO/PaymentWebhook.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentWebhook\\:\\:getStatuses\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: CRM/Paymentprocessingcore/BAO/PaymentWebhook.php + + - + message: "#^Parameter \\#3 \\$id of static method CRM_Utils_Hook\\:\\:pre\\(\\) expects int\\|null, mixed given\\.$#" + count: 1 + path: CRM/Paymentprocessingcore/BAO/PaymentWebhook.php + + - + message: "#^Parameter \\#3 \\$objectId of static method CRM_Utils_Hook\\:\\:post\\(\\) expects int, int\\|string\\|null given\\.$#" + count: 1 + path: CRM/Paymentprocessingcore/BAO/PaymentWebhook.php + + - + message: "#^Method BaseHeadlessTest\\:\\:setUpHeadless\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/BaseHeadlessTest.php + + - + message: "#^Call to static method create\\(\\) on an unknown class Civi\\\\Api4\\\\Contribution\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php + + - + message: "#^Cannot access property \\$contact_id on CRM_Paymentprocessingcore_DAO_PaymentAttempt\\|null\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php + + - + message: "#^Cannot access property \\$contribution_id on CRM_Paymentprocessingcore_DAO_PaymentAttempt\\|null\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php + + - + message: "#^Cannot access property \\$id on CRM_Paymentprocessingcore_DAO_PaymentAttempt\\|null\\.$#" + count: 3 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php + + - + message: "#^Cannot access property \\$processor_payment_id on CRM_Paymentprocessingcore_DAO_PaymentAttempt\\|null\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php + + - + message: "#^Cannot access property \\$processor_session_id on CRM_Paymentprocessingcore_DAO_PaymentAttempt\\|null\\.$#" + count: 2 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php + + - + message: "#^Cannot access property \\$processor_type on CRM_Paymentprocessingcore_DAO_PaymentAttempt\\|null\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php + + - + message: "#^Cannot access property \\$status on CRM_Paymentprocessingcore_DAO_PaymentAttempt\\|null\\.$#" + count: 2 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentAttemptTest\\:\\:testContributionIdUniqueConstraint\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentAttemptTest\\:\\:testCreate\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentAttemptTest\\:\\:testFindByContributionId\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentAttemptTest\\:\\:testFindByContributionIdNotFound\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentAttemptTest\\:\\:testFindByPaymentId\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentAttemptTest\\:\\:testFindByPaymentIdNotFound\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentAttemptTest\\:\\:testFindByPaymentIdWrongProcessorType\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentAttemptTest\\:\\:testFindBySessionId\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentAttemptTest\\:\\:testFindBySessionIdNotFound\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentAttemptTest\\:\\:testFindBySessionIdWrongProcessorType\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentAttemptTest\\:\\:testGetStatuses\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentAttemptTest\\:\\:testUpdate\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php + + - + message: "#^Offset 'id' does not exist on array\\|null\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php + + - + message: "#^Call to static method create\\(\\) on an unknown class Civi\\\\Api4\\\\Contribution\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Cannot access property \\$event_id on CRM_Paymentprocessingcore_DAO_PaymentWebhook\\|null\\.$#" + count: 3 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Cannot access property \\$event_type on CRM_Paymentprocessingcore_DAO_PaymentWebhook\\|null\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Cannot access property \\$id on CRM_Paymentprocessingcore_DAO_PaymentAttempt\\|null\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Cannot access property \\$id on CRM_Paymentprocessingcore_DAO_PaymentWebhook\\|null\\.$#" + count: 4 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Cannot access property \\$payment_attempt_id on CRM_Paymentprocessingcore_DAO_PaymentWebhook\\|null\\.$#" + count: 2 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Cannot access property \\$processed_at on CRM_Paymentprocessingcore_DAO_PaymentWebhook\\|null\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Cannot access property \\$processor_type on CRM_Paymentprocessingcore_DAO_PaymentWebhook\\|null\\.$#" + count: 2 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Cannot access property \\$result on CRM_Paymentprocessingcore_DAO_PaymentWebhook\\|null\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Cannot access property \\$status on CRM_Paymentprocessingcore_DAO_PaymentWebhook\\|null\\.$#" + count: 3 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentWebhookTest\\:\\:testAllStatuses\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentWebhookTest\\:\\:testCreate\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentWebhookTest\\:\\:testCreateWithoutPaymentAttempt\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentWebhookTest\\:\\:testDifferentProcessorTypes\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentWebhookTest\\:\\:testFindByEventId\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentWebhookTest\\:\\:testFindByEventIdNotFound\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentWebhookTest\\:\\:testGetStatuses\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentWebhookTest\\:\\:testIsProcessedReturnsFalseForErrorEvent\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentWebhookTest\\:\\:testIsProcessedReturnsFalseForNewEvent\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentWebhookTest\\:\\:testIsProcessedReturnsFalseForNonExistentEvent\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentWebhookTest\\:\\:testIsProcessedReturnsTrueForProcessedEvent\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentWebhookTest\\:\\:testIsProcessedReturnsTrueForProcessingEvent\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentWebhookTest\\:\\:testUniqueEventIdConstraint\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Method CRM_Paymentprocessingcore_BAO_PaymentWebhookTest\\:\\:testUpdate\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Offset 'id' does not exist on array\\|null\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Property CRM_Paymentprocessingcore_BAO_PaymentWebhookTest\\:\\:\\$attemptId \\(int\\) does not accept int\\|string\\|null\\.$#" + count: 1 + path: tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php + + - + message: "#^Call to static method create\\(\\) on an unknown class Civi\\\\Api4\\\\Contribution\\.$#" + count: 5 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Call to static method create\\(\\) on an unknown class Civi\\\\Api4\\\\PaymentProcessor\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Call to static method delete\\(\\) on an unknown class Civi\\\\Api4\\\\Contribution\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Method Civi_Api4_PaymentAttemptTest\\:\\:testAllStatuses\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Method Civi_Api4_PaymentAttemptTest\\:\\:testCascadeDeleteWithContribution\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Method Civi_Api4_PaymentAttemptTest\\:\\:testCreatePaymentAttemptWithAllFields\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Method Civi_Api4_PaymentAttemptTest\\:\\:testCreatePaymentAttemptWithRequiredFields\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Method Civi_Api4_PaymentAttemptTest\\:\\:testCreateWithoutContributionIdFails\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Method Civi_Api4_PaymentAttemptTest\\:\\:testCreateWithoutProcessorTypeFails\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Method Civi_Api4_PaymentAttemptTest\\:\\:testDelete\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Method Civi_Api4_PaymentAttemptTest\\:\\:testDifferentProcessorTypes\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Method Civi_Api4_PaymentAttemptTest\\:\\:testFilterByProcessorTypeAndStatus\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Method Civi_Api4_PaymentAttemptTest\\:\\:testGetByContributionId\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Method Civi_Api4_PaymentAttemptTest\\:\\:testGetByProcessorSessionId\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Method Civi_Api4_PaymentAttemptTest\\:\\:testSetNullWhenContactDeleted\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Method Civi_Api4_PaymentAttemptTest\\:\\:testUniqueContributionConstraint\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Method Civi_Api4_PaymentAttemptTest\\:\\:testUpdateStatus\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Offset 'contact_id' does not exist on array\\|null\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Offset 'id' does not exist on array\\|null\\.$#" + count: 13 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Offset 'processor_payment_id' does not exist on array\\|null\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Offset 'processor_session_id' does not exist on array\\|null\\.$#" + count: 2 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Offset 'processor_type' does not exist on array\\|null\\.$#" + count: 2 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Offset 'status' does not exist on array\\|null\\.$#" + count: 2 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Parameter \\#1 \\$exception of method PHPUnit\\\\Framework\\\\TestCase\\:\\:expectException\\(\\) expects class\\-string\\, string given\\.$#" + count: 3 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Property Civi_Api4_PaymentAttemptTest\\:\\:\\$contactId has no type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Property Civi_Api4_PaymentAttemptTest\\:\\:\\$contributionId has no type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Property Civi_Api4_PaymentAttemptTest\\:\\:\\$paymentProcessorId has no type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentAttemptTest.php + + - + message: "#^Call to static method create\\(\\) on an unknown class Civi\\\\Api4\\\\Contribution\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Method Civi_Api4_PaymentWebhookTest\\:\\:testAllResults\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Method Civi_Api4_PaymentWebhookTest\\:\\:testAllStatuses\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Method Civi_Api4_PaymentWebhookTest\\:\\:testCreatePaymentWebhookWithAllFields\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Method Civi_Api4_PaymentWebhookTest\\:\\:testCreatePaymentWebhookWithRequiredFields\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Method Civi_Api4_PaymentWebhookTest\\:\\:testCreateWithoutEventIdFails\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Method Civi_Api4_PaymentWebhookTest\\:\\:testCreateWithoutEventTypeFails\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Method Civi_Api4_PaymentWebhookTest\\:\\:testCreateWithoutPaymentAttempt\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Method Civi_Api4_PaymentWebhookTest\\:\\:testCreateWithoutProcessorTypeFails\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Method Civi_Api4_PaymentWebhookTest\\:\\:testDelete\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Method Civi_Api4_PaymentWebhookTest\\:\\:testDifferentProcessorTypes\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Method Civi_Api4_PaymentWebhookTest\\:\\:testErrorLogging\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Method Civi_Api4_PaymentWebhookTest\\:\\:testFilterByStatus\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Method Civi_Api4_PaymentWebhookTest\\:\\:testGetByEventId\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Method Civi_Api4_PaymentWebhookTest\\:\\:testGetByProcessorAndEventType\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Method Civi_Api4_PaymentWebhookTest\\:\\:testSetNullWhenPaymentAttemptDeleted\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Method Civi_Api4_PaymentWebhookTest\\:\\:testUniqueEventIdConstraint\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Method Civi_Api4_PaymentWebhookTest\\:\\:testUpdateStatus\\(\\) has no return type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Offset 'error_log' does not exist on array\\|null\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Offset 'event_id' does not exist on array\\|null\\.$#" + count: 2 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Offset 'event_type' does not exist on array\\|null\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Offset 'id' does not exist on array\\|null\\.$#" + count: 13 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Offset 'payment_attempt_id' does not exist on array\\|null\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Offset 'processed_at' does not exist on array\\|null\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Offset 'processor_type' does not exist on array\\|null\\.$#" + count: 2 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Offset 'result' does not exist on array\\|null\\.$#" + count: 2 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Offset 'status' does not exist on array\\|null\\.$#" + count: 3 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Parameter \\#1 \\$exception of method PHPUnit\\\\Framework\\\\TestCase\\:\\:expectException\\(\\) expects class\\-string\\, string given\\.$#" + count: 4 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Property Civi_Api4_PaymentWebhookTest\\:\\:\\$attemptId has no type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Property Civi_Api4_PaymentWebhookTest\\:\\:\\$contactId has no type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Property Civi_Api4_PaymentWebhookTest\\:\\:\\$contributionId has no type specified\\.$#" + count: 1 + path: tests/phpunit/Civi/Api4/PaymentWebhookTest.php + + - + message: "#^Function cv\\(\\) should return string but returns mixed\\.$#" + count: 1 + path: tests/phpunit/bootstrap.php + + - + message: "#^Function cv\\(\\) should return string but returns string\\|false\\.$#" + count: 2 + path: tests/phpunit/bootstrap.php + + - + message: "#^Parameter \\#1 \\$arg of function escapeshellarg expects string, string\\|false given\\.$#" + count: 1 + path: tests/phpunit/bootstrap.php + + - + message: "#^Parameter \\#1 \\$json of function json_decode expects string, string\\|false given\\.$#" + count: 1 + path: tests/phpunit/bootstrap.php + + - + message: "#^Parameter \\#1 \\$process of function proc_close expects resource, resource\\|false given\\.$#" + count: 1 + path: tests/phpunit/bootstrap.php + + - + message: "#^Parameter \\#1 \\$string of function trim expects string, string\\|false given\\.$#" + count: 2 + path: tests/phpunit/bootstrap.php + + - + message: "#^Parameter \\#2 \\$value of function ini_set expects string, int given\\.$#" + count: 1 + path: tests/phpunit/bootstrap.php diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..81f6a4d --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,31 @@ +includes: + - vendor/phpstan/phpstan-phpunit/extension.neon + - phpstan-baseline.neon + +parameters: + level: 9 + paths: + - Civi + - CRM + - tests + excludePaths: + analyse: + # Auto-generated files - don't analyze but allow scanning for type info + - CRM/Paymentprocessingcore/DAO/* + - paymentprocessingcore.civix.php + - '*.mgd.php' + - tests/bootstrap.php + scanFiles: + - paymentprocessingcore.civix.php + - /build/site/web/sites/all/modules/civicrm/Civi.php + # Test base classes needed for type information + - tests/phpunit/BaseHeadlessTest.php + scanDirectories: + # Scan DAO files for type information (but don't analyze them) + - CRM/Paymentprocessingcore/DAO + # Scan CiviCRM core for type information + - /build/site/web/sites/all/modules/civicrm/Civi + - /build/site/web/sites/all/modules/civicrm/CRM + - /build/site/web/sites/all/modules/civicrm/api + # Scan test infrastructure for type information + - tests diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..59c1ccb --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,32 @@ + + + + + ./tests/phpunit + + + + + ./ + + ./civicrm-core + ./vendor + + + + + + + + + \ No newline at end of file diff --git a/scripts/env-config.sh b/scripts/env-config.sh new file mode 100755 index 0000000..6421eca --- /dev/null +++ b/scripts/env-config.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Centralized environment configuration for CiviCRM development environments + +# Default CiviCRM and CMS versions +export DEFAULT_CIVICRM_VERSION="6.4.1" +export DEFAULT_CMS_VERSION="7.100" + +# Legacy civix environment (for reference - only needed if civix has issues with newer versions) +export LEGACY_CIVIX_CIVICRM_VERSION="5.51.3" +export LEGACY_CIVIX_CMS_VERSION="7.94" + +# Common paths +export WEB_ROOT="/build/site" +export CIVICRM_EXTENSIONS_DIR="$WEB_ROOT/web/sites/all/modules/civicrm/tools/extensions" +export CIVICRM_SETTINGS_DIR="$WEB_ROOT/web/sites/default" diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 0000000..077aa93 --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,107 @@ +#!/bin/bash +set -e + +COMPOSE_FILE="docker-compose.lint.yml" +SERVICE_NAME="php-lint" + +# Colors for output +BLUE='\033[0;34m' +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Function to run commands in the PHP container +run_in_container() { + docker compose -f "$COMPOSE_FILE" exec -T "$SERVICE_NAME" "$@" +} + +case "$1" in + check) + echo -e "${BLUE}๐Ÿ” Running PHP linter on changed files...${NC}" + + # Get list of changed PHP files + CHANGED_FILES=$(git diff --diff-filter=d origin/master --name-only -- '*.php' || echo "") + + if [ -z "$CHANGED_FILES" ]; then + echo -e "${GREEN}โœ… No PHP files changed${NC}" + exit 0 + fi + + echo "Changed files:" + echo "$CHANGED_FILES" + echo "" + + # Start container if not running + docker compose -f "$COMPOSE_FILE" up -d + + # Install dependencies if needed + if [ ! -d "bin/civicrm/coder" ]; then + echo -e "${BLUE}๐Ÿ“ฆ Installing linter dependencies...${NC}" + run_in_container apt-get update + run_in_container apt-get install -y git + run_in_container bash -c "cd bin && ./install-php-linter" + fi + + # Run phpcs on changed files + echo "$CHANGED_FILES" | run_in_container xargs ./bin/phpcs.phar --standard=phpcs-ruleset.xml + + echo -e "${GREEN}โœ… Linting complete${NC}" + ;; + + fix) + echo -e "${BLUE}๐Ÿ”ง Auto-fixing linting issues...${NC}" + + # Start container if not running + docker compose -f "$COMPOSE_FILE" up -d + + # Install dependencies if needed + if [ ! -d "bin/civicrm/coder" ]; then + echo -e "${BLUE}๐Ÿ“ฆ Installing linter dependencies...${NC}" + run_in_container apt-get update + run_in_container apt-get install -y git + run_in_container bash -c "cd bin && ./install-php-linter" + fi + + # Run phpcbf on all source directories + run_in_container ./bin/phpcbf.phar --standard=phpcs-ruleset.xml CRM/ Civi/ api/ || true + + echo -e "${GREEN}โœ… Auto-fix complete${NC}" + ;; + + check-all) + echo -e "${BLUE}๐Ÿ” Running PHP linter on all files...${NC}" + + # Start container if not running + docker compose -f "$COMPOSE_FILE" up -d + + # Install dependencies if needed + if [ ! -d "bin/civicrm/coder" ]; then + echo -e "${BLUE}๐Ÿ“ฆ Installing linter dependencies...${NC}" + run_in_container apt-get update + run_in_container apt-get install -y git + run_in_container bash -c "cd bin && ./install-php-linter" + fi + + # Run phpcs on all source directories + run_in_container ./bin/phpcs.phar --standard=phpcs-ruleset.xml CRM/ Civi/ api/ + + echo -e "${GREEN}โœ… Linting complete${NC}" + ;; + + stop) + echo -e "${BLUE}๐Ÿ›‘ Stopping linter container...${NC}" + docker compose -f "$COMPOSE_FILE" down + echo -e "${GREEN}โœ… Stopped${NC}" + ;; + + *) + echo "Usage: $0 {check|fix|check-all|stop}" + echo "" + echo "Commands:" + echo " check - Run linter on changed files (vs origin/master)" + echo " fix - Auto-fix linting issues in all source files" + echo " check-all - Run linter on all source files" + echo " stop - Stop the linter container" + exit 1 + ;; +esac diff --git a/scripts/phpstan.sh b/scripts/phpstan.sh new file mode 100755 index 0000000..46eb44f --- /dev/null +++ b/scripts/phpstan.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +# Colors for output +BLUE='\033[0;34m' +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Get the action (check or generate-baseline) +ACTION="${1:-check}" + +case "$ACTION" in + check) + echo -e "${BLUE}๐Ÿ” Running PHPStan analysis...${NC}" + docker compose -f docker-compose.phpstan.yml run --rm phpstan + EXIT_CODE=$? + + if [ $EXIT_CODE -eq 0 ]; then + echo -e "${GREEN}โœ… PHPStan analysis complete - no errors found${NC}" + else + echo -e "${RED}โŒ PHPStan found errors${NC}" + fi + + # Clean up + docker compose -f docker-compose.phpstan.yml down 2>/dev/null + exit $EXIT_CODE + ;; + + generate-baseline) + echo -e "${BLUE}๐Ÿ“ Generating PHPStan baseline...${NC}" + docker compose -f docker-compose.phpstan.yml run --rm phpstan analyse -c phpstan.neon --generate-baseline=phpstan-baseline.neon --memory-limit=1G + EXIT_CODE=$? + + if [ $EXIT_CODE -eq 0 ]; then + echo -e "${GREEN}โœ… Baseline generated successfully${NC}" + echo -e "${BLUE}โ„น๏ธ Baseline saved to phpstan-baseline.neon${NC}" + else + echo -e "${RED}โŒ Failed to generate baseline${NC}" + fi + + # Clean up + docker compose -f docker-compose.phpstan.yml down 2>/dev/null + exit $EXIT_CODE + ;; + + *) + echo "Usage: $0 {check|generate-baseline}" + echo "" + echo "Commands:" + echo " check Run PHPStan analysis" + echo " generate-baseline Generate baseline file for existing errors" + exit 1 + ;; +esac diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 0000000..87f1f74 --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,229 @@ +#!/bin/bash +# Helper script to run commands in the CiviCRM Docker environment + +set -e + +# Load environment configuration +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/env-config.sh" + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +function usage() { + echo "Usage: $0 [args]" + echo "" + echo "Commands:" + echo " setup [--civi-version VER] [--cms-version VER]" + echo " - Set up CiviCRM environment" + echo " Default: CiviCRM ${DEFAULT_CIVICRM_VERSION}, Drupal ${DEFAULT_CMS_VERSION}" + echo " civix - Run civix generate:entity-boilerplate" + echo " tests - Run all PHPUnit tests" + echo " test FILE - Run specific test file" + echo " phpstan - Run PHPStan on entire codebase" + echo " phpstan-changed - Run PHPStan on changed files only (recommended)" + echo " shell - Open bash shell in the container" + echo " cv - Run cv command" + echo " stop - Stop services" + echo " clean - Clean up (remove volumes)" + echo "" + echo "Examples:" + echo " $0 setup # Setup with default (CiviCRM ${DEFAULT_CIVICRM_VERSION})" + echo " $0 setup --civi-version 5.51.3 # Setup with CiviCRM 5.51.3 (legacy civix)" + echo " $0 setup --civi-version 5.75.0 --cms-version 7.94" + echo " $0 civix # Generate DAO files" + echo " $0 tests # Run all tests" + echo " $0 phpstan-changed # Run static analysis on your changes" + echo "" + exit 1 +} + +if [ $# -eq 0 ]; then + usage +fi + +COMMAND=$1 +shift + +case $COMMAND in + setup) + echo -e "${BLUE}๐Ÿš€ Starting services...${NC}" + docker-compose -f docker-compose.test.yml up -d + + echo -e "${BLUE}โณ Waiting for services to be ready...${NC}" + sleep 10 + + echo -e "${BLUE}๐Ÿ”ง Running setup script...${NC}" + docker-compose -f docker-compose.test.yml exec civicrm bash /extension/scripts/setup.sh "$@" + + echo -e "${GREEN}โœ… Setup complete!${NC}" + ;; + + civix) + echo -e "${BLUE}๐Ÿ”ง Running civix generate:entity-boilerplate...${NC}" + docker-compose -f docker-compose.test.yml exec civicrm bash -c " + set -e + # Install rsync if not present + which rsync || (apt update && apt install -y rsync) + + EXT_DIR=/build/site/web/sites/all/modules/civicrm/tools/extensions/io.compuco.paymentprocessingcore + # Remove symlink/directory temporarily + rm -rf \$EXT_DIR + # Copy extension files + cp -r /extension \$EXT_DIR + # Run civix + cd \$EXT_DIR && civix generate:entity-boilerplate --yes + # Sync all generated files back to /extension (overwrites existing) + rsync -av --delete \$EXT_DIR/CRM/ /extension/CRM/ + rsync -av \$EXT_DIR/sql/ /extension/sql/ + cp \$EXT_DIR/paymentprocessingcore.civix.php /extension/ + # Restore symlink + rm -rf \$EXT_DIR + ln -sfn /extension \$EXT_DIR + " + echo -e "${GREEN}โœ… DAO files regenerated!${NC}" + ;; + + tests) + echo -e "${BLUE}๐Ÿ—„๏ธ Ensuring test database exists...${NC}" + docker-compose -f docker-compose.test.yml exec civicrm bash -c " + echo 'CREATE DATABASE IF NOT EXISTS civicrm_test;' | mysql -u root --password=root --host=mysql + " + + echo -e "${BLUE}๐Ÿงช Setting up test database configuration...${NC}" + docker-compose -f docker-compose.test.yml exec civicrm bash -c " + FILE_PATH='/build/site/web/sites/default/civicrm.settings.php' + # Check if TEST_DB_DSN assignment is already set (not just the conditional check) + if ! grep -q \"\\\$GLOBALS\['_CV'\]\['TEST_DB_DSN'\] =\" \"\$FILE_PATH\"; then + INSERT_LINE=\"\\\$GLOBALS['_CV']['TEST_DB_DSN'] = 'mysql://root:root@mysql:3306/civicrm_test?new_link=true';\" + TMP_FILE=\$(mktemp) + while IFS= read -r line + do + echo \"\$line\" >> \"\$TMP_FILE\" + if [ \"\$line\" = \"> \"\$TMP_FILE\" + fi + done < \"\$FILE_PATH\" + mv \"\$TMP_FILE\" \"\$FILE_PATH\" + echo 'TEST_DB_DSN added successfully' + else + echo 'TEST_DB_DSN already set' + fi + " + + echo -e "${BLUE}๐Ÿงช Running all tests...${NC}" + docker-compose -f docker-compose.test.yml exec -w /build/site/web/sites/all/modules/civicrm/tools/extensions/io.compuco.paymentprocessingcore -e CIVICRM_SETTINGS=/build/site/web/sites/default/civicrm.settings.php civicrm phpunit9 + ;; + + test) + if [ $# -eq 0 ]; then + echo "Error: Please specify test file path" + echo "Example: $0 test tests/phpunit/Civi/PaymentProcessingCore/Service/PaymentAttemptServiceTest.php" + exit 1 + fi + TEST_FILE=$1 + + echo -e "${BLUE}๐Ÿ—„๏ธ Ensuring test database exists...${NC}" + docker-compose -f docker-compose.test.yml exec civicrm bash -c " + echo 'CREATE DATABASE IF NOT EXISTS civicrm_test;' | mysql -u root --password=root --host=mysql + " + + echo -e "${BLUE}๐Ÿงช Setting up test database configuration...${NC}" + docker-compose -f docker-compose.test.yml exec civicrm bash -c " + FILE_PATH='/build/site/web/sites/default/civicrm.settings.php' + # Check if TEST_DB_DSN assignment is already set (not just the conditional check) + if ! grep -q \"\\\$GLOBALS\['_CV'\]\['TEST_DB_DSN'\] =\" \"\$FILE_PATH\"; then + INSERT_LINE=\"\\\$GLOBALS['_CV']['TEST_DB_DSN'] = 'mysql://root:root@mysql:3306/civicrm_test?new_link=true';\" + TMP_FILE=\$(mktemp) + while IFS= read -r line + do + echo \"\$line\" >> \"\$TMP_FILE\" + if [ \"\$line\" = \"> \"\$TMP_FILE\" + fi + done < \"\$FILE_PATH\" + mv \"\$TMP_FILE\" \"\$FILE_PATH\" + echo 'TEST_DB_DSN added successfully' + else + echo 'TEST_DB_DSN already set' + fi + " + + echo -e "${BLUE}๐Ÿงช Running test: ${TEST_FILE}${NC}" + docker-compose -f docker-compose.test.yml exec -w /build/site/web/sites/all/modules/civicrm/tools/extensions/io.compuco.paymentprocessingcore -e CIVICRM_SETTINGS=/build/site/web/sites/default/civicrm.settings.php civicrm phpunit9 "$TEST_FILE" + ;; + + shell) + echo -e "${BLUE}๐Ÿš Opening shell in CiviCRM container...${NC}" + docker-compose -f docker-compose.test.yml exec civicrm bash + ;; + + cv) + docker-compose -f docker-compose.test.yml exec -w /site/web/sites/default civicrm cv "$@" + ;; + + phpstan) + echo -e "${BLUE}๐Ÿ” Running PHPStan in test environment...${NC}" + docker-compose -f docker-compose.test.yml exec civicrm bash -c " + # Download PHPStan if not present + if [ ! -f /tmp/phpstan.phar ]; then + echo 'Downloading PHPStan...' + curl -sL https://github.com/phpstan/phpstan/releases/download/1.12.10/phpstan.phar -o /tmp/phpstan.phar + chmod +x /tmp/phpstan.phar + fi + # Run PHPStan from the extension directory (using absolute path in phpstan.neon) + cd /extension && php /tmp/phpstan.phar analyse -c phpstan.neon --memory-limit=1G + " + ;; + + phpstan-changed) + echo -e "${BLUE}๐Ÿ” Running PHPStan on changed files only...${NC}" + + # Get list of changed PHP files (modified and new, excluding auto-generated files) + # Exclude: DAO files, paymentprocessingcore.civix.php, .mgd.php files, tests/bootstrap.php + # Combine git diff (modified) and git status (new untracked files) + MODIFIED_FILES=$(git diff --name-only origin/master 2>/dev/null | grep '\.php$' | grep -v '/DAO/' | grep -v 'paymentprocessingcore.civix.php' | grep -v '\.mgd\.php$' | grep -v 'tests/bootstrap.php' || echo "") + NEW_FILES=$(git status --porcelain | grep '^??' | awk '{print $2}' | grep '\.php$' | grep -v '/DAO/' | grep -v 'paymentprocessingcore.civix.php' | grep -v '\.mgd\.php$' | grep -v 'tests/bootstrap.php' || echo "") + CHANGED_FILES=$(echo "$MODIFIED_FILES $NEW_FILES" | tr '\n' ' ' | xargs) + + if [ -z "$CHANGED_FILES" ]; then + echo -e "${GREEN}โœ… No changed files to analyze${NC}" + exit 0 + fi + + echo "Analyzing files:" + echo "$CHANGED_FILES" + echo "" + + # Just run the full analysis - baseline handles the rest + docker-compose -f docker-compose.test.yml exec civicrm bash -c " + # Download PHPStan if not present + if [ ! -f /tmp/phpstan.phar ]; then + echo 'Downloading PHPStan...' + curl -sL https://github.com/phpstan/phpstan/releases/download/1.12.10/phpstan.phar -o /tmp/phpstan.phar + chmod +x /tmp/phpstan.phar + fi + # Run full PHPStan analysis - baseline ignores known errors + cd /extension && php /tmp/phpstan.phar analyse -c phpstan.neon --memory-limit=1G + " + ;; + + stop) + echo -e "${BLUE}๐Ÿ›‘ Stopping services...${NC}" + docker-compose -f docker-compose.test.yml down + echo -e "${GREEN}โœ… Services stopped${NC}" + ;; + + clean) + echo -e "${BLUE}๐Ÿงน Cleaning up (removing volumes)...${NC}" + docker-compose -f docker-compose.test.yml down -v + echo -e "${GREEN}โœ… Cleanup complete${NC}" + ;; + + *) + echo "Unknown command: $COMMAND" + usage + ;; +esac diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..cab8a7b --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,81 @@ +#!/bin/bash +set -e + +# Load environment configuration for defaults +source /extension/scripts/env-config.sh + +# Default values +CIVICRM_VERSION="$DEFAULT_CIVICRM_VERSION" +CMS_VERSION="$DEFAULT_CMS_VERSION" + +# Parse command-line arguments +while [[ $# -gt 0 ]]; do + case $1 in + --civi-version) + CIVICRM_VERSION="$2" + shift 2 + ;; + --cms-version) + CMS_VERSION="$2" + shift 2 + ;; + -h|--help) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --civi-version VERSION CiviCRM version (default: $DEFAULT_CIVICRM_VERSION)" + echo " --cms-version VERSION CMS version (default: $DEFAULT_CMS_VERSION)" + echo " -h, --help Show this help message" + echo "" + echo "Examples:" + echo " $0 # Use defaults (CiviCRM $DEFAULT_CIVICRM_VERSION)" + echo " $0 --civi-version 5.51.3 # Use CiviCRM 5.51.3 (legacy civix)" + echo " $0 --civi-version 5.75.0 --cms-version 7.94" + exit 0 + ;; + *) + echo "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +echo "๐Ÿš€ Setting up CiviCRM environment (CiviCRM ${CIVICRM_VERSION}, Drupal ${CMS_VERSION})..." + +echo "๐Ÿ“ฆ Installing required PHP extensions..." +apt update && apt install -y php-bcmath + +echo "โฌ‡๏ธ Downgrading Composer to 2.2.5..." +composer self-update 2.2.5 + +echo "๐Ÿ—„๏ธ Configuring MySQL..." +echo "SET GLOBAL sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''));" | mysql -u root --password=root --host=mysql + +echo "๐Ÿ”ง Configuring amp..." +amp config:set --mysql_dsn=mysql://root:root@mysql:3306 + +echo "๐Ÿ—๏ธ Building Drupal site with CiviCRM ${CIVICRM_VERSION}..." +civibuild create drupal-clean --civi-ver $CIVICRM_VERSION --cms-ver $CMS_VERSION --web-root $WEB_ROOT + +echo "๐Ÿ”— Creating symlink to extension directory..." +ln -sfn /extension $CIVICRM_EXTENSIONS_DIR/io.compuco.paymentprocessingcore +echo "๐Ÿ“‹ Extension linked at $CIVICRM_EXTENSIONS_DIR/io.compuco.paymentprocessingcore -> /extension" + +echo "๐Ÿ“ฆ Installing PaymentProcessingCore dependencies..." +cd $CIVICRM_EXTENSIONS_DIR/io.compuco.paymentprocessingcore +composer install --no-dev 2>/dev/null || echo "No composer dependencies (expected for core infrastructure)" + +echo "โœ… Enabling PaymentProcessingCore extension..." +cv en io.compuco.paymentprocessingcore + +echo "๐Ÿ—„๏ธ Creating test database..." +echo "CREATE DATABASE IF NOT EXISTS civicrm_test;" | mysql -u root --password=root --host=mysql + +echo "โœ… CiviCRM environment setup complete!" +echo "" +echo "Environment: CiviCRM ${CIVICRM_VERSION}, Drupal ${CMS_VERSION}" +echo "You can now run:" +echo " - civix (from extension directory)" +echo " - phpunit9 (to run tests)" +echo " - cv cli (for CiviCRM commands)" diff --git a/sql/auto_install.sql b/sql/auto_install.sql new file mode 100644 index 0000000..f03939a --- /dev/null +++ b/sql/auto_install.sql @@ -0,0 +1,81 @@ +-- +--------------------------------------------------------------------+ +-- | Copyright CiviCRM LLC. All rights reserved. | +-- | | +-- | This work is published under the GNU AGPLv3 license with some | +-- | permitted exceptions and without any warranty. For full license | +-- | and copyright information, see https://civicrm.org/licensing | +-- +--------------------------------------------------------------------+ +-- +-- Generated from schema.tpl +-- DO NOT EDIT. Generated by CRM_Core_CodeGen +-- +-- /******************************************************* +-- * +-- * Clean up the existing tables - this section generated from file:drop.tpl +-- * +-- *******************************************************/ + +SET FOREIGN_KEY_CHECKS=0; + +DROP TABLE IF EXISTS `civicrm_payment_webhook`; +DROP TABLE IF EXISTS `civicrm_payment_attempt`; + +SET FOREIGN_KEY_CHECKS=1; + +-- /******************************************************* +-- * +-- * Create new tables +-- * +-- *******************************************************/ + +-- /******************************************************* +-- * +-- * civicrm_payment_attempt +-- * +-- * Tracks payment attempts across all processors (Stripe, GoCardless, ITAS, etc.) +-- * +-- *******************************************************/ +CREATE TABLE `civicrm_payment_attempt` ( + `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT 'Unique ID', + `contribution_id` int unsigned NOT NULL COMMENT 'FK to Contribution', + `contact_id` int unsigned NULL COMMENT 'FK to Contact (donor)', + `payment_processor_id` int unsigned NULL COMMENT 'FK to Payment Processor', + `processor_type` varchar(50) NOT NULL COMMENT 'Processor type: \'stripe\', \'gocardless\', \'itas\', etc.', + `processor_session_id` varchar(255) COMMENT 'Processor session ID (cs_... for Stripe, mandate_... for GoCardless)', + `processor_payment_id` varchar(255) COMMENT 'Processor payment ID (pi_... for Stripe, payment_... for GoCardless)', + `status` varchar(25) NOT NULL DEFAULT 'pending' COMMENT 'Attempt status: pending, completed, failed, cancelled', + `created_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'When attempt was created', + `updated_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Last updated', + PRIMARY KEY (`id`), + UNIQUE INDEX `index_contribution_id`(contribution_id), + INDEX `index_processor_type`(processor_type), + INDEX `index_processor_session`(processor_session_id, processor_type), + INDEX `index_processor_payment`(processor_payment_id, processor_type), + CONSTRAINT FK_civicrm_payment_attempt_contribution_id FOREIGN KEY (`contribution_id`) REFERENCES `civicrm_contribution`(`id`) ON DELETE CASCADE, + CONSTRAINT FK_civicrm_payment_attempt_contact_id FOREIGN KEY (`contact_id`) REFERENCES `civicrm_contact`(`id`) ON DELETE SET NULL, + CONSTRAINT FK_civicrm_payment_attempt_payment_processor_id FOREIGN KEY (`payment_processor_id`) REFERENCES `civicrm_payment_processor`(`id`) ON DELETE SET NULL) +ENGINE=InnoDB; + +-- /******************************************************* +-- * +-- * civicrm_payment_webhook +-- * +-- * Webhook event log for de-duplication and idempotency across all processors +-- * +-- *******************************************************/ +CREATE TABLE `civicrm_payment_webhook` ( + `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT 'Unique ID', + `event_id` varchar(255) NOT NULL COMMENT 'Processor event ID (evt_... for Stripe, evt_... for GoCardless)', + `processor_type` varchar(50) NOT NULL COMMENT 'Processor type: \'stripe\', \'gocardless\', \'itas\', etc.', + `event_type` varchar(100) NOT NULL COMMENT 'Event type (e.g. checkout.session.completed, payment_intent.succeeded)', + `payment_attempt_id` int unsigned NULL COMMENT 'FK to Payment Attempt', + `status` varchar(25) NOT NULL DEFAULT 'new' COMMENT 'Processing status: new, processing, processed, error', + `result` varchar(50) COMMENT 'Processing result: applied, noop, ignored_out_of_order, error', + `error_log` text COMMENT 'Error details if processing failed', + `processed_at` timestamp COMMENT 'When event was processed', + `created_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'When webhook was received', + PRIMARY KEY (`id`), + UNIQUE INDEX `UI_event_processor`(event_id, processor_type), + INDEX `index_event_type`(event_type), + CONSTRAINT FK_civicrm_payment_webhook_payment_attempt_id FOREIGN KEY (`payment_attempt_id`) REFERENCES `civicrm_payment_attempt`(`id`) ON DELETE SET NULL) +ENGINE=InnoDB; diff --git a/sql/auto_uninstall.sql b/sql/auto_uninstall.sql new file mode 100644 index 0000000..b4890e2 --- /dev/null +++ b/sql/auto_uninstall.sql @@ -0,0 +1,23 @@ +-- +--------------------------------------------------------------------+ +-- | Copyright CiviCRM LLC. All rights reserved. | +-- | | +-- | This work is published under the GNU AGPLv3 license with some | +-- | permitted exceptions and without any warranty. For full license | +-- | and copyright information, see https://civicrm.org/licensing | +-- +--------------------------------------------------------------------+ +-- +-- Generated from drop.tpl +-- DO NOT EDIT. Generated by CRM_Core_CodeGen +-- +-- /******************************************************* +-- * +-- * Clean up the existing tables +-- * +-- *******************************************************/ + +SET FOREIGN_KEY_CHECKS=0; + +DROP TABLE IF EXISTS `civicrm_payment_webhook`; +DROP TABLE IF EXISTS `civicrm_payment_attempt`; + +SET FOREIGN_KEY_CHECKS=1; diff --git a/tests/phpunit/BaseHeadlessTest.php b/tests/phpunit/BaseHeadlessTest.php new file mode 100644 index 0000000..5c1c777 --- /dev/null +++ b/tests/phpunit/BaseHeadlessTest.php @@ -0,0 +1,20 @@ +installMe(__DIR__) + ->apply(); + } + + public function setUp(): void { + parent::setUp(); + } + +} diff --git a/tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php b/tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php new file mode 100644 index 0000000..b96e7d8 --- /dev/null +++ b/tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php @@ -0,0 +1,282 @@ +contactId = Contact::create(FALSE) + ->addValue('contact_type', 'Individual') + ->addValue('first_name', 'Test') + ->addValue('last_name', 'Donor') + ->execute() + ->first()['id']; + + // Create test contribution + $this->contributionId = Contribution::create(FALSE) + ->addValue('contact_id', $this->contactId) + ->addValue('financial_type_id:name', 'Donation') + ->addValue('total_amount', 100.00) + ->addValue('currency', 'GBP') + ->addValue('contribution_status_id:name', 'Pending') + ->execute() + ->first()['id']; + } + + /** + * Tests creating a payment attempt record. + */ + public function testCreate() { + $params = [ + 'contribution_id' => $this->contributionId, + 'contact_id' => $this->contactId, + 'processor_type' => 'stripe', + 'status' => 'pending', + 'processor_session_id' => 'cs_test_123', + 'processor_payment_id' => 'pi_test_456', + ]; + + $attempt = PaymentAttempt::create($params); + + $this->assertNotNull($attempt->id); + foreach ($params as $key => $value) { + $this->assertEquals($value, $attempt->{$key}, "Field {$key} should match"); + } + } + + /** + * Tests finding payment attempt by contribution ID. + */ + public function testFindByContributionId() { + // Create attempt + $params = [ + 'contribution_id' => $this->contributionId, + 'contact_id' => $this->contactId, + 'processor_type' => 'stripe', + 'status' => 'pending', + ]; + + PaymentAttempt::create($params); + + // Find by contribution ID + $found = PaymentAttempt::findByContributionId($this->contributionId); + + $this->assertNotNull($found); + $this->assertEquals($this->contributionId, $found['contribution_id']); + $this->assertEquals($this->contactId, $found['contact_id']); + $this->assertEquals('stripe', $found['processor_type']); + } + + /** + * Tests finding payment attempt by session ID. + */ + public function testFindBySessionId() { + $sessionId = 'cs_test_session_123'; + + // Create attempt + $params = [ + 'contribution_id' => $this->contributionId, + 'contact_id' => $this->contactId, + 'processor_type' => 'stripe', + 'status' => 'pending', + 'processor_session_id' => $sessionId, + ]; + + PaymentAttempt::create($params); + + // Find by session ID + $found = PaymentAttempt::findBySessionId($sessionId, 'stripe'); + + $this->assertNotNull($found); + $this->assertEquals($sessionId, $found['processor_session_id']); + $this->assertEquals('stripe', $found['processor_type']); + } + + /** + * Tests finding payment attempt by session ID with wrong processor type returns null. + */ + public function testFindBySessionIdWrongProcessorType() { + $sessionId = 'cs_test_session_456'; + + // Create Stripe attempt + PaymentAttempt::create([ + 'contribution_id' => $this->contributionId, + 'contact_id' => $this->contactId, + 'processor_type' => 'stripe', + 'status' => 'pending', + 'processor_session_id' => $sessionId, + ]); + + // Try to find with wrong processor type + $found = PaymentAttempt::findBySessionId($sessionId, 'gocardless'); + + $this->assertNull($found); + } + + /** + * Tests finding payment attempt by payment ID. + */ + public function testFindByPaymentId() { + $paymentId = 'pi_test_payment_789'; + + // Create attempt + $params = [ + 'contribution_id' => $this->contributionId, + 'contact_id' => $this->contactId, + 'processor_type' => 'stripe', + 'status' => 'pending', + 'processor_payment_id' => $paymentId, + ]; + + PaymentAttempt::create($params); + + // Find by payment ID + $found = PaymentAttempt::findByPaymentId($paymentId, 'stripe'); + + $this->assertNotNull($found); + $this->assertEquals($paymentId, $found['processor_payment_id']); + $this->assertEquals('stripe', $found['processor_type']); + } + + /** + * Tests finding payment attempt by payment ID with wrong processor type returns null. + */ + public function testFindByPaymentIdWrongProcessorType() { + $paymentId = 'pi_test_payment_101'; + + // Create Stripe attempt + PaymentAttempt::create([ + 'contribution_id' => $this->contributionId, + 'contact_id' => $this->contactId, + 'processor_type' => 'stripe', + 'status' => 'pending', + 'processor_payment_id' => $paymentId, + ]); + + // Try to find with wrong processor type + $found = PaymentAttempt::findByPaymentId($paymentId, 'gocardless'); + + $this->assertNull($found); + } + + /** + * Tests getStatuses returns correct status options. + */ + public function testGetStatuses() { + $statuses = PaymentAttempt::getStatuses(); + + $this->assertIsArray($statuses); + $this->assertArrayHasKey('pending', $statuses); + $this->assertArrayHasKey('completed', $statuses); + $this->assertArrayHasKey('failed', $statuses); + $this->assertArrayHasKey('cancelled', $statuses); + + $this->assertEquals('Pending', $statuses['pending']); + $this->assertEquals('Completed', $statuses['completed']); + $this->assertEquals('Failed', $statuses['failed']); + $this->assertEquals('Cancelled', $statuses['cancelled']); + } + + /** + * Tests updating an existing payment attempt. + */ + public function testUpdate() { + // Create initial attempt + $params = [ + 'contribution_id' => $this->contributionId, + 'contact_id' => $this->contactId, + 'processor_type' => 'stripe', + 'status' => 'pending', + ]; + + $attempt = PaymentAttempt::create($params); + $attemptId = $attempt->id; + + // Update with session ID + $updateParams = [ + 'id' => $attemptId, + 'processor_session_id' => 'cs_test_updated_123', + 'status' => 'completed', + ]; + + $updated = PaymentAttempt::create($updateParams); + + $this->assertEquals($attemptId, $updated->id); + $this->assertEquals('cs_test_updated_123', $updated->processor_session_id); + $this->assertEquals('completed', $updated->status); + } + + /** + * Tests that contribution_id has UNIQUE constraint. + * + * This test verifies that attempting to create duplicate attempts for the + * same contribution will fail, maintaining data integrity. + */ + public function testContributionIdUniqueConstraint() { + // Create first attempt + $params = [ + 'contribution_id' => $this->contributionId, + 'contact_id' => $this->contactId, + 'processor_type' => 'stripe', + 'status' => 'pending', + ]; + + PaymentAttempt::create($params); + + // Try to create duplicate attempt + $this->expectException(\Exception::class); + PaymentAttempt::create($params); + } + + /** + * Tests finding payment attempt returns NULL when not found. + */ + public function testFindByContributionIdNotFound() { + $nonExistentId = 999999; + $found = PaymentAttempt::findByContributionId($nonExistentId); + + $this->assertNull($found); + } + + /** + * Tests finding payment attempt by session ID returns NULL when not found. + */ + public function testFindBySessionIdNotFound() { + $found = PaymentAttempt::findBySessionId('cs_nonexistent', 'stripe'); + + $this->assertNull($found); + } + + /** + * Tests finding payment attempt by payment ID returns NULL when not found. + */ + public function testFindByPaymentIdNotFound() { + $found = PaymentAttempt::findByPaymentId('pi_nonexistent', 'stripe'); + + $this->assertNull($found); + } + +} diff --git a/tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php b/tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php new file mode 100644 index 0000000..e3c17f9 --- /dev/null +++ b/tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php @@ -0,0 +1,338 @@ +contactId = Contact::create(FALSE) + ->addValue('contact_type', 'Individual') + ->addValue('first_name', 'Test') + ->addValue('last_name', 'Webhook') + ->execute() + ->first()['id']; + + // Create test contribution + $this->contributionId = Contribution::create(FALSE) + ->addValue('contact_id', $this->contactId) + ->addValue('financial_type_id:name', 'Donation') + ->addValue('total_amount', 50.00) + ->addValue('currency', 'GBP') + ->addValue('contribution_status_id:name', 'Pending') + ->execute() + ->first()['id']; + + // Create test payment attempt + $attempt = PaymentAttempt::create([ + 'contribution_id' => $this->contributionId, + 'contact_id' => $this->contactId, + 'processor_type' => 'stripe', + 'status' => 'pending', + ]); + $this->attemptId = $attempt->id; + } + + /** + * Tests creating a webhook record. + */ + public function testCreate() { + $params = [ + 'event_id' => 'evt_test_123', + 'processor_type' => 'stripe', + 'event_type' => 'checkout.session.completed', + 'payment_attempt_id' => $this->attemptId, + 'status' => 'new', + ]; + + $webhook = PaymentWebhook::create($params); + + $this->assertNotNull($webhook->id); + foreach ($params as $key => $value) { + $this->assertEquals($value, $webhook->{$key}, "Field {$key} should match"); + } + } + + /** + * Tests finding webhook by event ID. + */ + public function testFindByEventId() { + $eventId = 'evt_test_unique_456'; + + // Create webhook + $params = [ + 'event_id' => $eventId, + 'processor_type' => 'stripe', + 'event_type' => 'payment_intent.succeeded', + 'payment_attempt_id' => $this->attemptId, + 'status' => 'new', + ]; + + PaymentWebhook::create($params); + + // Find by event ID + $found = PaymentWebhook::findByEventId($eventId); + + $this->assertNotNull($found); + $this->assertEquals($eventId, $found['event_id']); + $this->assertEquals('stripe', $found['processor_type']); + } + + /** + * Tests finding webhook returns NULL when not found. + */ + public function testFindByEventIdNotFound() { + $found = PaymentWebhook::findByEventId('evt_nonexistent'); + + $this->assertNull($found); + } + + /** + * Tests isProcessed returns TRUE for processed events. + */ + public function testIsProcessedReturnsTrueForProcessedEvent() { + $eventId = 'evt_test_processed_789'; + + // Create processed webhook + PaymentWebhook::create([ + 'event_id' => $eventId, + 'processor_type' => 'stripe', + 'event_type' => 'checkout.session.completed', + 'status' => 'processed', + 'result' => 'applied', + ]); + + $isProcessed = PaymentWebhook::isProcessed($eventId); + + $this->assertTrue($isProcessed); + } + + /** + * Tests isProcessed returns TRUE for events currently processing. + */ + public function testIsProcessedReturnsTrueForProcessingEvent() { + $eventId = 'evt_test_processing_101'; + + // Create processing webhook + PaymentWebhook::create([ + 'event_id' => $eventId, + 'processor_type' => 'stripe', + 'event_type' => 'checkout.session.completed', + 'status' => 'processing', + ]); + + $isProcessed = PaymentWebhook::isProcessed($eventId); + + $this->assertTrue($isProcessed); + } + + /** + * Tests isProcessed returns FALSE for new events. + */ + public function testIsProcessedReturnsFalseForNewEvent() { + $eventId = 'evt_test_new_202'; + + // Create new webhook + PaymentWebhook::create([ + 'event_id' => $eventId, + 'processor_type' => 'stripe', + 'event_type' => 'checkout.session.completed', + 'status' => 'new', + ]); + + $isProcessed = PaymentWebhook::isProcessed($eventId); + + $this->assertFalse($isProcessed); + } + + /** + * Tests isProcessed returns FALSE for error events. + */ + public function testIsProcessedReturnsFalseForErrorEvent() { + $eventId = 'evt_test_error_303'; + + // Create error webhook + PaymentWebhook::create([ + 'event_id' => $eventId, + 'processor_type' => 'stripe', + 'event_type' => 'checkout.session.completed', + 'status' => 'error', + 'error_log' => 'Test error', + ]); + + $isProcessed = PaymentWebhook::isProcessed($eventId); + + $this->assertFalse($isProcessed); + } + + /** + * Tests isProcessed returns FALSE for non-existent events. + */ + public function testIsProcessedReturnsFalseForNonExistentEvent() { + $isProcessed = PaymentWebhook::isProcessed('evt_nonexistent_404'); + + $this->assertFalse($isProcessed); + } + + /** + * Tests getStatuses returns correct status options. + */ + public function testGetStatuses() { + $statuses = PaymentWebhook::getStatuses(); + + $this->assertIsArray($statuses); + $this->assertArrayHasKey('new', $statuses); + $this->assertArrayHasKey('processing', $statuses); + $this->assertArrayHasKey('processed', $statuses); + $this->assertArrayHasKey('error', $statuses); + + $this->assertEquals('New', $statuses['new']); + $this->assertEquals('Processing', $statuses['processing']); + $this->assertEquals('Processed', $statuses['processed']); + $this->assertEquals('Error', $statuses['error']); + } + + /** + * Tests updating an existing webhook. + */ + public function testUpdate() { + // Create initial webhook + $params = [ + 'event_id' => 'evt_test_update_505', + 'processor_type' => 'stripe', + 'event_type' => 'checkout.session.completed', + 'status' => 'new', + ]; + + $webhook = PaymentWebhook::create($params); + $webhookId = $webhook->id; + + // Update to processed + $updateParams = [ + 'id' => $webhookId, + 'status' => 'processed', + 'result' => 'applied', + 'processed_at' => date('Y-m-d H:i:s'), + ]; + + $updated = PaymentWebhook::create($updateParams); + + $this->assertEquals($webhookId, $updated->id); + $this->assertEquals('processed', $updated->status); + $this->assertEquals('applied', $updated->result); + $this->assertNotNull($updated->processed_at); + } + + /** + * Tests unique constraint on event_id. + */ + public function testUniqueEventIdConstraint() { + $eventId = 'evt_test_duplicate_606'; + + // Create first webhook + PaymentWebhook::create([ + 'event_id' => $eventId, + 'processor_type' => 'stripe', + 'event_type' => 'checkout.session.completed', + 'status' => 'new', + ]); + + // Try to create duplicate - should fail + $this->expectException(\Exception::class); + + PaymentWebhook::create([ + 'event_id' => $eventId, + 'processor_type' => 'stripe', + 'event_type' => 'checkout.session.completed', + 'status' => 'new', + ]); + } + + /** + * Tests webhook without payment_attempt_id (not all webhooks link to attempts). + */ + public function testCreateWithoutPaymentAttempt() { + $params = [ + 'event_id' => 'evt_test_no_attempt_707', + 'processor_type' => 'stripe', + 'event_type' => 'account.updated', + 'status' => 'new', + ]; + + $webhook = PaymentWebhook::create($params); + + $this->assertNotNull($webhook->id); + $this->assertEquals('evt_test_no_attempt_707', $webhook->event_id); + $this->assertNull($webhook->payment_attempt_id); + } + + /** + * Tests different processor types. + */ + public function testDifferentProcessorTypes() { + $processors = [ + ['type' => 'stripe', 'event' => 'evt_stripe_808'], + ['type' => 'gocardless', 'event' => 'evt_gc_809'], + ['type' => 'itas', 'event' => 'evt_itas_810'], + ]; + + foreach ($processors as $processor) { + $webhook = PaymentWebhook::create([ + 'event_id' => $processor['event'], + 'processor_type' => $processor['type'], + 'event_type' => 'payment.succeeded', + 'status' => 'new', + ]); + + $this->assertEquals($processor['type'], $webhook->processor_type); + $this->assertEquals($processor['event'], $webhook->event_id); + } + } + + /** + * Tests all valid statuses. + */ + public function testAllStatuses() { + $statuses = ['new', 'processing', 'processed', 'error']; + + foreach ($statuses as $index => $status) { + $webhook = PaymentWebhook::create([ + 'event_id' => "evt_test_status_{$index}_911", + 'processor_type' => 'stripe', + 'event_type' => 'checkout.session.completed', + 'status' => $status, + ]); + + $this->assertEquals($status, $webhook->status); + } + } + +} diff --git a/tests/phpunit/Civi/Api4/PaymentAttemptTest.php b/tests/phpunit/Civi/Api4/PaymentAttemptTest.php new file mode 100644 index 0000000..8475c26 --- /dev/null +++ b/tests/phpunit/Civi/Api4/PaymentAttemptTest.php @@ -0,0 +1,387 @@ +contactId = Contact::create(FALSE) + ->addValue('contact_type', 'Individual') + ->addValue('first_name', 'Test') + ->addValue('last_name', 'Donor') + ->execute() + ->first()['id']; + + // Create test payment processor using Dummy type (built-in test processor) + $this->paymentProcessorId = PaymentProcessor::create(FALSE) + ->addValue('name', 'Test Processor') + ->addValue('payment_processor_type_id:name', 'Dummy') + ->addValue('class_name', 'Payment_Dummy') + ->addValue('is_active', 1) + ->addValue('is_test', 0) + ->execute() + ->first()['id']; + + // Create test contribution + $this->contributionId = Contribution::create(FALSE) + ->addValue('contact_id', $this->contactId) + ->addValue('financial_type_id:name', 'Donation') + ->addValue('total_amount', 100.00) + ->addValue('currency', 'GBP') + ->addValue('contribution_status_id:name', 'Pending') + ->execute() + ->first()['id']; + } + + /** + * Test creating a PaymentAttempt with required fields. + */ + public function testCreatePaymentAttemptWithRequiredFields() { + $created = PaymentAttempt::create(FALSE) + ->addValue('contribution_id', $this->contributionId) + ->addValue('processor_type', 'stripe') + ->execute() + ->first(); + + // Fetch the full record to get default values + $attempt = PaymentAttempt::get(FALSE) + ->addWhere('id', '=', $created['id']) + ->execute() + ->first(); + + $this->assertNotEmpty($attempt['id']); + $this->assertEquals($this->contributionId, $attempt['contribution_id']); + $this->assertEquals('stripe', $attempt['processor_type']); + $this->assertEquals('pending', $attempt['status']); + $this->assertNotEmpty($attempt['created_date']); + } + + /** + * Test creating a PaymentAttempt with all fields. + */ + public function testCreatePaymentAttemptWithAllFields() { + $attempt = PaymentAttempt::create(FALSE) + ->addValue('contribution_id', $this->contributionId) + ->addValue('contact_id', $this->contactId) + ->addValue('payment_processor_id', $this->paymentProcessorId) + ->addValue('processor_type', 'stripe') + ->addValue('processor_session_id', 'cs_test_123') + ->addValue('processor_payment_id', 'pi_test_456') + ->addValue('status', 'completed') + ->execute() + ->first(); + + $this->assertNotEmpty($attempt['id']); + $this->assertEquals($this->contributionId, $attempt['contribution_id']); + $this->assertEquals($this->contactId, $attempt['contact_id']); + $this->assertEquals($this->paymentProcessorId, $attempt['payment_processor_id']); + $this->assertEquals('stripe', $attempt['processor_type']); + $this->assertEquals('cs_test_123', $attempt['processor_session_id']); + $this->assertEquals('pi_test_456', $attempt['processor_payment_id']); + $this->assertEquals('completed', $attempt['status']); + } + + /** + * Test that contribution_id is required. + */ + public function testCreateWithoutContributionIdFails() { + $this->expectException(\CRM_Core_Exception::class); + + PaymentAttempt::create(FALSE) + ->addValue('processor_type', 'stripe') + ->execute(); + } + + /** + * Test that processor_type is required. + */ + public function testCreateWithoutProcessorTypeFails() { + $this->expectException(\CRM_Core_Exception::class); + + PaymentAttempt::create(FALSE) + ->addValue('contribution_id', $this->contributionId) + ->execute(); + } + + /** + * Test unique constraint on contribution_id. + */ + public function testUniqueContributionConstraint() { + // Create first attempt + PaymentAttempt::create(FALSE) + ->addValue('contribution_id', $this->contributionId) + ->addValue('processor_type', 'stripe') + ->execute(); + + // Try to create duplicate - should fail + $this->expectException(\CRM_Core_Exception::class); + + PaymentAttempt::create(FALSE) + ->addValue('contribution_id', $this->contributionId) + ->addValue('processor_type', 'stripe') + ->execute(); + } + + /** + * Test retrieving PaymentAttempt by contribution_id. + */ + public function testGetByContributionId() { + $created = PaymentAttempt::create(FALSE) + ->addValue('contribution_id', $this->contributionId) + ->addValue('processor_type', 'stripe') + ->addValue('processor_session_id', 'cs_test_789') + ->execute() + ->first(); + + $retrieved = PaymentAttempt::get(FALSE) + ->addWhere('contribution_id', '=', $this->contributionId) + ->execute() + ->first(); + + $this->assertEquals($created['id'], $retrieved['id']); + $this->assertEquals('cs_test_789', $retrieved['processor_session_id']); + } + + /** + * Test retrieving PaymentAttempt by processor session ID. + */ + public function testGetByProcessorSessionId() { + PaymentAttempt::create(FALSE) + ->addValue('contribution_id', $this->contributionId) + ->addValue('processor_type', 'stripe') + ->addValue('processor_session_id', 'cs_test_unique') + ->execute(); + + $retrieved = PaymentAttempt::get(FALSE) + ->addWhere('processor_session_id', '=', 'cs_test_unique') + ->addWhere('processor_type', '=', 'stripe') + ->execute() + ->first(); + + $this->assertEquals('cs_test_unique', $retrieved['processor_session_id']); + $this->assertEquals('stripe', $retrieved['processor_type']); + } + + /** + * Test updating PaymentAttempt status. + */ + public function testUpdateStatus() { + $attempt = PaymentAttempt::create(FALSE) + ->addValue('contribution_id', $this->contributionId) + ->addValue('processor_type', 'stripe') + ->addValue('status', 'pending') + ->execute() + ->first(); + + PaymentAttempt::update(FALSE) + ->addWhere('id', '=', $attempt['id']) + ->addValue('status', 'completed') + ->addValue('processor_payment_id', 'pi_completed_123') + ->execute(); + + $updated = PaymentAttempt::get(FALSE) + ->addWhere('id', '=', $attempt['id']) + ->execute() + ->first(); + + $this->assertEquals('completed', $updated['status']); + $this->assertEquals('pi_completed_123', $updated['processor_payment_id']); + } + + /** + * Test deleting PaymentAttempt. + */ + public function testDelete() { + $attempt = PaymentAttempt::create(FALSE) + ->addValue('contribution_id', $this->contributionId) + ->addValue('processor_type', 'stripe') + ->execute() + ->first(); + + PaymentAttempt::delete(FALSE) + ->addWhere('id', '=', $attempt['id']) + ->execute(); + + $count = PaymentAttempt::get(FALSE) + ->addWhere('id', '=', $attempt['id']) + ->execute() + ->count(); + + $this->assertEquals(0, $count); + } + + /** + * Test cascade delete when contribution is deleted. + */ + public function testCascadeDeleteWithContribution() { + $attempt = PaymentAttempt::create(FALSE) + ->addValue('contribution_id', $this->contributionId) + ->addValue('processor_type', 'stripe') + ->execute() + ->first(); + + // Delete contribution + Contribution::delete(FALSE) + ->addWhere('id', '=', $this->contributionId) + ->execute(); + + // PaymentAttempt should also be deleted (CASCADE) + $count = PaymentAttempt::get(FALSE) + ->addWhere('id', '=', $attempt['id']) + ->execute() + ->count(); + + $this->assertEquals(0, $count); + } + + /** + * Test SET NULL when contact is deleted. + */ + public function testSetNullWhenContactDeleted() { + // Create separate contact for testing deletion (not tied to contribution) + $tempContactId = Contact::create(FALSE) + ->addValue('contact_type', 'Individual') + ->addValue('first_name', 'Temp') + ->addValue('last_name', 'Contact') + ->execute() + ->first()['id']; + + $attempt = PaymentAttempt::create(FALSE) + ->addValue('contribution_id', $this->contributionId) + ->addValue('contact_id', $tempContactId) + ->addValue('processor_type', 'stripe') + ->execute() + ->first(); + + // Delete the temp contact (use skip_undelete to force permanent deletion) + Contact::delete(FALSE) + ->addWhere('id', '=', $tempContactId) + ->setUseTrash(FALSE) + ->execute(); + + // PaymentAttempt should still exist but with NULL contact_id + $retrieved = PaymentAttempt::get(FALSE) + ->addWhere('id', '=', $attempt['id']) + ->execute() + ->first(); + + $this->assertNull($retrieved['contact_id']); + } + + /** + * Test different processor types. + */ + public function testDifferentProcessorTypes() { + $processors = ['stripe', 'gocardless', 'itas', 'paypal']; + + foreach ($processors as $processor) { + // Create new contribution for each processor + $contribId = Contribution::create(FALSE) + ->addValue('contact_id', $this->contactId) + ->addValue('financial_type_id:name', 'Donation') + ->addValue('total_amount', 50.00) + ->addValue('currency', 'GBP') + ->addValue('contribution_status_id:name', 'Pending') + ->execute() + ->first()['id']; + + $attempt = PaymentAttempt::create(FALSE) + ->addValue('contribution_id', $contribId) + ->addValue('processor_type', $processor) + ->execute() + ->first(); + + $this->assertEquals($processor, $attempt['processor_type']); + } + } + + /** + * Test all valid statuses. + */ + public function testAllStatuses() { + $statuses = ['pending', 'completed', 'failed', 'cancelled']; + + foreach ($statuses as $status) { + // Create new contribution for each status + $contribId = Contribution::create(FALSE) + ->addValue('contact_id', $this->contactId) + ->addValue('financial_type_id:name', 'Donation') + ->addValue('total_amount', 25.00) + ->addValue('currency', 'GBP') + ->addValue('contribution_status_id:name', 'Pending') + ->execute() + ->first()['id']; + + $attempt = PaymentAttempt::create(FALSE) + ->addValue('contribution_id', $contribId) + ->addValue('processor_type', 'stripe') + ->addValue('status', $status) + ->execute() + ->first(); + + $this->assertEquals($status, $attempt['status']); + } + } + + /** + * Test querying by processor type and status. + */ + public function testFilterByProcessorTypeAndStatus() { + // Create multiple attempts with different combinations + $contribId1 = Contribution::create(FALSE) + ->addValue('contact_id', $this->contactId) + ->addValue('financial_type_id:name', 'Donation') + ->addValue('total_amount', 10.00) + ->addValue('currency', 'GBP') + ->addValue('contribution_status_id:name', 'Pending') + ->execute() + ->first()['id']; + + $contribId2 = Contribution::create(FALSE) + ->addValue('contact_id', $this->contactId) + ->addValue('financial_type_id:name', 'Donation') + ->addValue('total_amount', 20.00) + ->addValue('currency', 'GBP') + ->addValue('contribution_status_id:name', 'Pending') + ->execute() + ->first()['id']; + + PaymentAttempt::create(FALSE) + ->addValue('contribution_id', $contribId1) + ->addValue('processor_type', 'stripe') + ->addValue('status', 'completed') + ->execute(); + + PaymentAttempt::create(FALSE) + ->addValue('contribution_id', $contribId2) + ->addValue('processor_type', 'stripe') + ->addValue('status', 'pending') + ->execute(); + + $completed = PaymentAttempt::get(FALSE) + ->addWhere('processor_type', '=', 'stripe') + ->addWhere('status', '=', 'completed') + ->execute() + ->count(); + + $this->assertGreaterThanOrEqual(1, $completed); + } + +} diff --git a/tests/phpunit/Civi/Api4/PaymentWebhookTest.php b/tests/phpunit/Civi/Api4/PaymentWebhookTest.php new file mode 100644 index 0000000..f4a732e --- /dev/null +++ b/tests/phpunit/Civi/Api4/PaymentWebhookTest.php @@ -0,0 +1,409 @@ +contactId = Contact::create(FALSE) + ->addValue('contact_type', 'Individual') + ->addValue('first_name', 'Test') + ->addValue('last_name', 'Webhook') + ->execute() + ->first()['id']; + + // Create test contribution + $this->contributionId = Contribution::create(FALSE) + ->addValue('contact_id', $this->contactId) + ->addValue('financial_type_id:name', 'Donation') + ->addValue('total_amount', 75.00) + ->addValue('currency', 'GBP') + ->addValue('contribution_status_id:name', 'Pending') + ->execute() + ->first()['id']; + + // Create test payment attempt + $this->attemptId = PaymentAttempt::create(FALSE) + ->addValue('contribution_id', $this->contributionId) + ->addValue('contact_id', $this->contactId) + ->addValue('processor_type', 'stripe') + ->addValue('status', 'pending') + ->execute() + ->first()['id']; + } + + /** + * Test creating a PaymentWebhook with required fields. + */ + public function testCreatePaymentWebhookWithRequiredFields() { + $created = PaymentWebhook::create(FALSE) + ->addValue('event_id', 'evt_test_123') + ->addValue('processor_type', 'stripe') + ->addValue('event_type', 'checkout.session.completed') + ->execute() + ->first(); + + // Fetch the full record to get default values + $webhook = PaymentWebhook::get(FALSE) + ->addWhere('id', '=', $created['id']) + ->execute() + ->first(); + + $this->assertNotEmpty($webhook['id']); + $this->assertEquals('evt_test_123', $webhook['event_id']); + $this->assertEquals('stripe', $webhook['processor_type']); + $this->assertEquals('checkout.session.completed', $webhook['event_type']); + $this->assertEquals('new', $webhook['status']); + $this->assertNotEmpty($webhook['created_date']); + } + + /** + * Test creating a PaymentWebhook with all fields. + */ + public function testCreatePaymentWebhookWithAllFields() { + $webhook = PaymentWebhook::create(FALSE) + ->addValue('event_id', 'evt_test_456') + ->addValue('processor_type', 'stripe') + ->addValue('event_type', 'payment_intent.succeeded') + ->addValue('payment_attempt_id', $this->attemptId) + ->addValue('status', 'processed') + ->addValue('result', 'applied') + ->addValue('processed_at', date('Y-m-d H:i:s')) + ->execute() + ->first(); + + $this->assertNotEmpty($webhook['id']); + $this->assertEquals('evt_test_456', $webhook['event_id']); + $this->assertEquals('stripe', $webhook['processor_type']); + $this->assertEquals('payment_intent.succeeded', $webhook['event_type']); + $this->assertEquals($this->attemptId, $webhook['payment_attempt_id']); + $this->assertEquals('processed', $webhook['status']); + $this->assertEquals('applied', $webhook['result']); + $this->assertNotEmpty($webhook['processed_at']); + } + + /** + * Test that event_id is required. + */ + public function testCreateWithoutEventIdFails() { + $this->expectException(\CRM_Core_Exception::class); + + PaymentWebhook::create(FALSE) + ->addValue('processor_type', 'stripe') + ->addValue('event_type', 'checkout.session.completed') + ->execute(); + } + + /** + * Test that processor_type is required. + */ + public function testCreateWithoutProcessorTypeFails() { + $this->expectException(\CRM_Core_Exception::class); + + PaymentWebhook::create(FALSE) + ->addValue('event_id', 'evt_test_789') + ->addValue('event_type', 'checkout.session.completed') + ->execute(); + } + + /** + * Test that event_type is required. + */ + public function testCreateWithoutEventTypeFails() { + $this->expectException(\CRM_Core_Exception::class); + + PaymentWebhook::create(FALSE) + ->addValue('event_id', 'evt_test_101') + ->addValue('processor_type', 'stripe') + ->execute(); + } + + /** + * Test unique constraint on event_id. + */ + public function testUniqueEventIdConstraint() { + // Create first webhook + PaymentWebhook::create(FALSE) + ->addValue('event_id', 'evt_test_unique_202') + ->addValue('processor_type', 'stripe') + ->addValue('event_type', 'checkout.session.completed') + ->execute(); + + // Try to create duplicate - should fail + $this->expectException(\CRM_Core_Exception::class); + + PaymentWebhook::create(FALSE) + ->addValue('event_id', 'evt_test_unique_202') + ->addValue('processor_type', 'stripe') + ->addValue('event_type', 'checkout.session.completed') + ->execute(); + } + + /** + * Test retrieving PaymentWebhook by event_id. + */ + public function testGetByEventId() { + $created = PaymentWebhook::create(FALSE) + ->addValue('event_id', 'evt_test_303') + ->addValue('processor_type', 'stripe') + ->addValue('event_type', 'payment_intent.succeeded') + ->execute() + ->first(); + + $retrieved = PaymentWebhook::get(FALSE) + ->addWhere('event_id', '=', 'evt_test_303') + ->execute() + ->first(); + + $this->assertEquals($created['id'], $retrieved['id']); + $this->assertEquals('evt_test_303', $retrieved['event_id']); + } + + /** + * Test retrieving PaymentWebhook by processor type and event type. + */ + public function testGetByProcessorAndEventType() { + PaymentWebhook::create(FALSE) + ->addValue('event_id', 'evt_test_404') + ->addValue('processor_type', 'stripe') + ->addValue('event_type', 'checkout.session.completed') + ->execute(); + + $retrieved = PaymentWebhook::get(FALSE) + ->addWhere('processor_type', '=', 'stripe') + ->addWhere('event_type', '=', 'checkout.session.completed') + ->execute() + ->first(); + + $this->assertEquals('stripe', $retrieved['processor_type']); + $this->assertEquals('checkout.session.completed', $retrieved['event_type']); + } + + /** + * Test updating PaymentWebhook status. + */ + public function testUpdateStatus() { + $webhook = PaymentWebhook::create(FALSE) + ->addValue('event_id', 'evt_test_505') + ->addValue('processor_type', 'stripe') + ->addValue('event_type', 'checkout.session.completed') + ->addValue('status', 'new') + ->execute() + ->first(); + + PaymentWebhook::update(FALSE) + ->addWhere('id', '=', $webhook['id']) + ->addValue('status', 'processed') + ->addValue('result', 'applied') + ->addValue('processed_at', date('Y-m-d H:i:s')) + ->execute(); + + $updated = PaymentWebhook::get(FALSE) + ->addWhere('id', '=', $webhook['id']) + ->execute() + ->first(); + + $this->assertEquals('processed', $updated['status']); + $this->assertEquals('applied', $updated['result']); + $this->assertNotEmpty($updated['processed_at']); + } + + /** + * Test deleting PaymentWebhook. + */ + public function testDelete() { + $webhook = PaymentWebhook::create(FALSE) + ->addValue('event_id', 'evt_test_606') + ->addValue('processor_type', 'stripe') + ->addValue('event_type', 'checkout.session.completed') + ->execute() + ->first(); + + PaymentWebhook::delete(FALSE) + ->addWhere('id', '=', $webhook['id']) + ->execute(); + + $count = PaymentWebhook::get(FALSE) + ->addWhere('id', '=', $webhook['id']) + ->execute() + ->count(); + + $this->assertEquals(0, $count); + } + + /** + * Test SET NULL when payment attempt is deleted. + */ + public function testSetNullWhenPaymentAttemptDeleted() { + $webhook = PaymentWebhook::create(FALSE) + ->addValue('event_id', 'evt_test_707') + ->addValue('processor_type', 'stripe') + ->addValue('event_type', 'checkout.session.completed') + ->addValue('payment_attempt_id', $this->attemptId) + ->execute() + ->first(); + + // Delete payment attempt + PaymentAttempt::delete(FALSE) + ->addWhere('id', '=', $this->attemptId) + ->execute(); + + // PaymentWebhook should still exist but with NULL payment_attempt_id + $retrieved = PaymentWebhook::get(FALSE) + ->addWhere('id', '=', $webhook['id']) + ->execute() + ->first(); + + $this->assertNull($retrieved['payment_attempt_id']); + } + + /** + * Test different processor types. + */ + public function testDifferentProcessorTypes() { + $processors = [ + ['type' => 'stripe', 'event' => 'evt_stripe_808'], + ['type' => 'gocardless', 'event' => 'evt_gc_809'], + ['type' => 'itas', 'event' => 'evt_itas_810'], + ]; + + foreach ($processors as $processor) { + $webhook = PaymentWebhook::create(FALSE) + ->addValue('event_id', $processor['event']) + ->addValue('processor_type', $processor['type']) + ->addValue('event_type', 'payment.succeeded') + ->execute() + ->first(); + + $this->assertEquals($processor['type'], $webhook['processor_type']); + $this->assertEquals($processor['event'], $webhook['event_id']); + } + } + + /** + * Test all valid statuses. + */ + public function testAllStatuses() { + $statuses = ['new', 'processing', 'processed', 'error']; + + foreach ($statuses as $index => $status) { + $webhook = PaymentWebhook::create(FALSE) + ->addValue('event_id', "evt_test_status_{$index}_911") + ->addValue('processor_type', 'stripe') + ->addValue('event_type', 'checkout.session.completed') + ->addValue('status', $status) + ->execute() + ->first(); + + $this->assertEquals($status, $webhook['status']); + } + } + + /** + * Test all valid results. + */ + public function testAllResults() { + $results = ['applied', 'noop', 'ignored_out_of_order', 'error']; + + foreach ($results as $index => $result) { + $webhook = PaymentWebhook::create(FALSE) + ->addValue('event_id', "evt_test_result_{$index}_1001") + ->addValue('processor_type', 'stripe') + ->addValue('event_type', 'checkout.session.completed') + ->addValue('status', 'processed') + ->addValue('result', $result) + ->execute() + ->first(); + + $this->assertEquals($result, $webhook['result']); + } + } + + /** + * Test filtering by status. + */ + public function testFilterByStatus() { + // Create webhooks with different statuses + PaymentWebhook::create(FALSE) + ->addValue('event_id', 'evt_test_1101') + ->addValue('processor_type', 'stripe') + ->addValue('event_type', 'checkout.session.completed') + ->addValue('status', 'new') + ->execute(); + + PaymentWebhook::create(FALSE) + ->addValue('event_id', 'evt_test_1102') + ->addValue('processor_type', 'stripe') + ->addValue('event_type', 'checkout.session.completed') + ->addValue('status', 'processed') + ->execute(); + + $newCount = PaymentWebhook::get(FALSE) + ->addWhere('status', '=', 'new') + ->execute() + ->count(); + + $processedCount = PaymentWebhook::get(FALSE) + ->addWhere('status', '=', 'processed') + ->execute() + ->count(); + + $this->assertGreaterThanOrEqual(1, $newCount); + $this->assertGreaterThanOrEqual(1, $processedCount); + } + + /** + * Test webhook without payment_attempt_id (not all webhooks link to attempts). + */ + public function testCreateWithoutPaymentAttempt() { + $webhook = PaymentWebhook::create(FALSE) + ->addValue('event_id', 'evt_test_1203') + ->addValue('processor_type', 'stripe') + ->addValue('event_type', 'account.updated') + ->addValue('status', 'new') + ->execute() + ->first(); + + $this->assertNotEmpty($webhook['id']); + $this->assertEquals('evt_test_1203', $webhook['event_id']); + $this->assertTrue(!isset($webhook['payment_attempt_id']) || empty($webhook['payment_attempt_id'])); + } + + /** + * Test error logging. + */ + public function testErrorLogging() { + $errorMessage = 'Test error: payment processing failed'; + + $webhook = PaymentWebhook::create(FALSE) + ->addValue('event_id', 'evt_test_1304') + ->addValue('processor_type', 'stripe') + ->addValue('event_type', 'checkout.session.completed') + ->addValue('status', 'error') + ->addValue('result', 'error') + ->addValue('error_log', $errorMessage) + ->execute() + ->first(); + + $this->assertEquals('error', $webhook['status']); + $this->assertEquals($errorMessage, $webhook['error_log']); + } + +} diff --git a/tests/phpunit/bootstrap.php b/tests/phpunit/bootstrap.php new file mode 100644 index 0000000..dd6a796 --- /dev/null +++ b/tests/phpunit/bootstrap.php @@ -0,0 +1,71 @@ +add('CRM_', __DIR__); +$loader->add('Civi\\', __DIR__); +$loader->add('api_', __DIR__); +$loader->add('api\\', __DIR__); +$loader->register(); + +require_once 'BaseHeadlessTest.php'; + +/** + * Call the "cv" command. + * + * @param string $cmd + * The rest of the command to send. + * @param string $decode + * Ex: 'json' or 'phpcode'. + * @return string + * Response output (if the command executed normally). + * @throws \RuntimeException + * If the command terminates abnormally. + */ +function cv($cmd, $decode = 'json') { + $cmd = 'cv ' . $cmd; + $descriptorSpec = [0 => ["pipe", "r"], 1 => ["pipe", "w"], 2 => STDERR]; + $oldOutput = getenv('CV_OUTPUT'); + putenv("CV_OUTPUT=json"); + + // Execute `cv` in the original folder. This is a work-around for + // phpunit/codeception, which seem to manipulate PWD. + $cmd = sprintf('cd %s; %s', escapeshellarg(getenv('PWD')), $cmd); + + $process = proc_open($cmd, $descriptorSpec, $pipes, __DIR__); + putenv("CV_OUTPUT=" . $oldOutput); + fclose($pipes[0]); + $result = stream_get_contents($pipes[1]); + fclose($pipes[1]); + if (proc_close($process) !== 0) { + throw new RuntimeException("Command failed ($cmd):\n$result"); + } + switch ($decode) { + case 'raw': + return $result; + + case 'phpcode': + // If the last output is /*PHPCODE*/, then we managed to complete execution. + if (substr(trim($result), 0, 12) !== "/*BEGINPHP*/" || substr(trim($result), -10) !== "/*ENDPHP*/") { + throw new \RuntimeException("Command failed ($cmd):\n$result"); + } + return $result; + + case 'json': + return json_decode($result, TRUE); + + default: + throw new RuntimeException("Bad decoder format ($decode)"); + } +} diff --git a/xml/schema/CRM/Paymentprocessingcore/PaymentAttempt.entityType.php b/xml/schema/CRM/Paymentprocessingcore/PaymentAttempt.entityType.php new file mode 100644 index 0000000..54cef62 --- /dev/null +++ b/xml/schema/CRM/Paymentprocessingcore/PaymentAttempt.entityType.php @@ -0,0 +1,10 @@ + 'PaymentAttempt', + 'class' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', + 'table' => 'civicrm_payment_attempt', + ], +]; diff --git a/xml/schema/CRM/Paymentprocessingcore/PaymentAttempt.xml b/xml/schema/CRM/Paymentprocessingcore/PaymentAttempt.xml new file mode 100644 index 0000000..8f7ffa1 --- /dev/null +++ b/xml/schema/CRM/Paymentprocessingcore/PaymentAttempt.xml @@ -0,0 +1,167 @@ + + + CRM/Paymentprocessingcore + PaymentAttempt + civicrm_payment_attempt + Tracks payment attempts across all processors (Stripe, GoCardless, ITAS, etc.) + true + + + id + int unsigned + true + Unique ID + + Number + + + + id + true + + + + contribution_id + int unsigned + true + FK to Contribution + + EntityRef + + + + + contribution_id +
civicrm_contribution
+ id + CASCADE + + + index_contribution_id + contribution_id + true + + + + contact_id + int unsigned + false + FK to Contact (donor) + + EntityRef + + + + + contact_id + civicrm_contact
+ id + SET NULL +
+ + + payment_processor_id + int unsigned + false + FK to Payment Processor + + Select + + + + + payment_processor_id + civicrm_payment_processor
+ id + SET NULL +
+ + + + processor_type + varchar + 50 + true + Processor type: 'stripe', 'gocardless', 'itas', etc. + + Text + + + + + index_processor_type + processor_type + + + + processor_session_id + varchar + 255 + Processor session ID (cs_... for Stripe, mandate_... for GoCardless) + + Text + + + + + index_processor_session + processor_session_id + processor_type + + + + processor_payment_id + varchar + 255 + Processor payment ID (pi_... for Stripe, payment_... for GoCardless) + + Text + + + + + index_processor_payment + processor_payment_id + processor_type + + + + status + varchar + 25 + true + 'pending' + Attempt status: pending, completed, failed, cancelled + + CRM_Paymentprocessingcore_BAO_PaymentAttempt::getStatuses + + + Select + + + + + + created_date + timestamp + true + CURRENT_TIMESTAMP + When attempt was created + + Select Date + + + + + + updated_date + timestamp + true + CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + Last updated + + Select Date + + + + diff --git a/xml/schema/CRM/Paymentprocessingcore/PaymentWebhook.entityType.php b/xml/schema/CRM/Paymentprocessingcore/PaymentWebhook.entityType.php new file mode 100644 index 0000000..968b7c4 --- /dev/null +++ b/xml/schema/CRM/Paymentprocessingcore/PaymentWebhook.entityType.php @@ -0,0 +1,10 @@ + 'PaymentWebhook', + 'class' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', + 'table' => 'civicrm_payment_webhook', + ], +]; diff --git a/xml/schema/CRM/Paymentprocessingcore/PaymentWebhook.xml b/xml/schema/CRM/Paymentprocessingcore/PaymentWebhook.xml new file mode 100644 index 0000000..f36e2ad --- /dev/null +++ b/xml/schema/CRM/Paymentprocessingcore/PaymentWebhook.xml @@ -0,0 +1,145 @@ + + + CRM/Paymentprocessingcore + PaymentWebhook + civicrm_payment_webhook + Webhook event log for de-duplication and idempotency across all processors + true + + + id + int unsigned + true + Unique ID + + Number + + + + id + true + + + + event_id + varchar + 255 + true + Processor event ID (evt_... for Stripe, evt_... for GoCardless) + + Text + + + + + + processor_type + varchar + 50 + true + Processor type: 'stripe', 'gocardless', 'itas', etc. + + Text + + + + + + UI_event_processor + event_id + processor_type + true + + + + event_type + varchar + 100 + true + Event type (e.g. checkout.session.completed, payment_intent.succeeded) + + Text + + + + + index_event_type + event_type + + + + payment_attempt_id + int unsigned + false + FK to Payment Attempt + + EntityRef + + + + + payment_attempt_id +
civicrm_payment_attempt
+ id + SET NULL + + + + status + varchar + 25 + true + 'new' + Processing status: new, processing, processed, error + + CRM_Paymentprocessingcore_BAO_PaymentWebhook::getStatuses + + + Select + + + + + + result + varchar + 50 + Processing result: applied, noop, ignored_out_of_order, error + + Text + + + + + + error_log + text + Error details if processing failed + + TextArea + + + + + + processed_at + timestamp + When event was processed + + Select Date + + + + + + created_date + timestamp + true + CURRENT_TIMESTAMP + When webhook was received + + Select Date + + + +