From 64e4785418b2e86fb878d8f125e582fdb3aa897c Mon Sep 17 00:00:00 2001 From: Luna Date: Tue, 27 Oct 2026 15:25:50 +0000 Subject: [PATCH 1/2] feat(org): add remaining credit balance command --- README.md | 47 ++++++++ go.mod | 6 +- go.sum | 8 +- pkg/api/rpc.go | 4 + pkg/cmd/org/credits.go | 103 ++++++++++++++++ pkg/cmd/org/credits_test.go | 228 ++++++++++++++++++++++++++++++++++++ pkg/cmd/org/org.go | 1 + 7 files changed, 390 insertions(+), 7 deletions(-) create mode 100644 pkg/cmd/org/credits.go create mode 100644 pkg/cmd/org/credits_test.go diff --git a/README.md b/README.md index 74ddb491..fde55ec5 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Official CLI for [Depot](https://depot.dev) - you can use the CLI to build Docke - [`depot login`](#depot-login) - [`depot logout`](#depot-logout) - [`depot org`](#depot-org) + - [`depot org credits`](#depot-org-credits) - [`depot org list`](#depot-org-list) - [`depot org show`](#depot-org-show) - [`depot org switch`](#depot-org-switch) @@ -749,6 +750,52 @@ depot logout Manage Depot organizations. The `org` command consists of subcommands for each operation. +#### `depot org credits` + +Show the organization's remaining posted Stripe invoice credits. This is the same +balance shown on the billing page; unbilled usage and upcoming invoices are not +deducted, and free-trial allowances and included plan minutes are excluded. + +Use an organization API token or a read-only organization token through +`DEPOT_TOKEN`, `--token`, or the CLI configuration. A user token from `depot login` +is not supported by this endpoint. The token determines the organization, so +`depot org switch` does not change which balance is read and there is no `--org` +flag. The command never prompts for login. + +```shell +# With DEPOT_TOKEN set to an organization token: +depot org credits +depot org credits --output json +``` + +Default output uses the currency's decimal places without rounding the balance: + +```text +Remaining credits: USD 125.00 +Observed at: 2026-10-27T12:00:00Z +``` + +JSON output preserves the API's exact minor-unit amount as a string, including +`"0"` when credit is exhausted: + +```json +{ + "remainingCreditMinor": "12500", + "currency": "USD", + "observedAt": "2026-10-27T12:00:00Z" +} +``` + +For USD, `12500` means $125.00. Check the currency before converting other +currencies; not all currencies use two decimal places. The timestamp records +when the provider balance was read, not when usage was last processed. + +Each invocation reads the API once. Reads are limited to one per organization +per minute across all tokens and ten per ten seconds across the API fleet. +Excess polls exit with an error containing the server's retry delay. The command +does not retry automatically or cache balances. Poll hourly for most credit +alerts, and handle failed commands separately from a successful zero balance. + #### `depot org list` Display an interactive listing of organizations that you can access. The command shows organization IDs and names in a table format. diff --git a/go.mod b/go.mod index 417680d5..6709bf14 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/depot/cli go 1.26.8 require ( - buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261026154226-a1d46dc5bdf0.1 - buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261026154226-a1d46dc5bdf0.1 + buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261027120211-654d30620337.1 + buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261027120211-654d30620337.1 charm.land/bubbles/v2 v2.0.0 charm.land/bubbletea/v2 v2.0.2 charm.land/huh/v2 v2.0.0 @@ -242,7 +242,7 @@ require ( golang.org/x/crypto v0.57.0 golang.org/x/mod v0.41.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/text v0.42.0 // indirect + golang.org/x/text v0.42.0 golang.org/x/time v0.16.0 // indirect golang.org/x/tools v0.49.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect diff --git a/go.sum b/go.sum index f25ff632..e65965b9 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261026154226-a1d46dc5bdf0.1 h1:euMjmAIWUpU2cDOTtQ+7TEExRs8J9Fw340KgRcovbnQ= -buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261026154226-a1d46dc5bdf0.1/go.mod h1:ocyCo/PaEMnHD7rXfviQM0YvUEaOy0k3494BeBWP3Ak= -buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261026154226-a1d46dc5bdf0.1 h1:w7R+EBUQqZQ8u73PwJ8XPWEz1TjW82bvCoxokx6Yk2k= -buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261026154226-a1d46dc5bdf0.1/go.mod h1:MP5BDdzxwFdhf/KEm27tKfz9dKjtC9C1kXdpuSOb09g= +buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261027120211-654d30620337.1 h1:3BKAM0r63Pho+G/pv3ee2QlecyOfR8qjP+J6aap5fgM= +buf.build/gen/go/depot/api/connectrpc/go v1.15.0-20261027120211-654d30620337.1/go.mod h1:wQpUwo9QREPMGVrewsVebSjY7+hVeeCM0tzuUbr8lWs= +buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261027120211-654d30620337.1 h1:PBhFKLoMH99qsKwZ3IHvzFqJ2w/EEtIYhjiSNxq86HM= +buf.build/gen/go/depot/api/protocolbuffers/go v1.32.0-20261027120211-654d30620337.1/go.mod h1:MP5BDdzxwFdhf/KEm27tKfz9dKjtC9C1kXdpuSOb09g= charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= diff --git a/pkg/api/rpc.go b/pkg/api/rpc.go index 0a981626..ef050f91 100644 --- a/pkg/api/rpc.go +++ b/pkg/api/rpc.go @@ -39,6 +39,10 @@ func NewOrganizationsClient() corev1connect.OrganizationServiceClient { return corev1connect.NewOrganizationServiceClient(getHTTPClient(getBaseURL()), getBaseURL(), WithUserAgent()) } +func NewCreditClient() corev1connect.CreditServiceClient { + return corev1connect.NewCreditServiceClient(getHTTPClient(getBaseURL()), getBaseURL(), WithUserAgent()) +} + func NewSDKProjectsClient() corev1connect.ProjectServiceClient { return corev1connect.NewProjectServiceClient(getHTTPClient(getBaseURL()), getBaseURL(), WithUserAgent()) } diff --git a/pkg/cmd/org/credits.go b/pkg/cmd/org/credits.go new file mode 100644 index 00000000..d002e5bc --- /dev/null +++ b/pkg/cmd/org/credits.go @@ -0,0 +1,103 @@ +package org + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + corev1 "buf.build/gen/go/depot/api/protocolbuffers/go/depot/core/v1" + "connectrpc.com/connect" + "github.com/depot/cli/pkg/api" + "github.com/depot/cli/pkg/helpers" + "github.com/spf13/cobra" + "golang.org/x/text/currency" + "google.golang.org/protobuf/encoding/protojson" +) + +func NewCmdCredits() *cobra.Command { + var token, output string + cmd := &cobra.Command{ + Use: "credits", + Short: "Show remaining organization invoice credits", + Long: `Read the organization's posted Stripe invoice-credit balance. +Unbilled usage is not deducted; free-trial and included plan allowances are excluded. + +Requires an organization API token or a read-only organization token, supplied +with --token, DEPOT_TOKEN, or the CLI configuration. The token determines the +organization; the current organization selected by org switch is not used. +This command does not prompt for login. + +Reads are limited to once per organization per minute and share an API-wide +polling limit. Poll hourly for most monitoring needs. Errors are returned +without retrying or reporting a zero balance.`, + Example: ` # Show remaining credits using an organization token in DEPOT_TOKEN + depot org credits + + # Read the exact API response for a monitoring script + depot org credits --output json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if output != "" && output != "json" { + return fmt.Errorf("--output must be json") + } + resolvedToken := helpers.ResolveStaticOrgAuth(token) + if resolvedToken == "" { + return fmt.Errorf("missing organization API token: set DEPOT_TOKEN or pass --token (read-only organization tokens are supported)") + } + + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) + defer cancel() + resp, err := api.NewCreditClient().GetCreditBalance(ctx, + api.WithAuthentication(connect.NewRequest(&corev1.GetCreditBalanceRequest{}), resolvedToken)) + if err != nil { + var rpcErr *connect.Error + if errors.As(err, &rpcErr) && rpcErr.Code() == connect.CodeResourceExhausted { + if seconds, parseErr := strconv.Atoi(rpcErr.Meta().Get("Retry-After")); parseErr == nil && seconds > 0 { + return fmt.Errorf("credit balance polling limit exceeded; retry after %d seconds: %w", seconds, err) + } + } + return fmt.Errorf("failed to read credit balance: %w", err) + } + + balance := resp.Msg + if balance.RemainingCreditMinor == nil || balance.GetRemainingCreditMinor() < 0 { + return fmt.Errorf("invalid credit balance response: missing or negative amount") + } + unit, err := currency.ParseISO(balance.Currency) + if err != nil || unit == currency.XXX { + return fmt.Errorf("invalid credit balance response: unknown currency") + } + if balance.ObservedAt == nil || balance.ObservedAt.CheckValid() != nil { + return fmt.Errorf("invalid credit balance response: missing or invalid observation time") + } + if output == "json" { + data, err := (protojson.MarshalOptions{Indent: " "}).Marshal(balance) + if err != nil { + return err + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "%s\n", data) + return err + } + + // Keep the calculation in integers so even large balances remain exact. + minor := balance.GetRemainingCreditMinor() + scale, _ := currency.Standard.Rounding(unit) + divisor := int64(1) + for range scale { + divisor *= 10 + } + amount := strconv.FormatInt(minor/divisor, 10) + if scale > 0 { + amount += fmt.Sprintf(".%0*d", scale, minor%divisor) + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Remaining credits: %s %s\nObserved at: %s\n", + unit, amount, balance.ObservedAt.AsTime().UTC().Format(time.RFC3339Nano)) + return err + }, + } + cmd.Flags().StringVar(&token, "token", "", "Depot organization API token (read-only tokens supported)") + cmd.Flags().StringVarP(&output, "output", "o", "", "Output format (json)") + return cmd +} diff --git a/pkg/cmd/org/credits_test.go b/pkg/cmd/org/credits_test.go new file mode 100644 index 00000000..607f6072 --- /dev/null +++ b/pkg/cmd/org/credits_test.go @@ -0,0 +1,228 @@ +package org + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "math" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "buf.build/gen/go/depot/api/connectrpc/go/depot/core/v1/corev1connect" + corev1 "buf.build/gen/go/depot/api/protocolbuffers/go/depot/core/v1" + "connectrpc.com/connect" + "github.com/spf13/viper" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +var creditObservedAt = time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) + +type creditHandlerFunc func(context.Context, *connect.Request[corev1.GetCreditBalanceRequest]) (*connect.Response[corev1.GetCreditBalanceResponse], error) + +func (f creditHandlerFunc) GetCreditBalance(ctx context.Context, req *connect.Request[corev1.GetCreditBalanceRequest]) (*connect.Response[corev1.GetCreditBalanceResponse], error) { + return f(ctx, req) +} + +func startCreditServer(t *testing.T, handler creditHandlerFunc) { + t.Helper() + _, h := corev1connect.NewCreditServiceHandler(handler) + server := httptest.NewServer(h2c.NewHandler(h, &http2.Server{})) + t.Cleanup(server.Close) + t.Setenv("DEPOT_API_URL", server.URL) + t.Setenv("DEPOT_TOKEN", "depot_reader_test") +} + +func creditResponse(amount int64, currency string) *connect.Response[corev1.GetCreditBalanceResponse] { + return connect.NewResponse(&corev1.GetCreditBalanceResponse{ + RemainingCreditMinor: proto.Int64(amount), + Currency: currency, + ObservedAt: timestamppb.New(creditObservedAt), + }) +} + +func executeCredits(ctx context.Context, args ...string) (string, error) { + cmd := NewCmdOrg() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&bytes.Buffer{}) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs(append([]string{"credits"}, args...)) + err := cmd.ExecuteContext(ctx) + return out.String(), err +} + +func TestCreditsHumanOutput(t *testing.T) { + for _, tt := range []struct { + currency string + minor int64 + want string + }{ + {"USD", 12500, "USD 125.00"}, + {"USD", 0, "USD 0.00"}, + {"EUR", 1, "EUR 0.01"}, + {"JPY", 1250, "JPY 1250"}, + {"KWD", 1250, "KWD 1.250"}, + {"USD", math.MaxInt64, "USD 92233720368547758.07"}, + } { + t.Run(tt.want, func(t *testing.T) { + startCreditServer(t, func(context.Context, *connect.Request[corev1.GetCreditBalanceRequest]) (*connect.Response[corev1.GetCreditBalanceResponse], error) { + return creditResponse(tt.minor, tt.currency), nil + }) + out, err := executeCredits(context.Background()) + if err != nil { + t.Fatal(err) + } + if want := "Remaining credits: " + tt.want + "\nObserved at: 2026-10-27T12:00:00Z\n"; out != want { + t.Fatalf("output = %q, want %q", out, want) + } + }) + } +} + +func TestCreditsJSONPreservesExactMinorUnits(t *testing.T) { + for _, amount := range []int64{0, math.MaxInt64} { + startCreditServer(t, func(context.Context, *connect.Request[corev1.GetCreditBalanceRequest]) (*connect.Response[corev1.GetCreditBalanceResponse], error) { + return creditResponse(amount, "USD"), nil + }) + out, err := executeCredits(context.Background(), "-o", "json") + if err != nil { + t.Fatal(err) + } + var got struct { + RemainingCreditMinor string `json:"remainingCreditMinor"` + Currency string `json:"currency"` + ObservedAt string `json:"observedAt"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("invalid JSON output: %v\n%s", err, out) + } + wantAmount := "0" + if amount == math.MaxInt64 { + wantAmount = "9223372036854775807" + } + if got.RemainingCreditMinor != wantAmount || got.Currency != "USD" || got.ObservedAt != "2026-10-27T12:00:00Z" { + t.Fatalf("unexpected JSON response: %+v", got) + } + } +} + +func TestCreditsUsesTokenWithoutOrganizationOverride(t *testing.T) { + for _, tt := range []struct { + name, flag, env, stored, want string + }{ + {"flag", "explicit-token", "env-token", "stored-token", "explicit-token"}, + {"environment", "", "depot_reader_env", "stored-token", "depot_reader_env"}, + {"configuration", "", "", "stored-token", "stored-token"}, + } { + t.Run(tt.name, func(t *testing.T) { + requests := make(chan *connect.Request[corev1.GetCreditBalanceRequest], 1) + startCreditServer(t, func(_ context.Context, req *connect.Request[corev1.GetCreditBalanceRequest]) (*connect.Response[corev1.GetCreditBalanceResponse], error) { + requests <- req + return creditResponse(12500, "USD"), nil + }) + t.Setenv("DEPOT_TOKEN", tt.env) + viper.Set("api_token", tt.stored) + viper.Set("org_id", "other-organization") + t.Cleanup(viper.Reset) + out, err := executeCredits(context.Background(), "--token", tt.flag) + if err != nil { + t.Fatal(err) + } + req := <-requests + if got := req.Header().Get("Authorization"); got != "Bearer "+tt.want { + t.Fatalf("authorization = %q", got) + } + if req.Header().Get("X-Depot-Org") != "" { + t.Fatal("current organization must not override the token's organization") + } + if !strings.Contains(out, "USD 125.00") || !strings.HasPrefix(req.Header().Get("User-Agent"), "depot-cli/") { + t.Fatal("missing balance output or CLI user agent") + } + }) + } +} + +func TestCreditsErrorsDoNotPrintBalanceOrRetry(t *testing.T) { + for _, code := range []connect.Code{connect.CodeResourceExhausted, connect.CodeUnavailable, connect.CodeUnauthenticated, connect.CodeFailedPrecondition, connect.CodeUnimplemented} { + t.Run(code.String(), func(t *testing.T) { + var calls atomic.Int32 + startCreditServer(t, func(context.Context, *connect.Request[corev1.GetCreditBalanceRequest]) (*connect.Response[corev1.GetCreditBalanceResponse], error) { + calls.Add(1) + err := connect.NewError(code, errors.New("test rejection")) + if code == connect.CodeResourceExhausted { + err.Meta().Set("Retry-After", "60") + } + return nil, err + }) + out, err := executeCredits(context.Background(), "--output", "json") + if connect.CodeOf(err) != code || out != "" || calls.Load() != 1 { + t.Fatalf("output = %q, error = %v, requests = %d", out, err, calls.Load()) + } + if code == connect.CodeResourceExhausted && !strings.Contains(err.Error(), "retry after 60 seconds") { + t.Fatalf("retry delay missing: %v", err) + } + }) + } +} + +func TestCreditsRejectsMissingCredentialsAndInvalidFlagsBeforeRequest(t *testing.T) { + var calls atomic.Int32 + startCreditServer(t, func(context.Context, *connect.Request[corev1.GetCreditBalanceRequest]) (*connect.Response[corev1.GetCreditBalanceResponse], error) { + calls.Add(1) + return creditResponse(12500, "USD"), nil + }) + for _, key := range []string{"DEPOT_TOKEN", "DEPOT_API_TOKEN", "DEPOT_JIT_TOKEN", "DEPOT_CACHE_TOKEN"} { + t.Setenv(key, "") + } + viper.Set("api_token", "") + t.Cleanup(viper.Reset) + for _, tt := range []struct { + args []string + want string + }{ + {nil, "missing organization API token"}, + {[]string{"--output", "csv"}, "--output must be json"}, + {[]string{"unexpected"}, "unknown command"}, + {[]string{"--org", "other-org"}, "unknown flag"}, + } { + out, err := executeCredits(context.Background(), tt.args...) + if err == nil || !strings.Contains(err.Error(), tt.want) || out != "" { + t.Fatalf("args = %v, output = %q, error = %v; want %q", tt.args, out, err, tt.want) + } + } + if calls.Load() != 0 { + t.Fatal("invalid input must not consume the polling allowance") + } +} + +func TestCreditsRejectsIncompleteBalances(t *testing.T) { + for _, tt := range []struct { + name string + modify func(*corev1.GetCreditBalanceResponse) + }{ + {"missing amount", func(r *corev1.GetCreditBalanceResponse) { r.RemainingCreditMinor = nil }}, + {"negative amount", func(r *corev1.GetCreditBalanceResponse) { r.RemainingCreditMinor = proto.Int64(-1) }}, + {"missing currency", func(r *corev1.GetCreditBalanceResponse) { r.Currency = "" }}, + {"missing observation time", func(r *corev1.GetCreditBalanceResponse) { r.ObservedAt = nil }}, + } { + t.Run(tt.name, func(t *testing.T) { + startCreditServer(t, func(context.Context, *connect.Request[corev1.GetCreditBalanceRequest]) (*connect.Response[corev1.GetCreditBalanceResponse], error) { + resp := creditResponse(12500, "USD") + tt.modify(resp.Msg) + return resp, nil + }) + out, err := executeCredits(context.Background(), "--output", "json") + if err == nil || !strings.Contains(err.Error(), "invalid credit balance response") || out != "" { + t.Fatalf("output = %q, error = %v", out, err) + } + }) + } +} diff --git a/pkg/cmd/org/org.go b/pkg/cmd/org/org.go index 7d828e0d..362132b2 100644 --- a/pkg/cmd/org/org.go +++ b/pkg/cmd/org/org.go @@ -15,6 +15,7 @@ func NewCmdOrg() *cobra.Command { cmd.AddCommand(NewCmdList()) cmd.AddCommand(NewCmdSwitch()) cmd.AddCommand(NewCmdShow()) + cmd.AddCommand(NewCmdCredits()) return cmd } From ab7dc59ed24968e6364561b1e80e81b7ac2b3429 Mon Sep 17 00:00:00 2001 From: Luna Date: Tue, 27 Oct 2026 15:30:44 +0000 Subject: [PATCH 2/2] docs(org): simplify credit balance explanation --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fde55ec5..0b2d9f9e 100644 --- a/README.md +++ b/README.md @@ -786,9 +786,8 @@ JSON output preserves the API's exact minor-unit amount as a string, including } ``` -For USD, `12500` means $125.00. Check the currency before converting other -currencies; not all currencies use two decimal places. The timestamp records -when the provider balance was read, not when usage was last processed. +For USD, `12500` means $125.00. The timestamp records when the provider balance +was read, not when usage was last processed. Each invocation reads the API once. Reads are limited to one per organization per minute across all tokens and ten per ten seconds across the API fleet.