Files
pad/internal/store/billing_users.go
T
xarmian 8eebefde57 feat(cloud): GET /admin/billing-users pages the users the reconcile sweep re-syncs (TASK-3564, pad half) (#2042)
The Pad Cloud sidecar's reconcile sweep (TASK-3564, lead ruling day 91) is
the backstop for a Stripe webhook whose effect was lost. It re-runs the same
entitlement sync a webhook runs, for every user pad holds billing state for.
This is pad's half: a cloud-secret gated, paged list of those users.

GET /api/v1/admin/billing-users?after=<user_id>&limit=N returns
{users: [{user_id, stripe_customer_id, plan, plan_source, plan_expires_at}],
next}, ordered by id. limit defaults to 200, max 1000, and next is '' on the
last page. A user is listed when it has a stored Stripe customer id OR a
stripe-sourced plan: each finds a case the other misses. App principals are
excluded. Same credential rule as user-by-id: the cloud secret, or a
platform admin. The sidecar's startup key check reads a small page of it.

Wired like its siblings: cloudAdminPaths, requireCloudMode, the cloud-admin
rate limiter, the BUG-3361 cloud-only route list, and the TASK-2225 compress
census (compress: customer ids are not secrets, as for user-by-id). Its
response shape is pinned in the cloud contract goldens.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-10 23:11:23 -04:00

46 lines
1.7 KiB
Go

package store
import "fmt"
// BillingUser is one user the Pad Cloud reconcile sweep re-syncs (TASK-3564):
// someone pad holds a Stripe link for, or whose plan Stripe set.
type BillingUser struct {
UserID string `json:"user_id"`
StripeCustomerID string `json:"stripe_customer_id"`
Plan string `json:"plan"`
PlanSource string `json:"plan_source"`
PlanExpiresAt string `json:"plan_expires_at"`
}
// ListBillingUsers pages through human users with a stored Stripe customer id
// or a stripe-sourced plan, ordered by id: the users after afterID, at most
// limit of them. Both conditions, because each finds a case the other misses:
// a link pad holds for a customer Stripe no longer bills, and a stripe plan
// whose link was cleared. App principals never pay and are excluded.
func (s *Store) ListBillingUsers(afterID string, limit int) ([]BillingUser, error) {
if limit < 1 {
return nil, fmt.Errorf("list billing users: limit must be positive")
}
rows, err := s.db.Query(s.q(`
SELECT id, COALESCE(stripe_customer_id, ''), COALESCE(plan, ''), COALESCE(plan_source, ''), COALESCE(plan_expires_at, '')
FROM users
WHERE kind = 'human'
AND (COALESCE(stripe_customer_id, '') <> '' OR plan_source = ?)
AND id > ?
ORDER BY id ASC
LIMIT ?`), PlanSourceStripe, afterID, limit)
if err != nil {
return nil, fmt.Errorf("list billing users: %w", err)
}
defer rows.Close()
out := []BillingUser{}
for rows.Next() {
var u BillingUser
if err := rows.Scan(&u.UserID, &u.StripeCustomerID, &u.Plan, &u.PlanSource, &u.PlanExpiresAt); err != nil {
return nil, fmt.Errorf("list billing users (scan): %w", err)
}
out = append(out, u)
}
return out, rows.Err()
}