> 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/credit-automation.md).

# Credit automation: grace, suspension, termination

decides it: every passive meter sizes its charge through apply\_credit\_floor, which clamps a usage debit to whatever pooled credit is left, so the balance lands on zero and stays…

## 4.10 Credit automation: the $0.00 floor, grace window, suspension, termination <a href="#id-410-credit-automation-the-000-floor-grace-window-suspension-termination" id="id-410-credit-automation-the-000-floor-grace-window-suspension-termination"></a>

**Billing stops at $0.00.** `apps/billing/credit_floor.py` is the one place that decides it: every passive meter sizes its charge through `apply_credit_floor`, which clamps a usage debit to whatever pooled credit is left, so the balance lands on zero and stays there. The metered figure is kept in the entry's `metadata["metered_amount"]` beside `waived_amount` and `credit_floor_applied`, because "what would this hour have cost" is what every rate audit asks; only the billed amount moves the balance. Floored: VM runtime (`tick_usage_aggregation`, `initial_first_hour` and the final hours), floating IPs, managed services, backup storage, web hosting, bandwidth overage, API overage. **Not** floored: customer-initiated purchases. A bandwidth block is an affordability decision the caller already made against the balance, and discounting it to whatever credit was left would sell a $40 block for $3.

**"Not floored" is not "charge anyway".** A purchase that credit cannot cover is refused, not discounted — the two are different answers and only the second is wrong. A bandwidth block coming up for **auto-renewal** is the case that proves it: nobody makes an affordability decision on the renewal date, and charging in full regardless drove a depleted balance straight past `credit_suspend_balance_threshold`, which skips the grace window ([4.10](#id-410-credit-automation-the-000-floor-grace-window-suspension-termination)). A customer an hour into a 72-hour countdown had their servers stopped on the same maintenance tick, with no email. `renew_due_bandwidth_blocks` now compares the pooled balance against the renewal quote and lets the block **lapse**, which was always the modelled alternative — the reminder three days out offers it in those words — and writes `bandwidth_block_renewal_skipped` plus a notice saying the allowance has dropped. Transfer past the pool afterwards is overage, which *is* floored. **Nothing automatic may take an account below $0.00.**

**`waived_amount` is a column, not only a metadata key.** It is mirrored off `metadata` by `AccountLedgerEntry.save()` like `source`, `resource_type` and `usage_idempotency_key`, so the write-off can be summed over a window. It is reported on Revenue as **Waived by the $0.00 floor**, deliberately outside every total on that page: it is revenue that did not happen, not revenue collected in another form. Without it the floor was invisible — a month in which half the fleet ran dry showed hourly usage falling and nothing saying why or how much service was served unpaid.

Three rules follow and all three are load-bearing:

* **The entry is still written, at $0.00 if need be.** Floating IPs, managed services and web hosting resume from their own last entry's `period_end`, and backup storage carries a sub-cent remainder on it. Skipping the row makes the next pass re-meter the whole gap and bill it the moment credit arrives — the floor leaking one tick later.
* **A waived charge carries no remainder**, and a high-water mark (`overage_charged_tb`, `overage_charged_count`, `billing_metered_through_at`, `UsageRecord.metadata["billed"]`) still advances by the full measured amount. The traffic was served and the hour closed; re-charging them on top-up bills for a window we had stopped billing.
* **Run summaries report what credit covered**, not what the rate said, because those figures are read as revenue.

**The floor is not serialized against every writer, deliberately.** Most callers are stages of `run_billing_maintenance`, which will not start while another run holds the lock, so they cannot race each other. Three are outside it: `record_initial_virtual_machine_usage` (the provisioning worker), `meter_webhosting_usage` (its own task) and `set_backup_storage_quota` (a web request). One of those overlapping a metering pass can land a few cents below zero. Accepted rather than locked: the overshoot is bounded by one writer's single period, nothing is lost or double-charged, and a small negative balance behaves exactly like $0.00 everywhere downstream — `credit_is_depleted` tests `<= 0`, and the backstop is far below. Do **not** add an account lock inside `apply_credit_floor`: it is called from inside transactions that already hold VM, allocation, usage-period and billing-account locks in several different orders.

The old model metered past zero into a receivable the credit wallet cannot collect — no invoice, no dunning, no statement — so a negative balance grew until suspension and then blocked the customer's own recovery: they had to clear the hole *and* fund the next hour before anything restarted.

**The grace window.** Reaching $0.00 starts a clock (`BillingPolicy.zero_credit_grace_hours`, default 72). During it the running servers are untouched and nothing accrues, and **new deployments are refused**. The window is time we are giving away, and a new server deepens the hole while the customer is being emailed a countdown about the last one. Adding credit ends the window immediately, which is why the refusal names that as the way out — and why `create_credit_topup_order` is deliberately not gated.

The clock is a `BillingEvent` pair (`credit_negative_balance_started` / `_recovered`, action strings kept for the rows already in the database), not a column, so it replays: a maintenance run that was down for a day cannot quietly grant another day. Two consequences worth keeping straight:

* **A $0.00 balance is not a grace window; a running clock is.** A brand-new account is at $0.00 because it never held credit, not because it ran out. `account_credit_grace_window` therefore requires an active start event, and is read-only — a page load must never begin a customer's countdown. Without that check a first-time customer is refused their first deploy with no email and nothing to recover from.
* **The trigger is `<= 0`, not `< 0`, and recovery needs a&#x20;*****positive*****&#x20;balance.** With the meter floored, a depleted account sits at exactly zero forever and never crosses into the negative; the old strict test would start the clock for nobody and suspend no one ever again.
* **The window belongs to the pool, not to the tenant the loop is visiting.** An organization is one pot of credit, so it runs dry once. `_credit_depleted_window_started_at` takes the earliest running clock across the payer's tenants. Keyed on the visiting tenant it went wrong twice over: the payer was emailed a second "72 hours remaining" for one shortfall, and a tenant that deployed into an already-dry pool got a window of its own — so adding tenants to a depleted organization extended the free runtime indefinitely.
* **An open clock always implies the account is visited.** The pass's queryset is "live resources **or** a running clock" (`_accounts_with_an_open_credit_clock`). Without the second half, a tenant whose last server was destroyed *while the pool was dry* was never visited again, so nothing wrote its recovery event and its clock stayed open forever — and since the window is the pool's earliest running clock, that one stale row made every later shortfall look already-expired. A month later a **different** tenant's servers were suspended on the spot, with no window and nothing but the "0 hours remaining" notice.
* **`_running_credit_clocks` is the only place that decides what "running" means,** because the obvious ORM spelling is a trap: `exclude(created_at__lte=Subquery(latest_recovery))` silently drops every account that has *never* recovered, since a comparison against the NULL of an empty subquery is unknown rather than true. That is exactly the population it is meant to return.
* **Every population the meters bill has to be in that queryset.** It is VPS, floating IPs, **shared hosting**, **a purchased backup pool**, or a running clock. This pass is the only thing that starts a clock, the only thing that sends the countdown, and the only caller of `_suspend_account_for_billing`, so a billable resource missing from the union is a customer who is never visited — never warned, never suspended. Shared hosting meters on its own task and is sold without a server (the LET Beta trial needs neither a card nor credit), so a hosting-only customer reached $0.00 and kept being served indefinitely; a backup pool bills hourly on the payer whether or not a server is left. With the meter floored that is no longer a growing debt somebody eventually notices, it is service given away silently and for ever, which is why the union is stated here as an invariant rather than left to the queryset.
* **The recharge is sized off the burn, not off the server list.** `account_monthly_recharge_amounts` covers servers, managed services, backup storage, floating IPs **and hosting**; `_maybe_attempt_credit_auto_recharge` gates on that figure being positive rather than on there being a VM, so a hosting-only customer with a working card is recharged instead of counted down. A recharge order with no server to borrow a plan from is plan-less, which is what an account-credit purchase looks like anyway. Gating on `amount` alone is not enough: the deficit term is non-zero at a $0.00 balance, so an account holding nothing billable would have its card charged the threshold for no reason.

`hourly_billing_eligibility` enforces the block for the checkout page and `create_order_intake` enforces it again inside the account lock, because a page is stale by the time it POSTs and the client API arrives with no page at all. The block is gated on `plan` being supplied — that is the difference between "may this deployment be funded" and "is hourly billing unlocked", and `apps.api.access` turns the latter into a 402 on *every* API call. Applying it there would take a depleted customer's read-only access away too, including the calls they would use to see the countdown and top up. The window stops new servers, not the account.

**The countdown emails are hours, not days.** One automation (`automation_key="low_credit"`, named "Credit grace countdown") with four rungs at **72 / 48 / 24 / 0 hours remaining**. `grace_hours_remaining` rounds **up** and clamps at zero, so a message sent in the window's first minute cannot quote a shorter window than the customer has, and 0 means the deadline has passed rather than an hour still in progress. Rung selection is the ordinary `select_threshold_step` rule — the lowest threshold at or above the value — which reads naturally in hours: at 50 hours left the 72 rung matches and its cooldown keeps it quiet, at 47 the 48 rung fires.

* **The notice event key is the&#x20;*****checkpoint*****, never the raw hours.** This pass runs every few minutes and the key is the flat fallback's only dedupe, so a key carrying the exact hours remaining is a new key every hour and the customer gets 72 emails instead of four. `grace_countdown_checkpoint` snaps to the checkpoint set, which a shorter window trims (a 24-hour policy counts 24 then 0, never opening on a promise of 72) and a longer one extends with its own length (96 says "96 hours remaining" at the start rather than going silent for a day and joining at 72).
* **`unreachable_step_threshold()` returns −1, not 0.** 0 is a live rung now — the notice that goes out as the servers stop. Left at 0 the editor would have labelled the one message the customer most needs as dead copy.
* **Hours because the grace window is a promise against a fixed deadline.** Days of runway is an estimate against a burn rate that moves, and rounding it is honest. A customer told "1 day" at 25 hours and again at 2 hours has been told the same thing about two very different situations.

`low_credit_warning_days` (default 5) survives as a **console signal only** — the Insights "Low runway" client lens and the manual `AudienceSegment.LOW_CREDIT` targeting. It sends no customer email. There is no pre-depletion warning ladder *about the balance* any more: with the meter floored, an account either has credit or is inside a window with a deadline.

**The one pre-depletion ladder left is about the wallet, not the balance.** `automation_key="no_payment_method"` ("No saved payment method") warns twice on the way down — **15 days of credit left, then 7** — and only ever reaches an account with no active `PaymentMethodReference` at all. `_send_no_payment_method_notices` is its maintenance step, and it is a different question from the countdown rather than a revival of the old one:

* **The trigger is the missing card, not the thin balance.** An account with a working card is *charged* when it runs low, and one with a dead card is the payment-failure notice's business; neither has anything to do about a warning. An account with no card at all is the only population where the useful message is "save one", and it is the population the grace countdown can do least for, because there is nothing to charge when the deadline arrives. `has_saved_payment_method` is deliberately wider than both `has_active_default_payment_method` and `auto_charge_is_armed`: a second active card is still a saved card, and telling that customer there is no payment method on file is simply false.
* **It stops where the countdown starts.** `credit_is_depleted` ends it, so the two ladders never narrate the same account. Above $0.00 this one asks for a card; at $0.00 the grace countdown owns a fixed deadline, and two automations disagreeing about how long is left is worse than either alone.
* **Per payer, not per tenant.** Cards live on `payment_account_for` and credit pools on the organization, so an organization's siblings share one empty wallet and one balance; warning each of them mails the same person the same thing several times over.
* **The event key is the last credit added** (`c{ledger entry id}`), not the account and not the date. Keyed on the account the two warnings would be once-ever, so a customer who paid, drifted down a year later and still had no card would hear nothing; keyed on the date they would repeat on a calendar boundary with nothing about the account changed. Keyed on the last top-up they are what they claim to be — one first warning and one second warning per funding — and adding credit is exactly the event that should re-arm them.
* **The quoted figures come from the trigger, not from the renderer.** `runway`, the balance and the daily burn are passed as `campaign_context`, which overrides what `build_merge_context` would derive a moment later; otherwise a debit landing in between puts a different number in the subject line from the one that chose the rung.
* **The sweep's ceiling widens to the campaign's own top rung** (`widest_enabled_step_threshold`). An admin who moves the first rung to 30 days in the console means it to fire at 30, and a sweep holding its own ceiling at 15 would leave that rung looking armed and sending nothing.

Which is why **"In grace window" is its own Insights lens**, ahead of "Low runway" and not folded into it. Runway is an estimate against a burn rate that moves; a window is a fixed deadline the customer has already been emailed about, and the queue is read by hours remaining. It also catches accounts runway cannot: a depleted account whose servers are all suspended burns nothing, so it has no runway at all and appeared on that page nowhere. `_grace_windows_by_account` builds it from `_running_credit_clocks` in one grouped query, takes the **pool's** earliest clock exactly as the countdown emails do — a console queue that disagreed with the email the customer received would be worse than no queue — and skips a clock whose pool has since been funded, because listing a paid-up customer as facing suspension is the one error on that page that reaches them. An account with an open window is added to the client table even when it holds no live VPS, which is the same lesson the credit pass's own queryset had to learn.

`credit_suspend_balance_threshold` (default −$10.00) is now the **immediate-suspend backstop**, not the ordinary path. Metered usage stops at $0.00, so a balance that far under did not come from the meter — a refund, a chargeback or a staff adjustment put it there, and handing that a full window would give away hours against money already taken back. `$0.00` disables it and every depleted account takes the full window; the clamp in `_effective_credit_suspend_balance_threshold` survives because a row saved before the model validator could still hold a positive number.

`BillingPolicy` (singleton) controls the ladder:

| Field                               | Default | Meaning                                                                         |
| ----------------------------------- | ------- | ------------------------------------------------------------------------------- |
| `zero_credit_grace_hours`           | 72      | Hours a $0.00 account keeps its running servers; new deploys refused throughout |
| `credit_suspend_balance_threshold`  | −10.00  | Suspend at once, skipping the window, at or below this balance ($0.00 disables) |
| `low_credit_warning_days`           | 5       | Console-only: flag an account with this many days of runway or fewer            |
| `auto_charge_threshold`             | 1.00    | Attempt a saved-method recharge below this dollar balance                       |
| `final_deletion_warning_days`       | 7       | Open a termination review this many days after suspension                       |
| `require_manual_termination_review` | True    | Staff must approve a teardown                                                   |
| `upcoming_charge_reminder_days`     | 3       | Warn before a bandwidth block auto-renews (0 disables)                          |
| `send_payment_receipts`             | True    | Email a receipt for every payment                                               |
| `abandoned_order_offer_hours`       | 72      | How long a recovery discount stays valid (0 disables discounting)               |
| `abandoned_order_max_age_days`      | 7       | Ignore unpaid orders older than this                                            |

**The saved card is tried before anything else, and no longer silences the customer.** `_record_credit_balance_events` attempts `_maybe_attempt_credit_auto_recharge` before it settles the clock, because one large debit can carry an account from healthy to depleted in a single pass and suspending a customer whose card would have covered it cannot be taken back. It used to also mean the escalation stayed quiet for a carded account, and that was right while the warnings were about a falling balance: the fall was the *mechanism* by which they got charged, not a problem they could act on, and warning them daily trains them to ignore billing email. Reaching $0.00 is a different event. The recharge has already been attempted in the same pass, so an account still at zero here has a card that declined, is blocked, or is not chargeable — and it has a deadline and a deploy block. It hears from us.

| Account state                        | What they get                                                        |
| ------------------------------------ | -------------------------------------------------------------------- |
| Funded above `auto_charge_threshold` | nothing                                                              |
| Below the threshold, credit left     | the recharge attempt only; no countdown                              |
| Auto-charge succeeds                 | the **payment receipt**, same as any other payment                   |
| Auto-charge fails                    | the payment-failed notice that day; the countdown resumes next sweep |
| At $0.00, clock running              | the countdown rung for the current checkpoint                        |
| Window closed                        | the 0-hour rung, then suspension                                     |

**Credit-purchase orders never open a billing record.** `order_is_credit_purchase` is the test, and `Order.is_credit_topup` — just `plan_id is None` — is never used for it: a credit auto-recharge order carries the customer's existing plan to pick a currency and an amount, so the bare property reads it as an ordinary provisionable order. The guard lives inside `_activate_subscription_for_paid_order` and `_activate_local_stripe_billing_record` rather than at each of the four call sites, for the reason `payment_method_is_removable` is a single predicate. Read bare at the `payment_intent.succeeded` handler, every successful recharge activated an ACTIVE MONTHLY record with no server behind it and no cancellation path; `committed_service_credit` prices every live record against the balance, so the records stacked and held back the credit the recharges had just bought — a $3.00 recharge posted $3.00 and left $0.04 spendable, and the next credit-funded order was refused. `manage.py retire_credit_purchase_subscriptions [--apply]` cancels records already written that way.

Two predicates still gate the recharge and both halves matter:

* **`auto_charge_is_armed` is deliberately stricter than `has_active_default_payment_method`.** A card marked `credit_only`, `one_time_only`, `allow_auto_recharge: false`, or belonging to a provider the recharge path will not use is one `_attempt_credit_auto_recharge` then refuses to charge. Keep it in step with the eligibility checks there.
* **`auto_charge_last_attempt_failed` compares the latest `credit_auto_charge_succeeded` against the latest `credit_auto_charge_failed`** rather than scanning a time window. No window to tune.

A successful auto-charge sends the **payment receipt** (`_queue_payment_receipt_notice` fires for `source == "credit_auto_recharge"` exactly as for a manual top-up). The old dedicated `credit_auto_charged` automation is left in place so existing installs keep the copy, but billing no longer calls it.

**The balance is re-read after&#x20;*****every*****&#x20;recharge attempt**, not only a successful one. A card out of catch-up slots or refused as unchargeable is not evidence about the balance, and a customer top-up or webhook credit may have landed while the attempt was out. Everything downstream — the clock, the countdown figure, the suspension — keys off that number, and starting a 72-hour countdown on an account that has just paid is the one mistake here that reaches them as an email. An `already_started` or `verifying` outcome skips the account entirely: the money is on its way, and both suspending the servers it is paying for and starting a countdown against it are decisions to make after it lands.

Daily recharge uses up to three catch-up slots when the per-charge cap leaves an account below its floor. A `credit_auto_charge_started` slot without a matching success or failure is **in flight, not permission to allocate the next slot**, and that check is repeated under the billing-account row lock immediately before a payment attempt is created, because Stripe is called after the transaction commits and maintenance workers may overlap.

`_suspend_account_for_billing` **re-decides from the balance inside its transaction.** A webhook credit landing between the caller's read and the row lock used to pause live servers and send the suspension email anyway, unwound only by the next recovery pass.

**Suspension is account-level, not service-level.** When automation suspends:

1. the billing account is marked suspended;
2. active subscriptions are marked suspended;
3. running/staged/provisioning/stopped VMs are marked suspended locally;
4. Proxmox suspend jobs are queued for VMs that need a provider-side stop;
5. active shared-hosting services queue an exact generation-bound node suspension job, and their observed status changes only after node confirmation;
6. the suspension event records reason, balance, policy, queued jobs and the deletion countdown.

Stopped VMs need no provider job. Already-suspended accounts are repaired by maintenance if they are still negative and missing the expected suspend job. Every run also reconciles suspended accounts against the current credit policy, so legacy invoice-past-due suspensions are cleared or refreshed into current-policy suspension events.

**A suspension stops the guest, it does not pause it.** A billing suspension issues `status/stop` and clears `onboot`; it deliberately does **not** `qm suspend`. A RAM pause is not a state a hypervisor preserves: it does not survive a node reboot, and Proxmox brings a guest with `onboot: 1` straight back up. Because a SUSPENDED VM is excluded from metering, the result was the worst combination available — a delinquent customer's server running, reachable, serving traffic and billed for none of it, while both the portal and console showed it suspended, and nothing detected it because `billing_suspension_proxmox_synced_at` had already been stamped. A paused guest is also invisible in the Proxmox UI (`status: running` with `qmpstatus: paused`).

Consequences:

* `PROXMOX_BILLING_SUSPEND_STATUSES` includes STOPPED: there is nothing to stop on an already-stopped guest, but `onboot` still has to be cleared, and a stopped guest with `onboot: 1` is the same leak one node reboot later.
* Recovery cannot decide between `resume` and `start` from the database — servers suspended before this change are still RAM-paused — so `_power_on_requests_for_live_state` reads `qmpstatus` and picks. `start` on a paused guest is rejected as "already running", which `_is_idempotent_proxmox_result` treats as success, so without the probe the recovery job finished green with the customer's server still frozen.
* Only a server that was **running** when suspended is started again (`PROXMOX_BILLING_RESTORE_RUNNING_STATUSES`); one the customer had stopped gets its `onboot` back and stays down.
* `billing_suspension_previous_onboot` is captured before `onboot` is cleared and travels on the **recovery job's `request_payload`**, not on the VM, because recovery clears every suspension marker in the same transaction that queues the job. Repeated repairs preserve the first captured value.

**The suspension lifecycle must be repeatable.** A suspension writes a set of markers onto every VM and **all of them come off again** — `cleared_billing_suspension_metadata` is the one list, used by both exits (`_reactivate_account_after_billing_recovery` and the UNSUSPEND\_VM success branch). Two are load-bearing in opposite directions:

* `billing_suspension_proxmox_synced_at` means "already paused on the hypervisor", so `_vm_needs_proxmox_billing_suspend` returns False. Left behind, the *second* time an account went delinquent everything was marked SUSPENDED locally and **zero** Proxmox suspend jobs were queued — and the repair pass skipped it for the same reason.
* `billing_suspension_reason` is what refuses the customer's own power actions. Left behind, a customer who paid up could never start their servers again.

For the same reason `_vm_has_active_or_completed_suspend_action` only counts suspend actions no older than the current `billing_suspension_requested_at`: a SUCCEEDED suspend from a previous delinquency is history, not a permanent block.

**Nothing is trusted; the hypervisor is asked.** `billing.enforce_suspended_vm_power_state` checks provisioned SUSPENDED servers and every retained VPS belonging to a SUSPENDED account every five minutes (`SUSPENDED_VM_CHECK_INTERVAL_SECONDS`). Account status takes precedence over the VM label. It rechecks status and active jobs under a row lock before queueing a repair, and **never queues a start** — recognised billing recoveries are left to the normal recovery path or the daily audit. `billing.reconcile_suspension_state` runs daily and is the only code that compares the database against live Proxmox state; every other pass reasons about what it *asked* Proxmox to do. It repairs drift in both directions (SUSPENDED here but not stopped there → re-queue the suspension; not suspended here but RAM-paused there → the customer-facing mirror, billed for a server they cannot reach; recorded power state disagrees → correct the record). The repair deliberately bypasses `_vm_needs_proxmox_billing_suspend` and `_vm_has_active_or_completed_suspend_action`, because those exist to stop duplicate work from a pass that is *guessing* and this one has read the hypervisor. **A failed read is a skip, never a repair**, and `--live` is required before anything is queued. `manage.py reconcile_suspension_state --suspended-only [--check|--live]` runs the same scoped check by hand; `--check` exits unsuccessfully for drift, unreachable guests or deferred checks, and a queued repair is **not** proof of stopped power.

**Recovery.** A suspended account returning to a non-negative balance is unsuspended: subscriptions reactivated, Proxmox unsuspend jobs queued for VMs that were running/staged/provisioning before suspension, shared hosting restoration queued (visibly suspended/pending until its node confirms the exact restoration generation). Power-increasing provisioning actions **recheck the account before execution**, so an old queued recovery cannot undo a later hold.

**Termination ends at a human, not at a timer.** The deletion window closing used to cancel every subscription and queue a DESTROY\_VM for everything the account owned, unattended, on whichever five-minute tick crossed the threshold — an unrecoverable teardown of a customer's servers and data decided by a balance and a clock. `_process_suspended_account_terminations` now opens a `TerminationReview` and stops. Staff approve or decline at `/console/billing/terminations/`, which is the only path that destroys anything (`approve_termination_review`). The sidebar carries a badge for the pending count, because a queue that waits indefinitely for a person is a queue that gets forgotten.

* One PENDING review per account (`unique_pending_termination_review`).
* A recovery **cancels** a pending review rather than deleting it: an account that came one click from termination and then paid is worth looking up.
* Approval re-runs the guard under the account row lock. The queue is not a snapshot: a review may have waited days.
* `_suspended_account_deletion_guard` re-reads before anything irreversible: a non-negative balance runs the **recovery** path instead of the deletion, so a customer who pays on deletion day gets their servers back; a `PaymentAttemptStatus.VERIFYING` attempt skips the account entirely, because the provider may already have captured the money.
* **A server without a runnable destroy job blocks the approval.** `_tear_down_account_services` asks whether each guest is actually going away, not whether the request raised: it re-reads the VM and requires a PENDING or RUNNING `DESTROY_VM` for anything still short of DESTROYED. Anything else is an unteardownable id, which rolls the whole teardown back and leaves the review PENDING with `teardown_blocked_at`. Before this, a destroy job that had reached a terminal state without removing the server was handed straight back by the fixed `destroy:vm:{id}` key -- neither runnable nor resettable -- so approval queued nothing, reported "0 destroy job(s) queued" as a success, marked the review APPROVED, and the next maintenance pass opened another review for the same still-running server. `_destroy_job_idempotency_key` now gives that guest a fresh `destroy:vm:{id}:r{n}` generation, because a spent job cannot be reused: a reset only moves FAILED/RUNNING/SKIPPED steps back to PENDING, so re-queuing one skips its whole step list and deletes nothing. Termination also reaches guests stranded in DELETING; a customer delete still does not.
* **Approval closes the account out** (`_close_out_terminated_account`), and only after every service is queued for removal. Termination ends the relationship; it is not a step in collecting a debt. Every floating and additional `IPAllocation` the account still holds is released through its normal path (the address stays RESERVED until the ipfilter removal confirms); the balance is written off to exactly $0.00 with one more append-only ledger entry (`billing_termination_write_off`); the grace clock is closed, or the ladder would restart from the $0.00 the write-off just produced and re-suspend an account with nothing left to suspend; `hourly_billing_unlocked_at` is cleared, so coming back means depositing again; and the suspension is lifted. Lifting it is not leniency -- suspension is a lever against a customer who still has servers to get back, and on an emptied account it only hides them from every active-customer view while blocking the deposit that would let them return. A blocked teardown reaches none of this: the whole approval is one transaction.
* The close-out also clears `auto_renew` on the account's bandwidth blocks. Renewal only fires on an ACTIVE account, so lifting the suspension would otherwise re-arm a monthly charge that had been dormant while the account was suspended, and put the balance it just zeroed straight back under water for capacity nothing is left to use. A **purchased backup pool** (`BillingAccountAddons.backup_storage_gb`) is deliberately left alone: it is organization-wide and stored on the payer, so one tenant's termination must not drop its siblings' storage. An account holding one keeps metering for it and will fall back through the credit ladder after the write-off.
* A review containing live shared hosting goes through `admit_service_removal` with `operator_authorized=True`, one service at a time. A `WebHostingServiceRemovalError`, or a workflow that lands in FAILED, blocks the whole approval the same way an unteardownable VM does.

**Notices.** Upcoming-charge reminders cover bandwidth blocks with `auto_renew` inside `upcoming_charge_reminder_days` (event key `billing-notice:upcoming-charge:bandwidth:{block.id}` — no date stamp, a block renews exactly once). **Subscriptions are deliberately excluded**: `Subscription.next_bill_at` is set once at activation, never advanced, and generates no invoice, so "renews on X for $Y" would be false. VPS run-rate is the low-credit escalation's job. The suspension countdown fires at 7/3/1 days remaining (editable), skips the no-`delete_at` state, day 0 and suspension day itself. "Service restored" is sent after the recovery transaction closes, with a date-stamped key to guard flapping. Payment receipts hook `record_account_credit_for_paid_invoice` on the credit-creating branch, key `billing-notice:receipt:invoice:{id}` (once per invoice ever). `_flush_pending_billing_notices` redelivers PENDING deferred rows older than ten minutes (a worker that died between commit and task).


---

# 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/credit-automation.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.
