Skip to content

Commit 0d758ea

Browse files
authored
feat(branches): entitlement-aware billing links (#5040)
2 parents b984cd5 + 837e2fe commit 0d758ea

File tree

6 files changed

+292
-0
lines changed

6 files changed

+292
-0
lines changed

internal/branches/create/create.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ func Run(ctx context.Context, body api.CreateBranchBody, fsys afero.Fs) error {
3030
if err != nil {
3131
return errors.Errorf("failed to create preview branch: %w", err)
3232
} else if resp.JSON201 == nil {
33+
utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_limit", resp.StatusCode())
3334
return errors.Errorf("unexpected create branch status %d: %s", resp.StatusCode(), string(resp.Body))
3435
}
3536

internal/branches/create/create_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,45 @@ func TestCreateCommand(t *testing.T) {
7777
// Check error
7878
assert.ErrorContains(t, err, "unexpected create branch status 503:")
7979
})
80+
81+
t.Run("suggests upgrade on payment required", func(t *testing.T) {
82+
t.Cleanup(apitest.MockPlatformAPI(t))
83+
t.Cleanup(func() { utils.CmdSuggestion = "" })
84+
// Mock branches create returns 402
85+
gock.New(utils.DefaultApiHost).
86+
Post("/v1/projects/" + flags.ProjectRef + "/branches").
87+
Reply(http.StatusPaymentRequired).
88+
JSON(map[string]interface{}{"message": "branching requires a paid plan"})
89+
// Mock project lookup for SuggestUpgradeOnError
90+
gock.New(utils.DefaultApiHost).
91+
Get("/v1/projects/" + flags.ProjectRef).
92+
Reply(http.StatusOK).
93+
JSON(map[string]interface{}{
94+
"ref": flags.ProjectRef,
95+
"organization_slug": "test-org",
96+
"name": "test",
97+
"region": "us-east-1",
98+
"created_at": "2024-01-01T00:00:00Z",
99+
"status": "ACTIVE_HEALTHY",
100+
"database": map[string]interface{}{"host": "db.example.supabase.co", "version": "15.1.0.117"},
101+
})
102+
// Mock entitlements
103+
gock.New(utils.DefaultApiHost).
104+
Get("/v1/organizations/test-org/entitlements").
105+
Reply(http.StatusOK).
106+
JSON(map[string]interface{}{
107+
"entitlements": []map[string]interface{}{
108+
{
109+
"feature": map[string]interface{}{"key": "branching_limit", "type": "numeric"},
110+
"hasAccess": false,
111+
"type": "numeric",
112+
"config": map[string]interface{}{"enabled": false, "value": 0, "unlimited": false, "unit": "count"},
113+
},
114+
},
115+
})
116+
fsys := afero.NewMemMapFs()
117+
err := Run(context.Background(), api.CreateBranchBody{Region: cast.Ptr("sin")}, fsys)
118+
assert.ErrorContains(t, err, "unexpected create branch status 402")
119+
assert.Contains(t, utils.CmdSuggestion, "/org/test-org/billing")
120+
})
80121
}

internal/branches/update/update.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/supabase/cli/internal/branches/list"
1111
"github.com/supabase/cli/internal/branches/pause"
1212
"github.com/supabase/cli/internal/utils"
13+
"github.com/supabase/cli/internal/utils/flags"
1314
"github.com/supabase/cli/pkg/api"
1415
)
1516

@@ -22,6 +23,7 @@ func Run(ctx context.Context, branchId string, body api.UpdateBranchBody, fsys a
2223
if err != nil {
2324
return errors.Errorf("failed to update preview branch: %w", err)
2425
} else if resp.JSON200 == nil {
26+
utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_persistent", resp.StatusCode())
2527
return errors.Errorf("unexpected update branch status %d: %s", resp.StatusCode(), string(resp.Body))
2628
}
2729
fmt.Fprintln(os.Stderr, "Updated preview branch:")

internal/branches/update/update_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,45 @@ func TestUpdateBranch(t *testing.T) {
106106
err := Run(context.Background(), flags.ProjectRef, api.UpdateBranchBody{}, nil)
107107
assert.ErrorContains(t, err, "unexpected update branch status 503:")
108108
})
109+
110+
t.Run("suggests upgrade on payment required for persistent", func(t *testing.T) {
111+
t.Cleanup(apitest.MockPlatformAPI(t))
112+
t.Cleanup(func() { utils.CmdSuggestion = "" })
113+
// Mock branch update returns 402
114+
gock.New(utils.DefaultApiHost).
115+
Patch("/v1/branches/" + flags.ProjectRef).
116+
Reply(http.StatusPaymentRequired).
117+
JSON(map[string]interface{}{"message": "Persistent branches are not available on your plan"})
118+
// Mock project lookup for SuggestUpgradeOnError
119+
gock.New(utils.DefaultApiHost).
120+
Get("/v1/projects/" + flags.ProjectRef).
121+
Reply(http.StatusOK).
122+
JSON(map[string]interface{}{
123+
"ref": flags.ProjectRef,
124+
"organization_slug": "test-org",
125+
"name": "test",
126+
"region": "us-east-1",
127+
"created_at": "2024-01-01T00:00:00Z",
128+
"status": "ACTIVE_HEALTHY",
129+
"database": map[string]interface{}{"host": "db.example.supabase.co", "version": "15.1.0.117"},
130+
})
131+
// Mock entitlements
132+
gock.New(utils.DefaultApiHost).
133+
Get("/v1/organizations/test-org/entitlements").
134+
Reply(http.StatusOK).
135+
JSON(map[string]interface{}{
136+
"entitlements": []map[string]interface{}{
137+
{
138+
"feature": map[string]interface{}{"key": "branching_persistent", "type": "boolean"},
139+
"hasAccess": false,
140+
"type": "boolean",
141+
"config": map[string]interface{}{"enabled": false},
142+
},
143+
},
144+
})
145+
persistent := true
146+
err := Run(context.Background(), flags.ProjectRef, api.UpdateBranchBody{Persistent: &persistent}, nil)
147+
assert.ErrorContains(t, err, "unexpected update branch status 402")
148+
assert.Contains(t, utils.CmdSuggestion, "/org/test-org/billing")
149+
})
109150
}

internal/utils/plan_gate.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package utils
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
)
8+
9+
func GetOrgSlugFromProjectRef(ctx context.Context, projectRef string) (string, error) {
10+
resp, err := GetSupabase().V1GetProjectWithResponse(ctx, projectRef)
11+
if err != nil {
12+
return "", fmt.Errorf("failed to get project: %w", err)
13+
}
14+
if resp.JSON200 == nil {
15+
return "", fmt.Errorf("unexpected get project status %d: %s", resp.StatusCode(), string(resp.Body))
16+
}
17+
return resp.JSON200.OrganizationSlug, nil
18+
}
19+
20+
func GetOrgBillingURL(orgSlug string) string {
21+
return fmt.Sprintf("%s/org/%s/billing", GetSupabaseDashboardURL(), orgSlug)
22+
}
23+
24+
// SuggestUpgradeOnError checks if a failed API response is due to plan limitations
25+
// and sets CmdSuggestion with a billing upgrade link. Best-effort: never returns errors.
26+
// Only triggers on 402 Payment Required (not 403, which could be a permissions issue).
27+
func SuggestUpgradeOnError(ctx context.Context, projectRef, featureKey string, statusCode int) {
28+
if statusCode != http.StatusPaymentRequired {
29+
return
30+
}
31+
32+
orgSlug, err := GetOrgSlugFromProjectRef(ctx, projectRef)
33+
if err != nil {
34+
CmdSuggestion = fmt.Sprintf("This feature may require a plan upgrade. Manage billing: %s", Bold(GetSupabaseDashboardURL()))
35+
return
36+
}
37+
38+
billingURL := GetOrgBillingURL(orgSlug)
39+
40+
resp, err := GetSupabase().V1GetOrganizationEntitlementsWithResponse(ctx, orgSlug)
41+
if err != nil || resp.JSON200 == nil {
42+
CmdSuggestion = fmt.Sprintf("This feature may require a plan upgrade. Manage billing: %s", Bold(billingURL))
43+
return
44+
}
45+
46+
for _, e := range resp.JSON200.Entitlements {
47+
if string(e.Feature.Key) == featureKey && !e.HasAccess {
48+
CmdSuggestion = fmt.Sprintf("Your organization does not have access to this feature. Upgrade your plan: %s", Bold(billingURL))
49+
return
50+
}
51+
}
52+
53+
CmdSuggestion = fmt.Sprintf("This feature may require a plan upgrade. Manage billing: %s", Bold(billingURL))
54+
}

internal/utils/plan_gate_test.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package utils
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"testing"
7+
8+
"github.com/h2non/gock"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/supabase/cli/internal/testing/apitest"
11+
)
12+
13+
var planGateProjectJSON = map[string]interface{}{
14+
"ref": "test-ref",
15+
"organization_slug": "my-org",
16+
"name": "test",
17+
"region": "us-east-1",
18+
"created_at": "2024-01-01T00:00:00Z",
19+
"status": "ACTIVE_HEALTHY",
20+
"database": map[string]interface{}{"host": "db.example.supabase.co", "version": "15.1.0.117"},
21+
}
22+
23+
func TestGetOrgSlugFromProjectRef(t *testing.T) {
24+
ref := apitest.RandomProjectRef()
25+
26+
t.Run("returns org slug on success", func(t *testing.T) {
27+
t.Cleanup(apitest.MockPlatformAPI(t))
28+
gock.New(DefaultApiHost).
29+
Get("/v1/projects/" + ref).
30+
Reply(http.StatusOK).
31+
JSON(planGateProjectJSON)
32+
slug, err := GetOrgSlugFromProjectRef(context.Background(), ref)
33+
assert.NoError(t, err)
34+
assert.Equal(t, "my-org", slug)
35+
})
36+
37+
t.Run("returns error on not found", func(t *testing.T) {
38+
t.Cleanup(apitest.MockPlatformAPI(t))
39+
gock.New(DefaultApiHost).
40+
Get("/v1/projects/" + ref).
41+
Reply(http.StatusNotFound)
42+
_, err := GetOrgSlugFromProjectRef(context.Background(), ref)
43+
assert.ErrorContains(t, err, "unexpected get project status 404")
44+
})
45+
46+
t.Run("returns error on network failure", func(t *testing.T) {
47+
t.Cleanup(apitest.MockPlatformAPI(t))
48+
gock.New(DefaultApiHost).
49+
Get("/v1/projects/" + ref).
50+
ReplyError(assert.AnError)
51+
_, err := GetOrgSlugFromProjectRef(context.Background(), ref)
52+
assert.ErrorContains(t, err, "failed to get project")
53+
})
54+
}
55+
56+
func TestGetOrgBillingURL(t *testing.T) {
57+
url := GetOrgBillingURL("my-org")
58+
assert.Equal(t, GetSupabaseDashboardURL()+"/org/my-org/billing", url)
59+
}
60+
61+
func entitlementsJSON(featureKey string, hasAccess bool) map[string]interface{} {
62+
return map[string]interface{}{
63+
"entitlements": []map[string]interface{}{
64+
{
65+
"feature": map[string]interface{}{"key": featureKey, "type": "numeric"},
66+
"hasAccess": hasAccess,
67+
"type": "numeric",
68+
"config": map[string]interface{}{"enabled": hasAccess, "value": 0, "unlimited": false, "unit": "count"},
69+
},
70+
},
71+
}
72+
}
73+
74+
func TestSuggestUpgradeOnError(t *testing.T) {
75+
ref := apitest.RandomProjectRef()
76+
77+
t.Run("sets specific suggestion on 402 with gated feature", func(t *testing.T) {
78+
t.Cleanup(apitest.MockPlatformAPI(t))
79+
t.Cleanup(func() { CmdSuggestion = "" })
80+
gock.New(DefaultApiHost).
81+
Get("/v1/projects/" + ref).
82+
Reply(http.StatusOK).
83+
JSON(planGateProjectJSON)
84+
gock.New(DefaultApiHost).
85+
Get("/v1/organizations/my-org/entitlements").
86+
Reply(http.StatusOK).
87+
JSON(entitlementsJSON("branching_limit", false))
88+
SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired)
89+
assert.Contains(t, CmdSuggestion, "/org/my-org/billing")
90+
assert.Contains(t, CmdSuggestion, "does not have access")
91+
})
92+
93+
t.Run("sets generic suggestion when entitlements lookup fails", func(t *testing.T) {
94+
t.Cleanup(apitest.MockPlatformAPI(t))
95+
t.Cleanup(func() { CmdSuggestion = "" })
96+
gock.New(DefaultApiHost).
97+
Get("/v1/projects/" + ref).
98+
Reply(http.StatusOK).
99+
JSON(planGateProjectJSON)
100+
gock.New(DefaultApiHost).
101+
Get("/v1/organizations/my-org/entitlements").
102+
Reply(http.StatusInternalServerError)
103+
SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired)
104+
assert.Contains(t, CmdSuggestion, "/org/my-org/billing")
105+
assert.Contains(t, CmdSuggestion, "may require a plan upgrade")
106+
})
107+
108+
t.Run("sets fallback suggestion when project lookup fails", func(t *testing.T) {
109+
t.Cleanup(apitest.MockPlatformAPI(t))
110+
t.Cleanup(func() { CmdSuggestion = "" })
111+
gock.New(DefaultApiHost).
112+
Get("/v1/projects/" + ref).
113+
Reply(http.StatusNotFound)
114+
SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired)
115+
assert.Contains(t, CmdSuggestion, "plan upgrade")
116+
assert.Contains(t, CmdSuggestion, GetSupabaseDashboardURL())
117+
assert.NotContains(t, CmdSuggestion, "/org/")
118+
})
119+
120+
t.Run("sets generic suggestion when feature has access", func(t *testing.T) {
121+
t.Cleanup(apitest.MockPlatformAPI(t))
122+
t.Cleanup(func() { CmdSuggestion = "" })
123+
gock.New(DefaultApiHost).
124+
Get("/v1/projects/" + ref).
125+
Reply(http.StatusOK).
126+
JSON(planGateProjectJSON)
127+
gock.New(DefaultApiHost).
128+
Get("/v1/organizations/my-org/entitlements").
129+
Reply(http.StatusOK).
130+
JSON(entitlementsJSON("branching_limit", true))
131+
SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired)
132+
assert.Contains(t, CmdSuggestion, "/org/my-org/billing")
133+
assert.Contains(t, CmdSuggestion, "may require a plan upgrade")
134+
})
135+
136+
t.Run("skips suggestion on 403 forbidden", func(t *testing.T) {
137+
CmdSuggestion = ""
138+
SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusForbidden)
139+
assert.Empty(t, CmdSuggestion)
140+
})
141+
142+
t.Run("skips suggestion on non-billing status codes", func(t *testing.T) {
143+
CmdSuggestion = ""
144+
SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusInternalServerError)
145+
assert.Empty(t, CmdSuggestion)
146+
})
147+
148+
t.Run("skips suggestion on success status codes", func(t *testing.T) {
149+
CmdSuggestion = ""
150+
SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusOK)
151+
assert.Empty(t, CmdSuggestion)
152+
})
153+
}

0 commit comments

Comments
 (0)