mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 01:53:33 +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.
410 lines
12 KiB
Go
410 lines
12 KiB
Go
package billing
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// newStub starts a test HTTP server and returns a CloudClient pointed at it.
|
|
// Callers supply the handler to control the response.
|
|
func newStub(t *testing.T, h http.HandlerFunc) (*CloudClient, *httptest.Server) {
|
|
t.Helper()
|
|
ts := httptest.NewServer(h)
|
|
t.Cleanup(ts.Close)
|
|
return NewCloudClient(ts.URL, "test-secret"), ts
|
|
}
|
|
|
|
func TestGetBillingMetrics_HappyPath(t *testing.T) {
|
|
var (
|
|
gotMethod string
|
|
gotPath string
|
|
gotSecret string
|
|
gotAccept string
|
|
)
|
|
want := BillingMetricsResponse{
|
|
StripeConfigured: true,
|
|
ActiveSubscriptions: 7,
|
|
MRRCents: 49000,
|
|
ARRCents: 588000,
|
|
Currency: "usd",
|
|
ChurnRate30d: 0.05,
|
|
Cancelled30d: 2,
|
|
ComputedAt: time.Date(2026, 4, 27, 18, 0, 0, 0, time.UTC),
|
|
CacheAgeSeconds: 12,
|
|
}
|
|
client, _ := newStub(t, func(w http.ResponseWriter, r *http.Request) {
|
|
gotMethod = r.Method
|
|
gotPath = r.URL.Path
|
|
gotSecret = r.Header.Get("X-Cloud-Secret")
|
|
gotAccept = r.Header.Get("Accept")
|
|
w.WriteHeader(http.StatusOK)
|
|
body, _ := json.Marshal(want)
|
|
_, _ = w.Write(body)
|
|
})
|
|
|
|
got, err := client.GetBillingMetrics()
|
|
if err != nil {
|
|
t.Fatalf("expected nil error, got %v", err)
|
|
}
|
|
|
|
if gotMethod != http.MethodGet {
|
|
t.Errorf("method: want GET, got %s", gotMethod)
|
|
}
|
|
if gotPath != "/admin/metrics/billing" {
|
|
t.Errorf("path: want /admin/metrics/billing, got %s", gotPath)
|
|
}
|
|
if gotSecret != "test-secret" {
|
|
t.Errorf("X-Cloud-Secret: want test-secret, got %s", gotSecret)
|
|
}
|
|
if gotAccept != "application/json" {
|
|
t.Errorf("Accept: want application/json, got %s", gotAccept)
|
|
}
|
|
|
|
if got.ActiveSubscriptions != want.ActiveSubscriptions ||
|
|
got.MRRCents != want.MRRCents ||
|
|
got.ARRCents != want.ARRCents ||
|
|
got.Currency != want.Currency ||
|
|
got.ChurnRate30d != want.ChurnRate30d ||
|
|
got.Cancelled30d != want.Cancelled30d ||
|
|
got.CacheAgeSeconds != want.CacheAgeSeconds ||
|
|
!got.ComputedAt.Equal(want.ComputedAt) {
|
|
t.Errorf("decoded response mismatch:\nwant %+v\ngot %+v", want, *got)
|
|
}
|
|
}
|
|
|
|
func TestGetBillingMetrics_StripeNotConfigured(t *testing.T) {
|
|
// pad-cloud returns 200 + stripe_configured=false when STRIPE_SECRET_KEY
|
|
// is unset. The client must treat this as a successful call (no error).
|
|
client, _ := newStub(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"stripe_configured":false,"currency":"usd","computed_at":"2026-04-27T18:00:00Z"}`))
|
|
})
|
|
|
|
got, err := client.GetBillingMetrics()
|
|
if err != nil {
|
|
t.Fatalf("expected nil error, got %v", err)
|
|
}
|
|
if got.StripeConfigured {
|
|
t.Errorf("stripe_configured: want false, got true")
|
|
}
|
|
}
|
|
|
|
func TestGetBillingMetrics_NonOK_ReturnsSidecarError(t *testing.T) {
|
|
client, _ := newStub(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write([]byte(`{"error":"Forbidden"}`))
|
|
})
|
|
|
|
_, err := client.GetBillingMetrics()
|
|
var sidecarErr *SidecarError
|
|
if !errors.As(err, &sidecarErr) {
|
|
t.Fatalf("want *SidecarError, got %T: %v", err, err)
|
|
}
|
|
if sidecarErr.Status != http.StatusForbidden {
|
|
t.Errorf("status: want 403, got %d", sidecarErr.Status)
|
|
}
|
|
if !strings.Contains(sidecarErr.Body, "Forbidden") {
|
|
t.Errorf("body: want contains 'Forbidden', got %q", sidecarErr.Body)
|
|
}
|
|
}
|
|
|
|
func TestGetBillingMetrics_ServerError(t *testing.T) {
|
|
client, _ := newStub(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte(`{"error":"boom"}`))
|
|
})
|
|
|
|
_, err := client.GetBillingMetrics()
|
|
var sidecarErr *SidecarError
|
|
if !errors.As(err, &sidecarErr) || sidecarErr.Status != 500 {
|
|
t.Errorf("want *SidecarError status=500, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetBillingMetrics_TransportError(t *testing.T) {
|
|
// Closed server triggers a transport-level error from c.http.Do.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
client := NewCloudClient(srv.URL, "test-secret")
|
|
srv.Close()
|
|
|
|
_, err := client.GetBillingMetrics()
|
|
if err == nil {
|
|
t.Fatal("want transport error, got nil")
|
|
}
|
|
// Transport errors should NOT be wrapped as *SidecarError — handler
|
|
// callers branch on type to log them differently.
|
|
var sidecarErr *SidecarError
|
|
if errors.As(err, &sidecarErr) {
|
|
t.Errorf("transport error should not be SidecarError, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetBillingMetrics_NilClient(t *testing.T) {
|
|
var c *CloudClient
|
|
if _, err := c.GetBillingMetrics(); err == nil {
|
|
t.Error("nil client: want error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestGetBillingMetrics_UnconfiguredClient(t *testing.T) {
|
|
c := &CloudClient{} // empty baseURL + cloudSecret
|
|
if _, err := c.GetBillingMetrics(); err == nil {
|
|
t.Error("unconfigured client: want error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestGetBillingMetrics_MalformedJSON(t *testing.T) {
|
|
client, _ := newStub(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`not json`))
|
|
})
|
|
if _, err := client.GetBillingMetrics(); err == nil {
|
|
t.Error("malformed JSON: want error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestCancelCustomer_HappyPath_SendsCorrectRequest(t *testing.T) {
|
|
var (
|
|
gotMethod string
|
|
gotPath string
|
|
gotContentType string
|
|
gotBody map[string]string
|
|
)
|
|
client, _ := newStub(t, func(w http.ResponseWriter, r *http.Request) {
|
|
gotMethod = r.Method
|
|
gotPath = r.URL.Path
|
|
gotContentType = r.Header.Get("Content-Type")
|
|
b, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(b, &gotBody)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"ok":true,"subscriptions_cancelled":2}`))
|
|
})
|
|
|
|
if err := client.CancelCustomer("cus_abc"); err != nil {
|
|
t.Fatalf("expected nil error on 200, got %v", err)
|
|
}
|
|
|
|
if gotMethod != http.MethodPost {
|
|
t.Errorf("expected POST, got %s", gotMethod)
|
|
}
|
|
if gotPath != "/billing/cancel-customer" {
|
|
t.Errorf("expected /billing/cancel-customer, got %s", gotPath)
|
|
}
|
|
if gotContentType != "application/json" {
|
|
t.Errorf("expected application/json content type, got %s", gotContentType)
|
|
}
|
|
if gotBody["customer_id"] != "cus_abc" {
|
|
t.Errorf("expected customer_id=cus_abc, got %q", gotBody["customer_id"])
|
|
}
|
|
if gotBody["cloud_secret"] != "test-secret" {
|
|
t.Errorf("expected cloud_secret=test-secret, got %q", gotBody["cloud_secret"])
|
|
}
|
|
}
|
|
|
|
// Each real pad-cloud failure status maps to a SidecarError carrying that
|
|
// status. Together these cover every non-2xx shape pad-cloud returns:
|
|
// - 400 on malformed request or non-cus_ customer_id
|
|
// - 403 on cloud_secret mismatch
|
|
// - 500 on internal / Stripe failure
|
|
// - 503 when Stripe is not configured
|
|
//
|
|
// All of them must produce a SidecarError — the handler treats every one
|
|
// as "abort the delete", regardless of bucket.
|
|
func TestCancelCustomer_NonOK_ReturnsSidecarError(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
status int
|
|
body string
|
|
bodyHas string
|
|
}{
|
|
{"400_bad_request", http.StatusBadRequest, `{"error":"customer_id must start with 'cus_'"}`, "customer_id must start with"},
|
|
{"403_wrong_secret", http.StatusForbidden, `{"error":"Forbidden"}`, "Forbidden"},
|
|
{"500_stripe_failure", http.StatusInternalServerError, `{"error":"Failed to cancel subscription"}`, "Failed to cancel"},
|
|
{"503_not_configured", http.StatusServiceUnavailable, `{"error":"Stripe billing not configured"}`, "not configured"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
client, _ := newStub(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(tc.status)
|
|
_, _ = w.Write([]byte(tc.body))
|
|
})
|
|
|
|
err := client.CancelCustomer("cus_abc")
|
|
if err == nil {
|
|
t.Fatalf("expected error on %d, got nil", tc.status)
|
|
}
|
|
|
|
var se *SidecarError
|
|
if !errors.As(err, &se) {
|
|
t.Fatalf("expected *SidecarError, got %T: %v", err, err)
|
|
}
|
|
if se.Status != tc.status {
|
|
t.Errorf("expected status %d, got %d", tc.status, se.Status)
|
|
}
|
|
if !strings.Contains(se.Body, tc.bodyHas) {
|
|
t.Errorf("expected body to include %q, got %q", tc.bodyHas, se.Body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCancelCustomer_TransportFailure_NotSidecarError(t *testing.T) {
|
|
// Point at a URL nothing's listening on; use a short client timeout so
|
|
// the test doesn't hang if the OS accepts the connect.
|
|
client := NewCloudClient("http://127.0.0.1:1", "test-secret")
|
|
client.http.Timeout = 500 * time.Millisecond
|
|
|
|
err := client.CancelCustomer("cus_abc")
|
|
if err == nil {
|
|
t.Fatal("expected transport error, got nil")
|
|
}
|
|
|
|
// A transport failure must NOT be a *SidecarError — callers use this to
|
|
// distinguish "retry" (no status from upstream) from "upstream spoke".
|
|
var se *SidecarError
|
|
if errors.As(err, &se) {
|
|
t.Errorf("transport failure must not be SidecarError, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCancelCustomer_EmptyCustomerID_ReturnsError(t *testing.T) {
|
|
called := false
|
|
client, _ := newStub(t, func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
err := client.CancelCustomer("")
|
|
if err == nil {
|
|
t.Fatal("expected error for empty customer_id, got nil")
|
|
}
|
|
if called {
|
|
t.Error("expected no HTTP call for empty customer_id")
|
|
}
|
|
}
|
|
|
|
func TestCancelCustomer_NilClient_ReturnsError(t *testing.T) {
|
|
var c *CloudClient
|
|
if err := c.CancelCustomer("cus_abc"); err == nil {
|
|
t.Fatal("expected error from nil receiver, got nil")
|
|
}
|
|
}
|
|
|
|
func TestCancelCustomer_UnconfiguredClient_ReturnsError(t *testing.T) {
|
|
c := NewCloudClient("", "")
|
|
err := c.CancelCustomer("cus_abc")
|
|
if err == nil {
|
|
t.Fatal("expected error for unconfigured client, got nil")
|
|
}
|
|
if strings.Contains(err.Error(), "dial") {
|
|
t.Errorf("expected config error, got transport error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestResolveOutboundSecret(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
explicit string
|
|
inboundList string
|
|
want string
|
|
wantEmpty bool
|
|
}{
|
|
{
|
|
name: "explicit wins over inbound",
|
|
explicit: "explicit-key",
|
|
inboundList: "new,old",
|
|
want: "explicit-key",
|
|
},
|
|
{
|
|
name: "explicit with whitespace is trimmed",
|
|
explicit: " pinned-key ",
|
|
inboundList: "new,old",
|
|
want: "pinned-key",
|
|
},
|
|
{
|
|
name: "falls back to last inbound during rotation (new,old)",
|
|
explicit: "",
|
|
inboundList: "new-key,old-key",
|
|
want: "old-key",
|
|
},
|
|
{
|
|
name: "single inbound value is used",
|
|
explicit: "",
|
|
inboundList: "only-key",
|
|
want: "only-key",
|
|
},
|
|
{
|
|
name: "skips trailing empty entries",
|
|
explicit: "",
|
|
inboundList: "new-key,old-key,,",
|
|
want: "old-key",
|
|
},
|
|
{
|
|
name: "skips whitespace-only entries",
|
|
explicit: "",
|
|
inboundList: "new-key, ,old-key",
|
|
want: "old-key",
|
|
},
|
|
{
|
|
name: "explicit set but inbound empty → explicit",
|
|
explicit: "only-explicit",
|
|
inboundList: "",
|
|
want: "only-explicit",
|
|
},
|
|
{
|
|
name: "both empty → empty result (caller should fail startup)",
|
|
explicit: "",
|
|
inboundList: "",
|
|
wantEmpty: true,
|
|
},
|
|
{
|
|
name: "both whitespace → empty result",
|
|
explicit: " ",
|
|
inboundList: ", , ,",
|
|
wantEmpty: true,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := ResolveOutboundSecret(tc.explicit, tc.inboundList)
|
|
if tc.wantEmpty {
|
|
if got != "" {
|
|
t.Errorf("expected empty, got %q", got)
|
|
}
|
|
return
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("ResolveOutboundSecret(%q, %q) = %q, want %q",
|
|
tc.explicit, tc.inboundList, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCancelCustomer_LargeResponseBody_DoesNotReadPastCap(t *testing.T) {
|
|
// A broken sidecar or misrouted proxy can stream us an enormous payload.
|
|
// We cap the read at maxResponseBody so we never allocate MBs of error
|
|
// body. Build a body bigger than the cap and verify the client still
|
|
// returns a SidecarError with a truncated-but-bounded Body.
|
|
big := strings.Repeat("X", maxResponseBody*2)
|
|
client, _ := newStub(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
_, _ = w.Write([]byte(big))
|
|
})
|
|
|
|
err := client.CancelCustomer("cus_abc")
|
|
var se *SidecarError
|
|
if !errors.As(err, &se) {
|
|
t.Fatalf("expected SidecarError, got %T", err)
|
|
}
|
|
if len(se.Body) > maxResponseBody {
|
|
t.Errorf("body was %d bytes, expected <= %d (cap)", len(se.Body), maxResponseBody)
|
|
}
|
|
}
|