Looking to hire Laravel developers? Try LaraJobs

core maintained by payment_laravel

Description
Payment interface
Author
Last update
2026/06/16 14:51 (dev-master)
License
Links
Downloads
0

Comments
comments powered by Disqus

Noith Payment

Laravel package for creating payment invoices through pluggable payment systems.

The package provides:

  • invoice and invoice event models with migrations;
  • a small HTTP API for price calculation and invoice creation;
  • registries for product handlers and payment system drivers;
  • actions for confirmation, partial payment, cancellation, failure, expiry, and provider sync;
  • encrypted storage for private payload and billing details;
  • idempotent invoice creation by idempotency_key;
  • after-commit domain events for invoice lifecycle changes.

Requirements

  • PHP ^8.3
  • Laravel ^12 or ^13
  • akaunting/laravel-money ^6.0

Installation

Install the package with Composer:

composer require noith/payment

Publish and run the migrations:

php artisan vendor:publish --tag=payment-migrations
php artisan migrate

The service provider is auto-discovered by Laravel.

Routes

Routes are not registered automatically. Add them to your application routes:

use Noith\Payment\PaymentServiceProvider;

PaymentServiceProvider::routes(
    prefix: 'payment',
    middleware: ['web', 'auth:sanctum'],
);

Registered endpoints:

Method URI Description
POST /payment/price Calculate a price without creating an invoice.
POST /payment/invoices Create or return an invoice.
GET /payment/invoices/{uuid} Read an invoice as its owner, or with an invoice access token.

Core Concepts

Product handlers

A product handler describes what is being sold. It validates the payload, builds receipt items, optionally links the invoice to an Eloquent model, and handles terminal invoice outcomes.

Register handlers in your application service provider:

use Noith\Payment\Support\PaymentHandlerRegistry;

public function boot(PaymentHandlerRegistry $handlers): void
{
    $handlers->register('subscription', SubscriptionPaymentHandler::class);
}

Implement the contract:

use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Noith\Payment\Contracts\PaymentHandlerInterface;
use Noith\Payment\DTO\PayloadDto;
use Noith\Payment\DTO\ReceiptItem;
use Noith\Payment\Models\PaymentInvoice;

final class SubscriptionPaymentHandler implements PaymentHandlerInterface
{
    public function payloadClass(): string
    {
        return SubscriptionPayload::class;
    }

    public function items(PayloadDto $payload, ?Authenticatable $user, string $currency): array
    {
        return [
            new ReceiptItem('Monthly subscription', 1, 1000),
        ];
    }

    public function object(PayloadDto $payload): ?Model
    {
        return SubscriptionPlan::query()->find($payload->plan_id);
    }

    public function handle(PaymentInvoice $invoice): void
    {
        // Fulfil the purchase.
    }

    public function handleExpired(PaymentInvoice $invoice): void {}
    public function handleCanceled(PaymentInvoice $invoice): void {}
    public function handleFailed(PaymentInvoice $invoice): void {}
    public function handlePartiallyPaid(PaymentInvoice $invoice): void {}

    public function expiresAt(): ?DateTimeInterface
    {
        return now()->addMinutes(30);
    }
}

All lifecycle methods (handle, handleExpired, handleCanceled, handleFailed, handlePartiallyPaid) run inside a database transaction atomically with the status transition. Keep them limited to database writes. Dispatch external jobs with afterCommit: true; synchronous HTTP calls, emails, or jobs without after-commit semantics must not be placed here — they will fire even if the transaction rolls back.

Payload DTOs

Each handler returns a PayloadDto class. The package validates request payloads with rules() and creates DTO instances with named constructor arguments:

use Noith\Payment\DTO\PayloadDto;

final class SubscriptionPayload extends PayloadDto
{
    public function __construct(
        public readonly int $plan_id,
        public readonly int $quantity = 1,
    ) {}

    public static function rules(): array
    {
        return [
            'plan_id' => ['required', 'integer', 'min:1'],
            'quantity' => ['sometimes', 'integer', 'min:1'],
        ];
    }
}

Payment systems

A payment system driver creates invoices in the external provider and declares supported currencies.

use Noith\Payment\Contracts\PaymentSystemInterface;
use Noith\Payment\DTO\InvoiceResult\InvoiceResult;
use Noith\Payment\DTO\InvoiceResult\RedirectInvoiceResult;
use Noith\Payment\Models\PaymentInvoice;

final class AcmePaySystem implements PaymentSystemInterface
{
    public function createInvoice(PaymentInvoice $invoice): InvoiceResult
    {
        $response = $this->client->createPayment([
            'amount' => (int) $invoice->amount->getAmount(),
            'currency' => $invoice->getRawOriginal('currency'),
        ]);

        $invoice->provider_invoice_id = $response->id;

        return new RedirectInvoiceResult($response->paymentUrl);
    }

    public function supportedCurrencies(): array
    {
        return ['USD', 'EUR'];
    }
}

Register systems:

use Noith\Payment\Support\PaymentSystemRegistry;

public function boot(PaymentSystemRegistry $systems): void
{
    $systems->register('acme-pay', AcmePaySystem::class);
}

Supported invoice presentation results:

  • RedirectInvoiceResult($url)
  • QrInvoiceResult($qr, $deepLink = null)
  • DetailsInvoiceResult($details)
  • ImmediateInvoiceResult() for payments that are already confirmed

Custom result types can extend InvoiceResult and must be registered before deserialization:

InvoiceResult::register('custom', CustomInvoiceResult::class);

Syncable systems

If a provider supports status polling, implement SyncablePaymentSystemInterface:

use Noith\Payment\Contracts\SyncablePaymentSystemInterface;
use Noith\Payment\DTO\SyncResult;
use Noith\Payment\Enums\PaymentInvoiceStatus;

public function syncStatus(PaymentInvoice $invoice): SyncResult
{
    return new SyncResult(
        status: PaymentInvoiceStatus::Confirmed,
        payload: ['raw' => 'provider payload'],
        providerStatus: 'paid',
        providerEventId: 'event-123',
    );
}

For partial payments, return PaymentInvoiceStatus::PartiallyPaid with trancheAmount.

Price Calculation

The calculation pipeline is:

  1. handler receipt items;
  2. discount resolver;
  3. payment commission resolver;
  4. tax resolver.

Default resolvers pass prices through unchanged. NullTaxResolver additionally marks every item's VAT as none.

Bind custom resolvers in your application container:

$this->app->bind(
    \Noith\Payment\Contracts\DiscountResolverInterface::class,
    App\Payments\DiscountResolver::class,
);

All monetary values are integer minor units, for example cents or kopecks. Floats are rejected by MoneyCast.

Creating Invoices

HTTP request:

POST /payment/invoices
Content-Type: application/json

{
  "product_type": "subscription",
  "payment_system": "acme-pay",
  "currency": "USD",
  "payload": {
    "plan_id": 10
  },
  "billing_details": {
    "email": "buyer@example.test"
  },
  "idempotency_key": "client-generated-unique-key"
}

Response:

{
  "uuid": "7cb99418-8d4c-4e8a-9bb6-bc81f4b9ce0b",
  "status": "pending",
  "amount": 1000,
  "paid_amount": 0,
  "currency": "USD",
  "payment_system": "acme-pay",
  "product_type": "subscription",
  "user_id": 1,
  "object_type": "subscription_plan",
  "object_id": 10,
  "provider_data": {
    "type": "redirect",
    "url": "https://provider.example/pay/123"
  },
  "paid_at": null,
  "expires_at": "2026-06-16T12:00:00+00:00",
  "created_at": "2026-06-16T11:30:00+00:00"
}

POST /payment/price accepts the same product_type, payment_system, currency, and payload, but only returns the calculated price and does not create an invoice.

Idempotency

idempotency_key is optional, nullable, and globally unique. Reusing the same non-null key returns the existing invoice only when the request context matches the original invoice:

  • 201 Created for a new invoice;
  • 200 OK for an existing completed initialization;
  • 409 Conflict if the existing invoice is still initializing.
  • 409 Conflict if the key is already used with a different user_id, product_type, payment_system, or currency.

Use high-entropy keys that are unique across all users and products, not only per user.

Reading Invoices

GET /payment/invoices/{uuid} is allowed when:

  • the authenticated user owns the invoice; or
  • the request contains a valid X-Payment-Invoice-Token: {access_token} header.

For backwards compatibility, ?token={access_token} is also accepted. Prefer the header form for API clients, because query tokens are more likely to appear in logs, browser history, and referrer headers.

The response does not expose access_token, payload, or billing_details.

Statuses and Transitions

Statuses:

  • initializing
  • pending
  • partially_paid
  • confirmed
  • failed
  • canceled
  • expired

Allowed transitions:

From To
initializing pending, confirmed, failed
pending partially_paid, confirmed, failed, canceled, expired
partially_paid partially_paid, confirmed, failed, canceled, expired
final statuses none

Final statuses are confirmed, failed, canceled, and expired.

Use the provided actions for webhook and sync flows:

app(\Noith\Payment\Support\ConfirmInvoiceAction::class)
    ->execute($invoice, $payload, providerEventId: $eventId);

app(\Noith\Payment\Support\PartiallyPayInvoiceAction::class)
    ->execute($invoice, trancheAmount: 500, eventPayload: $payload, providerEventId: $eventId);

app(\Noith\Payment\Support\FailInvoiceAction::class)
    ->execute($invoice, $payload, providerEventId: $eventId);

app(\Noith\Payment\Support\CancelInvoiceAction::class)
    ->execute($invoice, $payload, providerEventId: $eventId);

app(\Noith\Payment\Support\ExpireInvoicesAction::class)
    ->execute($invoice);

Provider event IDs are idempotency keys for status transitions. Repeating the same provider event for the same invoice is a no-op.

Invalid transitions from final statuses are recorded as reconciliation events instead of running product handlers.

Expiring Invoices

Handlers can return expiresAt() during creation. Expirable statuses are pending and partially_paid.

Run the command from your scheduler:

use Illuminate\Support\Facades\Schedule;

Schedule::command('payment:expire-invoices')->everyMinute();

Models

Default models:

  • Noith\Payment\Models\PaymentInvoice
  • Noith\Payment\Models\PaymentInvoiceEvent

Override them during application boot if needed:

use Noith\Payment\PaymentServiceProvider;

PaymentServiceProvider::useInvoiceModel(App\Models\PaymentInvoice::class);
PaymentServiceProvider::useInvoiceEventModel(App\Models\PaymentInvoiceEvent::class);
PaymentServiceProvider::useUserModel(App\Models\User::class);

Private invoice fields are encrypted:

  • payload
  • billing_details
  • event payload

amount and paid_amount are cast to Akaunting\Money\Money; currency is cast to Akaunting\Money\Currency.

Events

All invoice lifecycle events implement ShouldDispatchAfterCommit:

  • PaymentInvoiceCreatedEvent
  • PaymentInvoiceConfirmedEvent
  • PaymentInvoicePartiallyPaidEvent
  • PaymentInvoiceCanceledEvent
  • PaymentInvoiceFailedEvent
  • PaymentInvoiceExpiredEvent
  • PaymentInvoiceStatusChangedEvent
  • PaymentInvoiceReconciliationNeededEvent

Testing

Run the package test suite:

composer test