Skip to content

Commit 1cb3c80

Browse files
authored
feat: add Stripe-based billing subscription management (#23)
* feat: add billing commands and quota checks for app deployment * refactor: use dedicated billing API client for billing operations with environment-specific auth * fix: handle nullable fields in subscription response to prevent nil pointer dereference * feat: display cancellation details and resubscribe prompt for canceled subscriptions * fix: prevent displaying next charge when upcoming invoice date is zero * refactor: replace custom browser opener with pkg/browser library and add portal URL display * full suspension support * feat: update billing commands for per-environment subscriptions - Refactored subscription management to handle subscriptions per environment instead of globally - Updated status command to show subscription details and app usage for current environment only - Modified cancel command to suspend apps only on current environment when canceling - Added improved handling of different subscription states (past_due, unpaid, etc.) with portal links - Enhanced status display formatting with clearer subscription * feat: improve billing status display format * add environment flag to billing commands * remove "USD" * improve billing status code organization and readability * fix: filename spelling
1 parent 1fdfde4 commit 1cb3c80

File tree

18 files changed

+665
-32
lines changed

18 files changed

+665
-32
lines changed

cmd/eigenx/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ func main() {
6868
Commands: []*cli.Command{
6969
commands.AppCommand,
7070
commands.AuthCommand,
71+
commands.BillingCommand,
7172
commands.EnvironmentCommand,
7273
version.VersionCommand,
7374
commands.UndelegateCommand,

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ go 1.24.0
55
toolchain go1.24.5
66

77
require (
8-
github.com/Layr-Labs/eigenx-contracts v0.0.0-20250929214419-f0d751f5c9bc
8+
github.com/Layr-Labs/eigenx-contracts v0.0.0-20251024194654-82395e726316
99
github.com/Layr-Labs/eigenx-kms v0.0.0-20251101091130-b725a6aaa815
1010
github.com/fatih/color v1.16.0
1111
github.com/google/go-containerregistry v0.20.6
@@ -90,6 +90,7 @@ require (
9090
github.com/mr-tron/base58 v1.2.0 // indirect
9191
github.com/opencontainers/go-digest v1.0.0 // indirect
9292
github.com/opencontainers/image-spec v1.1.1 // indirect
93+
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
9394
github.com/pkg/errors v0.9.1 // indirect
9495
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
9596
github.com/rivo/uniseg v0.4.7 // indirect

go.sum

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg6
99
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
1010
github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
1111
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
12-
github.com/Layr-Labs/eigenx-contracts v0.0.0-20250929214419-f0d751f5c9bc h1:sVrs4eGkhy7x6phdPdBaKaQxMapERXWXBRsIUhswZS8=
13-
github.com/Layr-Labs/eigenx-contracts v0.0.0-20250929214419-f0d751f5c9bc/go.mod h1:fcLQZBQ4RpTXv3Kz4cO/raRxKrfWUC+r8hSB94pSKFc=
12+
github.com/Layr-Labs/eigenx-contracts v0.0.0-20251024194654-82395e726316 h1:s4kSOoXaWfyYP0Tl5Qx8O7lriYWqZ0eXp2szbWznO2s=
13+
github.com/Layr-Labs/eigenx-contracts v0.0.0-20251024194654-82395e726316/go.mod h1:fcLQZBQ4RpTXv3Kz4cO/raRxKrfWUC+r8hSB94pSKFc=
1414
github.com/Layr-Labs/eigenx-kms v0.0.0-20250929214127-72a46e110b30 h1:JoqFUpny7myDIg8Wf2ymzTpkB+WMCJIMtl7S9DOpPtU=
1515
github.com/Layr-Labs/eigenx-kms v0.0.0-20250929214127-72a46e110b30/go.mod h1:Wi6n3O6+6iJWHQBr0ax901sz+BQsCSc9lM+Bs5yrN6A=
1616
github.com/Layr-Labs/eigenx-kms v0.0.0-20251021053316-00feddf36619 h1:2w1MyEjorYagro7BveY19RTKT5VrlU/gwetk/IPdGOo=
@@ -366,6 +366,8 @@ github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N
366366
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
367367
github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM=
368368
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
369+
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
370+
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
369371
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
370372
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
371373
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=

pkg/commands/app/deploy.go

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,58 +36,63 @@ func deployAction(cCtx *cli.Context) error {
3636
return err
3737
}
3838

39-
// 2. Check if docker is running, else try to start it
39+
// 2. Check quota availability
40+
if err := checkQuotaAvailable(cCtx, preflightCtx); err != nil {
41+
return err
42+
}
43+
44+
// 3. Check if docker is running, else try to start it
4045
err = common.EnsureDockerIsRunning(cCtx)
4146
if err != nil {
4247
return err
4348
}
4449

45-
// 3. Check for Dockerfile before asking for image reference
50+
// 4. Check for Dockerfile before asking for image reference
4651
dockerfilePath, err := utils.GetDockerfileInteractive(cCtx)
4752
if err != nil {
4853
return fmt.Errorf("failed to get dockerfile path: %w", err)
4954
}
5055
buildFromDockerfile := dockerfilePath != ""
5156

52-
// 4. Get image reference (context-aware based on Dockerfile decision)
57+
// 5. Get image reference (context-aware based on Dockerfile decision)
5358
imageRef, err := utils.GetImageReferenceInteractive(cCtx, 0, buildFromDockerfile)
5459
if err != nil {
5560
return fmt.Errorf("failed to get image reference: %w", err)
5661
}
5762

58-
// 5. Get app name upfront (before any expensive operations)
63+
// 6. Get app name upfront (before any expensive operations)
5964
environment := preflightCtx.EnvironmentConfig.Name
6065
name, err := utils.GetOrPromptAppName(cCtx, environment, imageRef)
6166
if err != nil {
6267
return fmt.Errorf("failed to get app name: %w", err)
6368
}
6469

65-
// 6. Get environment file configuration
70+
// 7. Get environment file configuration
6671
envFilePath, err := utils.GetEnvFileInteractive(cCtx)
6772
if err != nil {
6873
return fmt.Errorf("failed to get env file path: %w", err)
6974
}
7075

71-
// 7. Get instance type selection (uses first from backend as default for new apps)
76+
// 8. Get instance type selection (uses first from backend as default for new apps)
7277
instanceType, err := utils.GetInstanceTypeInteractive(cCtx, "")
7378
if err != nil {
7479
return fmt.Errorf("failed to get instance: %w", err)
7580
}
7681

77-
// 8. Get log settings from flags or interactive prompt
82+
// 9. Get log settings from flags or interactive prompt
7883
logRedirect, publicLogs, err := utils.GetLogSettingsInteractive(cCtx)
7984
if err != nil {
8085
return fmt.Errorf("failed to get log settings: %w", err)
8186
}
8287

83-
// 9. Generate random salt
88+
// 10. Generate random salt
8489
salt := [32]byte{}
8590
_, err = rand.Read(salt[:])
8691
if err != nil {
8792
return fmt.Errorf("failed to generate random salt: %w", err)
8893
}
8994

90-
// 10. Get app ID
95+
// 11. Get app ID
9196
_, appController, err := utils.GetAppControllerBinding(cCtx)
9297
if err != nil {
9398
return fmt.Errorf("failed to get app controller binding: %w", err)
@@ -97,25 +102,55 @@ func deployAction(cCtx *cli.Context) error {
97102
return fmt.Errorf("failed to get app id: %w", err)
98103
}
99104

100-
// 11. Prepare the release (includes build/push if needed, with automatic retry on permission errors)
105+
// 12. Prepare the release (includes build/push if needed, with automatic retry on permission errors)
101106
release, imageRef, err := utils.PrepareReleaseFromContext(cCtx, preflightCtx.EnvironmentConfig, appIDToBeDeployed, dockerfilePath, imageRef, envFilePath, logRedirect, instanceType, 3)
102107
if err != nil {
103108
return err
104109
}
105110

106-
// 12. Deploy the app
111+
// 13. Deploy the app
107112
appID, err := preflightCtx.Caller.DeployApp(cCtx.Context, salt, release, publicLogs, imageRef)
108113
if err != nil {
109114
return fmt.Errorf("failed to deploy app: %w", err)
110115
}
111116

112-
// 13. Save the app name mapping
117+
// 14. Save the app name mapping
113118
if err := common.SetAppName(environment, appID.Hex(), name); err != nil {
114119
logger.Warn("Failed to save app name: %s", err.Error())
115120
} else {
116121
logger.Info("App saved with name: %s", name)
117122
}
118123

119-
// 14. Watch until deployment completes
124+
// 15. Watch until deployment completes
120125
return utils.WatchUntilTransitionComplete(cCtx, appID, common.AppStatusDeploying)
121126
}
127+
128+
// checkQuotaAvailable verifies that the user has deployment quota available
129+
// by checking their allowlist status on the contract
130+
func checkQuotaAvailable(cCtx *cli.Context, preflightCtx *utils.PreflightContext) error {
131+
ctx := cCtx.Context
132+
133+
// Check user's quota limit from contract
134+
maxQuota, err := preflightCtx.Caller.GetMaxActiveAppsPerUser(ctx, preflightCtx.Caller.SelfAddress)
135+
if err != nil {
136+
return fmt.Errorf("failed to get quota limit: %w", err)
137+
}
138+
139+
// If quota is 0, user needs to subscribe
140+
if maxQuota == 0 {
141+
return fmt.Errorf("no app quota available. Run 'eigenx billing subscribe' to enable app deployment")
142+
}
143+
144+
// Check current active app count from contract
145+
activeCount, err := preflightCtx.Caller.GetActiveAppCount(ctx, preflightCtx.Caller.SelfAddress)
146+
if err != nil {
147+
return fmt.Errorf("failed to get active app count: %w", err)
148+
}
149+
150+
// Check if quota is reached
151+
if activeCount >= maxQuota {
152+
return fmt.Errorf("app quota reached for %s (%d/%d). Please contact the Eigen team at [email protected] for additional capacity", preflightCtx.EnvironmentConfig.Name, activeCount, maxQuota)
153+
}
154+
155+
return nil
156+
}

pkg/commands/app/info.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ func logsAction(cCtx *cli.Context) error {
203203
case common.AppStatusStopping:
204204
logger.Info("%s is currently stopping. Logs may be limited.", formattedApp)
205205
return nil
206-
case common.AppStatusStopped, common.AppStatusTerminating, common.AppStatusTerminated:
206+
case common.AppStatusStopped, common.AppStatusTerminating, common.AppStatusTerminated, common.AppStatusSuspended:
207207
logger.Info("%s is %s. Logs are not available.", formattedApp, strings.ToLower(status))
208208
return nil
209209
case common.AppStatusFailed:

pkg/commands/app/lifecycle.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ var StopCommand = &cli.Command{
3434

3535
var TerminateCommand = &cli.Command{
3636
Name: "terminate",
37-
Usage: "Terminate app (terminate GCP instance)",
37+
Usage: "Terminate app (terminate GCP instance) permanently",
3838
ArgsUsage: "[app-id|name]",
3939
Flags: append(common.GlobalFlags, []cli.Flag{
4040
common.EnvironmentFlag,

pkg/commands/billing.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package commands
2+
3+
import (
4+
"github.com/Layr-Labs/eigenx-cli/pkg/commands/billing"
5+
"github.com/urfave/cli/v2"
6+
)
7+
8+
var BillingCommand = &cli.Command{
9+
Name: "billing",
10+
Usage: "Manage billing and subscription",
11+
Subcommands: []*cli.Command{
12+
billing.SubscribeCommand,
13+
billing.CancelCommand,
14+
billing.StatusCommand,
15+
},
16+
}

pkg/commands/billing/cancel.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package billing
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/Layr-Labs/eigenx-cli/pkg/commands/utils"
8+
"github.com/Layr-Labs/eigenx-cli/pkg/common"
9+
"github.com/Layr-Labs/eigenx-cli/pkg/common/output"
10+
ethcommon "github.com/ethereum/go-ethereum/common"
11+
"github.com/urfave/cli/v2"
12+
)
13+
14+
var CancelCommand = &cli.Command{
15+
Name: "cancel",
16+
Usage: "Cancel subscription",
17+
Flags: append(common.GlobalFlags, []cli.Flag{
18+
common.EnvironmentFlag,
19+
}...),
20+
Action: func(cCtx *cli.Context) error {
21+
ctx := cCtx.Context
22+
logger := common.LoggerFromContext(cCtx)
23+
environmentConfig, err := utils.GetEnvironmentConfig(cCtx)
24+
if err != nil {
25+
return fmt.Errorf("failed to get environment config: %w", err)
26+
}
27+
envName := environmentConfig.Name
28+
29+
// Get API client
30+
apiClient, err := utils.NewUserApiClient(cCtx)
31+
if err != nil {
32+
return fmt.Errorf("failed to create API client: %w", err)
33+
}
34+
35+
// Check current subscription status
36+
subscription, err := apiClient.GetUserSubscription(cCtx)
37+
if err != nil {
38+
return fmt.Errorf("failed to check subscription status: %w", err)
39+
}
40+
41+
if !isSubscriptionActive(subscription.Status) {
42+
logger.Info("You don't have an active subscription on %s.", envName)
43+
return nil
44+
}
45+
46+
// Get contract caller for current environment
47+
caller, err := utils.GetContractCaller(cCtx)
48+
if err != nil {
49+
return fmt.Errorf("failed to get contract caller: %w", err)
50+
}
51+
52+
// Get developer address
53+
developerAddr, err := utils.GetDeveloperAddress(cCtx)
54+
if err != nil {
55+
return fmt.Errorf("failed to get developer address: %w", err)
56+
}
57+
58+
// Check active apps on current environment
59+
activeAppCount, err := caller.GetActiveAppCount(ctx, developerAddr)
60+
if err != nil {
61+
return fmt.Errorf("failed to get active app count: %w", err)
62+
}
63+
64+
// If apps exist, show warning and get confirmation
65+
if activeAppCount > 0 {
66+
logger.Info("You have %d active app(s) on %s that will be suspended.", activeAppCount, envName)
67+
logger.Info("")
68+
69+
confirmed, err := output.Confirm("Continue?")
70+
if err != nil {
71+
return fmt.Errorf("failed to get confirmation: %w", err)
72+
}
73+
74+
if !confirmed {
75+
logger.Info("Cancellation aborted.")
76+
return nil
77+
}
78+
79+
// Get only active apps for this developer
80+
activeApps, err := getActiveAppsByCreator(ctx, caller, developerAddr)
81+
if err != nil {
82+
return fmt.Errorf("failed to get active apps: %w", err)
83+
}
84+
85+
if len(activeApps) > 0 {
86+
logger.Info("Suspending apps...")
87+
88+
// Suspend only active apps
89+
err = caller.Suspend(ctx, developerAddr, activeApps)
90+
if err != nil {
91+
return fmt.Errorf("failed to suspend apps: %w", err)
92+
}
93+
94+
logger.Info("✓ Apps suspended")
95+
}
96+
} else {
97+
// No active apps, just confirm cancellation
98+
logger.Warn("Canceling your subscription on %s will prevent you from deploying new apps.", envName)
99+
confirmed, err := output.Confirm("Are you sure you want to cancel your subscription?")
100+
if err != nil {
101+
return fmt.Errorf("failed to get confirmation: %w", err)
102+
}
103+
104+
if !confirmed {
105+
logger.Info("Cancellation aborted.")
106+
return nil
107+
}
108+
}
109+
110+
// Cancel subscription via API
111+
logger.Info("Canceling subscription...")
112+
if err := apiClient.CancelSubscription(cCtx); err != nil {
113+
return fmt.Errorf("failed to cancel subscription: %w", err)
114+
}
115+
116+
logger.Info("\n✓ Subscription canceled successfully for %s.", envName)
117+
return nil
118+
},
119+
}
120+
121+
// getActiveAppsByCreator retrieves only the active apps (STARTED/STOPPED) for a creator
122+
func getActiveAppsByCreator(ctx context.Context, caller *common.ContractCaller, creator ethcommon.Address) ([]ethcommon.Address, error) {
123+
// Get all apps for this creator on this network
124+
allApps, appConfigs, err := caller.GetAppsByCreator(ctx, creator, 0, 1_000)
125+
if err != nil {
126+
return nil, fmt.Errorf("failed to get apps by creator: %w", err)
127+
}
128+
129+
// Filter to only active apps (STARTED/STOPPED)
130+
var activeApps []ethcommon.Address
131+
for i, app := range allApps {
132+
config := appConfigs[i]
133+
status := common.AppStatus(config.Status)
134+
if status == common.ContractAppStatusStarted || status == common.ContractAppStatusStopped {
135+
activeApps = append(activeApps, app)
136+
}
137+
}
138+
return activeApps, nil
139+
}

0 commit comments

Comments
 (0)