Skip to content

Commit ee8026a

Browse files
authored
Merge pull request #2735 from helixml/feat/wallet-plan-override
feat(quota): admin-granted paid plans via wallet PlanOverride (no Stripe)
2 parents 094f846 + 335ebcc commit ee8026a

18 files changed

Lines changed: 796 additions & 15 deletions

api/pkg/quota/quota.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ func (m *DefaultQuotaManager) getOrgQuotas(ctx context.Context, orgID string) (*
6060
MaxSpecTasks: -1,
6161
}
6262
// Active subscription
63+
// Admin plan override (paid out-of-band; independent of Stripe, so it's
64+
// never reverted by a webhook). Takes precedence over the subscription.
65+
case wallet.PlanOverride == types.PlanOverridePro:
66+
quotas = m.getProQuotas()
67+
case wallet.PlanOverride == types.PlanOverrideFree:
68+
quotas = m.getFreeQuotas()
6369
case wallet.StripeSubscriptionID != "" && wallet.IsSubscriptionActive():
6470
// Paid plan limits
6571
quotas = m.getProQuotas()
@@ -130,6 +136,12 @@ func (m *DefaultQuotaManager) getUserQuotas(ctx context.Context, userID string)
130136
MaxRepositories: -1,
131137
MaxSpecTasks: -1,
132138
}
139+
// Admin plan override (paid out-of-band; independent of Stripe, so it's
140+
// never reverted by a webhook). Takes precedence over the subscription.
141+
case wallet.PlanOverride == types.PlanOverridePro:
142+
quotas = m.getProQuotas()
143+
case wallet.PlanOverride == types.PlanOverrideFree:
144+
quotas = m.getFreeQuotas()
133145
case wallet.StripeSubscriptionID != "" && wallet.IsSubscriptionActive():
134146
// Paid plan limits
135147
quotas = m.getProQuotas()

api/pkg/quota/quota_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,29 @@ func (s *QuotaManagerSuite) TestGetQuotas_UserTrialingGetsProQuotas() {
161161
s.Equal(20, resp.MaxProjects)
162162
}
163163

164+
// An admin PlanOverride="pro" grants Pro with NO Stripe subscription at all —
165+
// the out-of-band-paid-customer case.
166+
func (s *QuotaManagerSuite) TestGetQuotas_PlanOverrideProGrantsProWithoutSub() {
167+
w := &types.Wallet{UserID: "user1", PlanOverride: types.PlanOverridePro}
168+
s.expectUserQuotaDefaults("user1", w, enforceQuotasSettings())
169+
170+
resp, err := s.manager.GetQuotas(context.Background(), &types.QuotaRequest{UserID: "user1"})
171+
s.NoError(err)
172+
s.Equal(5, resp.MaxConcurrentDesktops, "PlanOverride=pro grants Pro without any subscription")
173+
}
174+
175+
// PlanOverride="free" forces Free even when an active subscription exists
176+
// (override takes precedence over the Stripe-derived tier).
177+
func (s *QuotaManagerSuite) TestGetQuotas_PlanOverrideFreeBeatsActiveSub() {
178+
w := proWallet("user1")
179+
w.PlanOverride = types.PlanOverrideFree
180+
s.expectUserQuotaDefaults("user1", w, enforceQuotasSettings())
181+
182+
resp, err := s.manager.GetQuotas(context.Background(), &types.QuotaRequest{UserID: "user1"})
183+
s.NoError(err)
184+
s.Equal(2, resp.MaxConcurrentDesktops, "PlanOverride=free forces Free even with an active sub")
185+
}
186+
164187
func (s *QuotaManagerSuite) TestGetQuotas_UserQuotasDisabled() {
165188
s.expectUserQuotaDefaults("user1", freeWallet("user1"), disabledQuotasSettings())
166189

api/pkg/server/admin_org_handlers.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,69 @@
11
package server
22

33
import (
4+
"encoding/json"
45
"net/http"
56

7+
"github.com/gorilla/mux"
68
"github.com/helixml/helix/api/pkg/store"
79
"github.com/helixml/helix/api/pkg/types"
810
"github.com/rs/zerolog/log"
911
)
1012

13+
// SetOrgPlanRequest is the body for POST /admin/orgs/{id}/plan.
14+
type SetOrgPlanRequest struct {
15+
// Plan: "pro" | "free" forces the org's quota tier independent of Stripe
16+
// (for customers who paid out-of-band). "" clears the override and reverts
17+
// to the Stripe-derived tier.
18+
Plan string `json:"plan"`
19+
}
20+
21+
// adminSetOrgPlan godoc
22+
// @Summary Set an organization's plan override (admin only)
23+
// @Description Force an org's quota tier independent of Stripe — for customers who paid out-of-band. plan: "pro" | "free" | "" (clear). Never reverted by a Stripe webhook.
24+
// @Tags organizations
25+
// @Param id path string true "Organization ID"
26+
// @Param request body SetOrgPlanRequest true "Plan override"
27+
// @Success 200 {object} types.Wallet
28+
// @Router /api/v1/admin/orgs/{id}/plan [post]
29+
// @Security BearerAuth
30+
func (apiServer *HelixAPIServer) adminSetOrgPlan(rw http.ResponseWriter, r *http.Request) {
31+
orgID := mux.Vars(r)["id"]
32+
if orgID == "" {
33+
http.Error(rw, "organization id is required", http.StatusBadRequest)
34+
return
35+
}
36+
var body SetOrgPlanRequest
37+
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
38+
http.Error(rw, "invalid request body: "+err.Error(), http.StatusBadRequest)
39+
return
40+
}
41+
switch body.Plan {
42+
case "", types.PlanOverridePro, types.PlanOverrideFree:
43+
default:
44+
http.Error(rw, "plan must be one of: pro, free, or empty", http.StatusBadRequest)
45+
return
46+
}
47+
48+
adminUser := getRequestUser(r)
49+
wallet, err := apiServer.getOrCreateWallet(r.Context(), adminUser, orgID)
50+
if err != nil {
51+
log.Err(err).Str("org_id", orgID).Msg("failed to get/create org wallet")
52+
http.Error(rw, "failed to get org wallet: "+err.Error(), http.StatusInternalServerError)
53+
return
54+
}
55+
wallet.PlanOverride = body.Plan
56+
updated, err := apiServer.Store.UpdateWallet(r.Context(), wallet)
57+
if err != nil {
58+
log.Err(err).Str("org_id", orgID).Msg("failed to update org wallet plan override")
59+
http.Error(rw, "Internal server error: "+err.Error(), http.StatusInternalServerError)
60+
return
61+
}
62+
log.Info().Str("admin_id", adminUser.ID).Str("org_id", orgID).Str("plan", body.Plan).
63+
Msg("admin set org plan override")
64+
writeResponse(rw, updated, http.StatusOK)
65+
}
66+
1167
// adminListOrganizations godoc
1268
// @Summary List organizations with wallets (admin only)
1369
// @Description List all organizations

api/pkg/server/admin_trial_handlers.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ const (
2727
type ActivateTrialRequest struct {
2828
Days int `json:"days"`
2929
Credits float64 `json:"credits"`
30+
// Plan selects what to grant. "pro" grants a PAID plan via a PlanOverride
31+
// (no Stripe subscription) — for customers who paid out-of-band (bank
32+
// transfer). Empty or "trial" uses the Stripe trial path (Days applies).
33+
Plan string `json:"plan,omitempty"`
3034
}
3135

3236
// ActivateTrialResponse describes the outcome of a trial activation.
@@ -129,6 +133,41 @@ func (s *HelixAPIServer) consumeUserTrialIntent(ctx context.Context, user *types
129133
Msg(fmt.Sprintf("admin-granted trial subscription created for %d days", days))
130134
}
131135

136+
// consumeUserPlanOnFirstOrg applies any admin-stashed paid-plan intent
137+
// (PlanOnFirstOrg) to the given org's wallet as a PlanOverride. Called after an
138+
// org is created. Best-effort: errors are logged, never block org creation.
139+
// Independent of the Stripe trial path — a paid out-of-band grant needs no
140+
// subscription.
141+
func (s *HelixAPIServer) consumeUserPlanOnFirstOrg(ctx context.Context, user *types.User, orgID string) {
142+
if user == nil || user.PlanOnFirstOrg == nil || *user.PlanOnFirstOrg == "" {
143+
return
144+
}
145+
plan := *user.PlanOnFirstOrg
146+
147+
wallet, err := s.getOrCreateWallet(ctx, user, orgID)
148+
if err != nil {
149+
log.Warn().Err(err).Str("user_id", user.ID).Str("org_id", orgID).
150+
Msg("failed to get/create wallet for plan-override consumption")
151+
return
152+
}
153+
wallet.PlanOverride = plan
154+
if _, err := s.Store.UpdateWallet(ctx, wallet); err != nil {
155+
log.Warn().Err(err).Str("wallet_id", wallet.ID).
156+
Msg("failed to persist plan override to wallet")
157+
return
158+
}
159+
160+
user.PlanOnFirstOrg = nil
161+
if _, err := s.Store.UpdateUser(ctx, user); err != nil {
162+
log.Warn().Err(err).Str("user_id", user.ID).
163+
Msg("failed to clear PlanOnFirstOrg after consumption")
164+
return
165+
}
166+
167+
log.Info().Str("user_id", user.ID).Str("org_id", orgID).Str("plan", plan).
168+
Msg("admin-stashed paid plan applied to first org wallet")
169+
}
170+
132171
// adminActivateTrial godoc
133172
// @Summary Activate a trial for a user (Admin, cloud only)
134173
// @Description Stash a trial intent on the user, or immediately create a Stripe trial subscription on the user's oldest-owned org. Days defaults to 90; credits are taken verbatim from the request (0 means no admin top-up beyond what Stripe's subscription invoice contributes).
@@ -186,6 +225,46 @@ func (apiServer *HelixAPIServer) adminActivateTrial(_ http.ResponseWriter, req *
186225
return nil, system.NewHTTPError500("failed to list user organizations: " + err.Error())
187226
}
188227

228+
// Paid plan granted out-of-band (e.g. bank transfer): set a PlanOverride,
229+
// no Stripe subscription. Independent of Stripe so it is never reverted by
230+
// a webhook. Applied to the oldest owned org now, or stashed for the user's
231+
// first org.
232+
if body.Plan == types.PlanOverridePro {
233+
if oldestOrg == nil {
234+
plan := types.PlanOverridePro
235+
targetUser.PlanOnFirstOrg = &plan
236+
if body.Credits > 0 {
237+
c := body.Credits
238+
targetUser.PendingAdminCreditsOnFirstOrg = &c
239+
}
240+
updated, uErr := apiServer.Store.UpdateUser(ctx, targetUser)
241+
if uErr != nil {
242+
return nil, system.NewHTTPError500("failed to stash paid-plan intent: " + uErr.Error())
243+
}
244+
log.Info().Str("admin_id", adminUser.ID).Str("target_user_id", targetUserID).
245+
Msg("admin stashed paid-plan intent on user (no org yet)")
246+
return &ActivateTrialResponse{User: updated, Status: "stashed"}, nil
247+
}
248+
wallet, wErr := apiServer.getOrCreateWallet(ctx, targetUser, oldestOrg.ID)
249+
if wErr != nil {
250+
return nil, system.NewHTTPError500("failed to get wallet for oldest owned org: " + wErr.Error())
251+
}
252+
wallet.PlanOverride = types.PlanOverridePro
253+
if _, wErr := apiServer.Store.UpdateWallet(ctx, wallet); wErr != nil {
254+
return nil, system.NewHTTPError500("failed to set plan override: " + wErr.Error())
255+
}
256+
if body.Credits > 0 {
257+
if _, bErr := apiServer.Store.UpdateWalletBalance(ctx, wallet.ID, body.Credits, types.TransactionMetadata{
258+
TransactionType: types.TransactionTypeSubscription,
259+
}); bErr != nil {
260+
log.Warn().Err(bErr).Str("wallet_id", wallet.ID).Msg("failed to top up wallet with credits")
261+
}
262+
}
263+
log.Info().Str("admin_id", adminUser.ID).Str("target_user_id", targetUserID).Str("org_id", oldestOrg.ID).
264+
Msg("admin granted paid plan (PlanOverride=pro) on oldest owned org")
265+
return &ActivateTrialResponse{User: targetUser, OrgID: oldestOrg.ID, Status: "applied"}, nil
266+
}
267+
189268
// Path A: no owned org yet. Stash intent on the user; consumeUserTrialIntent
190269
// will apply it when they create their first org.
191270
if oldestOrg == nil {

api/pkg/server/docs.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,46 @@ const docTemplate = `{
743743
}
744744
}
745745
},
746+
"/api/v1/admin/orgs/{id}/plan": {
747+
"post": {
748+
"security": [
749+
{
750+
"BearerAuth": []
751+
}
752+
],
753+
"description": "Force an org's quota tier independent of Stripe — for customers who paid out-of-band. plan: \"pro\" | \"free\" | \"\" (clear). Never reverted by a Stripe webhook.",
754+
"tags": [
755+
"organizations"
756+
],
757+
"summary": "Set an organization's plan override (admin only)",
758+
"parameters": [
759+
{
760+
"type": "string",
761+
"description": "Organization ID",
762+
"name": "id",
763+
"in": "path",
764+
"required": true
765+
},
766+
{
767+
"description": "Plan override",
768+
"name": "request",
769+
"in": "body",
770+
"required": true,
771+
"schema": {
772+
"$ref": "#/definitions/server.SetOrgPlanRequest"
773+
}
774+
}
775+
],
776+
"responses": {
777+
"200": {
778+
"description": "OK",
779+
"schema": {
780+
"$ref": "#/definitions/types.Wallet"
781+
}
782+
}
783+
}
784+
}
785+
},
746786
"/api/v1/admin/users/{id}": {
747787
"delete": {
748788
"security": [
@@ -23094,6 +23134,10 @@ const docTemplate = `{
2309423134
},
2309523135
"days": {
2309623136
"type": "integer"
23137+
},
23138+
"plan": {
23139+
"description": "Plan selects what to grant. \"pro\" grants a PAID plan via a PlanOverride\n(no Stripe subscription) — for customers who paid out-of-band (bank\ntransfer). Empty or \"trial\" uses the Stripe trial path (Days applies).",
23140+
"type": "string"
2309723141
}
2309823142
}
2309923143
},
@@ -24970,6 +25014,15 @@ const docTemplate = `{
2497025014
}
2497125015
}
2497225016
},
25017+
"server.SetOrgPlanRequest": {
25018+
"type": "object",
25019+
"properties": {
25020+
"plan": {
25021+
"description": "Plan: \"pro\" | \"free\" forces the org's quota tier independent of Stripe\n(for customers who paid out-of-band). \"\" clears the override and reverts\nto the Stripe-derived tier.",
25022+
"type": "string"
25023+
}
25024+
}
25025+
},
2497325026
"server.SharePointSiteResolveRequest": {
2497425027
"type": "object",
2497525028
"properties": {
@@ -36870,6 +36923,10 @@ const docTemplate = `{
3687036923
"description": "PendingAdminCreditsOnFirstOrg holds credits stashed by admin via the\n/admin/users/{id}/credits endpoint when the user has no owned org yet.\nConsumed by consumeUserAdminCredits on first owned org, then cleared.\nKept separate from TrialCreditsOnFirstOrg so admins can comp credits\nwithout entangling the grant with trial-state UI or revocation flows.",
3687136924
"type": "number"
3687236925
},
36926+
"plan_on_first_org": {
36927+
"description": "PlanOnFirstOrg, when set (\"pro\"), grants a paid plan override to the\nuser's first owned org's wallet on creation — admin \"Activate\" with a\npaid (non-Stripe) plan for a user who has no org yet. Consumed alongside\nthe trial intent, then cleared.",
36928+
"type": "string"
36929+
},
3687336930
"project_id": {
3687436931
"description": "When running in Helix Code sandbox",
3687536932
"type": "string"
@@ -37264,6 +37321,10 @@ const docTemplate = `{
3726437321
"description": "If belongs to an organization",
3726537322
"type": "string"
3726637323
},
37324+
"plan_override": {
37325+
"description": "PlanOverride, when set (\"free\"|\"pro\"), forces the quota tier for this\nwallet regardless of the Stripe subscription — used to grant a paid plan\nto a customer who paid out-of-band (bank transfer, no card / no Stripe).\n\"\" means derive the tier from the Stripe subscription as usual. Stripe\nsync only ever writes the Subscription* fields, never this one, so an\nadmin grant can't be reverted by a webhook.",
37326+
"type": "string"
37327+
},
3726737328
"stripe_customer_id": {
3726837329
"type": "string"
3726937330
},

api/pkg/server/organization_handlers.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,10 @@ func (apiServer *HelixAPIServer) createOrganization(rw http.ResponseWriter, r *h
338338
// without entangling them with trial-state UI or revocation flows.
339339
apiServer.consumeUserAdminCredits(ctx, user, createdOrg.ID)
340340

341+
// Apply any admin-stashed paid-plan grant (PlanOnFirstOrg) as a
342+
// PlanOverride on this first org's wallet (out-of-band paid customers).
343+
apiServer.consumeUserPlanOnFirstOrg(ctx, user, createdOrg.ID)
344+
341345
writeResponse(rw, createdOrg, http.StatusCreated)
342346
}
343347

api/pkg/server/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1344,6 +1344,7 @@ func (apiServer *HelixAPIServer) registerRoutes(ctx context.Context) (*mux.Route
13441344
adminRouter.HandleFunc("/admin/users/{id}/owned-orgs", system.DefaultWrapper(apiServer.listUserOwnedOrgs)).Methods(http.MethodGet)
13451345

13461346
adminRouter.HandleFunc("/admin/orgs", apiServer.adminListOrganizations).Methods(http.MethodGet)
1347+
adminRouter.HandleFunc("/admin/orgs/{id}/plan", apiServer.adminSetOrgPlan).Methods(http.MethodPost)
13471348

13481349
// Sandbox-absorbs-runner pivot: /scheduler/heartbeats and /slots
13491350
// endpoints removed — no scheduler, no slots.

0 commit comments

Comments
 (0)