Files
pad/internal/server/handlers_plan_source_test.go
T
xarmian 249e44d18e feat(billing): record plan_source; a lowering applies only from the source that set the plan (TASK-3295) (#1657)
* 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

* fix(billing): the startup plan backfill takes plan_source over (TASK-3295)

Codex round 1 on 28c036de (P2): BackfillUserPlans("self-hosted") raises
free and blank plans to self-hosted without touching plan_source, which
bypasses the rule that a non-lowering write takes the source over. A free
user carrying a stripe source would become self-hosted still labelled
stripe. Both backfill branches now write plan_source = manual on every row
they change, and rows they skip keep their source (pinned by
TestBackfillUserPlans_TakesSourceOver).

The same round's P1 (pad-cloud does not send `source`) is the sidecar half
of this unit, which lands in pad-cloud. It also corrects the deploy order
stated in 28c036de: pad-cloud must deploy FIRST. A new pad with an old
sidecar receives a Stripe cancellation as `manual`, and for a user the
migration backfilled to `stripe` that is a refused lowering: they would
keep pro after they stopped paying. A new sidecar with an old pad is
harmless, because the old pad ignores `source`.

Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
2026-09-29 10:14:38 -04:00

149 lines
5.0 KiB
Go

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)
}
}