Luau pricing context

When a plan defines pricing = function(ctx) ... end, the engine calls it during POST /invoices/generate. This page documents ctx fields, helpers, and examples. Sandbox limits: Pricing.

When pricing(ctx) runs

The engine walks each subscription billing period that overlaps the invoice window. For plans with a Luau pricing hook it calls pricing(ctx) once per such period. Returned lines fully replace built-in base fee and usage math for that period. Adjustments and refunds are appended separately, outside Luau.

Use prorate(ctx.monthly_price, ctx.window_ratio) for the base fee — it handles mid-month upgrades, pauses, and partial invoice windows automatically.

Examples

Base fee + included overage

Classic SaaS: monthly platform fee plus metered API calls above the included allowance. With 2500 requests and 1000 included, the overage line is 1500 × 2 = 3000 on top of the prorated base fee.

pro-overage.luau
01 return plan("pro", "Pro", {
02 price = 5000,
03 metrics = list(
04 metric("api_requests", "API Requests", "request", { included = 1000 }),
05 ),
06 pricing = function(ctx)
07 local over = math.max(usage(ctx, "api_requests") - included(ctx, "api_requests"), 0)
08 return lines(
09 line("Base fee", prorate(ctx.monthly_price, ctx.window_ratio)),
10 over > 0 and line("API overage", over * 2, { qty = over, unit = 2, kind = "usage" }) or nil
11 )
12 end,
13 })

Volume discount on overage

After included units are consumed, the first chunk of overage bills at a higher rate and the rest at a volume price. At 2500 requests with 1000 included: 1000 overage at 3 + 500 at 1 → usage lines total 3500, plus base fee.

growth-volume.luau
01 return plan("growth", "Growth", {
02 price = 5000,
03 metrics = list(
04 metric("api_requests", "API Requests", "request", { included = 1000 }),
05 ),
06 pricing = function(ctx)
07 local over = math.max(usage(ctx, "api_requests") - included(ctx, "api_requests"), 0)
08 -- first 1000 overage units at 3, remainder at 1
09 local first = math.min(over, 1000)
10 local rest = math.max(over - first, 0)
11
12 return lines(
13 line("Base fee", prorate(ctx.monthly_price, ctx.window_ratio)),
14 first > 0 and line("API requests (standard)", first * 3, { qty = first, unit = 3 }) or nil,
15 rest > 0 and line("API requests (volume)", rest, { qty = rest, unit = 1 }) or nil
16 )
17 end,
18 })

Multiple meters at different rates

An LLM API meters input and output tokens separately. Each metric gets its own invoice line with quantity and unit price — no need to merge them in your app.

llm-api.luau
01 return plan("llm_api", "LLM API", {
02 price = 2000,
03 metrics = list(
04 metric("tokens_input", "Input tokens", "token"),
05 metric("tokens_output", "Output tokens", "token"),
06 ),
07 pricing = function(ctx)
08 local input = usage(ctx, "tokens_input")
09 local output = usage(ctx, "tokens_output")
10 return lines(
11 line("Platform fee", prorate(ctx.monthly_price, ctx.window_ratio)),
12 input > 0 and line("Input tokens", input * 2, { qty = input, unit = 2 }) or nil,
13 output > 0 and line("Output tokens", output * 6, { qty = output, unit = 6 }) or nil
14 )
15 end,
16 })

Billing up front: advance(ctx)

A plan can also define advance = function(ctx) ... end: lines billed up front for a period. They go on the invoice whose window contains the period's start (window start exclusive, window end inclusive), so:

  • the invoice for a closed period [P.start, P.end] carries the arrears of P (from pricing) and the advance of the next period, which starts at P.end; the advance of P itself is never repeated;
  • the first period is billed up front by a window that ends at the subscription's start, e.g. [start − 1s, start];
  • a subscription canceled at period end has no next period, so no advance.

ctx is the same as for pricing with the window collapsed to the period's start: usage and included are empty, latest holds the readings at period_start. Seats paid ahead, with seats added during the month billed at its end:

seats-ahead.luau
01 return plan("seats_ahead", "Seats Ahead", {
02 price = 0,
03 metrics = list(
04 metric("seats", "Seats", "seat", { included = 0, aggregation = "max" }),
05 ),
06 -- this period's seats, paid at its start
07 advance = function(ctx)
08 local seats = latest(ctx, "seats")
09 return lines(seats > 0 and line("Seats", seats * 1000, { qty = seats, unit = 1000 }) or nil)
10 end,
11 -- at its end: seats added during the period
12 pricing = function(ctx)
13 local added = math.max(usage(ctx, "seats") - latest(ctx, "seats"), 0)
14 return lines(added > 0 and line("Seats added", added * 1000, { qty = added, unit = 1000 }) or nil)
15 end,
16 })

Fields

ctx is a Luau table. Timestamps are RFC3339 strings; money-like values are integers in abstract billing units.

FieldTypeDescription
plan_codestringPlan code for this billing segment
monthly_priceintegerFull-period base price from the plan (not prorated — use prorate with window_ratio)
period_startRFC3339Start of the subscription billing period (inclusive)
period_endRFC3339End of the subscription billing period (exclusive)
window_startRFC3339max(period_start, invoice.period_start) — start of the overlap (inclusive)
window_endRFC3339min(period_end, invoice.period_end) — end of the overlap (exclusive)
window_rationumber(window_end − window_start) / (period_end − period_start) — fraction of the billing period inside the invoice window (0..1)
usagetablemetric_code → integer — sum of usage in [window_start, window_end)
includedtablemetric_code → integer — plan included_units scaled by window_ratio (rounded)
latesttablemetric_code → integer — the last reported quantity at or before period_start (a gauge reading, e.g. the seat count the period starts with)

Example ctx snapshot

Full-month invoice, monthly_price = 5000, metric api_requests with included_units = 1000, 2500 units recorded:

json
{
  "plan_code": "pro",
  "monthly_price": 5000,
  "period_start": "2026-07-01T00:00:00Z",
  "period_end": "2026-08-01T00:00:00Z",
  "window_start": "2026-07-01T00:00:00Z",
  "window_end": "2026-08-01T00:00:00Z",
  "window_ratio": 1.0,
  "usage": { "api_requests": 2500 },
  "included": { "api_requests": 1000 },
  "latest": { "api_requests": 0 }
}

Metric tables

  • Keys are metric_code strings (e.g. api_requests), not internal metric_id
  • Only metrics bound to the plan appear in usage and included
  • If the same metric is on several subscriptions for one user, only the first subscription (deterministic order) includes it — same rule as built-in invoice math
  • Missing keys read as 0

Not in ctx

  • user_id, subscription_id
  • Tier tables, feature flags
  • Adjustments and refunds (separate invoice lines)

Helpers

Available inside pricing(ctx) and advance(ctx) during invoice generation:

FunctionReturnsDescription
usage(ctx, metric_code)integerctx.usage[metric_code] or 0
included(ctx, metric_code)integerctx.included[metric_code] or 0
latest(ctx, metric_code)integerctx.latest[metric_code] or 0
prorate(amount, ratio)integerround(amount × ratio) — typically prorate(ctx.monthly_price, ctx.window_ratio)
lines(...) / line(desc, amount, opts)arrayBuild invoice lines; nil entries are skipped

Return value

pricing(ctx) and advance(ctx) must return an array of line tables from lines(...) / line(...). Each line requires description and amount (integer). Optional: quantity, unit_amount, type. Max 100 lines; 250ms wall timeout; runtime errors fail invoice generation.