Files
pad/internal/billing/cloud_client.go
T
xarmian 8e067c19db feat(admin): add /api/v1/admin/billing-stats proxy + cloud client (TASK-827) (#266)
* feat(admin): add /api/v1/admin/billing-stats proxy + cloud client (TASK-827)

New admin endpoint that powers the upcoming Pad Cloud Billing dashboard:
GET /api/v1/admin/billing-stats merges Stripe-derived metrics from pad-cloud
(active subs, MRR, ARR, churn, 30-day cancellations) with locally-computed
aggregates from the users table (customers_by_plan, new_signups_30d in the
last 30 days for plan='pro').

Architecture (PLAN-825 Option B):
- pad-cloud (TASK-826, already merged) hosts the Stripe API access in one
  place; this PR adds the reverse pad → pad-cloud client method.
- Existing internal/billing.CloudClient gains GetBillingMetrics(): GET on
  /admin/metrics/billing with the X-Cloud-Secret header (the same secret
  pad-cloud already validates inbound calls with).
- New CloudSidecar.GetBillingMetrics() interface method keeps the server
  package free of HTTP/Stripe dependencies and lets tests inject fakes.
- Existing fakeSidecar in handlers_account_test.go grows a no-op stub so
  the account-delete tests still satisfy the extended interface.

Degradation contract:
- The endpoint always returns 200. Two booleans tell the UI which fallback
  to render: cloud_unreachable=true (sidecar errored or unwired) and
  stripe_configured=false (sidecar reachable but no STRIPE_SECRET_KEY yet).
- requireCloudMode + requireAdmin gate the route. Self-host gets 404,
  non-admin gets 403.

Web glue:
- Added AdminBillingStats type to web/src/lib/types/index.ts.
- Added api.admin.getBillingStats() to web/src/lib/api/client.ts.
The Billing tab and metric cards land in TASK-828.

Tests:
- Billing package: GetBillingMetrics happy path (verifies method, path,
  X-Cloud-Secret header, Accept header), Stripe-not-configured pass-through,
  non-200 → SidecarError, transport error stays bare, malformed JSON,
  nil/unconfigured client guards.
- Server package: self-host 404, non-admin 403, admin happy path
  (merges local + remote correctly, handles plan="" → "free", filters
  new_signups_30d to plan='pro' AND created_at >30d ago), no-sidecar
  degrades to local-only, transport error degrades, sidecar 5xx degrades,
  stripe_configured=false propagates verbatim with cloud_unreachable=false.

Part of PLAN-825 (Pad Cloud Admin Billing Dashboard).

* fix(admin): address Codex review (round 1) on billing-stats proxy

- Replace handler-side ListUsers walk with store.CountBillingAggregates
  (two scalar SQL queries: COUNT(*) GROUP BY plan + a single COUNT(*)
  for new pro signups). Removes the per-row TOTP decrypt overhead that
  ListUsers performs and bounds CPU/bandwidth as the user table grows.
- Fix misleading TS comment on AdminBillingStats: clarify that "fully
  healthy" requires cloud_unreachable=false AND stripe_configured=true,
  not "both flags false" as previously stated.

Adds TestCountBillingAggregates exercising empty store, mixed plans,
empty-plan → "free" bucketing, and the 30-day cutoff filter for new
pro signups.

* fix(store): GROUP BY normalised plan expression in CountBillingAggregates

Codex round 2 caught a real bug: SELECT projected the COALESCE'd plan but
GROUP BY operated on the raw `plan` column, so users with plan='' and
plan='free' produced two distinct result rows that both scanned as "free"
in Go — the second iteration overwrote the first in CustomersByPlan,
silently underreporting the free-tier count.

Fix: GROUP BY COALESCE(NULLIF(plan, ''), 'free') so the grouping matches
the projection. Test updated: insertWithPlanAndDate now seeds an explicit
'' plan alongside two explicit 'free' rows and asserts the aggregate
rolls them up to 3 — the previous test only used CreateUser which always
inserts the column default ('free') and never exercised the empty-string
path.
2026-04-27 14:42:53 -04:00

248 lines
9.8 KiB
Go

// Package billing holds the reverse pad → pad-cloud client used to cascade
// billing operations (e.g. Stripe subscription cancel) from the pad binary
// out to the pad-cloud sidecar during account deletion. Kept in its own
// package so the server package stays free of Stripe / HTTP-client
// dependencies, and so tests can inject a fake via the server.CloudSidecar
// interface.
package billing
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// defaultTimeout bounds each request. 15s is deliberately longer than the
// pad-cloud 10s Stripe client timeout so a genuine upstream cancel that
// gets close to its own cap still returns to pad instead of being cut off
// locally — but short enough that a wedged sidecar doesn't block the
// account-delete handler (which also holds an open HTTP request to the
// end user) for more than a few seconds.
const defaultTimeout = 15 * time.Second
// maxResponseBody caps the size of the response body we will read when
// decoding pad-cloud's error shape. pad-cloud's cancel-customer endpoint
// returns tiny JSON objects; anything larger is almost certainly a misrouted
// HTML error page from a proxy, and we don't want to allocate MB of it.
const maxResponseBody = 64 * 1024
// ResolveOutboundSecret picks the secret to send on pad → pad-cloud calls
// given the inbound rotation list and an optional explicit override. Pulled
// out of main.go so the rotation-sensitive logic can be exercised directly
// by unit tests.
//
// - explicit takes precedence when non-empty: operators pin this to the
// exact value pad-cloud is currently validating against.
// - Otherwise, scan the comma-separated inboundList from RIGHT to LEFT
// and return the first non-empty entry. The older value is on the
// right in the conventional "new,old" layout — so during a rotation
// where pad-cloud is still running "old", pad's outbound call still
// matches.
// - Returns "" when neither source supplies a usable value. Callers
// should treat that as a hard startup error — a misconfigured sidecar
// URL is worse than no sidecar (every delete would 500 instead of
// silently skipping cancel).
func ResolveOutboundSecret(explicit, inboundList string) string {
if s := strings.TrimSpace(explicit); s != "" {
return s
}
parts := strings.Split(inboundList, ",")
for i := len(parts) - 1; i >= 0; i-- {
if s := strings.TrimSpace(parts[i]); s != "" {
return s
}
}
return ""
}
// CloudClient calls the pad-cloud sidecar over HTTP. Stateless — safe to
// share across goroutines; the underlying http.Client has its own pool.
type CloudClient struct {
baseURL string
cloudSecret string
http *http.Client
}
// NewCloudClient constructs a client pointed at the given pad-cloud base URL
// (e.g. "http://pad-cloud:7778") authenticated with cloudSecret. Both values
// must be non-empty — callers that can't provide them should skip wiring
// the client into the server rather than passing blanks, which would turn
// into silent 403s at request time.
func NewCloudClient(baseURL, cloudSecret string) *CloudClient {
return &CloudClient{
baseURL: strings.TrimRight(baseURL, "/"),
cloudSecret: cloudSecret,
http: &http.Client{
Timeout: defaultTimeout,
},
}
}
// SidecarError carries the structured details pad-cloud returns on a non-2xx
// response. Exposed so callers that want to log the status separately from
// the error message can extract it via errors.As. We deliberately do NOT
// expose a "this is a retryable/ignorable error" helper — pad-cloud
// normalizes Stripe's "already gone" cases to 200 internally, so every
// non-2xx we see is a real failure (ops misconfig, upstream breakage) and
// callers should treat the whole class uniformly as "abort".
type SidecarError struct {
// Status is the HTTP status pad-cloud returned (e.g. 400, 403, 500).
Status int
// Body is the raw response body for log diagnostics only. Do not surface
// this to end users — the sidecar's errors are internal and may leak
// infra detail.
Body string
}
func (e *SidecarError) Error() string {
return fmt.Sprintf("pad-cloud sidecar returned %d: %s", e.Status, e.Body)
}
// BillingMetricsResponse mirrors the JSON pad-cloud returns from
// GET /admin/metrics/billing. All numeric fields are zero when
// StripeConfigured is false (pad-cloud has no STRIPE_SECRET_KEY yet);
// callers should render a "Stripe not configured" placeholder rather than
// treating that as an error.
//
// Currency is lowercase ISO 4217 (Stripe's convention) and reports the
// dominant currency across active subscriptions when the Stripe account
// holds multiple currencies. ComputedAt is the wall-clock time of the
// pad-cloud cache entry; CacheAgeSeconds is its age at response time.
type BillingMetricsResponse struct {
StripeConfigured bool `json:"stripe_configured"`
ActiveSubscriptions int `json:"active_subscriptions"`
MRRCents int64 `json:"mrr_cents"`
ARRCents int64 `json:"arr_cents"`
Currency string `json:"currency"`
ChurnRate30d float64 `json:"churn_rate_30d"`
Cancelled30d int `json:"cancelled_30d"`
ComputedAt time.Time `json:"computed_at"`
CacheAgeSeconds int64 `json:"cache_age_seconds"`
}
// GetBillingMetrics asks pad-cloud for an aggregated Stripe-derived snapshot
// (active subs, MRR, ARR, churn, cancellations). Auth is the same shared
// CloudSecret pad-cloud uses for inbound calls — we send it via the
// X-Cloud-Secret header rather than a body field because this endpoint is
// a GET.
//
// Request: GET {baseURL}/admin/metrics/billing
// Header: X-Cloud-Secret: <CloudSecret>
// 200 OK: BillingMetricsResponse JSON
//
// Returns the decoded response on 200. On any non-200, returns a
// *SidecarError so the caller can branch on Status (typically 403 for an
// auth gate failure or 500 if the upstream Stripe call broke). On
// transport failure (DNS, connect, timeout) returns a bare error — the
// admin handler treats both as "degrade to local-only" and surfaces the
// distinction via the cloud_unreachable flag in its own response.
func (c *CloudClient) GetBillingMetrics() (*BillingMetricsResponse, error) {
if c == nil {
return nil, errors.New("billing: GetBillingMetrics called on nil CloudClient")
}
if c.baseURL == "" || c.cloudSecret == "" {
return nil, errors.New("billing: CloudClient is not configured (missing baseURL or cloudSecret)")
}
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/admin/metrics/billing", nil)
if err != nil {
return nil, fmt.Errorf("billing: build billing-metrics request: %w", err)
}
req.Header.Set("X-Cloud-Secret", c.cloudSecret)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("billing: billing-metrics request failed: %w", err)
}
defer resp.Body.Close()
bodyBytes, readErr := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
if resp.StatusCode != http.StatusOK {
return nil, &SidecarError{
Status: resp.StatusCode,
Body: string(bodyBytes),
}
}
if readErr != nil {
return nil, fmt.Errorf("billing: read billing-metrics response: %w", readErr)
}
var out BillingMetricsResponse
if err := json.Unmarshal(bodyBytes, &out); err != nil {
return nil, fmt.Errorf("billing: decode billing-metrics response: %w", err)
}
return &out, nil
}
// CancelCustomer asks pad-cloud to cancel every active Stripe subscription
// for customerID and then delete the Stripe customer object. Idempotent at
// the pad-cloud side (it treats a 404/resource_missing from Stripe as
// success), so retries after a partial failure complete cleanly.
//
// Request: POST {baseURL}/billing/cancel-customer
// Body: {"customer_id": "cus_xxx", "cloud_secret": "..."}
// 200 OK: {"ok": true, "subscriptions_cancelled": N}
//
// Returns nil on 200. On any non-200, returns a *SidecarError so the caller
// can branch on Status. On transport failure (DNS, connect, timeout) returns
// a bare error — treated by callers as "retryable, abort the delete".
func (c *CloudClient) CancelCustomer(customerID string) error {
if c == nil {
return errors.New("billing: CancelCustomer called on nil CloudClient")
}
if customerID == "" {
// Defensive — handleDeleteAccount is expected to skip the call when
// StripeCustomerID is empty, but if somebody wires it differently
// we refuse to POST an empty cus_ that would burn a sidecar call
// and log-spam the 400 it would return.
return errors.New("billing: customerID is empty")
}
if c.baseURL == "" || c.cloudSecret == "" {
return errors.New("billing: CloudClient is not configured (missing baseURL or cloudSecret)")
}
payload, err := json.Marshal(map[string]string{
"customer_id": customerID,
"cloud_secret": c.cloudSecret,
})
if err != nil {
return fmt.Errorf("billing: marshal cancel-customer request: %w", err)
}
req, err := http.NewRequest(http.MethodPost,
c.baseURL+"/billing/cancel-customer",
bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("billing: build cancel-customer request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
// Transport-level failure — DNS, connect refused, TLS, timeout. Caller
// must abort the delete so a retry can try again with state intact.
return fmt.Errorf("billing: cancel-customer request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
// We don't need the body — the happy-path envelope is advisory.
// Drain-and-discard so the connection returns to the pool; the
// limit keeps a broken sidecar from streaming us into memory pressure.
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBody))
return nil
}
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
return &SidecarError{
Status: resp.StatusCode,
Body: string(bodyBytes),
}
}