Payments and Gateways for Developers

TruPayments is the payment hub that Booking and TruCommerce share. A plugin uses it in one of two ways: it takes payments for its own records, or it adds a gateway that every consumer can then offer.

How the pieces fit

A consumer (Booking, TruCommerce, your plugin) owns something payable. TruPayments turns it into a Payment row, asks the chosen gateway for a payment request, and records every response in the payment history. Gateways never know which consumer they are serving, and consumers never talk to a gateway directly.

Your record (implements Payable)
   |  PaymentManager::createFromPayable($record, $method)
   v
payments row  --  PaymentManager::requestFor($payment)  -->  gateway builds a PaymentRequest
   |                                                           (redirect, inline form or offline note)
   |  customer pays; gateway calls back:
   |     GET|POST payments/{hash}/return     or     POST payments/webhook/{gateway}
   v
PaymentManager::applyResponse()  -> payment_history row, Vanilo payment events,
                                    status copied onto your record

Take payments for your own records

1. Declare the dependency

Your plugin cannot work without TruPayments, so say so in module.json. The Plugin Manager then refuses to activate your plugin until TruPayments is active.

module.json

{
    "requires": { "trupayments": "^1.0" },
    "payments": true
}

"payments": true lists your plugin in Module::paymentModules(), the same way Booking and TruCommerce are listed. It also adds your plugin to the Scope list on the payment-method form, so a site owner can create methods that only your plugin offers.

2. Make the record payable

The record you charge for must implement Vanilo\Contracts\Payable. TruCommerce uses a checkout intent for this; Booking makes the booking itself payable.

MethodReturn
getPayableId(), getPayableType()The key and the morph type of your record, so the payment can find it again
getAmount(), getCurrency()Amount as a float and an ISO 4217 currency code
getTitle(), getNumber()What the customer sees on the payment page and in history
getBillpayer()A Billpayer or null. When it has an e-mail address, TruPayments stores it on the payment so the customer finds it in their account history
getPayableRemoteId(), setPayableRemoteId()An optional id the gateway assigns
hasItems(), getItems()Line items, if you have any

Give the record a status column

After every status change PaymentManager writes a status straight onto the payable model: paid, partially_paid, failed or pending, and confirmed when the record was pending_payment and money arrived. It uses forceFill()->save(), so the column must exist and accept those strings. If your own workflow needs different states, keep them in another column and react to the payment events below.

3. Offer the available methods

Ask the manager which payment methods the customer may use. It filters by currency, by billing zone and by context, and it drops gateways that are not configured.

use Modules\TruPayments\Services\PaymentManager;

$methods = app(PaymentManager::class)->availableMethods(
    currency: 'EUR',
    billingAddress: ['country_code' => 'DE'],  // or null when you do not know it yet
    context: 'testimonials',                   // your module name, lower-cased
);

For a ready-made selector use renderAvailableMethodsList($currency, $billingAddress, $context), which returns the HTML list TruCommerce and Booking use.

4. Create the payment and send the customer to pay

php — in your checkout controller

use Modules\TruPayments\Models\PaymentMethod;
use Modules\TruPayments\Services\PaymentManager;

$manager = app(PaymentManager::class);
$method = PaymentMethod::query()->findOrFail($request->integer('payment_method_id'));

$payment = $manager->createFromPayable($order, $method, [
    'source' => 'testimonials',          // stored with the payment, handy in reports
]);

// Simplest: let TruPayments render its hosted payment page.
return redirect()->route('trupayments.pay', $payment);

If you want the payment step inside your own page, ask for the request yourself and render it. The URLs you pass are where the gateway sends the customer back:

$paymentRequest = $manager->requestFor($payment, null, [
    'returnUrl' => route('testimonials.checkout.return', $payment),
    'cancelUrl' => route('testimonials.checkout.cancel', $payment),
    'submitUrl' => route('testimonials.checkout.submit', $payment),
]);

if ($paymentRequest->willRedirect()) {
    // hosted checkout (PayPal, Mollie): the snippet is a form that leaves the site
}

return view('testimonials::checkout.pay', [
    'paymentHtml' => $paymentRequest->getHtmlSnippet(),
]);

TruCommerce's CheckoutPaymentService passes both camelCase and snake_case keys (returnUrl and return_url) because gateways read either. Do the same if you support every bundled gateway.

A request can throw GatewayNotReadyException when the gateway lost its credentials or does not support the currency. Catch it and show the message; the hosted trupayments.pay page already does.

5. React when the payment changes

Every response goes through PaymentManager::applyResponse(), which writes the history, updates the payment and fires the standard payment events. Listen to them to fulfil your order:

Event (Vanilo\Payment\Events\…)When
PaymentCreatedA payment row was created
PaymentCompletedThe full amount was received
PaymentPartiallyReceivedPart of the amount was received
PaymentDeclinedThe gateway declined the payment
PaymentTimedOutThe payment expired

Modules/Testimonials/app/Listeners/FulfilAfterPayment.php

use Vanilo\Payment\Events\PaymentCompleted;

class FulfilAfterPayment
{
    public function handle(PaymentCompleted $event): void
    {
        $payable = $event->getPayment()->getPayable();

        if (! $payable instanceof SponsoredTestimonial) {
            return;                                   // another module's payment
        }

        $payable->publish();
    }
}

Register the listener in your module's EventServiceProvider ($listen map) or with Event::listen() in boot(). Always check the payable type first: the events fire for every consumer. Refunds have their own events, Modules\TruPayments\Events\RefundCreated and RefundStatusChanged, both dispatched after the database transaction commits.

Add a new gateway

Stripe, PayPal, Braintree and Mollie are each a small plugin with no migrations. Copy that shape. A gateway plugin has four parts:

Modules/Acme/
  module.json                 "requires": {"trupayments": "^1.0"}
  config/config.php           defaults, merged into config('trupayments.gateways.acme')
  app/Providers/AcmeServiceProvider.php
  app/Gateways/Acme/AcmePaymentGateway.php
  app/Helpers/AcmeSettings.php     credentials screen, registered on AdminBooting
  resources/views/gateways/request.blade.php   (optional) the payment form
  lang/en/payments.php

The provider

Modules/Acme/app/Providers/AcmeServiceProvider.php (shape of StripeServiceProvider)

use App\Events\AdminBooting;
use Illuminate\Support\Facades\Event;
use Modules\Acme\Gateways\Acme\AcmePaymentGateway;
use Modules\Acme\Helpers\AcmeSettings;
use Modules\TruPayments\Services\GatewaySettings;
use Nwidart\Modules\Support\ModuleServiceProvider;
use Vanilo\Payment\PaymentGateways;

class AcmeServiceProvider extends ModuleServiceProvider
{
    protected string $name = 'Acme';

    protected string $nameLower = 'acme';

    public function register(): void
    {
        parent::register();

        $this->mergeConfigFrom(module_path($this->name, 'config/config.php'), 'trupayments.gateways.acme');

        $this->app->bind(AcmePaymentGateway::class, function (): AcmePaymentGateway {
            $settings = $this->app->make(GatewaySettings::class);

            return new AcmePaymentGateway(
                (string) $settings->credential('acme', 'api_key', ''),
            );
        });
    }

    public function boot(): void
    {
        parent::boot();

        $this->registerTranslations();   // namespaced override, see "The service provider"

        GatewaySettings::registerGateway('acme', [
            'label' => 'Acme Pay',
            'class' => AcmePaymentGateway::class,
            'required_credentials' => ['api_key'],
            'supported_currencies' => config('trupayments.gateways.acme.supported_currencies', []),
        ]);

        PaymentGateways::register(
            (string) app(GatewaySettings::class)->gatewayValue('acme', 'id', AcmePaymentGateway::DEFAULT_ID),
            AcmePaymentGateway::class
        );

        Event::listen(AdminBooting::class, function (): void {
            AcmeSettings::register();
        });
    }
}

GatewaySettings::registerGateway() tells TruPayments' admin screens about your gateway (label, required credentials, currencies). PaymentGateways::register() puts the class in the gateway registry that payment methods point at. Both run in boot() because the public payment pages and webhooks need them, not only the admin.

The gateway class

Implement two interfaces: Vanilo\Payment\Contracts\PaymentGateway (the payment flow) and Modules\TruPayments\Gateways\Contracts\ConfigurableGateway (readiness and presentation). The UsesGatewayPresentation trait supplies the currency helpers and lets an administrator rename the gateway or swap its icon.

MethodWhat to return
static getName(), static svgIcon()Display name and inline SVG. Wrap them with configuredGatewayName() / configuredGatewayIcon() from the trait so admin overrides apply
key()Your settings key (acme)
isConfigured(), readinessMessage(?string $currency)Whether the credentials are present, and a customer-safe reason when they are not
supportedCurrencies(), supportsCurrency()From the trait; an empty list means "any currency"
createPaymentRequest(Payment $payment, ?Address $shippingAddress, array $options)A PaymentRequest: getHtmlSnippet(), willRedirect(), getRemoteId()
processPaymentResponse(Request $request, array $options)A PaymentResponse built from the return or webhook request. $options['webhook'] is true for webhooks
isRedirect(), isOffline()Whether the customer leaves the site; whether no money moves online
paymentRequestView()Your namespaced request view (acme::gateways.request) or null
transactionHandler()A handler for refunds and captures, or null

The built-in OfflinePaymentGateway in Modules/TruPayments/app/Gateways/Offline is the shortest complete implementation; read it first, then a real one such as Modules/PayPal (redirect) or Modules/Stripe (inline).

Credentials and settings

Gateway settings follow one naming rule, which GatewaySettings relies on:

Webhooks

TruPayments already exposes POST payments/webhook/{gateway} (route trupayments.webhook). It calls your processPaymentResponse() with ['webhook' => true], finds the payment, reconciles refunds and applies the response. You do not add a route; show the URL in your settings screen as the bundled gateways do. Verify the provider's signature inside processPaymentResponse() and throw when it does not match.

Make it available

  1. Install and activateInstall the plugin, run composer dump-autoload, then activate it (TruPayments must be active).
  2. Enter credentialsOpen the gateway's settings screen and choose sandbox mode first.
  3. Create a payment methodIn Payments → Methods, add a method that uses your gateway, set its currencies, context and billing zones. Customers only ever see methods, never raw gateways.

Testing payment code