Skip to content

feat(dashboard): self-serve Stripe billing, free-credit accounting, and quota guard#141

Draft
JacobZuliani wants to merge 7 commits into
mainfrom
frontend-to-main
Draft

feat(dashboard): self-serve Stripe billing, free-credit accounting, and quota guard#141
JacobZuliani wants to merge 7 commits into
mainfrom
frontend-to-main

Conversation

@JacobZuliani

@JacobZuliani JacobZuliani commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

Why

The dashboard had no self-serve path for a customer to hand us a payment method — adding a card meant a human touching Firestore. The "Billing" tab only showed usage and had no link to the mechanism that's supposed to stop cluster creation once free credits run out. Separately, saving a cluster config above the customer's per-region machine-type quota succeeded in the UI and then failed opaquely at boot time.

This PR wires three tracks that together make the dashboard a real self-serve front door.

What changes

1. Stripe self-serve billing (main_service/endpoints/settings.py, BillingPortalSettings.tsx)

Settings gains a third tab, Billing, visible once a payment method is on file (or from the free-credits prompt). It opens Stripe Checkout (mode=setup) in a new tab to collect a card and returns to the dashboard via /billing/confirm-setup-session, which resolves the setup intent → payment method and writes the result to Firestore billing/billing (stripe_customer_id, has_payment_method, default_payment_method_id, billing_email). Subsequent updates go through Stripe's Customer Portal via /billing/portal-session[/redirect], scoped to the payment-method-update flow. /billing/webhook mirrors checkout.session.completed, setup_intent.succeeded, and payment_method.attached into the same doc. The Stripe secret is pulled from Secret Manager (stripe_secret_key_test by default, overridable via STRIPE_SECRET_KEY_SECRET_NAME; project fallback chain BURLA_BILLING_SECRETS_PROJECT_IDburla-prodPROJECT_ID).

2. Free-credit accounting and "free credits used" modal (main_service/endpoints/cluster.py, UsageSettings.tsx, UsageContext.tsx)

New monthly_usage/{YYYY-MM} Firestore collection caches each month's spend_dollars, usage_hours, credits_applied_dollars, billable_spend_dollars, and status (open for the current month, closed otherwise). _reconcile_monthly_usage_credits_from_scratch walks months chronologically and applies discount_credit_usd greedily, then flips the cluster-wide credits flag off once credits are exhausted and a payment method is on file. /v1/nodes/daily_hours now returns this summary (credits, has_payment_method, credits_usd, credits_used_usd, remaining_free_credit_usd, monthly_usage_hours, monthly_spend_dollars) alongside the daily breakdown so the dashboard can render:

  • a Usage tab (the old "Billing" tab, renamed) showing credits remaining/used and monthly spend
  • a blocking Free credits used modal that only shows when credits == true && has_payment_method == false && remaining_free_credit_usd <= 0, with a single "Add payment method" CTA that starts the Stripe setup flow

New endpoints: POST /v1/billing/reconcile-credits, GET /v1/billing/invoiceable-months, POST /v1/settings/credits/disable.

3. Quota guard on settings save (main_service/endpoints/settings.py, useSaveSettings.ts, Settings.tsx)

POST /v1/settings now reads cluster_quota/cluster_quota.{region}.machine_type_limits.{machine_type} and rejects any save that exceeds it with a structured 400:

{
  "detail": {
    "error_code": "quota_exceeded",
    "message": "Quota exceeded. Limit for a3-highgpu-8g in us-central1 is 2. ...",
    "machine_type": "a3-highgpu-8g",
    "region": "us-central1",
    "limit": 2,
    "requested": 8
  }
}

The frontend catches quota_exceeded specifically and either:

  • caps the quantity field to limit when limit >= 1 (so the user can retry with the allowed amount), or
  • reverts hardware fields (machine type, region, quantity) to the last-saved snapshot when limit == 0 (no quota at all for that config)

…and renders a "GCP Quota Limit Reached" dialog that names the configuration, region, requested count, available quota, and how to get it raised.

Smaller things rolled in

  • Machine-type labels across SettingsForm and constants.ts switch to "X vCPUs / Y GB RAM" (from "XvCPU / YG RAM").
  • _as_utc_datetime now tolerates epoch values in seconds, milliseconds, microseconds, nanoseconds, and numeric strings — the previous heuristic mis-scaled anything between 2e9 and 2e12.
  • Settings section-tabs visual refactor (sliding pill → three discrete buttons) to accommodate the new Billing tab.

Test plan

  • Fresh project, no billing/billing doc. Settings → Usage → "Free credits used" modal appears once credits hit zero; Add payment method → Stripe Checkout in test mode → return to dashboard; billing doc gains stripe_customer_id, has_payment_method: true, default_payment_method_id; cluster_config.credits flips to false.
  • With a payment method on file, Settings → Billing tab opens Stripe Customer Portal in a new tab scoped to payment-method update; updating the card updates default_payment_method_id via webhook.
  • POST /v1/settings with {machineType: "a3-highgpu-8g", gcpRegion: "us-central1", machineQuantity: 999} against a quota of {us-central1: {machine_type_limits: {"a3-highgpu-8g": 2}}} returns 400 with error_code: "quota_exceeded", limit: 2; frontend caps the quantity field to 2 and shows the dialog.
  • Same save with quota 0 for that config reverts hardware fields to the last-saved snapshot.
  • /v1/nodes/daily_hours response includes credits, has_payment_method, credits_usd, credits_used_usd, remaining_free_credit_usd, monthly_usage_hours, monthly_spend_dollars.
  • POST /v1/billing/reconcile-credits twice in a row produces the same credits_used_usd and does not double-apply credits to any month.
  • Webhook delivery for setup_intent.succeeded with no prior billing doc still writes has_payment_method: true and flips credits: false.

Made with Cursor

@JacobZuliani JacobZuliani changed the title Frontend to main feat(dashboard): self-serve Stripe billing, free-credit accounting, and quota guard Apr 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants