glueful / payvia
Payvia: Unified payment gateway bridge for the Glueful PHP Framework (Stripe, Paystack, Flutterwave, and more).
Package info
Type:glueful-extension
pkg:composer/glueful/payvia
Requires
- php: ^8.3
- glueful/extension-contracts: ^1.5.0
- vlucas/phpdotenv: ^5.6
Requires (Dev)
- glueful/framework: ^1.71.0
- phpstan/phpstan: ^1.0
- phpunit/phpunit: ^10.5
- squizlabs/php_codesniffer: ^3.6
README
Overview
Payvia is the official payment gateway bridge for the Glueful PHP Framework. It provides a unified, gateway‑agnostic interface for verifying and recording payments via multiple providers (Paystack, Stripe, Flutterwave [coming soon], and more) into a single payments table.
Features
- ✅ Generic
paymentstable with:gateway,gateway_transaction_id,referenceuser_uuidand polymorphicpayable_type/payable_idlinkmetadataJSON for app‑level contextraw_payloadJSON for full provider responses
- ✅ Gateway abstraction via
PaymentGatewayInterface - ✅
GatewayManagerto resolve gateways by config name (e.g.paystack,stripe) - ✅
PaymentServicewith a single entrypoint:confirmAndRecord() - ✅ Normalized provider-event outbox for webhooks and verify-origin confirmations
- ✅ Signature-verified webhook endpoint:
POST /payvia/webhooks/{gateway}
- ✅ Provider subscription projection in
gateway_subscriptions - ✅ HTTP endpoint for payment confirmation:
POST /payvia/payments/confirm
- ✅ Generic billing plans (
billing_plans) and invoices (invoices) with thin services
Requirements
- PHP 8.3+
- Glueful Framework 1.50.1+
- No extra libraries required for Paystack (uses Glueful HTTP client)
- Provider‑specific SDKs are optional if you add custom gateways
Installation
composer require glueful/payvia
# Run migrations for payments
php glueful migrate run
Enabling the extension
Installing the package does not auto-load it — its provider must be in
config/extensions.php's enabled allow-list.
Development (recommended): the CLI edits config/extensions.php and recompiles the
cache (validated before writing):
php glueful extensions:enable payvia
# disable with: php glueful extensions:disable payvia
By hand / in production: add the provider as a plain string FQCN (no ::class),
then build the manifest in your deploy step:
// config/extensions.php return [ 'enabled' => [ 'Glueful\\Extensions\\Payvia\\PayviaServiceProvider', // other providers... ], ];
php glueful extensions:cache # required in production
Payvia also auto-discovers the payvia:relay-events command. If your app caches command metadata during deploy, rebuild that cache after enabling or upgrading the extension.
Verify Installation
Check discovery and provider wiring:
php glueful extensions:list php glueful extensions:info payvia php glueful extensions:diagnose
Run database migrations (if not auto‑run):
php glueful migrate run
Configuration
Payvia ships with a package config file at config/payvia.php (inside the extension). You can override values via your app’s .env or by publishing / merging config.
Key environment variables:
# Default gateway (must exist in payvia.gateways) PAYVIA_DEFAULT_GATEWAY=paystack # Paystack PAYVIA_PAYSTACK_ENABLED=true PAYVIA_PAYSTACK_SECRET_KEY=sk_test_xxx PAYVIA_PAYSTACK_WEBHOOK_SECRET=sk_test_xxx PAYVIA_PAYSTACK_BASE_URL=https://api.paystack.co PAYVIA_PAYSTACK_TIMEOUT=15 # Stripe PAYVIA_STRIPE_ENABLED=false PAYVIA_STRIPE_SECRET_KEY=sk_test_xxx PAYVIA_STRIPE_WEBHOOK_SECRET=whsec_xxx PAYVIA_STRIPE_BASE_URL=https://api.stripe.com PAYVIA_STRIPE_TIMEOUT=15 # Whether to store full provider payload in raw_payload column PAYVIA_STORE_RAW_PAYLOAD=true # Webhook processing PAYVIA_WEBHOOKS_QUEUE=false PAYVIA_WEBHOOKS_QUEUE_NAME=default PAYVIA_WEBHOOKS_RELAY_STALE_SECONDS=300
Config structure (simplified):
return [ 'default_gateway' => env('PAYVIA_DEFAULT_GATEWAY', 'paystack'), 'gateways' => [ 'paystack' => [ 'enabled' => (bool) env('PAYVIA_PAYSTACK_ENABLED', true), 'driver' => 'paystack', 'secret_key' => env('PAYVIA_PAYSTACK_SECRET_KEY', env('PAYSTACK_SECRET_KEY', null)), 'webhook_secret' => env('PAYVIA_PAYSTACK_WEBHOOK_SECRET', env('PAYVIA_PAYSTACK_SECRET_KEY', env('PAYSTACK_SECRET_KEY', null))), 'base_url' => env('PAYVIA_PAYSTACK_BASE_URL', 'https://api.paystack.co'), 'timeout' => (int) env('PAYVIA_PAYSTACK_TIMEOUT', 15), ], 'stripe' => [ 'enabled' => (bool) env('PAYVIA_STRIPE_ENABLED', false), 'driver' => 'stripe', 'secret_key' => env('PAYVIA_STRIPE_SECRET_KEY', null), 'webhook_secret' => env('PAYVIA_STRIPE_WEBHOOK_SECRET', null), 'webhook_tolerance' => (int) env('PAYVIA_STRIPE_WEBHOOK_TOLERANCE', 300), 'base_url' => env('PAYVIA_STRIPE_BASE_URL', 'https://api.stripe.com'), 'timeout' => (int) env('PAYVIA_STRIPE_TIMEOUT', 15), ], ], 'features' => [ 'store_raw_payload' => (bool) env('PAYVIA_STORE_RAW_PAYLOAD', true), ], 'security' => [ // Three ordered profiles composed onto every /payvia/* route except the webhook // route (which stays signature-authenticated/tenantless). See "Middleware profiles // and tenancy" below. 'auth_middleware' => ['auth'], 'tenant_context_middleware' => [], 'manage_middleware' => ['admin'], ], 'webhooks' => [ 'queue' => (bool) env('PAYVIA_WEBHOOKS_QUEUE', false), 'queue_name' => env('PAYVIA_WEBHOOKS_QUEUE_NAME', 'default'), 'relay_stale_seconds' => (int) env('PAYVIA_WEBHOOKS_RELAY_STALE_SECONDS', 300), ], ];
Hosted Payment Initiation (the payable metadata convention)
PayviaPaymentCollector starts hosted payment flows through InitiationCapableGateway
(Paystack redirect pages; Stripe Checkout Sessions). It is payable-type-agnostic: it never
inspects payable_type, and per-consumer parameters are never threaded through it. Instead,
whoever builds a PayableReference supplies three well-known metadata keys, and the
collector lifts them into the gateway options once:
| Metadata key | Meaning |
|---|---|
email |
The payer's email (Paystack requires it; Stripe pre-fills the session). |
callback_url |
Absolute HTTPS URL the visitor returns to after paying (Stripe: REQUIRED — session creation throws without it; Paystack: falls back to the dashboard callback). |
cancel_url |
Absolute HTTPS URL for an abandoned Stripe session; falls back to callback_url. |
An order flow, a subscription flow, and an invoice flow each set their own values when constructing their payable — nothing here is order-specific. Two invariants:
- Webhooks stay the settlement authority. The callback/cancel URLs are browser navigation
only; payment truth always comes from webhook verification (
verify()/ provider events). - Initiation exceptions propagate. The collector has no catch — mapping failures (e.g. to an
init_failedresult) is the calling application's job.
Stripe session creation sends a deterministic Idempotency-Key per payable and validates the
response (a cs_… session id and an absolute HTTPS checkout URL) before any intent is persisted.
Webhooks and Provider Events
Payvia persists provider deliveries in provider_events, normalizes them into ProviderEvent, applies idempotent side effects, then dispatches PaymentProviderEvent through the framework event bus.
The provider_events table uses two event keys:
delivery_keydedupes exact provider redeliveries per gateway.logical_event_keydedupes the same business fact across delivery paths, such as a manual verify confirmation and a later webhook for the same payment.
normalized_payload stores Payvia's gateway-agnostic event shape for replay, while dispatch_status powers the outbox relay.
Provider webhook endpoints:
POST /payvia/webhooks/paystack
POST /payvia/webhooks/stripe
The webhook route intentionally has no auth middleware. Payvia verifies the provider signature inside the webhook pipeline before accepting the event.
payvia:relay-events replays processed provider events that were not dispatched yet, including crash recovery for rows stuck in dispatching.
Strict Delivery and Listener Contracts
Payvia supports an opt-in strict-delivery lane for consumers that require guaranteed at-least-once event delivery and are willing to pay the cost of mandatory idempotency.
Strict Listener Interface
Implement Glueful\Extensions\Payvia\Contracts\StrictPaymentEventListener:
namespace Glueful\Extensions\Payvia\Contracts; interface StrictPaymentEventListener { public const CONTAINER_TAG = 'payvia.strict_payment_event_listeners'; public function supports(PaymentProviderEventInterface $event): bool; public function handle(PaymentProviderEventInterface $event): void; }
Register your listener from your extension's static services() map, and publish the tag with the definition-level 'tags' key on the listener's own definition:
use Glueful\Extensions\Payvia\Contracts\StrictPaymentEventListener; public static function services(): array { return [ // ... other services ... MyStrictEventListener::class => [ 'class' => MyStrictEventListener::class, 'shared' => true, 'autowire' => true, 'tags' => [StrictPaymentEventListener::CONTAINER_TAG], ], ]; }
static tags()does not work here.ContainerFactory::loadExtensionDefinitions()only consults a provider's statictags()for typeddefs()-based providers; for aservices()-based (DSL) provider the tag comes exclusively from each definition's own'tags'key. Aservices()-based provider that publishes the tag viatags()is silently registered untagged, andcomposeStrictLane()never sees the listener. See the §3 correction indocs/superpowers/specs/2026-08-02-strict-payment-event-lane-design.md.
Listener Obligations
Strict listeners are invoked in a deterministic, FQCN-sorted order between ordinary (fault-isolated) listeners and chargebacks. A listener exception prevents dispatch-marking and produces a retryable delivery — both inline (non-2xx webhook response) and queued (retried job).
Implementations MUST be idempotent — delivery is at-least-once by design:
- Idempotency: a single business fact may be delivered multiple times (e.g. after a sibling listener fails, the strict lane re-runs from the start). Your handler must detect and skip duplicates or apply the same logic safely multiple times without side effects.
- At-least-once: a handler failure leaves the delivery marked retryable; a subsequent delivery will reinvoke all strict listeners, including those that already succeeded. Design handlers to tolerate re-execution.
- Failure is observable: if your handler throws, the exception propagates (after releasing the internal lease), producing a
500response in inline mode or a retried job in queue mode. Payvia does not log or swallow strict listener exceptions — that's your contract: throw to signal "this delivery must be retried."
Chargeback Lane
The existing chargeback delivery lane (the final step) is also strict — exceptions prevent dispatch-marking and produce retryable delivery. Chargeback listeners already operate under the at-least-once / idempotency contract; no change is required, but the timing of re-execution has improved (immediate retry instead of waiting for stale-lease recovery).
Provider Subscriptions
Payvia persists gateway-owned subscription state in gateway_subscriptions and exposes GatewaySubscriptionService::reconcile($gateway, $gatewaySubscriptionId). It stays tenancy-agnostic: tenant ownership and entitlement decisions belong to glueful/subscriptions.
gateway_subscriptions stores provider subscription state only. It intentionally does not store tenant ownership; glueful/subscriptions owns the tenant-to-provider-subscription map and all entitlement decisions.
The stored status is normalized and fails closed: provider statuses are mapped to one of active, past_due, canceled, incomplete, paused, or unknown. Only the explicitly active provider statuses (active, trialing) become active; any unrecognized, future, or missing provider status is recorded as unknown (never silently treated as live). Consumers deciding entitlement should treat anything other than active as not entitled.
Subscription Checkout
Payvia can start a provider-hosted checkout for a brand-new subscription — as opposed to GatewaySubscriptionService's existing management of an already-existing one — through SubscriptionCheckoutService, backed by its own origination ledger. This is a separate, additive surface: nothing above (payments, webhooks, gateway_subscriptions projection) changes shape or behavior.
Capability probing
A gateway opts in by implementing SubscriptionInitiationCapableGateway (required), and optionally SubscriptionCheckoutLifecycleCapableGateway (on-demand status/abandonment) and SubscriptionCancellationModeProvider (self-serve cancellation modes). Probe before use — never assume:
$manager->supports('stripe', 'subscription_checkout'); // true $manager->supports('stripe', 'cancellation_modes'); // true
The prepare() / initializeClaim() seam
SubscriptionCheckoutService deliberately splits origination into two calls so that provider I/O never happens inside a database transaction:
prepare(ApplicationContext $context, SubscriptionCheckoutRequest $request, callable $bindLocalReservation)owns exactly one transaction. It validates the gateway/plan identifier before any write, then claims (or — for a repeatedidempotencyKeywith a matching request fingerprint — idempotently replays) an origination row, claims the caller's subject guard only for a genuinely new claim, invokes$bindLocalReservation(your own local reservation, e.g. a pending host-side subscription record) inside the same transaction, and advances the rowpreparing -> initializing. A repeatedidempotencyKeywhose request shape has changed is a hard conflict (IdempotencyConflictException), never a silent overwrite. Everything after the claim rolls back together on any exception.initializeClaim(ApplicationContext $context, string $originationUuid)is a later, separate call. It acquires a narrow 120-second initialization lease (reclaimable if stale), calls the gateway driver outside of any transaction, and persists the outcome with a single atomic compare-and-swap. A concurrent second caller for the same origination never touches the provider at all — it either waits out the lease or observes the already-persisted result.
A definitive provider rejection (DefinitiveSubscriptionCheckoutRejection) marks the origination failed and releases the subject guard immediately, so the subject may originate another attempt. Any other failure is treated as unknown: only the execution lease is released — status, idempotency key, and the guard are left untouched so a retry can safely call the provider again with the same idempotency semantics.
Ledger semantics
subscription_checkout_originations is the permanent correlation identity for a checkout attempt — its opaque uuid is stamped into provider metadata (Stripe: subscription_data.metadata.origination_uuid) so a webhook arriving after any terminal state still correlates back to it. subscription_checkout_subject_guards is a separate, narrower authority answering one question only — "may this subject originate another checkout right now?" — and is never inferred from the ledger or any client-side TTL, because a hosted checkout may complete well after a client would expect it to have expired.
customer_email, stored only for initialization-crash recovery, is force-cleared the instant an origination reaches a definitive (terminal) outcome.
Ownership is resolved from the ledger, never invented by a webhook: when a provider event carries a resolvable origination_uuid, the origination row's own tenant_uuid is adopted by the gateway_subscriptions projection (never a bare metadata hint). A webhook that lands after a newer origination already owns the subject is recorded as late_settlement_conflict — a permanently terminal, operator-visible state — rather than silently re-activating a superseded attempt.
Acknowledgement contract for consumers
If your host requires its own durable confirmation before an origination is considered complete (e.g. a subscriptions package that must record entitlement before Payvia calls the flow finished), set requiredProjectionConsumer on the SubscriptionCheckoutRequest. Payvia then withholds the origination's dispatched status until your consumer calls back through the SubscriptionProjectionAcknowledger contract:
$acknowledger->acknowledge( originationUuid: $originationUuid, consumer: 'subscriptions', logicalEventKey: $event->logicalEventKey(), outcome: 'accepted', // or 'rejected' reason: null, // required context when rejecting );
- Call this after your projection has durably committed its own side of the subscription, in response to the correlated
subscription.createddelivery — never speculatively. - A duplicate delivery must re-compute and re-call with the same outcome; Payvia treats a repeat of the identical
(originationUuid, consumer, logicalEventKey, outcome)tuple as an idempotent no-op, not a failure. This is what makes crash recovery between "projection committed" and "acknowledgement sent" safe to simply retry. - A conflicting second outcome for the same
logicalEventKeythrows — Payvia never silently overwrites a committed verdict. acceptedlets Payvia's post-dispatch finalizer advance the originationprovider_observed -> dispatchedand release the subject guard, atomically.rejectedmoves the origination to operator-visibleprojection_rejectedand leaves the guard held for reconciliation — the underlying provider event still finishes dispatching either way.- If your consumer never acknowledges a delivery that required one, Payvia raises
RequiredProjectionAcknowledgementMissingand the delivery is retried — there is no silent timeout todispatched.
Operator reconciliation
A checkout stuck at pending (no webhook ever arrived) or resolved to projection_rejected / late_settlement_conflict is not a dead end. CheckoutReconciliationService::resolve() exposes exactly two explicit outcomes — there is no generic "ignore":
provider_confirmed_dead— the operator has verified externally that no payment or subscription was ever created. Only legal frompending; advances the origination toabandonedand reopens the subject guard.provider_canceled_or_refunded— the operator has already canceled/refunded on the provider side. Only legal fromprojection_rejectedorlate_settlement_conflict(both keep their own status — this only updates the audit trail) and reopens the subject guard.
Both write a bounded audit note into dedicated reconciliation_resolution/reconciliation_note/reconciled_at columns, never into the projection consumer's own committed projection_reason receipt.
Cancellation modes
Probe SubscriptionCancellationModeProvider::cancellationModes() (or GatewayManager::supports($gateway, 'cancellation_modes')) before offering a self-serve cancel option — do not assume every gateway honors both modes over its existing cancelSubscription():
- Stripe:
stop_renewalandimmediate. - Paystack:
stop_renewalonly.
Paystack: subscription checkout is unavailable
Paystack does not support hosted subscription checkout in Payvia, and this is a deliberate, sandbox-proven limitation rather than a gap. A 2026-08-04 sandbox run established that Paystack's API gives no non-ambiguous way to join a checkout attempt to the subscription it produces: subscription.create carries no transaction metadata or reference at all, and charge.success carries no subscription identifier. There is no field pair either event exposes that correlates the two without guessing.
Consequences:
PaystackGatewaydoes not implementSubscriptionInitiationCapableGateway.GatewayManager::supports('paystack', 'subscription_checkout')isfalse.SubscriptionCheckoutService::prepare()targetingpaystackthrowsCheckoutUnavailableExceptionbefore any ledger or subject-guard row is written — there is no fallback to a one-time payment.- Paystack's existing webhook projection (
gateway_subscriptions) and operator-created subscription support are completely unaffected — only hosted checkout origination is unavailable.
The negative proof is committed permanently as fixtures under tests/Fixtures/paystack-checkout/ (produced by the payvia:checkout:sandbox-proof console command) and pinned by a regression suite.
Revisit trigger: this will be reconsidered only if Paystack starts propagating transaction metadata onto subscription.create, or starts including a subscription identifier (e.g. subscription_code) in charge.success. Until then, a consuming application must offer subscription checkout through another gateway (e.g. Stripe) for tenants on Paystack.
Billing Plans and Entitlements
billing_plans is the priced-plan side of Payvia. It includes provider linkage fields:
gatewaygateway_product_idgateway_price_id
Use these fields to link a local priced plan to provider-side product, price, or plan objects. Paystack usually maps to gateway_price_id; Stripe can use both gateway_product_id and gateway_price_id.
Payvia does not store feature gates or entitlement catalogs on billing plans. Tenant plans, feature gates, and overrides belong in glueful/subscriptions.
HTTP API
Authorization
The billing write endpoints — creating, updating, or disabling plans, and
creating, marking-paid, or canceling invoices — require an admin caller by
default. They run the auth + admin middleware (the framework's
AdminPermissionMiddleware), so a plain authenticated end-user receives
403 Forbidden. Read endpoints (GET /payvia/plans, GET /payvia/invoices),
POST /payvia/payments/confirm, and the signature-verified webhook route are
not gated by admin.
Admin-gated write routes:
POST /payvia/plansPOST /payvia/plans/updatePOST /payvia/plans/disablePOST /payvia/invoicesPOST /payvia/invoices/mark-paidPOST /payvia/invoices/cancel
Middleware profiles and tenancy
Every /payvia/* route except the webhook route (which stays
signature-authenticated/tenantless) composes three ordered, independently
configurable middleware profiles:
// config/payvia.php (application override — neutral defaults shown; single-store // installs can leave this block out entirely) return [ 'security' => [ // Profile 1 — authentication. 'auth_middleware' => ['auth'], // Profile 2 — tenant context. Empty by default; a tenancy-enabled host sets // this to whatever establishes request-scoped tenant context before Payvia's // repositories run (Payvia never names or hardcodes host-specific aliases here). 'tenant_context_middleware' => [], // Profile 3 — authorization for the management (write) routes only. 'manage_middleware' => ['admin'], ], ];
Authenticated read/confirm routes compose profile 1 → 2; management (write) routes
compose 1 → 2 → 3. Each write route still appends its own rate_limit:N,60 after the
composed stack. Override manage_middleware to swap admin for a custom permission
middleware (e.g. ['permission:billing.manage']), or tenant_context_middleware to
plug in your tenancy resolution middleware.
Tenancy note: Payvia's repositories resolve the current tenant via a local
PayviaTenantResolver seam — consuming your app's CurrentTenantResolver (wrapped
fail-closed) when bound, or a sentinel when it isn't — so multi-workspace hosts get
per-tenant business keys (invoice numbers, plan names, payment-intent idempotency
keys) while single-store installs remain byte-identical.
Upgrading from 1.x: if your app previously overrode
payvia.security.manage_middleware, split it: move authentication entries (e.g.auth) intoauth_middlewareand leave only authorization checks inmanage_middleware. SeeCHANGELOG.mdfor the exact before/after.
Confirm and record a payment
- Endpoint:
POST /payvia/payments/confirm - Middleware:
auth,rate_limit:60,60 - Handler:
Glueful\Extensions\Payvia\Controllers\PaymentController::confirm
Request body (JSON / form / query):
reference(string, required): provider transaction reference.gateway(string, optional): gateway key fromconfig/payvia.php(payvia.gateways).
If omitted,payvia.default_gatewayis used.payable_type(string, optional): logical type of the thing being paid for
(e.g.subscription,order,invoice).payable_id(string, optional): identifier of that thing in its own domain
(e.g. subscription UUID, order ID).metadata(object, optional): app‑level metadata to store in themetadatacolumn.options(object, optional): gateway‑specific options (e.g. override verify URL).
Note: The stored
user_uuidis always derived from the authenticated session, not from the request body. It is not caller‑settable. If auser_uuidis supplied and it differs from the authenticated user's UUID, the request is rejected with422. This prevents an authenticated caller from attributing a payment to another user.
Response (200):
On success, the endpoint verifies the transaction through the configured gateway and
upserts a row in the payments table. The JSON response follows Glueful’s standard
Response::success shape and includes:
payment_statusgatewayreferenceamount(integer, minor units ofcurrency, e.g. cents)currencymessageverification(normalized gateway verification payload)
Quick cURL Example (Paystack)
API_BASE=http://localhost:8000 TOKEN="<YOUR_BEARER_TOKEN>" curl -s -X POST "$API_BASE/payvia/payments/confirm" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "reference": "PSK_tx_ref_123456", "gateway": "paystack", "payable_type": "subscription", "payable_id": "sub_plan_uuid_123", "metadata": { "source": "web_checkout", "campaign": "black_friday" } }'
Manage billing plans
Create a plan
- Endpoint:
POST /payvia/plans - Middleware:
auth,admin,rate_limit:30,60(admin-only — see Authorization) - Handler:
Glueful\Extensions\Payvia\Controllers\BillingPlanController::create
Body:
name(string, required)amount(integer, required) — minor units ofcurrency(e.g. cents;9900= $99.00)currency(string, optional, default:GHS)interval(string, optional, default:monthly)trial_days(int, optional)gateway(string, optional)gateway_product_id(string, optional)gateway_price_id(string, optional)metadata(object, optional)status(string, optional, default:active)
Example:
curl -s -X POST "$API_BASE/payvia/plans" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Pro Monthly", "amount": 9900, "currency": "USD", "interval": "monthly", "trial_days": 14, "gateway": "stripe", "gateway_product_id": "prod_123", "gateway_price_id": "price_123" }'
List plans
- Endpoint:
GET /payvia/plans - Middleware:
auth,rate_limit:60,60 - Handler:
Glueful\Extensions\Payvia\Controllers\BillingPlanController::index
Query parameters:
status– filter by plan status (active,inactive)interval– filter by billing interval (monthly,yearly,one_time, etc.)currency– filter by currency code
Example:
curl -s "$API_BASE/payvia/plans?status=active&interval=monthly" \ -H "Authorization: Bearer $TOKEN"
Manage invoices
Create an invoice
- Endpoint:
POST /payvia/invoices - Middleware:
auth,admin,rate_limit:60,60(admin-only — see Authorization) - Handler:
Glueful\Extensions\Payvia\Controllers\InvoiceController::create
Body:
amount(integer, required) — minor units ofcurrency(e.g. cents;9900= $99.00)currency(string, optional, default:GHS)user_uuid(string, optional)billing_plan_uuid(string, optional)payable_type(string, optional)payable_id(string, optional)number(string, optional; auto-generated if omitted)due_at(string, optional,Y-m-d H:i:s)metadata(object, optional)
List invoices (with JSON metadata filtering)
- Endpoint:
GET /payvia/invoices - Middleware:
auth,rate_limit:60,60 - Handler:
Glueful\Extensions\Payvia\Controllers\InvoiceController::index
Query parameters:
status–draft,pending,paid,canceled,faileduser_uuidbilling_plan_uuidpayable_typepayable_idmetadata_key– JSON key insidemetadatametadata_value– value thatmetadata_keymust contain
Example (invoices for a user with period=2025-01 in metadata):
curl -s "$API_BASE/payvia/invoices?user_uuid=$USER_UUID&metadata_key=period&metadata_value=2025-01" \ -H "Authorization: Bearer $TOKEN"
PHP Usage Examples
Payments via PaymentService
use Glueful\Extensions\Payvia\Services\PaymentService; /** @var PaymentService $payments */ $payments = container()->get(PaymentService::class); $result = $payments->confirmAndRecord( reference: 'PSK_tx_ref_123456', gatewayName: 'paystack', // or null to use default context: [ 'user_uuid' => $userUuid, 'payable_type' => 'subscription', 'payable_id' => $subscriptionId, 'metadata' => [ 'source' => 'web_checkout', 'campaign' => 'black_friday', ], ] ); if (($result['payment_status'] ?? '') === 'success') { // Start subscription, mark invoice paid, etc. }
Plans via BillingPlanService
use Glueful\Extensions\Payvia\Services\BillingPlanService; /** @var BillingPlanService $plans */ $plans = container()->get(BillingPlanService::class); // Create a plan $planUuid = $plans->create([ 'name' => 'Pro Monthly', 'description' => 'Pro plan billed monthly', 'amount' => 9900, // minor units of `currency` (cents); $99.00 = 9900 'currency' => 'USD', 'interval' => 'monthly', 'trial_days' => 14, 'gateway' => 'stripe', 'gateway_product_id' => 'prod_123', 'gateway_price_id' => 'price_123', ]); // List active monthly plans $activePlans = $plans->list([ 'status' => 'active', 'interval' => 'monthly', ]);
Invoices via InvoiceService
use Glueful\Extensions\Payvia\Services\InvoiceService; /** @var InvoiceService $invoices */ $invoices = container()->get(InvoiceService::class); // Create an invoice linked to a plan and payable entity $invoiceUuid = $invoices->create([ 'user_uuid' => $userUuid, 'billing_plan_uuid' => $planUuid, 'payable_type' => 'location_subscription', 'payable_id' => $locationUuid, 'amount' => 9900, // minor units of `currency` (cents); $99.00 = 9900 'currency' => 'USD', 'status' => 'pending', 'metadata' => [ 'period' => '2025-01', 'source' => 'subscription_renewal', ], ]); // After a successful payment, mark the invoice as paid $invoices->markPaid($invoiceUuid); // List paid invoices for a user for a given period $userInvoices = $invoices->list([ 'user_uuid' => $userUuid, 'status' => 'paid', 'metadata_contains' => [ 'key' => 'period', 'value' => '2025-01', ], ]);
Schema Notes
payable_type/payable_idform a polymorphic link to “what this payment is for”, so you can attach payments to subscriptions, orders, invoices, etc. without changing the schema.metadatais intended for lightweight, queryable app context (plan UUID, billing cycle, campaign tags).raw_payloadstores the full provider verification payload whenpayvia.features.store_raw_payloadis enabled.
Adding a New Gateway
To add another provider:
- Implement
Glueful\Extensions\Payvia\Contracts\PaymentGatewayInterface. - Register the gateway as a service in
PayviaServiceProvider::services(). - Map a driver name to the class in
GatewayManager::$drivers. - Add config under
payvia.gatewaysinconfig/payvia.php(withdriverset to your driver name).
After that, you can pass gateway: "stripe" to the confirm endpoint or set it as the default in config.