noith/payment-core

Payment interface

Maintainers

Package info

gitlab.com/noith-payment/laravel-core

Issues

pkg:composer/noith/payment-core

Statistics

Installs: 7

Dependents: 0

Suggesters: 0

Stars: 0

v1.0.4 2026-06-23 00:38 UTC

This package is auto-updated.

Last update: 2026-06-22 21:41:26 UTC


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 ^12 or ^13
  • akaunting/laravel-money ^6.0
  • noith/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:

KeyEnvDefaultDescription
log_channelPAYMENT_LOG_CHANNELnullLog channel the package writes to. null uses the application's default channel.
default_seller_idPAYMENT_DEFAULT_SELLER_IDnullSeller 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:

MethodURIDescription
POST/payment/priceCalculate a price without creating an invoice.
POST/payment/invoicesCreate 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:

  1. handler Item[];
  2. discount resolver (sets per-unit Item::$discount);
  3. 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 own createInvoice request, one pass (e.g. T-Bank). The core hands the built draft to the system via setReceipt() right before calling createInvoice()/ charge() (use the HandlesFiscalReceipt trait for the boilerplate); the driver reads receipt() and embeds it — no change to the createInvoice signature. 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 via PaymentReceipt::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 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 request context — user_id, product_type, payment_system, currency, payload, save_payment_method, customer_identifier, or payment_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:

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

Allowed transitions:

FromTo
initializingpending, confirmed, failed
pendingpartially_paid, confirmed, failed, canceled, expired
partially_paidpartially_paid, confirmed, failed, canceled, expired
final statusesnone

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
  • Noith\Payment\Models\PaymentMethod
  • Noith\Payment\Models\PaymentSeller
  • Noith\Payment\Models\PaymentCredential
  • Noith\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\PaymentSellerContractcredentials() 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:

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

Testing

Run the package test suite:

composer test