feat(billing): record plan_source; a lowering applies only from the source that set the plan (TASK-3295)

PLAN-3291 DR-6. users.plan_source ('manual' | 'stripe') records who set a
user's plan. The rule lives in the store write, not in callers: a write that
lowers the plan to free applies only when its source is the current
plan_source, the plan is already free or blank, or the write is forced.
Every other write applies and takes the source over. It is one conditional
UPDATE, so it is decided atomically against the row as it stands, and a
refused write changes nothing, plan_expires_at included.

- store.SetUserPlan(userID, PlanWrite{Plan, ExpiresAt, Source, Force})
  returns {Applied, Plan, Source}. Source is required and validated, so
  every caller names one; the compiler found all of them.
- POST /api/v1/admin/plan takes an optional `source` (omitted = manual,
  anything but manual/stripe = 400). A refused lowering is a 200 with
  `applied: false` and the plan the user keeps: pad-cloud treats any
  other status as a failure and the Stripe webhook would retry forever.
  Applied writes now also answer `applied: true` and `plan_source`
  (additive). The source is in the audit metadata.
- The admin user update writes manual with force: an operator's choice
  always applies, lowering included.
- plan_source is on models.User (json:"-") and in the four admin user
  responses that carry plan.
- Migrations 101 (SQLite) / 075 (Postgres): column default 'manual', plus a
  backfill to 'stripe' for pro users with a Stripe customer. Without it, an
  already-paying user's Stripe cancellation would be a refused lowering and
  they would keep pro after they stopped paying. Billing is not switched
  on yet, so it should match no rows in prod; it is there so the migration
  is safe either way.
- nulcolumns: users.plan_source recorded as an excluded server enum.

Writers of users.plan, from grep (SET plan / INSERT INTO users /
SetUserPlan): the two SetUserPlan callers above; BackfillUserPlans, which
fills blank rows only and is not a source write, so the source is left
alone; and the two user INSERTs, which write no plan and take the column
defaults.

Skew: a pad without this ignores `source` (json.Unmarshal) and applies the
write, which is today's behaviour, so pad deploys first, then the sidecar.

Tests (SQLite here; Postgres under make test-pg): a 9-case rule table
reading the row back through GetUser; a new user is free/manual; invalid
source and missing user refuse without writing; the migration's own UPDATE
run against a fixture per dialect; and router-level /admin/plan and admin
PATCH/GET tests. Mutants, each BUILD OK and killed by named tests: rule
removed, source not written, force ignored, same-source clause dropped,
admin update unforced, refused write answering 409, omitted source
defaulting to stripe.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
This commit is contained in:
xarmian
2026-09-29 13:25:36 +00:00
parent 3d13df619f
commit 28c036de79
20 changed files with 559 additions and 41 deletions
+1
View File
@@ -19,6 +19,7 @@ type User struct {
RecoveryCodes string `json:"-"` // Never serialized
Plan string `json:"plan"` // "free", "pro", or "self-hosted"
PlanExpiresAt string `json:"plan_expires_at,omitempty"`
PlanSource string `json:"-"` // Who set Plan: "manual" or "stripe" (TASK-3295); surfaced only on admin user responses
StripeCustomerID string `json:"-"` // Never serialized
PlanOverrides string `json:"plan_overrides,omitempty"` // JSON overrides for per-user limits
OAuthProviders string `json:"-"` // JSON array of linked providers, e.g. ["github","google"]
+10 -1
View File
@@ -94,6 +94,7 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
Role string `json:"role"`
Plan string `json:"plan"`
PlanExpiresAt string `json:"plan_expires_at,omitempty"`
PlanSource string `json:"plan_source"`
PlanOverrides string `json:"plan_overrides,omitempty"`
TOTPEnabled bool `json:"totp_enabled"`
DisabledAt string `json:"disabled_at,omitempty"`
@@ -117,6 +118,7 @@ func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
Role: u.Role,
Plan: u.Plan,
PlanExpiresAt: u.PlanExpiresAt,
PlanSource: u.PlanSource,
PlanOverrides: u.PlanOverrides,
TOTPEnabled: u.TOTPEnabled,
DisabledAt: u.DisabledAt,
@@ -182,6 +184,7 @@ func (s *Server) handleAdminGetUser(w http.ResponseWriter, r *http.Request) {
"role": user.Role,
"plan": user.Plan,
"plan_expires_at": user.PlanExpiresAt,
"plan_source": user.PlanSource,
"plan_overrides": user.PlanOverrides,
"totp_enabled": user.TOTPEnabled,
"disabled_at": user.DisabledAt,
@@ -349,6 +352,7 @@ func (s *Server) handleAdminGetUserDetail(w http.ResponseWriter, r *http.Request
"role": user.Role,
"plan": user.Plan,
"plan_expires_at": user.PlanExpiresAt,
"plan_source": user.PlanSource,
"plan_overrides": user.PlanOverrides,
"totp_enabled": user.TOTPEnabled,
"disabled_at": user.DisabledAt,
@@ -453,7 +457,11 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
if input.PlanExpiresAt != nil {
expiresAt = *input.PlanExpiresAt
}
if err := s.store.SetUserPlan(userID, *input.Plan, expiresAt); err != nil {
// An operator's explicit choice always applies, lowering included
// (PLAN-3291 DR-6), and takes the plan's source over as manual.
if _, err := s.store.SetUserPlan(userID, store.PlanWrite{
Plan: *input.Plan, ExpiresAt: expiresAt, Source: store.PlanSourceManual, Force: true,
}); err != nil {
writeInternalError(w, err)
return
}
@@ -508,6 +516,7 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
"plan": updated.Plan,
"plan_overrides": updated.PlanOverrides,
"plan_expires_at": updated.PlanExpiresAt,
"plan_source": updated.PlanSource,
"totp_enabled": updated.TOTPEnabled,
"created_at": updated.CreatedAt,
"updated_at": updated.UpdatedAt,
+2 -1
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
// TestAdminUpdateUser_StorageOverrideRoundTrip pins TASK-883's
@@ -30,7 +31,7 @@ func TestAdminUpdateUser_StorageOverrideRoundTrip(t *testing.T) {
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := srv.store.SetUserPlan(target.ID, "free", ""); err != nil {
if _, err := srv.store.SetUserPlan(target.ID, store.PlanWrite{Plan: "free", Source: store.PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan: %v", err)
}
+41 -6
View File
@@ -307,11 +307,19 @@ func (s *Server) handleOAuthLogin(w http.ResponseWriter, r *http.Request) {
// handleSetPlan handles POST /api/v1/admin/plan.
// Called by the pad-cloud sidecar to update a user's billing plan
// after Stripe subscription events.
//
// `source` says who is setting the plan ("manual" when omitted, "stripe"
// from the sidecar). The store decides whether the write applies: a write
// lowering the plan to free from a source other than the one that set it is
// refused (PLAN-3291 DR-6). A refused write is still a 200, with
// `applied: false` and the plan the user keeps: the sidecar treats any other
// status as a failure and the Stripe webhook would then retry forever.
func (s *Server) handleSetPlan(w http.ResponseWriter, r *http.Request) {
var input struct {
UserID string `json:"user_id"`
Plan string `json:"plan"`
ExpiresAt string `json:"expires_at"`
Source string `json:"source"`
CloudSecret string `json:"cloud_secret"`
}
if err := decodeJSON(r, &input); err != nil {
@@ -339,6 +347,14 @@ func (s *Server) handleSetPlan(w http.ResponseWriter, r *http.Request) {
return
}
if input.Source == "" {
input.Source = store.PlanSourceManual
}
if !store.ValidPlanSource(input.Source) {
writeError(w, http.StatusBadRequest, "bad_request", "source must be 'manual' or 'stripe'")
return
}
// 2b. Validate expires_at format if provided
if input.ExpiresAt != "" {
if _, err := time.Parse(time.RFC3339, input.ExpiresAt); err != nil {
@@ -358,12 +374,28 @@ func (s *Server) handleSetPlan(w http.ResponseWriter, r *http.Request) {
return
}
// 4. Update plan
// 4. Update plan, under the plan-source rule
oldPlan := targetUser.Plan
if err := s.store.SetUserPlan(input.UserID, input.Plan, input.ExpiresAt); err != nil {
result, err := s.store.SetUserPlan(input.UserID, store.PlanWrite{
Plan: input.Plan, ExpiresAt: input.ExpiresAt, Source: input.Source,
})
if err != nil {
writeInternalError(w, err)
return
}
if !result.Applied {
slog.Info("plan update refused: lowering from a source that did not set the plan",
"user_id", input.UserID, "requested_plan", input.Plan, "source", input.Source,
"held_plan", result.Plan, "held_source", result.Source)
writeJSON(w, http.StatusOK, map[string]interface{}{
"user_id": input.UserID,
"plan": result.Plan,
"plan_source": result.Source,
"applied": false,
"ok": true,
})
return
}
// 5. Audit log
actorID := ""
@@ -375,14 +407,17 @@ func (s *Server) handleSetPlan(w http.ResponseWriter, r *http.Request) {
"old_plan": oldPlan,
"new_plan": input.Plan,
"expires_at": input.ExpiresAt,
"source": input.Source,
}))
slog.Info("plan updated", "user_id", input.UserID, "old_plan", oldPlan, "new_plan", input.Plan)
slog.Info("plan updated", "user_id", input.UserID, "old_plan", oldPlan, "new_plan", input.Plan, "source", input.Source)
writeJSON(w, http.StatusOK, map[string]interface{}{
"user_id": input.UserID,
"plan": input.Plan,
"ok": true,
"user_id": input.UserID,
"plan": result.Plan,
"plan_source": result.Source,
"applied": true,
"ok": true,
})
}
@@ -13,6 +13,7 @@ import (
"github.com/PerpetualSoftware/pad/internal/artifact"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
// BUG-2808: a plan limit is a count taken before the write. When a competing
@@ -36,7 +37,7 @@ func newPlanLimitEnv(t *testing.T) *planLimitEnv {
t.Helper()
e := newConsentEnv(t, true)
e.srv.cloudMode = true
if err := e.srv.store.SetUserPlan(e.user.ID, "free", ""); err != nil {
if _, err := e.srv.store.SetUserPlan(e.user.ID, store.PlanWrite{Plan: "free", Source: store.PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan: %v", err)
}
all, err := e.srv.store.ListWorkspaces()
@@ -206,7 +207,7 @@ func newTokenLimitEnv(t *testing.T) (*tokenAuthEnv, int) {
t.Helper()
env := setupTokenAuthEnv(t)
env.srv.cloudMode = true
if err := env.srv.store.SetUserPlan(env.user.ID, "free", ""); err != nil {
if _, err := env.srv.store.SetUserPlan(env.user.ID, store.PlanWrite{Plan: "free", Source: store.PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan: %v", err)
}
limit := ownedTokens(t, env) + 1
@@ -321,7 +322,7 @@ func bundleLimitRace(t *testing.T, compete bool) (*httptest.ResponseRecorder, in
srv.cloudMode = true
u := mintTestUser(t, srv, "bundle-limit@example.com")
tok := loginUser(t, srv, "bundle-limit@example.com", "correct-horse-battery-staple")
if err := srv.store.SetUserPlan(u.ID, "free", ""); err != nil {
if _, err := srv.store.SetUserPlan(u.ID, store.PlanWrite{Plan: "free", Source: store.PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan: %v", err)
}
src, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: "Bundle Source", OwnerID: u.ID})
@@ -0,0 +1,148 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
// TASK-3295 / PLAN-3291 DR-6, through the router: the sidecar's
// /api/v1/admin/plan names a source, a refused lowering is a 200 with
// applied:false (any other status makes the Stripe webhook retry forever),
// an omitted source means manual, and the admin user update forces.
const planSourceSecret = "plan-source-secret"
type setPlanResponse struct {
Plan string `json:"plan"`
PlanSource string `json:"plan_source"`
Applied bool `json:"applied"`
OK bool `json:"ok"`
}
func planSourceServer(t *testing.T) (*Server, string, *models.User) {
t.Helper()
srv := testServer(t)
adminToken := bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
srv.SetCloudMode(planSourceSecret)
u, err := srv.store.CreateUser(models.UserCreate{
Email: "payer@example.com", Name: "Payer", Password: "correct-horse-battery-staple", Role: "member",
})
if err != nil {
t.Fatal(err)
}
return srv, adminToken, u
}
func postSetPlan(t *testing.T, srv *Server, body map[string]string) *httptest.ResponseRecorder {
t.Helper()
body["cloud_secret"] = planSourceSecret
req := cloudAdminReq(t, "POST", "/api/v1/admin/plan", body, map[string]string{"X-Cloud-Secret": planSourceSecret})
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
return rr
}
func decodeSetPlan(t *testing.T, rr *httptest.ResponseRecorder) setPlanResponse {
t.Helper()
if rr.Code != http.StatusOK {
t.Fatalf("status %d, want 200: %s", rr.Code, rr.Body.String())
}
var out setPlanResponse
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v: %s", err, rr.Body.String())
}
return out
}
func TestSetPlan_StripeFreeDoesNotClobberManualPro(t *testing.T) {
srv, _, u := planSourceServer(t)
// An operator grants pro; an omitted source is manual.
got := decodeSetPlan(t, postSetPlan(t, srv, map[string]string{"user_id": u.ID, "plan": "pro"}))
if !got.Applied || got.Plan != "pro" || got.PlanSource != store.PlanSourceManual {
t.Fatalf("grant: %+v, want applied pro/manual", got)
}
// A Stripe cancellation for the same user is refused, as a 200.
got = decodeSetPlan(t, postSetPlan(t, srv, map[string]string{"user_id": u.ID, "plan": "free", "source": "stripe"}))
if got.Applied || !got.OK || got.Plan != "pro" || got.PlanSource != store.PlanSourceManual {
t.Fatalf("stripe free: %+v, want ok, not applied, holding pro/manual", got)
}
row, err := srv.store.GetUser(u.ID)
if err != nil {
t.Fatal(err)
}
if row.Plan != "pro" {
t.Fatalf("row plan = %q after a refused lowering, want pro", row.Plan)
}
}
func TestSetPlan_StripeFreeLowersStripePro(t *testing.T) {
srv, _, u := planSourceServer(t)
got := decodeSetPlan(t, postSetPlan(t, srv, map[string]string{"user_id": u.ID, "plan": "pro", "source": "stripe"}))
if !got.Applied || got.PlanSource != store.PlanSourceStripe {
t.Fatalf("stripe pro: %+v", got)
}
got = decodeSetPlan(t, postSetPlan(t, srv, map[string]string{"user_id": u.ID, "plan": "free", "source": "stripe"}))
if !got.Applied || got.Plan != "free" {
t.Fatalf("stripe free: %+v, want applied free", got)
}
}
func TestSetPlan_RejectsUnknownSource(t *testing.T) {
srv, _, u := planSourceServer(t)
rr := postSetPlan(t, srv, map[string]string{"user_id": u.ID, "plan": "pro", "source": "apple"})
if rr.Code != http.StatusBadRequest {
t.Fatalf("source apple: status %d, want 400: %s", rr.Code, rr.Body.String())
}
row, err := srv.store.GetUser(u.ID)
if err != nil {
t.Fatal(err)
}
if row.Plan != "free" {
t.Fatalf("a refused source still wrote: plan = %q", row.Plan)
}
}
// The admin console's plan change is an operator's explicit choice: it
// lowers a Stripe pro, and the user detail then reports the source.
func TestAdminUpdateUser_PlanForcesAndReportsSource(t *testing.T) {
srv, adminToken, u := planSourceServer(t)
decodeSetPlan(t, postSetPlan(t, srv, map[string]string{"user_id": u.ID, "plan": "pro", "source": "stripe"}))
rr := doRequestWithCookie(srv, "PATCH", "/api/v1/admin/users/"+u.ID, map[string]any{"plan": "free"}, adminToken)
if rr.Code != http.StatusOK {
t.Fatalf("patch: %d %s", rr.Code, rr.Body.String())
}
var patched struct {
Plan string `json:"plan"`
PlanSource string `json:"plan_source"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &patched); err != nil {
t.Fatal(err)
}
if patched.Plan != "free" || patched.PlanSource != store.PlanSourceManual {
t.Fatalf("patch response = %+v, want free/manual", patched)
}
rr = doRequestWithCookie(srv, "GET", "/api/v1/admin/users/"+u.ID, nil, adminToken)
if rr.Code != http.StatusOK {
t.Fatalf("get: %d %s", rr.Code, rr.Body.String())
}
var detail struct {
Plan string `json:"plan"`
PlanSource string `json:"plan_source"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &detail); err != nil {
t.Fatal(err)
}
if detail.Plan != "free" || detail.PlanSource != store.PlanSourceManual {
t.Fatalf("detail = %+v, want free/manual", detail)
}
}
@@ -7,6 +7,7 @@ import (
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
// TestWorkspaceCap_FreeTierAllowsUpToThree verifies that a free-tier user
@@ -29,7 +30,7 @@ func TestWorkspaceCap_FreeTierAllowsUpToThree(t *testing.T) {
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := srv.store.SetUserPlan(u.ID, "free", ""); err != nil {
if _, err := srv.store.SetUserPlan(u.ID, store.PlanWrite{Plan: "free", Source: store.PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan(free): %v", err)
}
token := loginUser(t, srv, "free@test.com", "correct-horse-battery-staple")
@@ -62,7 +63,7 @@ func TestWorkspaceCap_FreeTierBlocksFourth(t *testing.T) {
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := srv.store.SetUserPlan(u.ID, "free", ""); err != nil {
if _, err := srv.store.SetUserPlan(u.ID, store.PlanWrite{Plan: "free", Source: store.PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan(free): %v", err)
}
token := loginUser(t, srv, "free@test.com", "correct-horse-battery-staple")
@@ -130,7 +131,7 @@ func TestWorkspaceCap_ProTierUnlimited(t *testing.T) {
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := srv.store.SetUserPlan(u.ID, "pro", ""); err != nil {
if _, err := srv.store.SetUserPlan(u.ID, store.PlanWrite{Plan: "pro", Source: store.PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan(pro): %v", err)
}
token := loginUser(t, srv, "pro@test.com", "correct-horse-battery-staple")
@@ -179,7 +180,7 @@ func TestWorkspaceCap_OverrideUnblocks(t *testing.T) {
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := srv.store.SetUserPlan(u.ID, "free", ""); err != nil {
if _, err := srv.store.SetUserPlan(u.ID, store.PlanWrite{Plan: "free", Source: store.PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan(free): %v", err)
}
// Override raises the workspace cap to 10.
@@ -220,7 +221,7 @@ func TestPlanLimitError_ResponseShape(t *testing.T) {
if err != nil {
t.Fatalf("CreateUser(owner): %v", err)
}
if err := srv.store.SetUserPlan(owner.ID, "free", ""); err != nil {
if _, err := srv.store.SetUserPlan(owner.ID, store.PlanWrite{Plan: "free", Source: store.PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan(free): %v", err)
}
ownerToken := loginUser(t, srv, "owner@test.com", "correct-horse-battery-staple")
+2 -2
View File
@@ -64,7 +64,7 @@ func TestWorkspaceStorageInfo_FreePlanResolution(t *testing.T) {
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := s.SetUserPlan(owner.ID, "free", ""); err != nil {
if _, err := s.SetUserPlan(owner.ID, PlanWrite{Plan: "free", Source: PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan(free): %v", err)
}
@@ -109,7 +109,7 @@ func TestWorkspaceStorageInfo_FreePlanResolution(t *testing.T) {
// 3. Pro plan: the limit is unlimited regardless of override (Phase 1).
// The override_active flag still surfaces the configured override
// so the admin UI can show it; it just doesn't affect the limit.
if err := s.SetUserPlan(owner.ID, "pro", ""); err != nil {
if _, err := s.SetUserPlan(owner.ID, PlanWrite{Plan: "pro", Source: PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan(pro): %v", err)
}
info, err = s.WorkspaceStorageInfo(wsID)
+2 -2
View File
@@ -43,7 +43,7 @@ func importFixture(t *testing.T, s *Store, tag string, capItems, items int) (own
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := s.SetUserPlan(owner.ID, "free", ""); err != nil {
if _, err := s.SetUserPlan(owner.ID, PlanWrite{Plan: "free", Source: PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan: %v", err)
}
@@ -214,7 +214,7 @@ func TestImportWorkspace_OrphanedItemsDoNotCountTowardTheCap(t *testing.T) {
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := s.SetUserPlan(owner.ID, "free", ""); err != nil {
if _, err := s.SetUserPlan(owner.ID, PlanWrite{Plan: "free", Source: PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan: %v", err)
}
src, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "bug3103-src-orphan", OwnerID: owner.ID})
@@ -567,7 +567,7 @@ func TestCopyItemAcrossWorkspaces_ConcurrentCopiesCannotJointlyExceedQuota(t *te
s := testStore(t)
owner := createTestUser(t, s, "joint-quota@example.com", "Owner", "s3cret")
if err := s.SetUserPlan(owner.ID, "free", ""); err != nil {
if _, err := s.SetUserPlan(owner.ID, PlanWrite{Plan: "free", Source: PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan: %v", err)
}
if err := s.SetUserPlanOverrides(owner.ID, `{"items_per_workspace": 1}`); err != nil {
@@ -920,7 +920,7 @@ func newQuotaFixture(t *testing.T, limit int) copyFixture {
t.Helper()
s := testStore(t)
owner := createTestUser(t, s, "quota-owner@example.com", "Owner", "s3cret")
if err := s.SetUserPlan(owner.ID, "free", ""); err != nil {
if _, err := s.SetUserPlan(owner.ID, PlanWrite{Plan: "free", Source: PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan: %v", err)
}
if err := s.SetUserPlanOverrides(owner.ID, fmt.Sprintf(`{"items_per_workspace": %d}`, limit)); err != nil {
@@ -999,7 +999,7 @@ func TestCheckLimitTx_SeesUncommittedRowsInTheTransaction(t *testing.T) {
t.Parallel()
s := testStore(t)
owner := createTestUser(t, s, "tx-count@example.com", "Owner", "s3cret")
if err := s.SetUserPlan(owner.ID, "free", ""); err != nil {
if _, err := s.SetUserPlan(owner.ID, PlanWrite{Plan: "free", Source: PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan: %v", err)
}
if err := s.SetUserPlanOverrides(owner.ID, `{"items_per_workspace": 1}`); err != nil {
@@ -1481,7 +1481,7 @@ func TestCopyItemAcrossWorkspaces_NoPoolIOUnderLocks(t *testing.T) {
// the platform-settings read instead of returning early.
s := testStore(t)
owner := createTestUser(t, s, "no-pool-io-owner@example.com", "Owner", "s3cret")
if err := s.SetUserPlan(owner.ID, "free", ""); err != nil {
if _, err := s.SetUserPlan(owner.ID, PlanWrite{Plan: "free", Source: PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan: %v", err)
}
wsA, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Locked Source", OwnerID: owner.ID})
+82 -7
View File
@@ -495,14 +495,89 @@ func (s *Store) SeedPlanLimits() error {
return nil
}
// SetUserPlan updates a user's billing plan.
func (s *Store) SetUserPlan(userID, plan, expiresAt string) error {
_, err := s.db.Exec(s.q(`UPDATE users SET plan = ?, plan_expires_at = ?, updated_at = ? WHERE id = ?`),
plan, expiresAt, now(), userID)
if err != nil {
return fmt.Errorf("set user plan: %w", err)
// Plan sources: who set a user's plan (TASK-3295, PLAN-3291 DR-6). 'apple'
// and 'google' are reserved for store billing and not accepted yet.
const (
PlanSourceManual = "manual"
PlanSourceStripe = "stripe"
)
// ValidPlanSource reports whether src is a plan source SetUserPlan accepts.
func ValidPlanSource(src string) bool {
return src == PlanSourceManual || src == PlanSourceStripe
}
// PlanWrite is one write to a user's plan. Source is required: every caller
// says who is setting the plan, because the lowering rule is decided by it.
type PlanWrite struct {
Plan string
ExpiresAt string
Source string
// Force applies the write even when it would lower the plan from a
// source other than the one that set it. Only an operator's explicit
// choice (the admin user update) sets it.
Force bool
}
// PlanWriteResult says whether a PlanWrite applied, and the plan and source
// the user holds after it, whichever way it went.
type PlanWriteResult struct {
Applied bool
Plan string
Source string
}
// SetUserPlan writes a user's billing plan under the plan-source rule
// (PLAN-3291 DR-6), enforced here rather than by callers:
//
// - a write that LOWERS the plan (new plan free, current plan neither free
// nor blank) applies only if its source is the current plan_source, or it
// is forced;
// - every other write applies and takes plan_source over.
//
// So a Stripe cancellation cannot clobber a plan an operator granted, and an
// operator can still lower anything by forcing. The rule is the UPDATE's own
// WHERE clause, so it is decided atomically against the row as it stands. A
// refused write changes nothing, plan_expires_at included, and is not an
// error: Applied is false.
func (s *Store) SetUserPlan(userID string, w PlanWrite) (PlanWriteResult, error) {
if !ValidPlanSource(w.Source) {
return PlanWriteResult{}, fmt.Errorf("set user plan: invalid plan source %q", w.Source)
}
return nil
tx, err := s.db.Begin()
if err != nil {
return PlanWriteResult{}, fmt.Errorf("set user plan: %w", err)
}
defer tx.Rollback()
force := 0 // bound as an int: a bare boolean placeholder is dialect-sensitive
if w.Force {
force = 1
}
res, err := tx.Exec(s.q(`
UPDATE users SET plan = ?, plan_expires_at = ?, plan_source = ?, updated_at = ?
WHERE id = ? AND (? = 1 OR ? <> 'free' OR plan IN ('', 'free') OR plan_source = ?)`),
w.Plan, w.ExpiresAt, w.Source, now(), userID, force, w.Plan, w.Source)
if err != nil {
return PlanWriteResult{}, fmt.Errorf("set user plan: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return PlanWriteResult{}, fmt.Errorf("set user plan: %w", err)
}
out := PlanWriteResult{Applied: n > 0}
err = tx.QueryRow(s.q(`SELECT plan, plan_source FROM users WHERE id = ?`), userID).Scan(&out.Plan, &out.Source)
if err == sql.ErrNoRows {
return PlanWriteResult{}, fmt.Errorf("set user plan: user %s not found", userID)
}
if err != nil {
return PlanWriteResult{}, fmt.Errorf("set user plan: %w", err)
}
if err := tx.Commit(); err != nil {
return PlanWriteResult{}, fmt.Errorf("set user plan: %w", err)
}
return out, nil
}
// SetUserPlanOverrides updates per-user limit overrides (JSON string).
+3 -3
View File
@@ -96,7 +96,7 @@ func limitedMintFixture(t *testing.T, s *Store, m limitedMint) (owner string, sr
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := s.SetUserPlan(u.ID, "free", ""); err != nil {
if _, err := s.SetUserPlan(u.ID, PlanWrite{Plan: "free", Source: PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan: %v", err)
}
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "source", OwnerID: u.ID})
@@ -208,7 +208,7 @@ func TestLimitedMint_WithoutOption_NotEnforced(t *testing.T) {
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := s.SetUserPlan(u.ID, "free", ""); err != nil {
if _, err := s.SetUserPlan(u.ID, PlanWrite{Plan: "free", Source: PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan: %v", err)
}
if err := s.SetUserPlanOverrides(u.ID, `{"workspaces":0,"api_tokens":0}`); err != nil {
@@ -249,7 +249,7 @@ func TestLimitedMint_CreateAndImportOfOneName_NoLockCycle(t *testing.T) {
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := s.SetUserPlan(u.ID, "free", ""); err != nil {
if _, err := s.SetUserPlan(u.ID, PlanWrite{Plan: "free", Source: PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan: %v", err)
}
if err := s.SetUserPlanOverrides(u.ID, `{"workspaces":5}`); err != nil {
+3 -3
View File
@@ -17,7 +17,7 @@ func TestCheckUserLimit_WorkspacesFreeTier(t *testing.T) {
s := testStore(t)
owner := createTestUser(t, s, "owner@example.com", "Owner", "s3cret")
if err := s.SetUserPlan(owner.ID, "free", ""); err != nil {
if _, err := s.SetUserPlan(owner.ID, PlanWrite{Plan: "free", Source: PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan(free): %v", err)
}
@@ -85,7 +85,7 @@ func TestCheckUserLimit_WorkspacesFreeWithOverride(t *testing.T) {
s := testStore(t)
owner := createTestUser(t, s, "override@example.com", "Override", "s3cret")
if err := s.SetUserPlan(owner.ID, "free", ""); err != nil {
if _, err := s.SetUserPlan(owner.ID, PlanWrite{Plan: "free", Source: PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan(free): %v", err)
}
// Override raises cap to 10.
@@ -121,7 +121,7 @@ func TestCheckUserLimit_WorkspacesProUnlimited(t *testing.T) {
s := testStore(t)
owner := createTestUser(t, s, "pro@example.com", "Pro", "s3cret")
if err := s.SetUserPlan(owner.ID, "pro", ""); err != nil {
if _, err := s.SetUserPlan(owner.ID, PlanWrite{Plan: "pro", Source: PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan(pro): %v", err)
}
@@ -125,7 +125,7 @@ func newLimitedWorkspaceFixture(t *testing.T, s *Store, c limitedWorkspaceInsert
return u.ID
}
owner := user("owner")
if err := s.SetUserPlan(owner, "free", ""); err != nil {
if _, err := s.SetUserPlan(owner, PlanWrite{Plan: "free", Source: PlanSourceManual, Force: true}); err != nil {
t.Fatalf("SetUserPlan: %v", err)
}
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "limited", OwnerID: owner})
@@ -0,0 +1,16 @@
-- Migration 101: where a user's plan came from (TASK-3295, PLAN-3291 DR-6).
--
-- 'manual' (an operator, or any caller that names no source) or 'stripe'
-- (the pad-cloud sidecar); 'apple' / 'google' are reserved for store billing.
-- SetUserPlan enforces the rule in its own UPDATE: a write that lowers the plan
-- to free applies only when its source is the one that set the current plan,
-- or the plan is already free; any other write applies and takes the source
-- over. No CHECK constraint: the store validates the vocabulary, and widening
-- a CHECK on SQLite would cost a table rebuild.
ALTER TABLE users ADD COLUMN plan_source TEXT NOT NULL DEFAULT 'manual';
-- A pro user who already has a Stripe customer got pro from Stripe. Leaving
-- them 'manual' would make their Stripe cancellation (a 'free' from source
-- stripe) a refused lowering, keeping them on pro after they stopped paying.
UPDATE users SET plan_source = 'stripe'
WHERE plan = 'pro' AND COALESCE(stripe_customer_id, '') <> '';
+1
View File
@@ -379,6 +379,7 @@ var nulExcluded = map[string]string{
"mcp_audit_log.tool_name": "server enum, mcp_audit.go",
"mcp_audit_log.error_kind": "server enum, mcp_audit.go",
"mcp_audit_log.tool_name_source": "server enum (models.MCPToolNameSource), CHECK-constrained by migration 096; never request-derived",
"users.plan_source": "server enum (store.PlanSource*), validated by SetUserPlan before any write; request-derived only after ValidPlanSource accepts it (TASK-3295)",
"attachments.filename_source": "server enum (attachments.FilenameSource), CHECK-constrained by migration 096; a bundle import may carry the bundle's value, but only after attachments.ValidFilenameSource accepts it",
"users.recovery_codes": "newline-joined bcrypt hashes of server-generated codes; looks like JSON, is not",
"workspace_members.collection_access": "validated enum all/selected",
@@ -0,0 +1,6 @@
-- Migration 075 (Postgres): where a user's plan came from (TASK-3295, PLAN-3291 DR-6).
-- Postgres counterpart to migrations/101_user_plan_source.sql; the rationale is there.
ALTER TABLE users ADD COLUMN IF NOT EXISTS plan_source TEXT NOT NULL DEFAULT 'manual';
UPDATE users SET plan_source = 'stripe'
WHERE plan = 'pro' AND COALESCE(stripe_customer_id, '') <> '';
+224
View File
@@ -0,0 +1,224 @@
package store
import (
"strings"
"testing"
)
// TASK-3295 / PLAN-3291 DR-6: a write that lowers a plan to free applies only
// from the source that set the plan (or when forced); every other write
// applies and takes the source over. Runs on SQLite, and on Postgres under
// make test-pg, because the rule is one conditional UPDATE in each dialect.
func TestSetUserPlan_SourceRule(t *testing.T) {
t.Parallel()
type state struct{ plan, source, expires string }
cases := []struct {
name string
start state
write PlanWrite
wantApplied bool
want state
}{
{
name: "stripe free vs a manual pro stays pro",
start: state{"pro", PlanSourceManual, "2030-01-01T00:00:00Z"},
write: PlanWrite{Plan: "free", Source: PlanSourceStripe},
wantApplied: false,
want: state{"pro", PlanSourceManual, "2030-01-01T00:00:00Z"},
},
{
name: "stripe free vs a stripe pro goes free",
start: state{"pro", PlanSourceStripe, "2030-01-01T00:00:00Z"},
write: PlanWrite{Plan: "free", Source: PlanSourceStripe},
wantApplied: true,
want: state{"free", PlanSourceStripe, ""},
},
{
name: "an admin force-lower applies over a stripe pro",
start: state{"pro", PlanSourceStripe, ""},
write: PlanWrite{Plan: "free", Source: PlanSourceManual, Force: true},
wantApplied: true,
want: state{"free", PlanSourceManual, ""},
},
{
name: "an unforced manual free lowers a manual pro",
start: state{"pro", PlanSourceManual, ""},
write: PlanWrite{Plan: "free", Source: PlanSourceManual},
wantApplied: true,
want: state{"free", PlanSourceManual, ""},
},
{
name: "an unforced manual free does not lower a stripe pro",
start: state{"pro", PlanSourceStripe, ""},
write: PlanWrite{Plan: "free", Source: PlanSourceManual},
wantApplied: false,
want: state{"pro", PlanSourceStripe, ""},
},
{
name: "stripe free does not lower a manual self-hosted",
start: state{"self-hosted", PlanSourceManual, ""},
write: PlanWrite{Plan: "free", Source: PlanSourceStripe},
wantApplied: false,
want: state{"self-hosted", PlanSourceManual, ""},
},
{
name: "a raise always applies and takes the source over",
start: state{"free", PlanSourceManual, ""},
write: PlanWrite{Plan: "pro", Source: PlanSourceStripe, ExpiresAt: "2031-01-01T00:00:00Z"},
wantApplied: true,
want: state{"pro", PlanSourceStripe, "2031-01-01T00:00:00Z"},
},
{
name: "a same-level write is not a lowering and takes the source over",
start: state{"pro", PlanSourceManual, ""},
write: PlanWrite{Plan: "pro", Source: PlanSourceStripe},
wantApplied: true,
want: state{"pro", PlanSourceStripe, ""},
},
{
name: "free to free from another source applies",
start: state{"free", PlanSourceManual, ""},
write: PlanWrite{Plan: "free", Source: PlanSourceStripe},
wantApplied: true,
want: state{"free", PlanSourceStripe, ""},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := testStore(t)
u := createTestUser(t, s, "plan-source@example.com", "Plan Source", "s3cret")
if _, err := s.db.Exec(s.q(`UPDATE users SET plan = ?, plan_source = ?, plan_expires_at = ? WHERE id = ?`),
tc.start.plan, tc.start.source, tc.start.expires, u.ID); err != nil {
t.Fatal(err)
}
res, err := s.SetUserPlan(u.ID, tc.write)
if err != nil {
t.Fatalf("SetUserPlan: %v", err)
}
if res.Applied != tc.wantApplied {
t.Errorf("Applied = %v, want %v", res.Applied, tc.wantApplied)
}
if res.Plan != tc.want.plan || res.Source != tc.want.source {
t.Errorf("result = (%q, %q), want (%q, %q)", res.Plan, res.Source, tc.want.plan, tc.want.source)
}
// The row itself, read back through the user loader, not only
// what SetUserPlan reported.
got, err := s.GetUser(u.ID)
if err != nil {
t.Fatal(err)
}
if got.Plan != tc.want.plan || got.PlanSource != tc.want.source || got.PlanExpiresAt != tc.want.expires {
t.Errorf("row = (%q, %q, %q), want (%q, %q, %q)",
got.Plan, got.PlanSource, got.PlanExpiresAt, tc.want.plan, tc.want.source, tc.want.expires)
}
})
}
}
// A new user starts free with source manual (the column default), which is
// what a caller that omits a source writes too.
func TestSetUserPlan_NewUserIsManual(t *testing.T) {
t.Parallel()
s := testStore(t)
u := createTestUser(t, s, "fresh@example.com", "Fresh", "s3cret")
got, err := s.GetUser(u.ID)
if err != nil {
t.Fatal(err)
}
if got.Plan != "free" || got.PlanSource != PlanSourceManual {
t.Fatalf("new user = (%q, %q), want (free, manual)", got.Plan, got.PlanSource)
}
}
func TestSetUserPlan_RefusesInvalidSourceAndMissingUser(t *testing.T) {
t.Parallel()
s := testStore(t)
u := createTestUser(t, s, "bad-source@example.com", "Bad Source", "s3cret")
for _, src := range []string{"", "apple", "Stripe"} {
if _, err := s.SetUserPlan(u.ID, PlanWrite{Plan: "pro", Source: src}); err == nil {
t.Errorf("source %q: want an error, got nil", src)
}
}
got, err := s.GetUser(u.ID)
if err != nil {
t.Fatal(err)
}
if got.Plan != "free" {
t.Errorf("a refused source still wrote: plan = %q", got.Plan)
}
if _, err := s.SetUserPlan("no-such-user", PlanWrite{Plan: "pro", Source: PlanSourceManual}); err == nil {
t.Error("missing user: want an error, got nil")
}
}
// The migration's backfill marks a pro user with a Stripe customer as
// stripe-sourced, so their Stripe cancellation still lowers them. The
// statement is taken from the migration file of the dialect under test and
// run against rows reset to the pre-backfill state.
func TestMigrationPlanSourceBackfill(t *testing.T) {
t.Parallel()
s := testStore(t)
fs, path := migrationsFS, "migrations/101_user_plan_source.sql"
if s.dialect.Driver() != DriverSQLite {
fs, path = pgMigrationsFS, "pgmigrations/075_user_plan_source.sql"
}
body, err := fs.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var backfill string
for _, stmt := range strings.Split(string(body), ";") {
lines := []string{}
for _, l := range strings.Split(stmt, "\n") {
if !strings.HasPrefix(strings.TrimSpace(l), "--") {
lines = append(lines, l)
}
}
if sql := strings.TrimSpace(strings.Join(lines, "\n")); strings.HasPrefix(sql, "UPDATE users") {
backfill = sql
}
}
if backfill == "" {
t.Fatalf("%s: no UPDATE users statement found", path)
}
rows := []struct {
email, plan, customer, want string
}{
{"paying@example.com", "pro", "cus_123", PlanSourceStripe},
{"comped@example.com", "pro", "", PlanSourceManual},
{"lapsed@example.com", "free", "cus_456", PlanSourceManual},
{"selfhost@example.com", "self-hosted", "cus_789", PlanSourceManual},
}
ids := make([]string, len(rows))
for i, r := range rows {
u := createTestUser(t, s, r.email, r.email, "s3cret")
ids[i] = u.ID
if _, err := s.db.Exec(s.q(`UPDATE users SET plan = ?, stripe_customer_id = ?, plan_source = 'manual' WHERE id = ?`),
r.plan, r.customer, u.ID); err != nil {
t.Fatal(err)
}
}
if _, err := s.db.Exec(backfill); err != nil {
t.Fatalf("backfill: %v", err)
}
for i, r := range rows {
got, err := s.GetUser(ids[i])
if err != nil {
t.Fatal(err)
}
if got.PlanSource != r.want {
t.Errorf("%s (plan %q, customer %q): plan_source = %q, want %q", r.email, r.plan, r.customer, got.PlanSource, r.want)
}
}
}
+3 -3
View File
@@ -30,7 +30,7 @@ var usernameCleanRe = regexp.MustCompile(`[^a-z0-9-]+`)
var bcryptCost = 12
// user SELECT columns — used by all user queries.
const userColumns = `id, email, username, name, password_hash, role, avatar_url, totp_secret, totp_enabled, recovery_codes, plan, plan_expires_at, stripe_customer_id, plan_overrides, oauth_providers, password_set, disabled_at, email_verified_at, last_active_at, last_write_at, created_at, updated_at`
const userColumns = `id, email, username, name, password_hash, role, avatar_url, totp_secret, totp_enabled, recovery_codes, plan, plan_expires_at, plan_source, stripe_customer_id, plan_overrides, oauth_providers, password_set, disabled_at, email_verified_at, last_active_at, last_write_at, created_at, updated_at`
// scanUser scans a user row into a User struct.
// Note: does NOT decrypt the TOTP secret — call store.decryptUserTOTP() after
@@ -43,7 +43,7 @@ func scanUser(row interface{ Scan(...interface{}) error }) (*models.User, error)
err := row.Scan(
&u.ID, &u.Email, &u.Username, &u.Name, &u.PasswordHash, &u.Role, &u.AvatarURL,
&u.TOTPSecret, &u.TOTPEnabled, &u.RecoveryCodes,
&u.Plan, &u.PlanExpiresAt, &u.StripeCustomerID, &u.PlanOverrides, &u.OAuthProviders,
&u.Plan, &u.PlanExpiresAt, &u.PlanSource, &u.StripeCustomerID, &u.PlanOverrides, &u.OAuthProviders,
&u.PasswordSet,
&disabledAt, &emailVerifiedAt, &lastActiveAt, &lastWriteAt, &createdAt, &updatedAt,
)
@@ -492,7 +492,7 @@ func (s *Store) SearchUsers(params AdminUserSearchParams) (*AdminUserSearchResul
if err := rows.Scan(
&entry.ID, &entry.Email, &entry.Username, &entry.Name, &entry.PasswordHash, &entry.Role, &entry.AvatarURL,
&entry.TOTPSecret, &entry.TOTPEnabled, &entry.RecoveryCodes,
&entry.Plan, &entry.PlanExpiresAt, &entry.StripeCustomerID, &entry.PlanOverrides, &entry.OAuthProviders,
&entry.Plan, &entry.PlanExpiresAt, &entry.PlanSource, &entry.StripeCustomerID, &entry.PlanOverrides, &entry.OAuthProviders,
&entry.PasswordSet,
&disabledAt, &emailVerifiedAt, &lastActiveAt, &lastWriteAt, &createdAt, &updatedAt,
&workspaceCount, &storageBytes,