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 eventA 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 ofrenewal_days × $order->daily_pricewithOrderRenewalHandler. On completion,Order::actions()->renewOrderAsClient()extends the due date and firesOrderRenewed. - Balance auto-renewal: the daily
cronjobs:orders:renew-balance-renewalscommand 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
| Field | Meaning |
|---|---|
orders.cycle_price | The base price as a daily rate (price / period_in_days), or the full price for one-time orders |
orders.period_in_days | The billing cycle. 0 means one-time |
order_prices | Extra lines (config options) with their own daily cycle_price, key and value |
$order->daily_price | Sum of active daily rates |
$order->price | daily_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_atWhen 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.