Billing Lifecycle

How carts, payments, handlers, orders and subscriptions connect, and how to build your own billable flows.

Checkout to provisioned order

CartActions::addPackageToCart()          CartItem (cartable = PackagePrice, options)
          │
CartActions::checkoutAsClient()          validates stock, client limits, license limits
          │                              registers guest users
          │                              snapshots items → CartOrderItem (immutable)
          ▼
Payment::createWithTax([...])            status=unpaid, handler=CartCompletedHandler
          │                              data: cart_id, order_items, tax_details
          ▼
GET /payments/pay/{gateway}/{token}  →  $payment->payWith($gatewayConfigId)
          │                              converts currency, calls Gateway::pay()
          ▼
Gateway webhook / callback  →  $payment->completed($txId, $data)
          │
          ├─ CartCompletedHandler::onPaymentCompleted()
          │     └─ per item × quantity: PackagePurchasedHandler::handle()
          │           ├─ Order::create(status=pending, daily cycle_price, due_date)
          │           ├─ OrderPrice lines for config options
          │           ├─ decrement package global_quantity
          │           └─ $order->createServer()  → queued OrderCreateServer
          ├─ PaymentCompleted event
          └─ "payment.paid" email
                                         OrderCreateServer: pending → processing
                                         → ServerExtension::create() → active
                                         → OrderActivated event

A payment with a zero total is completed immediately in payWith(), without calling a gateway.

Payment handlers

A handler is any class with an onPaymentCompleted(Payment $payment) method. Its class name is stored on the payment and instantiated when the payment completes. This decouples the gateway from what is being bought.

Building a custom billable flow

Say a module sells one-off add-ons, such as an extra backup slot for an order:

use App\Models\Payment;

// 1. Create the payment (with tax, if enabled)
$payment = Payment::createWithTax(
    attributes: [
        'user_id' => $user->id,
        'description' => "Extra backup slot for order #{$order->id}",
        'payable_type' => Order::class,      // optional morph target
        'payable_id' => $order->id,
        'currency' => baseCurrency(),
        'handler' => \Extensions\Modules\Backups\Handlers\BackupSlotPurchased::class,
        'data' => ['order_id' => $order->id, 'slots' => 1],
    ],
    subtotal: 2.50,
    country: $user->address?->country ?? 'US',
);

// 2. Send the customer to the payment page, where they pick a gateway
return redirect()->route('payments.view', $payment->token);
namespace Extensions\Modules\Backups\Handlers;

use App\Models\Payment;

class BackupSlotPurchased
{
    public function onPaymentCompleted(Payment $payment): void
    {
        $orderId = $payment->data('order_id');
        // grant the slot…
    }
}

Your handler runs exactly once, whichever gateway the customer used, including when an admin marks the payment paid manually.

Use Payment::create() instead of createWithTax() when the charge shouldn't be taxed, for example balance top-ups or renewals of already-taxed services.

Renewals

  • Client renewal: Payment::actions()->renewalPaymentForClient(['order_id', 'renewal_days']) creates a payment of renewal_days × $order->daily_price with OrderRenewalHandler. On completion, Order::actions()->renewOrderAsClient() extends the due date and fires OrderRenewed.
  • Balance auto-renewal: the daily cronjobs:orders:renew-balance-renewals command calls $order->attemptBalanceRenewal(). It deducts the balance, extends the due date and records a paid payment on the Balance gateway.
  • Subscriptions: see below.

Order price model

FieldMeaning
orders.cycle_priceThe base price as a daily rate (price / period_in_days), or the full price for one-time orders
orders.period_in_daysThe billing cycle. 0 means one-time
order_pricesExtra lines (config options) with their own daily cycle_price, key and value
$order->daily_priceSum of active daily rates
$order->pricedaily_price × period_in_days (recurring), or the total including setup fee (one-time)

Subscriptions

OrdersController@subscribe  →  OrderActions::createSubscriptionAsClient()
       → Subscription (handler = OrderSubscriptionHandler, subscribable = Order)
       → GET /payments/subscribe/{gateway}/{token} → SubscriptionGateway::subscribe()

provider events → $subscription->activated() / updateNextBillingDate() / inactive() / cancelled()
       → OrderSubscriptionHandler keeps orders.due_date = subscription.next_billing_at

When a subscription activates, the order's remaining prepaid days are stored on order_subscriptions.remaining_days and credited back if the subscription later becomes inactive.

Expiry

The scheduler's cronjobs:orders:suspend-expired and cronjobs:orders:terminate-expired commands find recurring orders past due_date + suspend_period (or terminate_period) days, and call $order->suspendServer() / $order->terminateServer(). Both log the action on the order, write an AppTaskLog, and email a batch summary to the primary admin.

On this page