mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
8eebefde57
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
46 lines
1.7 KiB
Go
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()
|
|
}
|