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
- ttpryg/event-dispatcher: ^1.0
Requires (Dev)
- laravel/pint: ^1.20
- phpunit/phpunit: ^10.0
- rector/rector: ^2.0
Suggests
- ext-curl: Required to use the bundled CurlHttpClient for the real Midtrans/Xendit gateway drivers
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-18 06:15:09 UTC
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.MidtransGateway: Real Midtrans Snap + Core API driver (sandbox/production) with SHA-512signature_keywebhook verification.XenditGateway: Real Xendit Invoice + Refund API driver with constant-timex-callback-tokenwebhook verification.
- Pluggable HTTP Transport: Real drivers communicate through the
HttpClientInterfaceabstraction (a bundledCurlHttpClientis the default), 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
To add a new payment gateway (such as Midtrans, Xendit, Stripe, or DOKU), implement PaymentGatewayInterface:
namespace MyNamespace\Gateways; use Ttpryg\PaymentEngine\Contracts\PaymentGatewayInterface; use Ttpryg\PaymentEngine\DTOs\GatewayResponse; use Ttpryg\PaymentEngine\DTOs\GatewayStatusResponse; use Ttpryg\PaymentEngine\DTOs\RefundResponse; use Ttpryg\PaymentEngine\DTOs\WebhookVerificationResult; use Ttpryg\PaymentEngine\Entities\Payment; use Ttpryg\PaymentEngine\ValueObjects\Money; class CustomPaymentGateway implements PaymentGatewayInterface { public function getName(): string { return 'custom'; } public function createTransaction(Payment $payment, array $options = []): GatewayResponse { // Call your gateway API using $payment->totalAmount->amount return new GatewayResponse( isSuccessful: true, transactionReference: 'tx_123', redirectUrl: 'https://pay.example.com/checkout/123' ); } public function verifyWebhook(array $payload, array $headers = []): WebhookVerificationResult { // Validate the gateway signature/token, then map the status return new WebhookVerificationResult( isValid: true, transactionReference: $payload['transaction_reference'] ?? null, mappedStatus: \Ttpryg\PaymentEngine\Enums\PaymentStatus::COMPLETED, paidAmount: new Money((int) $payload['paid_amount'], 'IDR') ); } public function checkStatus(string $transactionReference): GatewayStatusResponse { // Query gateway API } public function refund(Payment $payment, Money $amount, ?string $reason = null): RefundResponse { // Execute refund API } }
Register drivers using GatewayManager:
$gatewayManager = new GatewayManager(); $gatewayManager->register(new MockPaymentGateway('mock')); $gatewayManager->register(new ManualTransferGateway('manual')); // Real drivers (see below) $gatewayManager->register(new MidtransGateway(getenv('MIDTRANS_SERVER_KEY'))); $gatewayManager->register(new XenditGateway(getenv('XENDIT_SECRET_KEY'), getenv('XENDIT_WEBHOOK_TOKEN')));
Built-in Real Gateway Drivers
Two production drivers ship with the engine. Both extend AbstractHttpGateway and require only credentials; the bundled CurlHttpClient is used unless you inject your own HttpClientInterface.
Midtrans (MidtransGateway)
use Ttpryg\PaymentEngine\Gateways\MidtransGateway; $midtrans = new MidtransGateway( serverKey: getenv('MIDTRANS_SERVER_KEY'), isProduction: false, // sandbox by default ); $gatewayManager->register($midtrans);
createTransaction()calls the Snap API (/snap/v1/transactions) and exposes the hostedredirect_urlplussnap_token.checkStatus()queries/v2/{order_id}/status; the payment number is used as the Midtransorder_id.refund()calls/v2/{order_id}/refundwith a generated uniquerefund_key.verifyWebhook()validatessignature_keyagainsthash('sha512', order_id . status_code . gross_amount . serverKey)using a constant-time comparison. Mapping:settlementandcapture(withfraud_status = accept) βCOMPLETED,capture(challenged) βPENDING,denyβFAILED,cancelβCANCELLED,expireβEXPIRED,refund/partial_refundβREFUNDED/PARTIALLY_REFUNDED.
Xendit (XenditGateway)
use Ttpryg\PaymentEngine\Gateways\XenditGateway; $xendit = new XenditGateway( secretKey: getenv('XENDIT_SECRET_KEY'), webhookToken: getenv('XENDIT_WEBHOOK_TOKEN'), ); $gatewayManager->register($xendit);
createTransaction()creates an invoice via/v2/invoices, stores the Xenditinvoice_idinpayment->metadata['xendit_invoice_id'](reused for refunds), and returns theinvoice_url.checkStatus()queries/v2/invoices/{invoice_id}.refund()calls the unified/refundsendpoint with the storedinvoice_id.verifyWebhook()performs a constant-time (hash_equals) comparison of thex-callback-tokenheader against the configuredwebhookToken. Mapping:PAID/SETTLEDβCOMPLETED,EXPIREDβEXPIRED,FAILEDβFAILED,PENDINGβPENDING.- A
PayerReferencewhose type isemailis automatically forwarded as the Xenditpayer_email.
Custom HTTP Transport
Every real driver accepts an optional HttpClientInterface as its third constructor argument:
use Ttpryg\PaymentEngine\Http\CurlHttpClient; $gateway = new MidtransGateway($serverKey, false, new CurlHttpClient(timeout: 15));
Because drivers depend only on the Ttpryg\PaymentEngine\Contracts\HttpClientInterface abstraction, you can inject a PSR-18-backed adapter, add retries/logging, or supply a fake client in tests without modifying the driver. Transport failures surface as GatewayException.
π‘οΈ 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' => 'midtrans', '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\Exceptions\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\Services\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\Services\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 Schema
Run the SQL script from database/schema.sql:
CREATE TABLE IF NOT EXISTS payments ( id VARCHAR(36) PRIMARY KEY, payment_number VARCHAR(50) NOT NULL UNIQUE, payable_type VARCHAR(50) NOT NULL, payable_id VARCHAR(100) NOT NULL, payer_type VARCHAR(50) NULL, payer_id VARCHAR(100) NULL, method VARCHAR(50) NOT NULL, gateway_provider VARCHAR(50) NOT NULL, status VARCHAR(30) NOT NULL, currency VARCHAR(3) NOT NULL DEFAULT 'IDR', amount BIGINT NOT NULL DEFAULT 0, fee BIGINT NOT NULL DEFAULT 0, total_amount BIGINT NOT NULL DEFAULT 0, paid_amount BIGINT NOT NULL DEFAULT 0, refunded_amount BIGINT NOT NULL DEFAULT 0, expires_at DATETIME NULL, paid_at DATETIME NULL, metadata TEXT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); CREATE INDEX IF NOT EXISTS idx_payments_payable ON payments (payable_type, payable_id); CREATE INDEX IF NOT EXISTS idx_payments_payer ON payments (payer_type, payer_id); CREATE INDEX IF NOT EXISTS idx_payments_status ON payments (status); CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments (created_at); CREATE TABLE IF NOT EXISTS payment_attempts ( id VARCHAR(36) PRIMARY KEY, payment_id VARCHAR(36) NOT NULL, gateway_provider VARCHAR(50) NOT NULL, transaction_reference VARCHAR(150) NULL, status VARCHAR(30) NOT NULL, amount BIGINT NOT NULL, currency VARCHAR(3) NOT NULL DEFAULT 'IDR', raw_request TEXT NULL, raw_response TEXT NULL, created_at DATETIME NOT NULL, FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_payment_attempts_payment_id ON payment_attempts (payment_id); CREATE INDEX IF NOT EXISTS idx_attempts_tx_ref ON payment_attempts (gateway_provider, transaction_reference); CREATE TABLE IF NOT EXISTS payment_refunds ( id VARCHAR(36) PRIMARY KEY, payment_id VARCHAR(36) NOT NULL, refund_number VARCHAR(50) NOT NULL UNIQUE, amount BIGINT NOT NULL, currency VARCHAR(3) NOT NULL DEFAULT 'IDR', reason TEXT NULL, status VARCHAR(30) NOT NULL DEFAULT 'completed', gateway_refund_id VARCHAR(150) NULL, actor_type VARCHAR(50) NULL, actor_id VARCHAR(100) NULL, created_at DATETIME NOT NULL, FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_payment_refunds_payment_id ON payment_refunds (payment_id); CREATE TABLE IF NOT EXISTS payment_histories ( id VARCHAR(36) PRIMARY KEY, payment_id VARCHAR(36) NOT NULL, action VARCHAR(50) NOT NULL, from_status VARCHAR(30) NULL, to_status VARCHAR(30) NULL, actor_type VARCHAR(50) NULL, actor_id VARCHAR(100) NULL, note TEXT NULL, metadata TEXT NULL, created_at DATETIME NOT NULL, FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_payment_histories_payment_id ON payment_histories (payment_id); CREATE TABLE IF NOT EXISTS webhook_events ( id VARCHAR(36) PRIMARY KEY, gateway_provider VARCHAR(50) NOT NULL, event_id VARCHAR(150) NULL, payload_fingerprint VARCHAR(64) NOT NULL, payload TEXT NOT NULL, is_processed TINYINT(1) NOT NULL DEFAULT 0, processed_at DATETIME NULL, created_at DATETIME NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS uq_webhook_fingerprint ON webhook_events (gateway_provider, payload_fingerprint); CREATE UNIQUE INDEX IF NOT EXISTS uq_webhook_event_id ON webhook_events (gateway_provider, event_id);
Versioned Migrations
Instead of importing the SQL manually, you can apply the schema (and any future incremental migrations) programmatically:
use Ttpryg\PaymentEngine\Database\DatabaseMigrator; $pdo = new PDO('mysql:host=localhost;dbname=app', 'root', 'secret'); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); (new DatabaseMigrator($pdo))->migrate();
DatabaseMigrator:
- Fresh install β runs
database/schema.sql(the current source of truth) and baselines every migration so it is not re-applied. - Existing database β runs only the incremental migrations in
database/migrations/that are not yet recorded, leaving existing data untouched. Applied migrations are tracked in thepayment_engine_migrationstable, somigrate()is safe to run repeatedly.
π 11. Quick Usage Example
use DateTimeImmutable; use PDO; use Ttpryg\PaymentEngine\Enums\PaymentMethod; use Ttpryg\PaymentEngine\Gateways\GatewayManager; use Ttpryg\PaymentEngine\Gateways\ManualTransferGateway; use Ttpryg\PaymentEngine\Gateways\MockPaymentGateway; use Ttpryg\PaymentEngine\Repositories\Pdo\PdoPaymentAttemptRepository; use Ttpryg\PaymentEngine\Repositories\Pdo\PdoPaymentHistoryRepository; use Ttpryg\PaymentEngine\Repositories\Pdo\PdoPaymentRefundRepository; use Ttpryg\PaymentEngine\Repositories\Pdo\PdoPaymentRepository; use Ttpryg\PaymentEngine\Repositories\Pdo\PdoWebhookEventRepository; use Ttpryg\PaymentEngine\Services\GatewayReconciliationService; use Ttpryg\PaymentEngine\Services\PaymentExpirationService; use Ttpryg\PaymentEngine\Services\PaymentRefundService; use Ttpryg\PaymentEngine\Services\PaymentService; use Ttpryg\PaymentEngine\Services\PaymentStatusService; use Ttpryg\PaymentEngine\Services\WebhookProcessorService; use Ttpryg\PaymentEngine\ValueObjects\PayableReference; use Ttpryg\PaymentEngine\ValueObjects\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( 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:
vendor/bin/phpunit
Or using Docker Compose:
docker compose up -d
docker compose exec app vendor/bin/phpunit
π 13. License
MIT License.