noith / payment-core
Payment interface
Requires
- php: ^8.3
- akaunting/laravel-money: ^6.0
- laravel/framework: ^12|^13
- noith/fiscal-receipt: ^1.0
Requires (Dev)
- mockery/mockery: ^1.6
- orchestra/testbench: ^10|^11
- phpunit/phpunit: ^11.0
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; - recurring (off-session) payments: saved payment methods and token-based charges;
- after-commit domain events for invoice lifecycle changes.
Requirements
- PHP
^8.3 - Laravel
^12or^13 akaunting/laravel-money^6.0noith/fiscal-receipt^1.0(54-ФЗ receipt DTOs/enums for RF fiscalization)
Installation
Install the package with Composer:
composer require noith/payment-core
Publish and run the migrations:
php artisan vendor:publish --tag=payment-migrations
php artisan migrate
Optionally publish the config:
php artisan vendor:publish --tag=payment-config
The service provider is auto-discovered by Laravel.
Configuration
config/payment.php:
| Key | Env | Default | Description |
|---|---|---|---|
log_channel | PAYMENT_LOG_CHANNEL | null | Log channel the package writes to. null uses the application's default channel. |
default_seller_id | PAYMENT_DEFAULT_SELLER_ID | null | Seller used when no resolveSellerUsing() callback is set. Convenient for a single-entity install. |
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 the economic line 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 FiscalReceipt\Common\Enum\PaymentMethod;
use FiscalReceipt\Common\Enum\PaymentObject;
use FiscalReceipt\Common\Enum\QuantityMeasure;
use FiscalReceipt\Common\Enum\VatRate;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Noith\Payment\Contracts\PaymentHandlerInterface;
use Noith\Payment\DTO\Item;
use Noith\Payment\DTO\PayloadDto;
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
{
// The four fiscal tags are mandatory — only the handler knows the tax nature, settlement
// subject/method and unit of the product. "No VAT" is the explicit VatRate::NoVat.
return [
new Item(
name: 'Monthly subscription',
quantity: 1,
originalPrice: 1000,
vatRate: VatRate::Vat20,
paymentObject: PaymentObject::Service,
paymentMethod: PaymentMethod::FullPayment,
measure: QuantityMeasure::Piece,
),
];
}
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'],
];
}
}
Sellers and credentials
A seller is the legal entity money is collected for — it owns the acquiring credentials. Every
invoice belongs to one (invoice.seller_id, NOT null), and the driver is handed that seller's
credentials at call time. This lets a single install collect for many entities (e.g. a marketplace
where each shop has its own terminal) without the driver being a singleton with fixed config.
The built-in PaymentSeller model is identity-only; credentials are normalized into a separate
payment_credentials table — one row per (seller_id, payment_system) rail, with an encrypted
credentials JSON section and an is_active flag:
use Noith\Payment\Models\PaymentSeller;
use Noith\Payment\Models\PaymentCredential;
$seller = PaymentSeller::create(['inn' => '7700000000', 'name' => 'Acme LLC']);
PaymentCredential::create([
'seller_id' => $seller->id,
'payment_system' => 'acme-pay', // the registry slug
'credentials' => ['terminal_key' => 'TK', 'password' => 'secret'],
]);
One seller can accept any number of rails (one row each). $seller->credentials('acme-pay')
returns the active section; a missing or disabled rail throws MissingSellerCredentialsException
(fail-fast). The driver's PaymentCredentials DTO types that raw section.
Resolution. The seller is derived from the product being paid for, not taken from the request (so a buyer can't spoof it). Configure how:
use Noith\Payment\Models\PaymentInvoice;
use Noith\Payment\PaymentServiceProvider;
// Derive from the invoice's product/object (the object relation is available at resolution time):
PaymentServiceProvider::resolveSellerUsing(
fn (PaymentInvoice $invoice) => $invoice->object?->paymentSeller
);
When no resolver is set, the single payment.default_seller_id is used (handy for one-entity
installs). If neither yields a seller, SellerNotResolvedException is thrown. The seller is resolved
inside the invoice pipeline (on create/charge, not on POST /price — quotes don't touch a
driver), and its credentials are hydrated and passed to the driver on createInvoice / charge /
syncStatus.
Both models are swappable (e.g. point the seller at your own School model — it just needs to
implement Noith\Payment\Contracts\PaymentSellerContract):
PaymentServiceProvider::useSellerModel(App\Models\School::class);
PaymentServiceProvider::useCredentialModel(App\Models\PaymentCredential::class);
The payment_credentials.seller_id column carries no DB-level foreign key (the seller table is
swappable); add one in your own migration if you want it.
Payment systems
A payment system driver creates invoices in the external provider and declares supported currencies.
It also declares the typed credentials DTO it needs (credentialsClass()); the action hydrates that
DTO from the invoice's seller and passes it in — the driver never reads
config or knows about the seller model. See Sellers and credentials.
use Noith\Payment\Contracts\PaymentCredentials;
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, PaymentCredentials $credentials): InvoiceResult
{
// $credentials is your own AcmeCredentials DTO (see credentialsClass()).
$response = $this->client($credentials)->createPayment([
'amount' => (int) $invoice->amount->getAmount(),
'currency' => $invoice->getRawOriginal('currency'),
]);
$invoice->provider_invoice_id = $response->id;
return new RedirectInvoiceResult($response->paymentUrl);
}
public function credentialsClass(): string
{
return AcmeCredentials::class;
}
public function supportedCurrencies(): array
{
return ['USD', 'EUR'];
}
}
The credentials DTO declares the shape the driver expects and types the seller's raw section:
use Noith\Payment\Contracts\PaymentCredentials;
use Noith\Payment\Exceptions\MissingSellerCredentialsException;
final class AcmeCredentials implements PaymentCredentials
{
public function __construct(
public readonly string $terminalKey,
public readonly string $password,
) {}
public static function fromArray(array $c): static
{
return new self(
$c['terminal_key'] ?? throw new MissingSellerCredentialsException('terminal_key'),
$c['password'] ?? throw new MissingSellerCredentialsException('password'),
);
}
}
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, PaymentCredentials $credentials): SyncResult
{
return new SyncResult(
status: PaymentInvoiceStatus::Confirmed,
payload: ['raw' => 'provider payload'],
providerStatus: 'paid',
providerEventId: 'event-123',
);
}
For partial payments, return PaymentInvoiceStatus::PartiallyPaid with trancheAmount.
Reconcile-only sync
Sometimes a provider reports a state that should not drive the lifecycle — typically a refund,
reversal, or chargeback of an already-confirmed payment. Returning a status here is dangerous: from
a still-Pending invoice a Canceled/Failed status is a valid transition and would silently
apply. Set requiresReconciliation: true to record the observation as a reconciliation event
(PaymentInvoiceReconciliationNeededEvent) with status as the attempted status, without ever
auto-applying it — regardless of the invoice's current status:
return new SyncResult(
status: PaymentInvoiceStatus::Canceled, // observed-but-not-applied
payload: ['raw' => 'provider payload'],
providerStatus: 'refunded',
providerEventId: 'event-123',
requiresReconciliation: true,
);
Recording is idempotent on providerEventId, so a polling system that re-observes the same refund
writes a single reconciliation row — whether the observations arrive sequentially or as two
concurrent polls (the (payment_invoice_uuid, provider_event_id) unique index closes the race; the
losing insert is swallowed). Always supply a stable, non-null providerEventId in this mode — a null
one cannot be de-duplicated, produces a fresh event on every poll, and is not protected against
a concurrent duplicate.
Reusing the dispatch from a webhook
Polling and push share one dispatcher, SyncResultApplier, so the "status → action" mapping exists
in a single place. SyncInvoiceAction fetches a SyncResult from syncStatus() and applies it with
source Sync; a driver's webhook controller already holds the status in its signed payload, so it
builds the same SyncResult and applies it directly — no extra status round-trip and no second copy
of the mapping that could drift:
use Noith\Payment\Enums\PaymentInvoiceEventSource;
use Noith\Payment\Support\SyncResultApplier;
public function __invoke(Request $request, SyncResultApplier $applier)
{
// ... verify signature, load $invoice, map the provider status to a SyncResult ...
$applier->apply($invoice, $result, PaymentInvoiceEventSource::Webhook);
return response('OK', 200);
}
The transition source you pass (Sync / Webhook) is recorded on the resulting event; a
requiresReconciliation result is always recorded with source Reconciliation regardless.
Recurring (off-session) payments
A provider that supports saved tokens implements RecurringPaymentSystemInterface: it extracts a
reusable token from the first payment and later debits it without the buyer present.
The "save this method" intent lives on the invoice — set save_payment_method and
customer_identifier when creating it (both are required together, and the buyer must be
authenticated). Both ends of the flow read the same fields:
use Noith\Payment\Contracts\PaymentCredentials;
use Noith\Payment\Contracts\RecurringPaymentSystemInterface;
use Noith\Payment\DTO\InvoiceResult\ImmediateInvoiceResult;
use Noith\Payment\DTO\InvoiceResult\InvoiceResult;
use Noith\Payment\DTO\PaymentMethodData;
use Noith\Payment\Models\PaymentInvoice;
final class AcmePaySystem implements RecurringPaymentSystemInterface
{
public function createInvoice(PaymentInvoice $invoice, PaymentCredentials $credentials): InvoiceResult
{
$params = [/* amount, currency, order id ... */];
// Enable tokenization at init when the buyer opted in. Without this the provider
// never issues a reusable token (e.g. T-Bank RebillId).
if ($invoice->save_payment_method) {
$params['Recurrent'] = 'Y';
$params['CustomerKey'] = $invoice->customer_identifier;
}
// ... call provider, set $invoice->provider_invoice_id ...
return new RedirectInvoiceResult($paymentUrl);
}
public function extractPaymentMethod(PaymentInvoice $invoice, array $webhookPayload): ?PaymentMethodData
{
// Pure parsing of the confirmation payload — no HTTP calls (runs in the confirm transaction).
if (empty($webhookPayload['RebillId'])) {
return null;
}
return new PaymentMethodData(
token: (string) $webhookPayload['RebillId'],
type: 'card',
meta: ['mask' => $webhookPayload['Pan'] ?? null],
);
}
public function charge(PaymentInvoice $invoice, PaymentCredentials $credentials): InvoiceResult
{
// Token is on $invoice->paymentMethod->token.
// Return ImmediateInvoiceResult on synchronous success, or a redirect/pending result
// when the provider requires a 3DS/SCA challenge (the normal webhook flow then applies).
return new ImmediateInvoiceResult();
}
public function credentialsClass(): string
{
return AcmeCredentials::class;
}
public function supportedCurrencies(): array
{
return ['RUB'];
}
}
On the first payment, when save_payment_method is set and the system is recurring-capable,
ConfirmInvoiceAction calls extractPaymentMethod() inside the confirmation transaction and
persists a PaymentMethod owned by the invoice's user_id + customer_identifier, and bound to
the invoice's seller_id. Acquiring tokens are issued by one seller's terminal and are only
chargeable with that seller's credentials, so the binding is recorded on the method.
Later, charge a saved method off-session with ChargeRecurringAction:
use Noith\Payment\Models\PaymentMethod;
use Noith\Payment\Support\ChargeRecurringAction;
$method = PaymentMethod::query()->active()->where('user_id', $user->id)->firstOrFail();
app(ChargeRecurringAction::class)->execute(
productType: 'subscription',
paymentSystem: 'acme-pay',
currency: 'RUB',
payload: new SubscriptionPayload(plan_id: 10),
user: $user,
method: $method,
idempotencyKey: 'subscription-10-2026-06',
);
It builds the invoice through the same pipeline as CreateInvoiceAction, sets
payment_method_uuid, and calls charge() instead of createInvoice(). A synchronous charge is
confirmed immediately; a deferred one stays pending until the provider webhook arrives.
The charge is refused (InvalidArgumentException) when the method's seller_id differs from the
invoice's resolved seller — a token saved on one seller's terminal can never be charged with another
seller's credentials.
Saved methods carry an active / inactive / expired status (PaymentMethod::active() scope)
and an encrypted, hidden token. Subscription cadence, retries, and dunning are out of scope
for this package: schedule ChargeRecurringAction from your application and react to
PaymentInvoiceConfirmedEvent / PaymentInvoiceFailedEvent.
A refund, reversal, or chargeback on a confirmed recurring charge must not mutate the invoice
lifecycle — surface it from syncStatus() as a reconcile-only result
(requiresReconciliation: true) and act on PaymentInvoiceReconciliationNeededEvent.
Price Calculation
The economic pipeline is:
- handler
Item[]; - discount resolver (sets per-unit
Item::$discount); - payment commission resolver (sets per-unit signed
Item::$commission).
There is no tax step: RF VAT is inclusive, so the rate already travels on each Item and the
amount is the sum of Item::price() (originalPrice − discount + commission) × quantity. The invoice
amount and the receipt are built from the same Item[], so they reconcile by construction. The VAT
amount is never computed in the core — only the rate goes into the fiscal draft and the cash
register / OFD computes the sum (see Fiscalization).
Default resolvers pass items through unchanged. 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.
Fiscalization (receipts)
RF 54-ФЗ receipts are built over the noith/fiscal-receipt
package (DTOs, enums and serialization for FFD 1.05 and 1.2). Fiscalization is a property of the
seller — the cash register is one INN.
Each seller carries a fiscal configuration (added to payment_sellers): fiscalize (bool),
sno (TaxationType), ffd_version (FfdVersion::Ffd105 / FfdVersion::Ffd12) and
retail_place (required by FFD 1.2). The built-in model exposes this via
PaymentSellerContract::fiscalProfile(): ?FiscalProfile — a null profile is the single "no
receipt" mechanism.
fiscalProfile() returns either a fully-formed profile or null — never a half-set one. When a
seller has fiscalize=true but a required field is missing (inn, sno, ffd_version, or
retail_place for FFD 1.2), the built-in model throws IncompleteFiscalProfileException listing the
gaps, instead of failing later as a cryptic TypeError or stamping an empty INN onto the receipt.
The inn is frozen onto every receipt as its issuer, so FiscalProfile validates its shape on
construction (10 or 12 digits) and throws InvalidSellerInnException otherwise — a blank or malformed
INN can never reach a persisted snapshot, and payment_receipts.seller_inn is NOT NULL. Checksum
validity is left to the OFD/register.
$seller->update([
'fiscalize' => true,
'sno' => \FiscalReceipt\Common\Enum\TaxationType::Osn,
'ffd_version' => \FiscalReceipt\Common\Enum\FfdVersion::Ffd105,
'retail_place' => 'https://shop.example', // required for FFD 1.2
]);
FiscalizeService turns the resolved Item[] + seller into a package ReceiptDraft (the version is
chosen by ffd_version) and the core persists it as a PaymentReceipt (1:N to the invoice — an
income receipt plus, on a refund, a separate income-return document). The core runs no fiscal
status machine; status is a free informational string.
The invoice row and its receipt snapshot are written in a single transaction: if the receipt insert
fails, the invoice is rolled back with it, so no orphaned initializing invoice (which carries the
idempotency key) can wedge a later retry on the initializing guard — the retry rebuilds from scratch.
Delivery channel and the gate. A receipt is built only when it can actually be delivered. The
core decides how through ReceiptDeliveryResolverInterface, which returns a ReceiptDeliveryChannel
(Embedded / External / None) for each invoice; the chosen channel is frozen onto the receipt
snapshot (payment_receipts.delivery_channel). The default DefaultReceiptDeliveryResolver supports
embedded fiscalization only — bind your own resolver to route a receipt to External (see
External OFD below):
- the rail fiscalizes in-band — the payment system implements
FiscalizingPaymentSystemInterface, meaning the receipt rides inside its owncreateInvoicerequest, one pass (e.g. T-Bank). The core hands the built draft to the system viasetReceipt()right before callingcreateInvoice()/charge()(use theHandlesFiscalReceipttrait for the boilerplate); the driver readsreceipt()and embeds it — no change to thecreateInvoicesignature. The core does not issue it. The fiscal document ids are not in that response — the provider sends them later in a separate async fiscal notification, which the driver/host records viaPaymentReceipt::recordFiscalization(...).
Who writes the fiscal ids back — and what the core does not track. For an in-band rail the
fiscal document ids (ФД/ФП/ФН) arrive asynchronously, after the charge, so writing them onto the
PaymentReceipt via recordFiscalization(...) is the driver/host's responsibility — typically
from the provider's fiscal-notification webhook. The core deliberately runs no fiscal status
machine: it builds and persists the snapshot, then stops. It does not poll, enforce, or track that
the write-back ever happens (and for an embedded rail it usually can't — the provider exposes no way
to pull a receipt's fiscal status). Querying for stale receipts and deciding how to alert or retry is
an operational concern of the host, not the core, exactly like subscription dunning and retries
live outside the core.
A rail that is neither embedded-capable nor handled by a custom delivery resolver builds no receipt — no dangling, unsendable draft. So "T-Bank → receipt, crypto → none" is just configuration.
RUB-only. A 54-ФЗ receipt is always RUB and the fiscal Money value carries no currency (amounts
are kopecks by convention). When a fiscalizing seller is paired with a non-RUB invoice, FiscalizeService
throws NonRubFiscalizationException instead of minting a document where foreign minor units masquerade
as kopecks — the mismatch is a configuration error and fails up front, before the invoice row is written.
Explicit opt-out. A rail that must never be fiscalized (crypto, "grey" rails) implements
NonFiscalPaymentSystemInterface (a marker). It skips the fiscal pipeline entirely — no receipt is
built even when another package binds a custom delivery resolver. This suppresses fiscalization
unconditionally at the rail level, and is mutually exclusive with FiscalizingPaymentSystemInterface.
External OFD. The core does not contain OFD operators, OFD credentials, or post-confirmation
issuing. Use noith/payment-ofd (or bind your own ReceiptDeliveryResolverInterface) when a seller
must issue a receipt through a separate OFD independently of the payment rail.
Both channels converge on the same write-back:
PaymentReceipt::recordFiscalization($providerReceiptId, $fiscalDocumentNumber, $fiscalSign, $status).
Embedded drivers/hosts call it from provider fiscal notifications; external packages call it after
their own issuing flow. The method is idempotent once issued_at is set.
Swap the service or the model, and control where the buyer contact comes from:
PaymentServiceProvider::fiscalizeServiceUsing(App\Payments\MyFiscalizeService::class);
PaymentServiceProvider::useReceiptModel(App\Models\Receipt::class);
// 54-ФЗ needs a buyer email/phone for internet settlements. The default reads
// billing_details['email'] then ['phone']; override for a different billing_details shape:
PaymentServiceProvider::resolveBuyerContactUsing(
fn (PaymentInvoice $invoice) => new \FiscalReceipt\Common\Value\BuyerContact($invoice->object->email)
);
A fiscalizing seller with no resolvable buyer contact raises BuyerContactRequiredException before
the invoice is persisted (rather than producing a draft that fails the fiscal format checks). The
resolver runs while the invoice is still in memory (the draft is built pre-save), but the invoice
already carries its final uuid — so the resolver and the exception both see a stable key, not a
blank one.
Creating Invoices
A seller must be resolvable for the invoice (via resolveSellerUsing()
or payment.default_seller_id) and must have credentials for the chosen payment_system —
otherwise creation fails with SellerNotResolvedException / MissingSellerCredentialsException.
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/invoices also accepts two optional fields for recurring payments: save_payment_method (boolean) and customer_identifier (string, required when save_payment_method is true).
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 different request context —user_id,product_type,payment_system,currency,payload,save_payment_method,customer_identifier, orpayment_method_uuid.
The payload is compared directly (rather than the derived amount), so a different quantity, plan, or — for recurring charges — a different saved payment_method_uuid reusing the same key is reported as a conflict instead of silently returning the original invoice.
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\PaymentInvoiceEventNoith\Payment\Models\PaymentMethodNoith\Payment\Models\PaymentSellerNoith\Payment\Models\PaymentCredentialNoith\Payment\Models\PaymentReceipt
Override them during application boot if needed:
use Noith\Payment\PaymentServiceProvider;
PaymentServiceProvider::useInvoiceModel(App\Models\PaymentInvoice::class);
PaymentServiceProvider::useInvoiceEventModel(App\Models\PaymentInvoiceEvent::class);
PaymentServiceProvider::usePaymentMethodModel(App\Models\PaymentMethod::class);
PaymentServiceProvider::useSellerModel(App\Models\School::class);
PaymentServiceProvider::useCredentialModel(App\Models\PaymentCredential::class);
PaymentServiceProvider::useReceiptModel(App\Models\Receipt::class);
PaymentServiceProvider::useUserModel(App\Models\User::class);
A swapped-in seller model must implement Noith\Payment\Contracts\PaymentSellerContract —
credentials() and fiscalProfile(). Return null from fiscalProfile() only for a deliberate
opt-out; on a half-set fiscal configuration it must throw, never return null (the delivery resolver
reads null as "no receipt" and silently skips fiscalization).
Private fields are encrypted:
- invoice
payload - invoice
billing_details - event
payload - credential
credentials
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