payroad/payroad-core

Payment domain core — aggregates, ports, and application use cases for Payroad

Maintainers

Package info

github.com/payroad/payroad-core

pkg:composer/payroad/payroad-core

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-04-04 20:16 UTC

This package is auto-updated.

Last update: 2026-04-04 20:16:42 UTC


README

Tests Latest Version PHP Version License

Framework-agnostic payment domain for the Payroad ecosystem — aggregates, use cases, and provider ports with no framework or database dependencies.

Installation

composer require payroad/payroad-core

Requires PHP 8.2+.

Overview

payroad-core is the shared kernel of the Payroad payment platform. It defines the domain model and the contracts (ports) that infrastructure must implement.

┌──────────────────────────────────┐
│         Your Application         │
│   (Symfony / Laravel / custom)   │
└────────────────┬─────────────────┘
                 │ uses
┌────────────────▼─────────────────┐
│           payroad-core           │
│  Domain · Use Cases · Ports      │
└───────┬──────────────┬───────────┘
        │ implements   │ implements
┌───────▼──────┐ ┌─────▼────────────┐
│   Provider   │ │  Infrastructure  │
│ stripe, etc. │ │  DB, Events, …   │
└──────────────┘ └──────────────────┘

This package contains no HTTP, ORM, or provider-specific code.

Payment flows

Four payment methods are supported, each with its own aggregate, state machine, and refund flow:

Flow Attempt class Key steps
Card CardPaymentAttempt Authorize → (3DS) → Capture / Void
Crypto CryptoPaymentAttempt Address issued → Confirmations → Settled
P2P P2PPaymentAttempt QR / redirect → User confirms → Settled
Cash CashPaymentAttempt Voucher issued → Cash collected → Settled

Usage

Create a payment

use Payroad\Application\UseCase\Payment\CreatePaymentCommand;
use Payroad\Domain\Money\Currency;
use Payroad\Domain\Money\Money;
use Payroad\Domain\Payment\CustomerId;
use Payroad\Domain\Payment\PaymentMetadata;

$command = new CreatePaymentCommand(
    amount:    Money::ofDecimal('49.99', new Currency('USD', 2)),
    customerId: CustomerId::of('customer-456'),
    metadata:  PaymentMetadata::fromArray(['orderId' => '789']),
    expiresAt: new DateTimeImmutable('+30 minutes'),
);

$useCase->execute($command);

Supply your own ID when needed (e.g. client-generated UUID):

use Payroad\Domain\Payment\PaymentId;

$command = new CreatePaymentCommand(
    // ...
    id: PaymentId::fromUuid('018e4c3d-1a2b-7000-...'),
);

Initiate a card attempt

use Payroad\Application\UseCase\Card\InitiateCardAttemptCommand;
use Payroad\Port\Provider\Card\CardAttemptContext;

$command = new InitiateCardAttemptCommand(
    paymentId:    $payment->getId(),
    providerName: 'stripe',
    context:      new CardAttemptContext(ip: '1.2.3.4', userAgent: '...'),
);

$useCase->execute($command);

Capture / void an authorized card

use Payroad\Application\UseCase\Card\CaptureCardAttemptCommand;
use Payroad\Application\UseCase\Card\VoidCardAttemptCommand;

// Full capture
$captureUseCase->execute(new CaptureCardAttemptCommand($attempt->getId()));

// Partial capture
$captureUseCase->execute(new CaptureCardAttemptCommand(
    $attempt->getId(),
    Money::ofDecimal('25.00', new Currency('USD', 2)),
));

// Void (release the authorization)
$voidUseCase->execute(new VoidCardAttemptCommand($attempt->getId()));

Handle an incoming webhook

use Payroad\Application\UseCase\Webhook\HandleWebhookCommand;

$useCase->execute(new HandleWebhookCommand(
    providerName: 'stripe',
    payload:      $request->toArray(),
    headers:      $request->headers->all(),
));

The use case is idempotent — duplicate webhooks from at-least-once providers are safely ignored.

Cancel or expire a payment

use Payroad\Application\UseCase\Payment\CancelPaymentCommand;
use Payroad\Application\UseCase\Payment\ExpirePaymentCommand;

$cancelUseCase->execute(new CancelPaymentCommand($payment->getId()));
$expireUseCase->execute(new ExpirePaymentCommand($payment->getId()));

Key design decisions

Payment and PaymentAttempt are separate aggregates

Payment is a thin business document — amount, customer, status. It never holds attempt objects, only the ID of the winning attempt once resolved.

PaymentAttempt is the operational aggregate that drives actual money movement through a provider. Multiple attempts may exist per payment (retries).

Money carries precision explicitly

// Fiat — precision from ISO 4217, provided by infrastructure KnownCurrencies
new Currency('USD', 2)   // 1 USD = 100 cents
new Currency('JPY', 0)   // 1 JPY, no subunits
new Currency('KWD', 3)   // 1 KWD = 1000 fils

// Crypto — precision always explicit
new Currency('BTC',  8)  // 1 BTC = 100_000_000 satoshis
new Currency('USDT', 6)  // 1 USDT = 1_000_000 micro-USDT
new Currency('ETH',  18) // ⚠ int overflow above ~9.2 ETH — use ofDecimal()

Money::ofMinor(4999, new Currency('USD', 2))           // $49.99
Money::ofDecimal('0.00100000', new Currency('BTC', 8)) // 100 000 satoshis

Currency carries no registry — precision is resolved by the infrastructure layer before entering the domain.

State machines are embedded per flow

Each attempt subclass validates transitions before applying them:

Card:    PENDING → AWAITING_CONFIRMATION → AUTHORIZED → PROCESSING → SUCCEEDED
                 └──────────────────────→             └→ FAILED
                                                       └→ EXPIRED

Crypto:  PENDING → PROCESSING → SUCCEEDED | FAILED | EXPIRED

P2P:     PENDING → AWAITING_CONFIRMATION → PROCESSING → SUCCEEDED | FAILED | EXPIRED

Cash:    PENDING → AWAITING_CONFIRMATION → SUCCEEDED | FAILED | EXPIRED

An invalid transition throws \DomainException before any state changes.

Domain events

Every state change produces typed events consumed by your application layer:

Event Trigger
PaymentCreated Payment created
PaymentProcessingStarted First attempt initiated
PaymentSucceeded Attempt settled
PaymentRetryAvailable Attempt failed, payment re-queued for retry
PaymentCanceled / PaymentExpired / PaymentFailed Terminal transitions
AttemptInitiated Attempt created
AttemptAuthorized Card authorized, ready to capture
AttemptRequiresUserAction 3DS / P2P redirect needed
AttemptSucceeded / AttemptFailed / AttemptCanceled / AttemptExpired Terminal attempt states

Implementing a provider

See docs/writing-a-provider.md for a complete step-by-step guide covering data classes, provider implementation, factory, Symfony registration, and tests.

Quick overview — create a Composer package and implement the port interface for your payment method:

use Payroad\Domain\Attempt\AttemptStatus;
use Payroad\Domain\Attempt\PaymentAttemptId;
use Payroad\Domain\Money\Money;
use Payroad\Domain\Payment\PaymentId;
use Payroad\Domain\Channel\Card\CardPaymentAttempt;
use Payroad\Port\Provider\Card\CardAttemptContext;
use Payroad\Port\Provider\Card\CardProviderInterface;
use Payroad\Port\Provider\Card\CaptureResult;
use Payroad\Port\Provider\WebhookResult;

final class StripeCardProvider implements CardProviderInterface
{
    public function name(): string
    {
        return 'stripe';
    }

    public function initiateCardAttempt(
        PaymentAttemptId   $id,
        PaymentId          $paymentId,
        string             $providerName,
        Money              $amount,
        CardAttemptContext $context,
    ): CardPaymentAttempt {
        $intent = $this->stripe->paymentIntents->create([
            'amount'   => $amount->getMinorAmount(),
            'currency' => strtolower((string) $amount->getCurrency()),
        ]);

        $attempt = CardPaymentAttempt::create(
            $id, $paymentId, $providerName, $amount,
            new StripeCardData(clientSecret: $intent->client_secret),
        );
        $attempt->setProviderReference($intent->id);

        return $attempt;
    }

    public function captureAttempt(string $providerReference, ?Money $amount = null): CaptureResult
    {
        $this->stripe->paymentIntents->capture($providerReference);

        return new CaptureResult(AttemptStatus::SUCCEEDED, 'succeeded');
    }

    public function parseWebhook(array $payload, array $headers): WebhookResult
    {
        // verify signature, parse event …

        return new WebhookResult(
            providerReference: $payload['data']['object']['id'],
            providerStatus:    $payload['data']['object']['status'],
            newStatus:         AttemptStatus::SUCCEEDED,
            statusChanged:     true,
        );
    }

    // … voidAttempt, initiateRefund, savePaymentMethod
}

Register the provider in your ProviderRegistryInterface implementation. No changes to this package are needed.

Testing

With Docker (no local PHP required):

make test                                    # run all tests
make filter FILTER=testPaymentMarkedSucceeded # run a single test
make shell                                   # open a shell inside the container

Without Docker:

composer install
vendor/bin/phpunit
vendor/bin/phpunit --filter testPaymentMarkedSucceededOnSyncCapture

Ecosystem

Package Description
payroad/payroad-core This package
payroad/stripe-provider Card payments via Stripe
payroad/braintree-provider Card payments via Braintree
payroad/nowpayments-provider Crypto payments via NOWPayments
payroad/coingate-provider Crypto payments via CoinGate
payroad/quickstart 5-minute quickstart — mock card + Stripe
payroad/payroad-symfony-demo Full reference application (all flows)

License

MIT — see LICENSE.