payment_laravel/core
Composer 安装命令:
composer require payment_laravel/core
包简介
Payment interface
README 文档
README
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
^12or^13 akaunting/laravel-money^6.0
Installation
Install the package with Composer:
composer require payment_laravel/core
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:
- handler receipt items;
- discount resolver;
- payment commission resolver;
- 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 Createdfor a new invoice;200 OKfor an existing completed initialization;409 Conflictif the existing invoice is stillinitializing.409 Conflictif the key is already used with a differentuser_id,product_type,payment_system, orcurrency.
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:
initializingpendingpartially_paidconfirmedfailedcanceledexpired
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\PaymentInvoiceNoith\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:
payloadbilling_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:
PaymentInvoiceCreatedEventPaymentInvoiceConfirmedEventPaymentInvoicePartiallyPaidEventPaymentInvoiceCanceledEventPaymentInvoiceFailedEventPaymentInvoiceExpiredEventPaymentInvoiceStatusChangedEventPaymentInvoiceReconciliationNeededEvent
Testing
Run the package test suite:
composer test
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 0
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-06-16