Skip to content

Latest commit

 

History

History
895 lines (666 loc) · 28.3 KB

File metadata and controls

895 lines (666 loc) · 28.3 KB

Pay

The Pay package provides a unified, type-safe interface for accepting payments via multiple providers. It leverages Data Transfer Objects (DTOs) for consistency across gateways and the Money package for precise monetary calculations, ensuring your financial transactions are always accurate.

Features

  • Standardized DTOs: Consistent response objects regardless of the gateway.
  • Precision Monetary Handling: Full integration with the Money package.
  • Multi-Driver Architecture: Support for 9+ popular global and local gateways.
  • Atomic Transactions: Automatic logging of payment attempts and statuses.
  • Idempotency: Prevents double-charging across all drivers by enforcing unique references.
  • Failover & Fallbacks: Intelligent fallback to secondary drivers if primary ones fail.
  • Webhook Management: Standardized job for processing asynchronous payment events.
  • Reconciliation: CLI command to verify pending payments when webhooks fail.
  • Analytics: Dashboard-ready reporting with revenue metrics, conversion rates, and charts.

Installation

Pay is a package that requires installation before use.

Install the Package

php dock package:install Pay --packages

This will automatically:

  • Publish the configuration to App/Config/pay.php.
  • Run the migration for Pay tables.
  • Register the PayServiceProvider.

Ensure your database is configured in .env before running the installation command.

Configuration

Configuration is located at App/Config/pay.php. Most settings can be managed via your .env file.

return [
    // Default driver to use (paystack, stripe, paypal, etc.)
    'default' => env('PAY_DRIVER', 'paystack'),

    // Default currency for transactions (NGN, USD, EUR, etc.)
    'currency' => env('PAY_CURRENCY', 'NGN'),

    // Logging settings for the automated PaymentService
    'logging' => [
        'enabled' => true,
        'table' => 'pay_transaction',
        'model' => \Pay\Models\PaymentTransaction::class,
    ],

    // Webhook settings (requires driver-specific signature validation)
    'webhooks' => [
        'path' => '/pay/webhook',
        'queue' => true,
        'job' => \Pay\Jobs\ProcessWebhook::class,
    ],

    // Gateway configurations
    'drivers' => [
        'paystack' => [
            'public_key' => env('PAYSTACK_PUBLIC_KEY'),
            'secret_key' => env('PAYSTACK_SECRET_KEY'),
        ],
        // ... stripe, paypal, flutterwave, mollie, nowpayments, square, opay, monnify
    ],
];

Core Concepts

Precise Monetary Units

The Pay package uses the Money package to prevent floating-point errors. Drivers automatically handle the conversion between "major" units (e.g., Dollars) and "minor" units (e.g., Cents) required by APIs.

Data Transfer Objects (DTOs)

Instead of messy arrays, you interact with strictly-typed objects:

  • PaymentData: The request object containing amount, email, and reference.
  • PaymentResponse: Returned after initialization (contains authorization URL).
  • VerificationResponse: Returned after verification (contains status and final amount).

Basic Usage

Initializing a Payment

Use the Pay facade to create a payment session. You can use the fluent interface or pass an array (which will be automatically hydrated). By default, the amount is treated as major units (e.g., 50 means $50.00 or ₦50.00).

use Pay\Pay;

$response = Pay::amount(50)
    ->email('customer@example.com')
    ->reference('trx_' . uniqid())
    ->initialize();

// Redirect user to gateway
return $this->response->redirect($response->authorizationUrl);

Verifying a Payment

Verify the status of a transaction using its reference.

use Pay\Pay;

$response = Pay::verify($reference);

if ($response->isSuccessful()) {
    // Payment confirmed!
    $amountPaid = $response->amount; // \Money\Money object
    $timestamp = $response->paidAt; // \DateTimeImmutable
}

API Reference

PaymentResponse Properties

Returned by initialize().

Property Type Description
reference string Your unique transaction reference.
authorizationUrl string The URL to redirect the user for payment.
status Status Current status (usually PENDING).
providerReference string The reference generated by the gateway.
metadata array Raw provider response for debug/logging.

VerificationResponse Properties

Returned by verify().

Property Type Description
reference string Your unique transaction reference.
status Status The verified status (SUCCESS, FAILED).
amount Money The actual amount paid (Money object).
paidAt DateTimeImmutable The timestamp of the successful payment.
metadata array Raw verification data from the provider.

Service API Reference

Pay (Facade) / PaymentBuilder

Method Description
amount(mixed $val) Sets the transaction amount (major units).
email(string $email) Sets the customer email.
reference(string $ref) Sets a custom unique reference.
callbackUrl(string $url) Sets the redirect URL after payment.
currency(string $code) Overrides the default currency (e.g., USD).
metadata(array $data) Attaches custom metadata to the transaction.
driver(string $driver) Sets the payment gateway driver.
fallback(array $drivers) Sets fallback drivers if primary fails.
initialize() Starts the payment flow (returns PaymentResponse).
verify(string $reference) Verifies a transaction status via the gateway.
analytics() Returns the PayAnalytics service for reporting.

PaymentService

Method Description
initialize(PaymentData $data, array $fallbackDrivers = []) Standardized flow with automated logging and fallbacks.
verify(string $reference) Verifies and updates the internal transaction record.

WebhookService

Method Description
handle(string $driver, string $payload, string $signature) Processes and validates asynchronous gateway notifications.

PayAnalytics

Fluent Filter Methods:

Method Description
successful() Filter by successful payments.
failed() Filter by failed payments.
pending() Filter by pending payments.
driver(string $name) Filter by payment gateway.
between(string $from, string $to) Filter by date range.
count() Get count with current filters applied.

Analytics Methods:

Method Description
getTotalRevenue(?string $from, ?string $to, string $currency) Total revenue as Money object.
getTransactionCount(?string $status, ?string $driver, ?string $from, ?string $to) Count transactions with filters.
getDailyVolume(string $from, string $to, ?string $currency) Daily payment stats for charts.
getMonthlyVolume(string $from, string $to, ?string $currency) Monthly payment stats for charts.
getRevenueByDriver(?string $from, ?string $to) Revenue breakdown by payment gateway.
getTopCustomers(int $limit, ?string $from, ?string $to) Highest paying customers.
getConversionRate(?string $from, ?string $to) Success vs failure ratio.
getSummary(?string $from, ?string $to, ?string $currency) Comprehensive payment analytics.
getAverageTransactionValue(?string $from, ?string $to) Average successful payment amount.

Advanced Usage

Fluent Driver & Fallback

Switch gateways and configure fallbacks fluently:

use Pay\Pay;

// Single driver
$response = Pay::amount(100)
    ->email('customer@example.com')
    ->driver('stripe')
    ->initialize();

// With fallback drivers (tries stripe first, then paypal if it fails)
$response = Pay::amount(100)
    ->email('customer@example.com')
    ->driver('stripe')
    ->fallback(['paypal', 'flutterwave'])
    ->initialize();

Transaction Logging

The logging configuration controls automatic persistence of payment transactions to your database. When enabled, PaymentService automatically creates a database record for every payment attempt.

How It Works

// In pay.php config
'logging' => [
    'enabled' => true,                                    // Enable/disable logging
    'table' => 'pay_transaction',                         // Database table name
    'model' => \Pay\Models\PaymentTransaction::class,     // Model class to use
],

When you call PaymentService::initialize():

  • A new PaymentTransaction record is created with status PENDING
  • The transaction stores: reference, driver, amount (minor units), currency, email, metadata
  • If the gateway returns a different reference, the record is updated
  • On verification or webhook, the status is updated to SUCCESS or FAILED

When to Use

Scenario Use PaymentService Use Pay Facade
E-commerce checkout ✅ Automatic audit trail ❌ Manual tracking
Subscription billing ✅ Track all attempts ❌ No history
One-time tip/donation ✅ or ❌ Your choice ✅ Lightweight
Testing/prototyping ❌ Unnecessary overhead ✅ Quick iteration

Example: Using PaymentService for Logged Transactions

use Pay\Pay;

class CheckoutController
{
    public function pay(Request $request)
    {
        // Fluent approach with automatic logging
        $response = Pay::amount($request->amount)
            ->email($request->email)
            ->reference('order_' . uniqid())
            ->metadata(['order_id' => $request->order_id])
            ->currency('USD')
            ->fallback(['stripe'])
            ->initialize();

        return redirect($response->authorizationUrl, internal: false);
    }
}

Querying Transaction History

use Pay\Models\PaymentTransaction;

// Find by reference (fluent scope)
$transaction = PaymentTransaction::byReference($reference)->first();

// Get all successful payments (fluent scope with enum)
$successful = PaymentTransaction::successful()->get();

// Get payments for a user (fluent scope)
$userPayments = PaymentTransaction::byEmail($user->email)->get();

// Chain scopes
$userSuccessful = PaymentTransaction::byEmail($user->email)->successful()->get();

Webhook Handling

Standardized webhook processing is handled via the /pay/webhook route (configurable). When a gateway sends a notification:

  • The request is routed to the ProcessWebhook job.
  • If queue is enabled, the job is deferred for background processing.
  • The WebhookService validates the signature (where supported by the driver).
  • Relevant events are dispatched for your application to handle.

Setting Up Your Webhook Controller

Create a controller to receive webhook notifications from payment gateways:

// App/Http/Controllers/PaymentWebhookController.php
namespace App\Http\Controllers;

use Pay\Jobs\ProcessWebhook;
use Queue\Queue;

class PaymentWebhookController extends Controller
{
    public function handle(string $driver)
    {
        $payload = $this->request->body();        // Raw request body
        $signature = $this->request->header('X-Webhook-Signature')
            ?? $this->request->header('X-Paystack-Signature')
            ?? $this->request->header('Stripe-Signature')
            ?? '';

        // Dispatch to queue (recommended for reliability)
        Queue::dispatch(ProcessWebhook::class, [
            'driver' => $driver,
            'payload' => $payload,
            'signature' => $signature,
        ]);

        // Return 200 OK immediately to acknowledge receipt
        return $this->response->json(['status' => 'received']);
    }
}

Register the Route

With Anchor's convention-based routing, place your controller in the appropriate module:

App/
  Pay/
    Controllers/
      WebhookController.php   → /pay/webhook/{driver}

The webhook URL yourapp.com/pay/webhook/paystack automatically maps to App\Pay\Controllers\WebhookController::paystack().

Configure Gateway Webhooks

Set your webhook URL in each payment gateway's dashboard:

Gateway Webhook URL
Paystack https://yourapp.com/pay/webhook/paystack
Stripe https://yourapp.com/pay/webhook/stripe
Flutterwave https://yourapp.com/pay/webhook/flutterwave

Use tools like webhook.site or ngrok during local development.

Events

Pay dispatches the following events that you can listen to:

Event When Dispatched Payload
PaymentSuccessfulEvent Webhook confirms successful payment PaymentTransaction, array $gatewayResponse
PaymentFailedEvent Webhook confirms failed payment PaymentTransaction, ?string $reason

Registering Event Listeners

Register your listeners in your application's ServiceProvider (e.g., App/Providers/AppServiceProvider.php). See Service Providers for details on creating and registering providers. For more on events and listeners, see Events.

namespace App\Providers;

use Core\Event;
use Pay\Events\PaymentSuccessfulEvent;
use App\Listeners\OrderFulfillmentListener;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // Option 1: Class-based listener (recommended)
        Event::listen(PaymentSuccessfulEvent::class, OrderFulfillmentListener::class);

        // Option 2: Closure listener
        Event::listen(PaymentSuccessfulEvent::class, function ($event) {
            $transaction = $event->transaction;
            // Handle payment...
        });
    }
}

Creating a Listener Class

// App/Listeners/OrderFulfillmentListener.php
namespace App\Listeners;

use Pay\Events\PaymentSuccessfulEvent;

class OrderFulfillmentListener
{
    public function handle(PaymentSuccessfulEvent $event): void
    {
        $transaction = $event->transaction;
        $orderId = $transaction->metadata['order_id'] ?? null;

        // Fulfill the order
    }
}

Wallet Integration

Pay integrates bidirectionally with the Wallet package, supporting both wallet funding (crediting a wallet via external payment) and wallet payments (debiting from wallet balance).

Wallet as a Payment Driver

Use the wallet driver to debit payments directly from a user's wallet balance. This is ideal for in-app purchases, subscriptions, or any scenario where users have pre-funded wallets.

use Pay\Pay;

// Pay from wallet balance
$response = Pay::amount(25)
    ->email($user->email)
    ->driver('wallet')
    ->metadata(['wallet_id' => $user->wallet->id])
    ->initialize();

if ($response->isSuccessful()) {
    // Payment completed instantly - wallet was debited
    $walletTransactionId = $response->metadata['wallet_transaction_id'];
}

The wallet driver requires wallet_id in metadata. Unlike external gateways, wallet payments are synchronous - the response indicates immediate success or failure (e.g., insufficient balance).

Wallet Funding via External Payment

Fund a user's wallet using any external payment gateway. When the webhook confirms payment, the wallet is automatically credited:

// Initialize payment to fund a wallet
Pay::amount(100)
    ->email($user->email)
    ->metadata([
        'wallet_id' => $user->wallet->id,
        'intention' => 'fund'  // Required for wallet funding
    ])
    ->initialize();

When the webhook confirms payment, WalletFundingListener automatically:

  • Checks for wallet_id and intention: fund in metadata
  • Credits the wallet with the paid amount
  • Logs the transaction with payment processor details

Wallet with Fallback

Use the wallet driver with external fallbacks for a seamless checkout experience:

// Try wallet first, fall back to Paystack if insufficient balance
$response = Pay::amount(50)
    ->email($user->email)
    ->driver('wallet')
    ->metadata(['wallet_id' => $user->wallet->id])
    ->fallback(['paystack', 'stripe'])
    ->initialize();

Use Case

Complete E-Commerce Checkout Flow

// Step 1: Checkout Controller - Initialize Payment
class CheckoutController
{
    public function checkout(CheckoutRequest $request)
    {
        $user = $this->auth->user();

        $order = Order::create([
            'user_id' => $user->id,
            'total' => $request->total,
            'status' => 'pending',
            'refid' => Str::random('secure')
        ]);

        $response = Pay::amount($order->total)
            ->email($user->email)
            ->reference('order_' . $order->refid)
            ->callbackUrl(url('orders/' . $order->id . '/confirm'))
            ->metadata(['order_id' => $order->id])
            ->initialize();

        return redirect($response->authorizationUrl, internal: false);
    }

    // Step 2: Callback - User returns from gateway
    public function confirm(string $reference)
    {
        $order = Order::findByReference($reference);

        if (!$order) {
            return redirect(route());
        }

        $result = Pay::verify($reference);

        if ($result->isSuccessful()) {
            // Don't fulfill here - wait for webhook for security
            return $this->asView('orders.pending', ['order' => $order]);
        }

        return $this->asView('orders.failed', ['order' => $order]);
    }
}

// Step 3: Event Listener - Handle webhook (recommended for fulfillment)
class OrderFulfillmentListener
{
    public function handle(PaymentSuccessfulEvent $event)
    {
        $orderId = $event->transaction->metadata['order_id'] ?? null;

        if ($orderId) {
            $order = Order::find($orderId);
            $order->update(['status' => 'paid']);

            // Send confirmation email, dispatch shipping, etc.
            Mail::send(new OrderConfirmation($order));
        }
    }
}

Supported Drivers

Driver Region Strengths
paystack Africa Best for Nigeria/Ghana/SA, easy setup.
stripe Global Market leader, best for Cards/Subscriptions.
flutterwave Africa Pan-African support, multi-currency.
paypal Global High consumer trust, easy checkout.
mollie Europe Popular in EU (iDEAL, Klarna).
nowpayments Crypto 100+ Cryptocurrencies, low fees.
square Global Integrated with POS, great for US/CA/UK.
opay Africa Strong mobile wallet support in Nigeria.
monnify Africa Best for virtual accounts/bank transfers.
wallet In-App Debit from user wallet balance, instant.

Best Practices

  • Always store transaction references in your database before redirecting the user to the gateway. The PaymentService handles this automatically if used.

  • Never trust the callbackUrl alone for critical fulfillment. Always use Pay::verify() or configure Webhooks to confirm the payment status independently.

  • Ensure your secret_key and public_key are never committed to version control. Use .env variables for all sensitive credentials.

Model Integration

Payable Trait

Add the Payable trait to any model (User, Organization, Team, etc.) to enable convenient payment methods with robust polymorphic relationship linking:

use Pay\Traits\Payable;

class User extends BaseModel
{
    use Payable;

    // Must have an 'email' property for gateway communication
}

This provides:

// Initiate a payment for this entity
// The entity is automatically linked via payable_id/payable_type
$response = $user->pay(100.00, ['order_id' => 123], 'USD');

// Get all payment transactions for this entity (polymorphic relationship)
$transactions = $user->transactions()->get();

// Chain with scopes
$successful = $user->transactions()->successful()->get();
$pending = $user->transactions()->pending()->get();

Implementation Details

When you call $user->pay():

  • The trait injects payable_id (user's ID) and payable_type (class name) into metadata
  • PaymentService extracts this info and saves it directly on the transaction record
  • The transactions() relationship uses morphMany() to query by these columns

Why not email? Email-based linking is fragile; if a user changes their email, the relationship breaks. Polymorphic relationships use immutable IDs, making them built for stability and scale.

Querying Transactions by Payable

use Pay\Models\PaymentTransaction;

// Get all transactions for a specific user
$userPayments = $user->transactions()->get();

// Or query directly via the model (fluent scope)
$tx = PaymentTransaction::byPayable($user)->successful()->get();

// Access the payable from a transaction
$transaction = PaymentTransaction::find(1);
$owner = $transaction->payable;  // Returns User, Organization, etc.

Enums Reference

Status

use Pay\Enums\Status;

Status::PENDING;    // 'pending' - Payment initiated but not confirmed
Status::SUCCESS;    // 'success' - Payment confirmed successful
Status::FAILED;     // 'failed'  - Payment failed or declined
Status::CANCELLED;  // 'cancelled' - Payment cancelled by user

Currency

use Pay\Enums\Currency;

Currency::NGN;  // Nigerian Naira
Currency::USD;  // US Dollar
Currency::GBP;  // British Pound
Currency::EUR;  // Euro
Currency::KES;  // Kenyan Shilling
Currency::GHS;  // Ghanaian Cedi
Currency::ZAR;  // South African Rand

Exceptions

Exception When Thrown
PaymentException General payment errors (gateway errors, etc.)
InvalidDriverException Unknown or misconfigured driver requested
PaymentVerificationFailedException Verification call failed or returned unexpected
use Pay\Exceptions\PaymentException;

try {
    $response = Pay::amount(100)->email($email)->initialize();
} catch (PaymentException $e) {
    Log::error('Payment failed: ' . $e->getMessage());
}

Analytics & Reporting

The PayAnalytics service provides methods for dashboards, reports, and chart data.

Access via Facade:

$analytics = Pay::analytics();

Get Payment Summary:

$summary = Pay::analytics()->getSummary('2024-01-01', '2024-12-31');

// Returns:
// [
//     'total_revenue' => 5000000,      // In minor units (cents)
//     'transaction_count' => 1500,
//     'successful_count' => 1200,
//     'failed_count' => 250,
//     'pending_count' => 50,
//     'conversion_rate' => 80.0,       // Percentage
//     'revenue_by_driver' => [...],
// ]

Revenue & Transaction Metrics:

// Total revenue as Money object
$revenue = Pay::analytics()->getTotalRevenue('2024-01-01', '2024-12-31', 'NGN');
echo $revenue->format(); // ₦5,000,000.00

// Fluent transaction counts
$successCount = Pay::analytics()->successful()->count();
$failedCount = Pay::analytics()->failed()->count();
$paystackCount = Pay::analytics()->driver('paystack')->count();

// Chain filters
$paystackSuccess = Pay::analytics()
    ->successful()
    ->driver('paystack')
    ->between('2024-01-01', '2024-12-31')
    ->count();

// Or use the direct method with parameters
$total = Pay::analytics()->getTransactionCount();

Daily Volume for Charts:

$dailyData = Pay::analytics()->getDailyVolume('2024-12-01', '2024-12-31', 'NGN');

// Returns array with Money objects for revenue:
// [
//     ['date' => '2024-12-01', 'successful' => 45, 'failed' => 5, 'revenue' => Money (₦250,000)],
//     ['date' => '2024-12-02', 'successful' => 52, 'failed' => 3, 'revenue' => Money (₦310,000)],
// ]

foreach ($dailyData as $day) {
    echo "{$day['date']}: {$day['revenue']->format()} ({$day['successful']} successful)";
}

Revenue by Payment Gateway:

$byDriver = Pay::analytics()->getRevenueByDriver('2024-01-01', '2024-12-31', 'NGN');

// Returns array with Money objects:
// [
//     ['driver' => 'paystack', 'revenue' => Money (₦3,500,000), 'count' => 800],
//     ['driver' => 'stripe', 'revenue' => Money (₦1,500,000), 'count' => 400],
// ]

foreach ($byDriver as $item) {
    echo "{$item['driver']}: {$item['revenue']->format()} ({$item['count']} transactions)";
}

Top Customers:

$topCustomers = Pay::analytics()->getTopCustomers(10, '2024-01-01', '2024-12-31');

// Returns:
// [
//     ['email' => 'vip@example.com', 'total_paid' => 500000, 'payment_count' => 25],
//     ...
// ]

Conversion Rate:

$conversion = Pay::analytics()->getConversionRate('2024-01-01', '2024-12-31');

// Returns:
// [
//     'total_attempts' => 1500,
//     'successful' => 1200,
//     'failed' => 250,
//     'pending' => 50,
//     'cancelled' => 0,
//     'conversion_rate' => 80.0,  // Percentage
// ]

Console Commands

Verify Pending Payments

The pay:verify-pending command acts as a reconciliation fallback for when webhooks fail to arrive. It queries pending payments, verifies their status with the respective gateways, and triggers events for successful payments.

# Verify all pending payments from the last 24 hours
php dock pay:verify-pending

# Verify last 48 hours, limit to 50 transactions
php dock pay:verify-pending --hours=48 --limit=50

# Verify only Paystack transactions
php dock pay:verify-pending --driver=paystack

# Preview what would be verified without making changes
php dock pay:verify-pending --dry-run

Options:

Option Description
--hours=24 Only check payments created within the last N hours
--driver=paystack Filter by specific payment driver
--limit=100 Maximum number of transactions to process
--dry-run Preview mode - no changes made

Automation

The Pay package uses automated scheduling to reconcile pending payments that missed their webhooks. This is registered in the framework scheduler:

// packages/Pay/Schedules/PayVerifyPendingPaymentSchedule.php
namespace Pay\Schedules;
 
use Cron\Interfaces\Schedulable;
use Cron\Schedule;
 
class PayVerifyPendingPaymentSchedule implements Schedulable
{
    public function schedule(Schedule $schedule): void
    {
        $schedule->task()
            ->signature('pay:verify-pending')
            ->hourly();
    }
}

Environment Variables

Configure your .env file with the required credentials for each driver:

# Default Settings
PAY_DRIVER=paystack
PAY_CURRENCY=NGN

# Paystack
PAYSTACK_PUBLIC_KEY=pk_test_xxx
PAYSTACK_SECRET_KEY=sk_test_xxx
PAYSTACK_MERCHANT_EMAIL=merchant@example.com

# Stripe
STRIPE_PUBLIC_KEY=pk_test_xxx
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx

# Flutterwave
FLW_PUBLIC_KEY=FLWPUBK_TEST-xxx
FLW_SECRET_KEY=FLWSECK_TEST-xxx
FLW_ENCRYPTION_KEY=xxx

# PayPal
PAYPAL_CLIENT_ID=xxx
PAYPAL_CLIENT_SECRET=xxx
PAYPAL_MODE=sandbox  # sandbox or live

# Monnify
MONNIFY_API_KEY=xxx
MONNIFY_SECRET_KEY=xxx
MONNIFY_CONTRACT_CODE=xxx
MONNIFY_MODE=sandbox

# Square
SQUARE_ACCESS_TOKEN=xxx
SQUARE_LOCATION_ID=xxx
SQUARE_MODE=sandbox

# OPay
OPAY_PUBLIC_KEY=xxx
OPAY_SECRET_KEY=xxx
OPAY_MERCHANT_ID=xxx
OPAY_MODE=sandbox

# Mollie
MOLLIE_API_KEY=test_xxx

# NOWPayments
NOWPAYMENTS_API_KEY=xxx
NOWPAYMENTS_MODE=sandbox

# Webhooks
PAY_WEBHOOK_SECRET=your_webhook_secret