ttpryg / payment-engine
Production-ready, framework-agnostic PHP 8.1+ Payment Engine with generic payable/payer references, gateway abstraction, idempotent webhook processing, and append-only audit trail.
Requires
- php: ^8.1
- psr/event-dispatcher: ^1.0
- ttpryg/database-migrator: ^1.0
Requires (Dev)
- laravel/pint: ^1.20
- phpstan/phpstan: ^2.3
- phpunit/phpunit: ^10.0
- rector/rector: ^2.0
Suggests
- ext-curl: Required to use the bundled CurlHttpClient for HTTP-based gateway drivers
Provides
None
Conflicts
None
Replaces
None
README
ttpryg/payment-engine is a production-ready, framework-agnostic standalone PHP 8.1+ Payment Engine designed to manage payment lifecycles, polymorphic payable/payer references, gateway abstraction, idempotent webhook processing, partial/full refunds, and append-only audit histories without coupling to any specific framework or external domain.
π 1. Introduction
In modern microservices and modular monolithic architectures, handling payments requires strict separation of domain boundaries. A Payment Engine must be responsible for orchestrating payment attempts, validating state transitions, guaranteeing idempotency during webhook retries, and recording immutable audit logsβwithout possessing hardcoded dependencies on orders, shopping carts, product catalogs, or specific third-party payment gateway SDKs.
ttpryg/payment-engine decouples payment processing via a clean driver/adapter pattern (PaymentGatewayInterface) and generic references (PayableReference and PayerReference).
π 2. Features
- Framework Agnostic: Pure PHP 8.1+ with zero framework dependencies (works on Vanilla PHP, Slim, Laravel, Symfony, CodeIgniter).
- Polymorphic / Generic References: Decoupled from orders and users via
PayableReference(payable_type,payable_id) andPayerReference(payer_type,payer_id). - Strict Money Representation: Uses immutable
MoneyValue Object with integer values (representing lowest denomination/cents or integer units for IDR), eliminating floating-point rounding errors. - Provider-Agnostic Gateway Abstraction: Decouples payment gateway integration using
PaymentGatewayInterfaceandGatewayManager. - Built-in Gateway Drivers:
MockPaymentGateway: Predictable driver for unit tests, CI/CD, and local sandboxes.ManualTransferGateway: Built-in driver for manual bank transfers, cash on delivery (COD), or offline payments.
- Bring Your Own Gateway: Implement
PaymentGatewayInterface(or extendAbstractHttpGateway) to integrate any provider. See Β§6. - Pluggable HTTP Transport: HTTP-based drivers communicate through the
HttpClientInterfaceabstraction (a bundledCurlHttpClientis available), so you can swap in your own client, add retries/logging, or inject a fake without touching the driver. - Robust Webhook Idempotency: Prevents duplicate executions, double settlements, or duplicate history entries using gateway
event_idand SHA-256payload_fingerprint. - Async-Ready Webhook Ingestion: A recommendation for acknowledging gateways immediately, queueing the raw delivery, and processing status updates/events in a background worker (see Β§7.1).
- Strict State Machine: Enforces valid status transitions across the entire payment lifecycle (
pending,authorized,captured,completed,failed,cancelled,expired,partially_refunded,refunded). - Partial & Full Refunds: Verifies that refund requests never exceed the remaining refundable
paid_amount. - ACID Transactional Mutations: When a
PDOconnection is provided, status changes, refunds, and webhook processing run inside a transaction so payment, refund, attempt, and audit writes either commit together or roll back atomically. - Pessimistic Row Locking: Critical mutations acquire a
SELECT ... FOR UPDATErow lock inside the transaction (viafindByIdForUpdate()) to prevent concurrent over-refunds and lost status updates. Drivers without row-lock support (e.g. SQLite) fall back to a plain read. - Payment Expiration & Auto-Cancel:
PaymentExpirationServicefinds pending payments past theirexpires_atand transitions them toexpired, ready to be driven by a cron or scheduled worker. - Gateway Status Reconciliation:
GatewayReconciliationServicepollsPaymentGatewayInterface::checkStatus()for pending payments approaching expiry to recover dropped webhooks (network timeouts, firewall drops) and syncs the local status accordingly. - Append-Only Audit History: Tracks every critical transition and webhook with
actor_type,actor_id, and structuredmetadata. - PSR-14 Domain Events: Dispatches standardized domain events (
PaymentCreatedEvent,PaymentStatusChangedEvent,PaymentCompletedEvent,PaymentRefundedEvent, etc.). - Multiple Storage Drivers: Clean separation via PDO (MariaDB, MySQL, SQLite) & In-Memory drivers for ultra-fast unit testing.
π¦ 3. Installation
Install via Composer:
composer require ttpryg/payment-engine
𧬠4. Domain Model
Value Objects
Money: Strictly integer-based amount (int $amount,string $currency = 'IDR'). Providesadd(),subtract(),multiply(),equals(),isGreaterThan(),isLessThan().PaymentNumber: Unique human-readable identifier (e.g.,PAY-20260925-A1B2C3).PayableReference: Generic reference to the billable entity (payable_type,payable_id).- Example:
new PayableReference('order', 'ORD-2026-001')ornew PayableReference('subscription', 'SUB-88').
- Example:
PayerReference: Generic reference to the customer/payer (payer_type,payer_id).
Entities
Payment: Core aggregate root representing the payment record, containing monetary balances (amount,fee,totalAmount,paidAmount,refundedAmount), status, and metadata.PaymentAttempt: Log of each payment attempt made against a gateway (gateway_provider,transaction_reference,status, raw request/response payloads).PaymentRefund: Detailed record of full or partial refunds issued against a completed payment.PaymentHistory: Immutable append-only audit trail capturing every status transition or webhook occurrence.WebhookEvent: Audit and deduplication log capturing inbound webhook deliveries with SHA-256 fingerprinting.
π 5. Payment Lifecycle & State Machine
ββββββββββββββββ> CANCELLED
β
PENDING βββββββΌβββββββββββββββ> EXPIRED
β β
β ββββββββββββββββ> FAILED
β
βββ (AUTHORIZED) ββ> CAPTURED ββ> COMPLETED ββ> PARTIALLY_REFUNDED ββ> REFUNDED
β β
βββββββββββββββββββββββββββββββββββββββ
- Invalid transitions (e.g. attempting to complete an already
CANCELLEDorFAILEDpayment) throwInvalidStatusTransitionException. - Idempotent transitions (calling
complete()on an alreadyCOMPLETEDpayment) result in a safe no-op without creating duplicate histories or events.
π 6. Gateway Driver Architecture
The engine ships without provider-specific integrations. To connect Stripe, DOKU, Midtrans,
Xendit, or any internal payment provider, implement the PaymentGatewayInterface port.
This keeps provider APIs, credentials, and HTTP details entirely outside the module core.
6.1 Implement PaymentGatewayInterface
Each driver must translate the engine's domain model to the provider API and map provider responses back to engine DTOs:
namespace App\Payments; use Ttpryg\PaymentEngine\Application\DTO\GatewayResponse; use Ttpryg\PaymentEngine\Application\DTO\GatewayStatusResponse; use Ttpryg\PaymentEngine\Application\DTO\RefundResponse; use Ttpryg\PaymentEngine\Application\DTO\WebhookVerificationResult; use Ttpryg\PaymentEngine\Contracts\PaymentGatewayInterface; use Ttpryg\PaymentEngine\Domain\Entity\Payment; use Ttpryg\PaymentEngine\Domain\Enum\PaymentStatus; use Ttpryg\PaymentEngine\Domain\ValueObject\Money; final class AcmePayGateway implements PaymentGatewayInterface { public function __construct( private readonly string $apiKey, private readonly string $webhookSecret, ) {} public function getName(): string { return 'acmepay'; } public function createTransaction(Payment $payment, array $options = []): GatewayResponse { // Send $payment->paymentNumber and $payment->totalAmount->amount to the provider. return new GatewayResponse( isSuccessful: true, transactionReference: 'acme-tx-123', redirectUrl: 'https://pay.acme.test/checkout/acme-tx-123', metadata: ['provider' => 'acmepay'], rawResponse: ['id' => 'acme-tx-123'] ); } public function verifyWebhook(array $payload, array $headers = []): WebhookVerificationResult { // Always verify the provider signature before trusting the payload. $expected = hash_hmac('sha256', (string) ($payload['raw'] ?? ''), $this->webhookSecret); $provided = (string) ($headers['x-acme-signature'] ?? ''); if (! hash_equals($expected, $provided)) { return new WebhookVerificationResult( isValid: false, failureReason: 'Invalid AcmePay signature.' ); } return new WebhookVerificationResult( isValid: true, transactionReference: (string) $payload['transaction_id'], eventId: isset($payload['event_id']) ? (string) $payload['event_id'] : null, mappedStatus: match ($payload['status'] ?? '') { 'paid' => PaymentStatus::COMPLETED, 'expired' => PaymentStatus::EXPIRED, 'failed' => PaymentStatus::FAILED, default => PaymentStatus::PENDING, }, paidAmount: isset($payload['paid_amount']) ? new Money((int) $payload['paid_amount'], (string) ($payload['currency'] ?? 'IDR')) : null ); } public function checkStatus(string $transactionReference): GatewayStatusResponse { // Query the provider and map its status to PaymentStatus. return new GatewayStatusResponse( mappedStatus: PaymentStatus::PENDING, transactionReference: $transactionReference ); } public function refund(Payment $payment, Money $amount, ?string $reason = null): RefundResponse { // Call the provider refund API and return its identifier. return new RefundResponse( isSuccessful: true, gatewayRefundId: 'acme-refund-123', refundedAmount: $amount ); } }
6.2 Optional: extend AbstractHttpGateway
For JSON-over-HTTP providers, extend AbstractHttpGateway to reuse request encoding,
Basic auth, response decoding, and error extraction:
namespace App\Payments; use Ttpryg\PaymentEngine\Application\DTO\GatewayResponse; use Ttpryg\PaymentEngine\Contracts\HttpClientInterface; use Ttpryg\PaymentEngine\Domain\Entity\Payment; use Ttpryg\PaymentEngine\Domain\Exception\GatewayException; use Ttpryg\PaymentEngine\Infrastructure\Gateway\AbstractHttpGateway; final class AcmePayGateway extends AbstractHttpGateway { public function __construct( private readonly string $apiKey, ?HttpClientInterface $httpClient = null, ) { parent::__construct($httpClient); } public function getName(): string { return 'acmepay'; } public function createTransaction(Payment $payment, array $options = []): GatewayResponse { $response = $this->request( 'POST', 'https://api.acme.test/v1/transactions', ['Authorization' => $this->basicAuth($this->apiKey)], [ 'reference' => $payment->paymentNumber->value, 'amount' => $payment->totalAmount->amount, 'currency' => $payment->totalAmount->currency, ] ); $data = $this->decode($response); if (! $response->isSuccessful()) { throw GatewayException::failed( $this->getName(), $this->extractErrorMessage($data) ?? 'AcmePay transaction creation failed.' ); } return new GatewayResponse( isSuccessful: true, transactionReference: $this->firstString($data, ['id']), redirectUrl: $this->firstString($data, ['checkout_url']), rawResponse: $data ); } // verifyWebhook(), checkStatus(), and refund() follow the same pattern. }
AbstractHttpGateway provides request(), decode(), basicAuth(), header(),
extractErrorMessage(), and firstString(). See the source for their exact signatures.
6.3 Register the driver
use Ttpryg\PaymentEngine\Infrastructure\Gateway\GatewayManager; use Ttpryg\PaymentEngine\Infrastructure\Gateway\ManualTransferGateway; use Ttpryg\PaymentEngine\Infrastructure\Gateway\MockPaymentGateway; $gatewayManager = new GatewayManager; $gatewayManager->register(new MockPaymentGateway('mock')); $gatewayManager->register(new ManualTransferGateway('manual')); $gatewayManager->register(new AcmePayGateway(getenv('ACME_API_KEY'), getenv('ACME_WEBHOOK_SECRET'))); // The provider key is matched case-insensitively. $gatewayManager->get('acmepay');
Pass the GatewayManager to PaymentService, WebhookProcessorService,
PaymentRefundService, and GatewayReconciliationService.
6.4 Driver contract checklist
getName()returns the stable provider key stored onPayment::$gatewayProvider.createTransaction()returnsGatewayResponse; on success settransactionReferenceto the provider identifier used for status checks and webhooks.verifyWebhook()returns an invalidWebhookVerificationResult(never throws) for bad signatures or missing fields. Return the originaleventIdwhen the provider sends one, so idempotency can rely on it; otherwise the engine fingerprints the payload.checkStatus()maps provider states toPaymentStatusand returns it inGatewayStatusResponse::$mappedStatus. Throw on transport/provider errors; the reconciliation worker catches failures and retries on the next run.refund()returnsRefundResponseand must never report success for a failed provider operation.- Amounts are always integer
Moneyvalues; convert provider decimal amounts carefully. - Security uses constant-time comparison (
hash_equals) for signatures/tokens and never logs credentials or raw secrets. - Errors should use
GatewayException::failed()fromTtpryg\PaymentEngine\Domain\Exceptionso consumers can catch the module marker interface (ExceptionInterface). - Tests inject a fake
HttpClientInterface(seetests/Support/FakeHttpClient.php) instead of calling the provider network.
6.5 Custom HTTP transport
HTTP-based drivers accept an optional HttpClientInterface. The bundled CurlHttpClient
requires ext-curl; you can also supply your own PSR-18-backed adapter with retries or
logging:
use Ttpryg\PaymentEngine\Infrastructure\Http\CurlHttpClient; $gateway = new AcmePayGateway( apiKey: getenv('ACME_API_KEY'), httpClient: new CurlHttpClient(timeout: 15), );
Because drivers depend only on the Ttpryg\PaymentEngine\Contracts\HttpClientInterface
abstraction, transport failures can be mapped to GatewayException without changing the
driver and tests can inject a fake client.
π‘οΈ 7. Idempotent Webhook Processing
Webhooks sent by gateways are often redelivered multiple times. WebhookProcessorService prevents duplicate balance updates and duplicate domain events using:
- Gateway Event ID: Tracks unique delivery IDs supplied by the gateway.
- Payload Fingerprint: Computes SHA-256 hashes of the payload:
$fingerprint = WebhookEvent::generateFingerprint($payload);
If a webhook with the same event_id or payload_fingerprint has already been processed, it returns immediately without firing duplicate events or inserting duplicate history entries.
Idempotency is additionally enforced at the database level via unique indexes on webhook_events (gateway_provider, payload_fingerprint) and webhook_events (gateway_provider, event_id). When two identical deliveries arrive concurrently (TOCTOU race), the losing INSERT raises DuplicateWebhookException, which WebhookProcessorService treats as a safe no-op.
7.1 Asynchronous Webhook Ingestion (Recommended)
Gateways typically expect a webhook to be acknowledged within ~3 seconds, while WebhookProcessorService::process() performs signature verification, database writes, and synchronous PSR-14 event dispatch. Slow listeners (sending emails, decrementing stock, calling third-party APIs) can cause the gateway to time out and retry.
The recommended architecture is acknowledge first, process in the background:
Gateway ββHTTP POSTβββΆ Webhook Endpoint ββenqueue(raw payload)βββΆ Queue (Redis / RabbitMQ / DB)
β β
return HTTP 200 Worker: verify + process
β
WebhookProcessorService::process()
β
status change + history + events
-
Acknowledge immediately. The HTTP endpoint should do the minimum work possible: read the raw body and headers, push them onto a durable queue (Redis, RabbitMQ, SQS, or a database-backed queue), and return
200 OK.// Framework-agnostic pseudocode for the webhook endpoint. $payload = json_decode(file_get_contents('php://input'), true, flags: JSON_THROW_ON_ERROR); $queue->push('payment.webhooks', [ 'provider' => 'acmepay', 'payload' => $payload, 'headers' => $requestHeaders, ]); http_response_code(200);
-
Process asynchronously. A background worker consumes the job and calls
WebhookProcessorService::process(). Because verification runs in the worker, an invalid/tampered payload simply throwsGatewayExceptionand no side-effects are applied.while ($job = $queue->pop('payment.webhooks')) { try { $webhookProcessor->process($job['provider'], $job['payload'], $job['headers']); } catch (\Ttpryg\PaymentEngine\Domain\Exception\GatewayException) { // Invalid signature / malformed payload β drop or move to a dead-letter queue. } catch (\Throwable) { $queue->retry($job); // transient failure (DB/gateway) β safe to retry (idempotent) } }
-
Let listeners queue their own side-effects. PSR-14 listeners registered on the engine still run synchronously inside the worker. For any heavy or external side-effect (email, stock, outbound webhooks), enqueue a second background job from the listener instead of doing the work inline.
$listenerProvider->addListener(PaymentCompletedEvent::class, function (PaymentCompletedEvent $event) use ($queue): void { $queue->push('orders.fulfil', ['payment_id' => $event->payment->id]); });
Why it is safe: the event_id + payload_fingerprint unique indexes make process() idempotent, so at-least-once delivery from the queue (retries, gateway redeliveries) never double-charges or duplicates history/events. Keep the queue delivery at-least-once and let the engine deduplicate.
Don't persist inbound webhooks through
WebhookEventRepositoryInterfacein the HTTP request path and then callprocess()later: a stored-but-unprocessedwebhook_eventsrow is treated as a duplicate by the unique constraints and the later call becomes a no-op. Keep raw ingestion in your own queue/table and runprocess()exactly once per delivery.
π° 8. Partial & Full Refunds
Refunds are processed through PaymentRefundService:
// Partial refund $refund = $refundService->issueRefund( paymentId: 'pay-001', amount: new Money(50000, 'IDR'), reason: 'Defective item returned' ); // Payment status automatically transitions to PARTIALLY_REFUNDED. // When all paid amount has been refunded, status becomes REFUNDED.
If a refund request exceeds the remaining refundable amount, a RefundAmountExceededException is thrown.
When a PDO connection is supplied, refund issuance first locks the payment row (SELECT ... FOR UPDATE) and re-validates the refundable balance against the latest committed state, so two concurrent refund requests can never overshoot the paid amount.
β³ 9. Payment Expiration, Reconciliation & Auto-Cancel
Pending payments that pass their expires_at timestamp can be swept automatically:
use Ttpryg\PaymentEngine\Application\Service\PaymentExpirationService; $expirationService = new PaymentExpirationService($paymentRepo, $statusService); // Transition every pending payment whose expires_at is in the past. $expired = $expirationService->expireOverdue(); // Preview how many are eligible without mutating anything. $count = $expirationService->countExpirable();
expireOverdue() uses PaymentRepositoryInterface::findPendingExpired() and calls PaymentStatusService::expire() for each candidate. Payments that changed state (completed or cancelled) between the snapshot query and the transition are skipped so a single batch never aborts. Invoke it from your framework scheduler, cron entry, or queue worker (for example, once per minute).
Gateway Status Reconciliation
Gateways may fail to deliver a webhook (network timeout, firewall drop), leaving a payment stuck in pending. GatewayReconciliationService polls the gateway for pending payments approaching expiry and syncs the local status:
use Ttpryg\PaymentEngine\Application\Service\GatewayReconciliationService; $reconciliationService = new GatewayReconciliationService( $gatewayManager, $paymentRepo, $attemptRepo, $statusService, pdo: $pdo, ); // Reconcile pending payments expiring within the next 5 minutes (and any already past). $reconciled = $reconciliationService->reconcile((new DateTimeImmutable)->modify('+5 minutes'));
For each candidate the service resolves the latest payment attempt with a transaction reference, calls PaymentGatewayInterface::checkStatus(), updates the attempt, and drives the payment status through PaymentStatusService. Gateway/network failures and payments that moved on concurrently are skipped, so the batch is safe to retry and should be scheduled before the expiration sweep.
ποΈ 10. Database Migrations
Schema changes are managed exclusively through
ttpryg/database-migrator. Every
migration is a PHP class that returns a Migration instance with up() and
down() methods:
database/migrations/
βββ 202610180001_create_payments_tables.php
βββ 202610180002_add_payment_indexes.php
The module ships a thin convenience wrapper that uses its own
payment_engine_migrations tracker table, so it can share a database with other
modules without collisions:
use Ttpryg\PaymentEngine\Infrastructure\Database\DatabaseMigrator; $pdo = new PDO('mysql:host=localhost;dbname=app', 'root', 'secret'); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $migrator = new DatabaseMigrator($pdo); // Apply every pending migration. Safe to run repeatedly. $executed = $migrator->migrate(); // Inspect what has been applied. $applied = $migrator->appliedMigrations(); // Revert the latest migration (or the latest N migrations). $rolledBack = $migrator->rollback(); $rolledBack = $migrator->rollback(2);
You can also use the package runner directly:
use Ttpryg\DatabaseMigrator\MigrationRunner; $runner = new MigrationRunner( $pdo, __DIR__.'/database/migrations', 'payment_engine_migrations' ); $runner->migrate();
Notes:
- No schema snapshot is maintained in this module; migrations are the only source of truth.
- Migration files are applied in filename order and recorded in the tracker table only after they succeed.
down()is provided for both migrations. Rolling back the table migration drops the module's tables, so use it only when data loss is intended.- Do not run migrations automatically from application requests; execute them through an explicit deployment or operational step.
- The DDL and index helpers avoid driver-specific syntax and are exercised against SQLite and MariaDB; the same statements are supported by PostgreSQL.
π 11. Quick Usage Example
use DateTimeImmutable; use PDO; use Ttpryg\PaymentEngine\Domain\Enum\PaymentMethod; use Ttpryg\PaymentEngine\Infrastructure\Gateway\GatewayManager; use Ttpryg\PaymentEngine\Infrastructure\Gateway\ManualTransferGateway; use Ttpryg\PaymentEngine\Infrastructure\Gateway\MockPaymentGateway; use Ttpryg\PaymentEngine\Infrastructure\Persistence\Pdo\PdoPaymentAttemptRepository; use Ttpryg\PaymentEngine\Infrastructure\Persistence\Pdo\PdoPaymentHistoryRepository; use Ttpryg\PaymentEngine\Infrastructure\Persistence\Pdo\PdoPaymentRefundRepository; use Ttpryg\PaymentEngine\Infrastructure\Persistence\Pdo\PdoPaymentRepository; use Ttpryg\PaymentEngine\Infrastructure\Persistence\Pdo\PdoWebhookEventRepository; use Ttpryg\PaymentEngine\Application\DTO\CreatePaymentRequest; use Ttpryg\PaymentEngine\Application\Service\GatewayReconciliationService; use Ttpryg\PaymentEngine\Application\Service\PaymentExpirationService; use Ttpryg\PaymentEngine\Application\Service\PaymentRefundService; use Ttpryg\PaymentEngine\Application\Service\PaymentService; use Ttpryg\PaymentEngine\Application\Service\PaymentStatusService; use Ttpryg\PaymentEngine\Application\Service\WebhookProcessorService; use Ttpryg\PaymentEngine\Domain\ValueObject\PayableReference; use Ttpryg\PaymentEngine\Domain\ValueObject\PayerReference; // 1. Setup PDO & Repositories $pdo = new PDO('mysql:host=localhost;dbname=payment_db', 'root', 'secret'); $paymentRepo = new PdoPaymentRepository($pdo); $attemptRepo = new PdoPaymentAttemptRepository($pdo); $refundRepo = new PdoPaymentRefundRepository($pdo); $historyRepo = new PdoPaymentHistoryRepository($pdo); $webhookRepo = new PdoWebhookEventRepository($pdo); // 2. Setup Gateway Manager $gatewayManager = new GatewayManager(); $gatewayManager->register(new MockPaymentGateway('mock')); $gatewayManager->register(new ManualTransferGateway('manual')); // 3. Initialize Services $statusService = new PaymentStatusService($paymentRepo, $historyRepo, pdo: $pdo); $paymentService = new PaymentService($paymentRepo, $attemptRepo, $historyRepo, $gatewayManager, $statusService, pdo: $pdo); $refundService = new PaymentRefundService($paymentRepo, $refundRepo, $historyRepo, $gatewayManager, pdo: $pdo); $webhookProcessor = new WebhookProcessorService($gatewayManager, $webhookRepo, $paymentRepo, $attemptRepo, $historyRepo, $statusService, pdo: $pdo); $expirationService = new PaymentExpirationService($paymentRepo, $statusService); $reconciliationService = new GatewayReconciliationService($gatewayManager, $paymentRepo, $attemptRepo, $statusService, pdo: $pdo); // 4. Create Payment $payment = $paymentService->createPayment(new CreatePaymentRequest( id: 'pay-001', payable: new PayableReference('order', 'ORD-2026-001'), amount: 250000, // IDR 250,000 (integer) method: PaymentMethod::QRIS, gatewayProvider: 'mock', payer: new PayerReference('user', 'usr-100'), fee: 1500 )); // 5. Ingest Inbound Gateway Webhook (Safe & Idempotent) $webhookProcessor->process('mock', [ 'event_id' => 'evt_12345', 'transaction_reference' => $payment->metadata['redirect_url'] ?? 'mock_tx_...', 'status' => 'completed', 'paid_amount' => 251500, ]); // 6. Reconcile pending payments nearing expiry against the gateway (recover dropped webhooks) $reconciled = $reconciliationService->reconcile((new DateTimeImmutable)->modify('+5 minutes')); // 7. Expire overdue pending payments (run from a scheduled worker/cron) $expiredPayments = $expirationService->expireOverdue();
π§ͺ 12. Testing
Run tests with PHPUnit:
composer test
Or using Docker Compose:
docker compose up -d docker compose exec app composer test
Additional Composer scripts:
composer analyse # PHPStan (level 8) composer cs-check # Pint style check composer fix:check # Rector dry-run + Pint style check composer fix # Apply Rector + Pint fixes
π 13. License
MIT License.