Skip to content

Credit Auto-Recharge

Auto-recharge monitors your org’s general credit balance during billable operations and triggers an off-session Stripe charge when it drops below a configured threshold — but only when the operation actually draws from general credits.

The trigger fires inside record_operation_service after the credit waterfall completes. Five conditions must all be true:

  1. auto_replenish_enabled is true
  2. auto_replenish_pack_id is set
  3. auto_replenish_in_progress is false (no concurrent dispatch)
  4. The operation drew from included_credits or purchased_credits (general pools)
  5. The new general balance (included_credits + purchased_credits) is below auto_replenish_threshold_credits

Condition 4 is the key refinement: orgs consuming only per-op-type pools (e.g. voice credits) never trigger auto-recharge, even when their general balance sits below threshold.

let op_drew_from_general = from_included > 0 || from_purchased > 0;
let should_dispatch_replenish = auto_replenish_enabled
&& auto_replenish_pack_id.is_some()
&& !effective_in_progress
&& op_drew_from_general
&& new_general_balance < auto_replenish_threshold;

The in_progress flag is set inside the existing SELECT FOR UPDATE transaction on org_budget, then the dispatcher is spawned fire-and-forget after the transaction commits.

ColumnTypeDefaultPurpose
auto_replenish_threshold_creditsinteger0Balance floor that triggers a recharge
auto_replenish_in_progressbooleanfalsePrevents concurrent dispatches
auto_replenish_in_progress_started_attimestamptznullStuck-flag recovery (10-min timeout)
auto_replenish_consecutive_failuresinteger0Auto-disables at 3 consecutive failures
ColumnTypeDefaultPurpose
statusvarchar'succeeded'pending, succeeded, or failed
failure_reasonvarcharnullSanitized Stripe error code
is_auto_replenishbooleanfalseDistinguishes auto from manual purchases

A partial index covers the auto-recharge history query:

CREATE INDEX idx_credit_pack_purchase_is_auto_replenish
ON credit_pack_purchase (organization_id, created_at DESC)
WHERE is_auto_replenish = true;

dispatch_auto_replenish_service handles the Stripe charge:

  1. Looks up the org’s Stripe customer ID and calls first_payment_method_id to get the first card on file (Stripe Billing Portal does not set a default invoice PM)
  2. Inserts a pending credit_pack_purchase row with is_auto_replenish = true
  3. Creates an off-session PaymentIntent with payment_method set explicitly and metadata including purpose: "auto_replenish" and organization_id
  4. On immediate Stripe decline: records a failed purchase, clears in_progress, increments consecutive_failures
  5. On success: the payment_intent.succeeded webhook grants credits (see below)

An outer wrapper guarantees auto_replenish_in_progress clears on any error path. If the flag is stale (older than 10 minutes), the next record_operation call clears it automatically.

Two new handlers in dispatch_stripe_event_service:

payment_intent.succeeded — when metadata contains purpose = "auto_replenish":

  • lock_exclusive the purchase row (prevents double-grant under Stripe redelivery)
  • Idempotency check on row status (not the in_progress flag)
  • Grants credits to purchased_credits
  • Clears in_progress, resets consecutive_failures to 0
  • Sends a BillingAutoRecharge success notification to org owners

payment_intent.payment_failed — same metadata gate:

  • Records failed status with sanitized failure_reason (allowlisted codes only)
  • Clears in_progress, increments consecutive_failures
  • At 3 consecutive failures: sets auto_replenish_enabled = false and notifies owners
  • Returns 500 on missing purchase row to force Stripe retry through the insert race window

Owner-only. Returns AutoReplenishStatus:

pub struct AutoReplenishStatus {
pub enabled: bool,
pub pack_id: Option<Uuid>,
pub threshold_credits: i32,
pub in_progress: bool,
pub consecutive_failures: i32,
pub has_payment_method: bool,
pub current_balance_credits: i32,
pub pack_price_cents: Option<i32>,
}

Degrades gracefully when the Stripe PM lookup fails — reports has_payment_method: false instead of returning a 500.

Owner-only. Accepts UpdateAutoReplenishInput:

pub struct UpdateAutoReplenishInput {
pub enabled: bool,
pub pack_id: Option<Uuid>,
pub threshold_credits: i32,
}

Validations: pack must exist and be active, threshold ≥ 0, payment method required when enabling. Uses lock_exclusive to prevent concurrent owner saves from racing. If enabling while balance is already below threshold, dispatches an immediate recharge inline.

Returns the fresh AutoReplenishStatus so the UI updates without a separate GET.

AutoReplenishSection renders in the billing tab for org owners only:

  • Toggle — enables/disables auto-recharge
  • Threshold input — minimum credit balance before recharge fires
  • Pack picker — which credit pack to purchase
  • In-progress indicator — shows while a charge is pending
  • Failure warning — yellow notice with consecutive failure count
  • Disabled banner — red banner with “Update payment method” CTA when auto-disabled after 3 failures
  • Recent attempts — history list of auto-recharge purchases with status
  • Confirmation dialog — when enabling while below threshold, confirms immediate recharge

The UI polls in_progress on a 3-second interval until the webhook lands, using a generation counter to prevent duplicate concurrent polls.

New BillingAutoRecharge category with a Wallet icon using the text-status-approved semantic token. Notifications without an attached entity route to /settings/billing on click.

notify_org_owners is now pub and in-app inserts are batched via insert_many (one round-trip instead of N).

FilePurpose
migration/src/m20260501_131944_credit_auto_recharge.rsSchema migration
src/mods/billing/services/dispatch_auto_replenish_service.rsStripe PaymentIntent dispatch
src/mods/billing/services/record_operation_service.rsTrigger evaluation in credit waterfall
src/mods/billing/services/dispatch_stripe_event_service.rsWebhook handlers for succeeded/failed
src/mods/billing/services/has_payment_method_service.rsStripe PM lookup
src/mods/billing/api/get_auto_replenish_status_api.rsStatus endpoint
src/mods/billing/api/update_auto_replenish_settings_api.rsSettings update endpoint
src/mods/billing/types/auto_replenish_settings_type.rsRequest/response types
src/mods/billing/components/auto_replenish_section_component.rsUI component