Skip to content

Qirava Cloud docs

Billing & metering

Qirava Cloud bills on a single pay-as-you-go plan: there are no fixed tiers, just a per-unit price for each resource a tenant consumes. The cloud.generate_invoice function turns a tenant's current resource cap into an invoice, writing both a usage sample to _cp_usage and an invoice to _cp_invoices. The invoice rows are real and durable; the metering itself is currently simulated — usage is synthesized from the tenant's cap over a nominal 24-hour period, where a production meter would instead stream live per-tenant counters. This page covers the plan model, the exact pricing formula, the two catalogs that billing writes, the role and error rules, and a faithful request/response example.

The per-unit plan#

The control plane seeds exactly one plan, with id metered, on first boot. The slider in the Console IS the plan — a customer chooses how many threads, how much memory, and how much storage they want, and is charged per unit. The prices are stored as strings in _cp_plans so that billing reads a single source of truth rather than hardcoding rates.

Plan fieldSeeded valueUnit
idmetered
labelPay-as-you-go (per unit)
price_thread_hr0.012per thread, per hour
price_mem_gb_hr0.006per GB memory, per hour
price_storage_gb_mo0.10per GB storage, per month
price_bw_gb0.02per GB bandwidth

The invoice formula#

cloud.generate_invoice computes the invoice total from the tenant's CURRENT cap, the plan rates, a fixed 24-hour period, and the replica count. Threads and memory are billed by the hour; storage is billed by the month, so the 24h slice of the monthly storage price is the rate divided by 30. The whole sum is multiplied by the number of replicas, because a cluster runs that many copies of the per-instance cap.

text
hours    = 24.0
replicas = max(tenant.replicas, 1)

total = ( threads     * price_thread_hr     * hours
        + mem_gb      * price_mem_gb_hr     * hours
        + storage_gb  * price_storage_gb_mo / 30.0 )
        * replicas

total_str = format total with 2 decimals   // e.g. "4.91"

Worked arithmetic for a tenant with 4 threads, 8 GB memory, 30 GB storage, 1 replica, at the seeded rates: threads = 4 × 0.012 × 24 = 1.152; memory = 8 × 0.006 × 24 = 1.152; storage = 30 × 0.10 / 30 = 0.10; sum = 2.404; total_str = "2.40".

What gets written: _cp_usage and _cp_invoices#

A single call to generate_invoice writes two rows. First a usage sample to _cp_usage — the data point a real meter would stream — then the invoice itself to _cp_invoices with status "due". Both ids are time-based (use-<micros> and inv-<micros>).

_cp_usage row
{ id: "use-<micros>", tenant_id, ts_us, thread_sec: threads*hours*3600*replicas, mem_gb_hours: mem_gb*hours*replicas, storage_gb, bw_gb: 0 }
_cp_invoices row
{ id: "inv-<micros>", tenant_id, period: "last 24h (simulated)", total: <total_str>, status: "due", created_us }
_cp_audit row
{ operator, action: "generate_invoice", tenant_id, node_id, result: <total_str> }
_cp_usage fieldValue for 4t/8g/30s, 1 replica
thread_sec4 * 24 * 3600 * 1 = 345600
mem_gb_hours8 * 24 * 1 = 192
storage_gb30
bw_gb0

Roles and where it runs#

generate_invoice requires the cloud admin role (rank 2) or higher; the check is deny-by-default inside the function. In the Cloud Console it is the Generate invoice form on the Billing screen (/cloud/billing), which itself is admin+. The form POSTs to /cloud/actions/generate-invoice; that handler verifies the session and CSRF token, injects the operator's role into ctx, and calls the function through the runtime — the Console can never do anything the operator's cloud role cannot.

Required field
id — the tenant id to invoice.
Required role
admin or higher (ctx.role).
Console screen
/cloud/billing (admin+) lists invoices newest-first and hosts the Generate invoice form.

Worked example: generate an invoice#

Below is the underlying function call and its real response envelope, transcribed from the source. The tenant billme has 4 threads, 8 GB memory, 30 GB storage, 1 replica. Unlike the lifecycle functions, generate_invoice returns a custom payload (invoice_id, total, ok) rather than the standard tenant-state shape.

Request

cloud.generate_invoice
ctx  = { user: "op", role: "admin" }
input = { id: "billme" }

Response

{
  "tenant_id": "billme",
  "invoice_id": "inv-1751020800000000",
  "total": "2.40",
  "simulated": "PLANNED meter: usage synthesized from cap × period; a real meter streams per-tenant counters into _cp_usage",
  "ok": true
}

Output

Console flash (PRG redirect to /cloud/billing?flash=...):
  Generated invoice for 'billme' — PLANNED meter: usage synthesized from cap × period; a real meter streams per-tenant counters into _cp_usage

(invoice_id's numeric suffix is the microsecond timestamp at call time; the value above is illustrative)

The two rows the call wrote can be read back through the planner:

Request

SELECT id, tenant_id, period, total, status FROM _cp_invoices WHERE tenant_id = "billme"

Response

[ {
    "id": "inv-1751020800000000",
    "tenant_id": "billme",
    "period": "last 24h (simulated)",
    "total": "2.40",
    "status": "due"
} ]

Simulated meter vs. real metering#

The invoice and usage rows are real and durable — they go through the same execute write funnel and planner gate as everything else in the control plane. What is simulated is the metering input: instead of measuring actual consumption, the function synthesizes a 24h period at the tenant's configured cap. A production meter would continuously stream per-tenant counters (CPU-seconds, memory GB-hours, storage GB, bandwidth GB) into _cp_usage, and invoicing would aggregate those real samples over a real billing period.

AspectToday (simulated)Planned (real)
Usage sourcecap × fixed 24h periodstreamed per-tenant counters
Period"last 24h (simulated)"real billing window
Bandwidthalways 0 in the samplemeasured bw_gb
Invoice/usage rowsreal + durablereal + durable (unchanged)
PaymentPLANNED (not collected)PLANNED (not collected)

Error conditions#

ConditionResponse codeMessage
caller role below admin / unauthenticatedAccessDeniedcloud role 'admin' or higher required
missing or empty idInvalidInputmissing required field 'id'
no such tenantInvalidInputno tenant '<id>'