Orders
An order is a persisted, immutable cart: one row in meteric_orders holding a frozen cart in a single contents jsonb column, plus the amounts computed when it was opened. No Subscription, Charge, or Invoice exists until the order is paid, and the frozen amounts are the source of truth, so a later catalog price change never moves a pending order's figures.
One order can hold several items: a webhosting plan and a domain registration in the same cart. Because each order is a row with a state, orders are queryable for an admin view of pending, paid, and abandoned orders.
When to use an order
Meteric::quote() prices a live cart read-only for a checkout page and persists nothing. The live "edit as you shop" cart belongs to your app. An order is the place-order moment: create() freezes that cart into a row you can pay later.
Meteric::subscribe()->…->checkout() subscribes and invoices in one call. It is separate from orders. Reach for an order when you want a pending, payable cart that holds its prices until the customer pays.
Building an order
createOrder() returns a OrderBuilder. Each add() opens a cart line; addon() and option() attach to the line most recently added. create() freezes the cart and stores a pending Order.
use Meteric\Facades\Meteric;
$order = Meteric::createOrder($customer)
->add($hosting, 1, label: 'site.example', group: 'Hosting')
->addon($backups, group: 'backups')
->option('ram', '1024', 'choice', $ramPrice, label: '1 GB RAM')
->add($domainRegister, 1, label: 'example.com', group: 'Domains')
->create();This order has two lines. The hosting line carries a backups addon and a RAM option; the domain line stands alone. An option freezes a raw value for provisioning ('1024') and a display label ('1 GB RAM') side by side.
Builder methods
account(BillingAccount $account): bill to an explicit account. Sets the currency from the account.for(Model $customer): the billable customer. Resolves or creates the customer's billing account.currency(string $currency): override the currency.anchor(AnchorMode $mode, ?int $day = null): align the billing cycle (e.g.FixedDay, 1).firstPeriod(FirstPeriodPolicy $policy): how the first partial period is billed.trialDays(int $days): trial length. A trial defers the first charge and starts the subscriptionTrialing.at(CarbonImmutable $at): price as of a fixed instant (deterministic).tax(TaxContext $context): price under an explicit tax context instead of the account's profile.expiresIn(?int $minutes): override the pending TTL. Null leaves the configured default.add(Price $price, float $qty = 1, ?Model $resource = null, ?string $label = null, ?string $group = null): open a cart line.resourcelinks the line to a host model;grouptags it for grouped display.addon(Price $price, ?string $group = null, float $qty = 1): attach an addon to the current line.bookAddon(ProductAddon $addon, float $qty = 1): attach a catalog addon to the current line, priced on that line's term.option(string $key, string $value, string $type, ?Price $price = null, float $qty = 1, ?float $min = null, ?float $max = null, ?string $label = null): attach a configurable option to the current line.chooseOption(ProductOptionValue $value, float $qty = 1): attach a catalog option value to the current line.quote(): Quote: price the cart ascreate()would and return the quote. Persists nothing.create(): Order: freeze the cart, store a pending order, fireOrderCreated.
create() and quote() throw if the cart is empty; create() also throws if the priced total is negative.
From the catalog
bookAddon() and chooseOption() build the cart from what the product declares, so the order carries exactly what the catalog offered.
$term = $vps->priceFor('EUR', PricePurpose::Recurring, Interval::Year);
$order = Meteric::createOrder($customer)
->add($term, 1, label: 'web1')
->bookAddon($backupsAddon) // ProductAddon: price on the yearly term, group key, bounds
->chooseOption($ipv4Value, qty: 4) // ProductOptionValue: key, type, price, bounds, label
->create();bookAddon() throws InvalidArgumentException when the addon is not offered with the line's product, has no price on the line's term, or the quantity is outside min_qty / max_qty. chooseOption() checks the option's bounds the same way. Both refuse a row withdrawn from sale (active false on the addon, the value or its option) with Meteric\Exceptions\CatalogRowInactive. See Addon catalog and Catalog options.
Quoting the cart
quote() runs the same pricer as create() on the same cart and returns the Quote without writing a row, so a checkout page renders exactly the figures the order will freeze. Tax comes from tax(), else the customer's existing billing account; a customer with no account yet is quoted untaxed and none is created.
$quote = Meteric::createOrder($customer)
->add($term)
->bookAddon($backupsAddon)
->quote();
$quote->dueNowTotal; // Money, gross
$quote->setupTotal(); // Money, the one-time setup fees inside due now
$quote->toArray(); // the same shape create() stores in quote_snapshotSetup fees
A setup_fee_minor on the base price, or on an option's price, is frozen with the order (setup_minor on the line and on the option) and charged once as a setup line when the order is paid. The quote lists it as its own line of kind setup and totals it under due_now.setup_minor. Renewals bill the recurring amounts only. Addon prices do not carry a setup fee through checkout.
The order row
$order->total(); // Money: gross owed at checkout (subtotal + tax)
$order->total_minor; // int: same figure in minor units
$order->state; // OrderState
$order->contents; // the frozen cart (array of line entries)
$order->isPending();
$order->isConverted();Order maps to the meteric_orders table. State runs through OrderState:
Pending: open, payable.Converted: paid or confirmed, materialized into a subscription and invoice.Expired: passed its TTL before payment.Canceled: abandoned.
Only Pending is non-terminal. The other three are settled and immutable.
Paying an order
payOrder() verifies the amount against the frozen total, then materializes everything in one transaction: a Subscription with its items, addons, and options, and a Paid invoice built from the frozen amounts.
use Meteric\Facades\Meteric;
$order = Meteric::payOrder($order, $order->total(), ref: 'stripe_pi_123');The amount must equal the order's gross total in the order's currency, or payOrder() throws InvalidArgumentException. Paying an order that has already converted returns it unchanged, so a retried payment never double-bills. A canceled or expired order is rejected.
On success it fires OrderPaid (with the invoice and payment) and SubscriptionStarted (with the order, subscription, and invoice). Hook SubscriptionStarted to provision the service:
use Meteric\Events\SubscriptionStarted;
class ProvisionOnStart
{
public function handle(SubscriptionStarted $event): void
{
foreach ($event->subscription->items as $item) {
// $item->resource, $item->options -> provision
}
}
}One subscription per line
payOrder() and confirmOrder() build one subscription for the whole cart. A host that wants one subscription per line, so a renewal or a cancellation acts on a single service, materializes the lines itself and records the conversion on its own:
$sub = Subscription::create([...]); // the host's own row, same currency as the order
$item = Meteric::materializeLine($order, 'hosting', $sub, resource: $service);materializeLine(Order $order, string $group, Subscription $subscription, ?Model $resource = null, ?CarbonImmutable $at = null): SubscriptionItem takes the frozen line whose group matches and creates its item, its Addon rows and its ItemOption rows on the given subscription, then accrues that line's frozen charges: the first period, the base setup, each addon, each option and each option setup. The money is the frozen money; nothing is repriced. The service window is recomputed at $at (default now) under the order's anchor and first-period settings, and the subscription's current_period is set or shortened to end with it. $resource wins over the resource frozen on the line. Nothing else about the order moves: its state, subscription_id and invoice_id stay for the host to write.
It is idempotent on the group: a line already on the subscription is returned unchanged. It throws InvalidArgumentException for an unknown group or a currency mismatch, and LogicException for a canceled or expired order.
What renews exactly, and what does not
An item renews through SubscriptionItem::periodAmount(), which is Price::amountFor(qty) and nothing more. Relative pricing, included_qty, block_size and cap_minor are applied only by amountOfBase() and amountForQuantity(), which addons and options bill through. So:
- A line's addons and options renew exactly, because they become
AddonandItemOptionrows on the item and the accruer prices them with the full engine. - A base line whose price is relative, or has an allowance, block size or cap, would renew at the wrong figure as an item.
materializeLine()refuses it withMeteric\Exceptions\LineNotMaterializablerather than approximating. Book such a price as an addon of the line it belongs to instead.
Completing an order by other means
A basket does not always become a new subscription. Staff review a plan change order and apply it through changePlan() on the subscription the customer already has, say. Close the order without materializing anything:
$order = Meteric::completeOrder($order, $subscription, ['applied_by' => $staffId]);completeOrder(Order $order, ?Subscription $subscription = null, array $meta = [], ?CarbonImmutable $at = null): Order moves a pending order to Converted, stamps converted_at and, when given, subscription_id, merges $meta into the order's metadata, and fires OrderConverted. It creates no subscription, charge or invoice, and throws LogicException for an order that is not pending.
Zero-total orders
A fully trialed signup owes nothing now. Confirm it without a payment:
Meteric::confirmOrder($order);confirmOrder() materializes the subscription the same way, with no payment recorded. It throws if the order is not pending.
Canceling and expiry
Cancel an abandoned order:
Meteric::cancelOrder($order);This is a no-op once the order is terminal, and fires OrderCanceled.
Stale pending orders expire on their own. create() stamps expires_at from the order TTL (config('meteric.order.ttl_minutes'), default 1440, one day). The meteric:run tick expires every pending order past its expires_at, sets state Expired, and fires OrderExpired. To expire on demand outside the tick:
$count = Meteric::expireOrders();Events
| Event | When | Payload |
|---|---|---|
OrderCreated | create() stores a pending order | Order |
OrderPaid | an order is paid or confirmed | Order, ?Invoice, ?Payment |
OrderConverted | an order reaches converted, through payment, confirmation or completeOrder() | Order, ?Subscription |
SubscriptionStarted | an order materializes its subscription | Order, Subscription, ?Invoice |
OrderCanceled | a pending order is canceled | Order |
OrderExpired | a pending order passes its TTL | Order |