mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
e3e76daae3
A user can own a Stripe customer stamped with their id that is not the linked one (TASK-3549 keeps one link and unions the rest), and before this only the linked customer was cancelled; with nothing linked the sidecar was never asked. The other customer kept charging after the account was gone. CancelCustomer now carries the user id, and in cloud mode deletion asks the sidecar even with no linked customer. A sidecar that predates user_id refuses an empty customer_id with 400; for a user with no linked customer that is exactly what not asking did before, so that one answer lets the deletion proceed, and either deploy order is safe. Any other failure, and any failure with a linked customer, still aborts. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
254 lines
10 KiB
Go
254 lines
10 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", "user_id": "...", "cloud_secret": "..."}
|
|
//
|
|
// userID (BUG-3560) lets the sidecar also cancel every other customer the
|
|
// user owns (stamped pad_user_id), and makes an empty customerID valid: a
|
|
// user can own a stamped customer with nothing linked. A sidecar that
|
|
// predates it ignores user_id and refuses an empty customer_id with 400.
|
|
// 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, userID string) error {
|
|
if c == nil {
|
|
return errors.New("billing: CancelCustomer called on nil CloudClient")
|
|
}
|
|
if customerID == "" && userID == "" {
|
|
// Nothing to name: the sidecar would refuse it with 400.
|
|
return errors.New("billing: customerID and userID are both empty")
|
|
}
|
|
if c.baseURL == "" || c.cloudSecret == "" {
|
|
return errors.New("billing: CloudClient is not configured (missing baseURL or cloudSecret)")
|
|
}
|
|
|
|
body := map[string]string{
|
|
"customer_id": customerID,
|
|
"cloud_secret": c.cloudSecret,
|
|
}
|
|
if userID != "" {
|
|
body["user_id"] = userID
|
|
}
|
|
payload, err := json.Marshal(body)
|
|
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),
|
|
}
|
|
}
|