> For the complete documentation index, see [llms.txt](https://docs.layeronecloud.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.layeronecloud.com/platform/billing/ledger-and-metering.md).

# Credit ledger, billing records and metering

record\_account\_credit\_for\_paid\_invoice (apps/billing/services.py).

## 4.7 The credit ledger <a href="#id-47-the-credit-ledger" id="id-47-the-credit-ledger"></a>

**Account credit for paid invoices must go through `record_account_credit_for_paid_invoice`** (`apps/billing/services.py`). At most one credit ledger entry may exist per invoice (partial unique constraint `unique_credit_ledger_entry_per_invoice`). Concurrent webhook and receipt-page paths must not double-post credit — they race on **every** deposit.

That function is also the single hook point for everything that keys off "a deposit cleared": the payment receipt, referral accrual, the tenure deposit bonus, and the LET first-deposit match. Each rides the once-per-invoice guarantee on the branch that actually created the credit row.

**Editing a client's credit means appending to the ledger, never editing it.** There is no balance column to overwrite. `adjust_account_credit` (`apps/billing/client_services.py`) posts one more entry and carries the direction in the entry type the sum already understands (`ADJUSTMENT` adds, `DEBIT` subtracts). Rewriting or deleting earlier rows would reach the same number and break everything that replays the history: the invoice/credit pairing, metering, suspension and the low-credit escalation. The balance is re-read under the account row lock, so two admins adjusting at once each record a true before/after pair. A credit that brings a suspended account back to `>= 0` runs the same recovery path billing maintenance would, immediately; a partial one that leaves the balance negative deliberately does not.

The console credit-adjustment route is **admin-only and stays off `SUPPORT_CONSOLE_ROUTES`**. It posts from a Support-readable page, so the panel is wrapped in `{% if not console_nav_is_support_staff %}` — but default deny is the check.

**Entry direction is defined once.** `BALANCE_POSITIVE_ENTRY_TYPES` / `BALANCE_NEGATIVE_ENTRY_TYPES` are what `account_credit_balance` sums, and every bulk query (`insights._balances_by_account`, the automatic-charges classifier) builds its CASE from those sets rather than restating the directions. **`REFUND` is in the negative set**: a refund returns money to the customer's card, so it removes the credit that payment bought — which is what "a negative balance can only have come from a refund, a chargeback or a staff adjustment" ([4.1](/platform/billing/model-and-catalog.md#id-41-the-billing-model-an-account-credit-wallet)) means. Nothing writes one (`record_account_debit_for_refunded_payment` posts a `DEBIT`), but `AccountLedgerEntry` is editable in the Django admin and the enum is the whole description of what a type does to a balance.

**Refund clawbacks are unique per provider refund, in the database.** `provider_refund_id` and `provider_charge_group` are columns mirrored off `metadata` by `save()`, with a partial unique constraint on the first — the same promotion `usage_idempotency_key` got, for the same reason. Read out of JSON the dedupe was a hopeful read that nothing backed, so the `except IntegrityError` around the insert could never fire, and the running-total sum behind `refunded_amount_already_clawed_back` was an unindexed scan of the whole ledger taken while deciding how much credit to take back. That sum now also runs under the **billing-account row lock**: the constraint stops one refund being clawed back twice and says nothing about two refunds against the same charge, which Stripe reports as a running total.

**No customer balance renders as `-$0.00`.** `account_credit_balance` and `insights._balances_by_account` both collapse a signed zero, because Decimal keeps the sign and a sum of four-decimal debits against equal credits lands a hair below zero on SQLite. It matters more now than it used to: with the meter floored, a drained account comes to rest exactly on zero instead of passing through it.

**Every list of `BillingEvent` goes through `.log_visible()`.** The hourly usage aggregation writes one event per floating IP and per managed-service VM per hour, so a handful of reserved IPs buries every order, payment and provisioning event under hundreds of rows a day. The rows are kept — they point at the `AccountLedgerEntry` that is the actual charge, and the event detail page still renders them — and excluded from the *feeds* by `HOURLY_USAGE_EVENT_ACTIONS` on `BillingEventQuerySet`. A new feed that calls plain `.objects` or a bare `account.billing_events` reintroduces the flood, and it looks fine in dev where nobody has reserved an IP for a week.

## 4.8 Billing records (`Subscription`) <a href="#id-48-billing-records-subscription" id="id-48-billing-records-subscription"></a>

> **A `Subscription` is a&#x20;*****billing record*****: the customer-facing billing arrangement for exactly one service, and it must be linked to that service.**

The link is the invariant everything else rests on, and it was missing for months in a way that made the whole model look vestigial. `create_provisioning_job_for_order` puts the order's record on the job (`subscription_for_order`) and `_ensure_client_visible_virtual_machine` copies it to `vm.subscription`. **Nothing later fills it in**, so a path that queues a job without it produces an unlinked server.

Unlinked, money still meters correctly — it comes from `vm.plan` — which is exactly why nothing screams. Instead:

* `billing_business_metrics` counts that server **twice**: once at its metered rate, once as a standalone record at list price;
* destroying it never closes its record (the sync is behind `if vm.subscription_id`), so records stay ACTIVE forever and inflate that figure;
* cancelling the record destroys nothing (it finds servers by `filter(subscription=...)`);
* the client's invoice list and cancel controls silently vanish.

`kind` (SERVER/ADDON) plus the nullable `virtual_machine` FK carry the addon case. **Never re-derive `kind` from `metadata`, and never infer "serverless" from whether a server links back** — that inference is the bug.

There is **no `amount` column** and **no Stripe mirror**. This platform raises PaymentIntents and SetupIntents and has never created a Stripe Billing subscription, so `customer.subscription.*` events cannot describe anything it set up: they are logged as ignored. `SubscriptionStatus` is ACTIVE/SUSPENDED/CANCELED only — `past_due` was a lie, because a declined card does not put a prepaid arrangement in arrears; the credit balance drives suspension. `LIVE_SUBSCRIPTION_STATUSES` and `CANCELABLE_SUBSCRIPTION_STATUSES` replaced seven repeated literals (a suspended arrangement still has something to cancel). `amount` was dropped because four paths wrote it with two meanings, so a customer who signed up mid-cycle on a $1.29 prorated invoice had $1.29 projected as permanent monthly revenue; price derives from plan + cadence + `hourly_rate_override`. `provider` / `provider_subscription_id` survive as historical columns with no live writer.

`vm_billing_cadence` **logs a warning** when it falls back to the order, because a silent second pricing source is how this hid. It logs rather than writing a `BillingEvent` because it runs once per server per hour.

The plan-upgrade sync stays guarded by `if vm.subscription_id`: provisioning is allowed to deploy a paid server whose record is somehow missing rather than refuse it, and an upgrade must not 500 on that server. The drift the guard used to hide is fixed by the link existing.

**Cancel at period end** is the customer's control; the single predicate `subscription_scheduled_cancellation_blocker` backs both the button and the guard, so the page cannot offer what the service refuses.

## 4.9 Runtime metering <a href="#id-49-runtime-metering" id="id-49-runtime-metering"></a>

`run_billing_maintenance` (Celery Beat every five minutes, `BILLING_MAINTENANCE_INTERVAL_SECONDS=300`) aggregates elapsed runtime into immutable `UsageRecord` rows and account-ledger debits. **Dry-run by default; `--live` writes.** A PostgreSQL advisory singleton lock wraps the complete pass, so a concurrent Celery delivery or manual command exits before creating an automation run or changing billing state (`BillingAutomationRun` records each pass).

* Hourly VPS runtime is charged from the plan's `vm-runtime` `UsageMeter` at `vm_effective_hourly_rate`. The in-progress `UsageRecord` stays mutable until its hour closes.
* Floating IP reservations are charged **while reserved**, even when unassigned.
* Managed services (the addon) accrue separately.
* Backup storage is billed hourly per purchased 10 GB on the organization's payer tenant, not per VM (`aggregate_backup_storage_usage`).
* Web hosting meters completed post-trial hours in its own five-minute task ([6.3](/platform/web-hosting/provisioning.md#id-63-provisioning-and-activation)).
* API overage and bandwidth overage are charged in the same pass ([7](/platform/client-api.md), [4.12](/platform/billing/statements-and-pools.md#id-412-bandwidth-pool)).

**Every meter stops at $0.00**, before any of the status rules below apply. `apply_credit_floor` sizes each charge against remaining credit, so runtime never takes an account into deficit — see [4.10](/platform/billing/credit-automation.md#id-410-credit-automation-the-000-floor-grace-window-suspension-termination) for the floor and the grace window it starts.

**Suspended servers are not metered either.** `_aggregate_hourly_usage` excludes SUSPENDED alongside DESTROYED and DELETING. This is a decision, not an oversight: the customer cannot reach a paused server, and metering it would keep closing $0.00 hours against a floored balance for as long as the suspension lasted, filling the ledger with rows that bill nothing and say nothing. It also contradicted the rest of the suspension code — `account_monthly_recharge_amount` counts only `SUSPENSION_CANDIDATE_VM_STATUSES`, which excludes SUSPENDED, so the auto-recharge for a fully suspended account was sized at $0 while metering carried on. The reserved disk capacity is accepted deliberately: it is bounded by `final_deletion_warning_days`, after which termination review can reclaim it. **Termination is the remedy for non-payment; an ever-deepening debit is not** — and with the floor in place it is no longer even reachable.

Skipping by status alone would not have been enough: the accrual window is derived from the last *billed* hour, so the moment a server resumed the entire paused window would fall inside one window and be charged in a single pass. `runtime_accrual_resumed_at` is written on recovery and read by `_runtime_accrual_start` as a third floor, so the paused window is genuinely not billed rather than deferred.

**Hourly classification for reporting.** An entry counts as hourly revenue only when it is a USD `USAGE` row whose `metadata["source"]` is in `initial_first_hour`, `tick_usage_aggregation`, `hourly_usage_aggregation` or `webhosting_hourly_usage`, **and** carries a non-null non-blank `metadata["effective_hourly_rate"]` — the auditable rate the writer used. The allowlist and the rate check are both intentional: a new lumpy source cannot enter a projection by accident, and a legacy row without an auditable rate stays visible without being assumed recurring. `initial_first_hour` counts as hourly because it still buys one real hour at the normal rate.

`hourly_price_from_monthly`, `as_money` and `seconds_between` (`usage_monitoring`) are the shared arithmetic; elapsed **seconds** against the month's real length, never elapsed days, because today is a partial day and a month containing a DST change is 743 or 745 hours.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.layeronecloud.com/platform/billing/ledger-and-metering.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
