mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
8e067c19db
* 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.
248 lines
9.8 KiB
Go
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),
|
|
}
|
|
}
|