diff --git a/README.md b/README.md index 8c30b0e..5e7769a 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,33 @@ runcrate envs switch staging && runcrate secrets list A value passed for one launch (`runcrate run --env HF_TOKEN=...`) wins over the secret of the same name. +## Billing + +An agent or a script can see how long the balance lasts and top it up from the +workspace's default card. Charging needs the billing permission on your API key +or sign-in, turned on by a workspace owner or manager on the dashboard API keys +page (with daily and monthly caps). Cards are added in the dashboard +(https://runcrate.ai/dashboard/billing), never from the CLI. + +```bash +runcrate billing balance [--json] # credits, burn per hour, runway +runcrate billing usage [--period 7d] # inference usage +runcrate billing topup 50 # asks first; --yes to skip, --json for scripts +runcrate billing topup 50 --idempotency-key --yes # check an earlier top-up, never charges twice +runcrate billing auto-recharge # on or off, threshold, target, last failure +runcrate billing auto-recharge set --threshold 20 --target 100 +runcrate billing auto-recharge off # keeps the amounts +runcrate billing payment-methods # brand, last 4 digits, expiry, default +``` + +- Every top-up has an idempotency key, a new one unless you pass + `--idempotency-key`, and the CLI prints it. Sending the same key again + answers that top-up's current status instead of charging again. +- `requires_action` means the bank wants the payment confirmed: a workspace + owner or manager opens the printed link, signed in. Nothing is charged until + then. `processing` settles on its own; check it with the same key. +- A declined top-up prints why and exits non-zero. + ## AI clients (MCP) Runcrate runs a remote MCP server at `https://mcp.runcrate.ai/mcp`. The quickest diff --git a/internal/api/billing.go b/internal/api/billing.go index d7c9930..a72cb62 100644 --- a/internal/api/billing.go +++ b/internal/api/billing.go @@ -1,16 +1,71 @@ package api import ( + "bytes" + "context" "encoding/json" "fmt" "net/url" ) // Balance mirrors /api/v1/billing/balance (getProjectBalance), which returns -// camelCase creditsBalance + activeUsageCost. +// camelCase creditsBalance + activeUsageCost. The snake_case fields are nil on +// servers that do not send them; RunwayHours is also nil when nothing is +// spending. type Balance struct { - Credits float64 `json:"creditsBalance"` - ActiveUsageCost float64 `json:"activeUsageCost"` + Credits float64 `json:"creditsBalance"` + ActiveUsageCost float64 `json:"activeUsageCost"` + FloatingBalanceUsd *float64 `json:"floating_balance_usd,omitempty"` + BurnUsdPerHour *float64 `json:"burn_usd_per_hour,omitempty"` + RunwayHours *float64 `json:"runway_hours,omitempty"` +} + +// Top-up statuses. Every one answers HTTP 200. +const ( + TopUpSucceeded = "succeeded" + TopUpProcessing = "processing" + TopUpRequiresAction = "requires_action" + TopUpDeclined = "declined" +) + +// TopUp mirrors POST /api/v1/billing/topups. BalanceUsd is set when it +// succeeded; ConfirmURL and ExpiresAt when it needs a workspace owner or +// manager to confirm it; Reason and Message when it was declined. +type TopUp struct { + ID string `json:"id"` + Status string `json:"status"` + AmountUsd float64 `json:"amount_usd"` + IdempotencyKey string `json:"idempotency_key"` + InvoiceNumber *string `json:"invoice_number"` + BalanceUsd *float64 `json:"balance_usd,omitempty"` + ConfirmURL string `json:"confirm_url,omitempty"` + ExpiresAt *string `json:"expires_at,omitempty"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + CreatedAt string `json:"created_at"` + // Replayed is true when the key had been sent before: the answer is that + // top-up's current status, not a new charge. + Replayed bool `json:"-"` +} + +// AutoRecharge mirrors /api/v1/billing/auto-recharge: below ThresholdUsd the +// balance is topped up to TargetUsd from the default card. +type AutoRecharge struct { + Enabled bool `json:"enabled"` + ThresholdUsd *float64 `json:"threshold_usd"` + TargetUsd *float64 `json:"target_usd"` + LastFailureReason *string `json:"last_failure_reason"` + LastFailureAt *string `json:"last_failure_at"` +} + +// PaymentMethod mirrors one entry of /api/v1/billing/payment-methods. +type PaymentMethod struct { + Type string `json:"type"` + Brand *string `json:"brand"` + Last4 *string `json:"last4"` + ExpMonth *int `json:"exp_month"` + ExpYear *int `json:"exp_year"` + Default bool `json:"default"` } // UsageSummary mirrors the get_usage_summary RPC behind /api/v1/billing/usage. @@ -64,3 +119,59 @@ func (c *Client) GetUsage(from string) (*UsageSummary, error) { } return &result.Data, nil } + +// TopUp charges amount (USD) to the workspace's default card and adds it to +// the balance. An empty idempotencyKey gets a fresh one, returned on the +// result. Sending the same key again answers that top-up's current status +// instead of charging again, which is how a processing or requires_action +// top-up is checked. When no answer came, the error is a *SubmitNetworkError +// carrying the key to send again. +func (c *Client) TopUp(amount float64, idempotencyKey string) (*TopUp, error) { + if idempotencyKey == "" { + idempotencyKey = NewIdempotencyKey() + } + raw, err := json.Marshal(map[string]any{"amount_usd": amount, "idempotency_key": idempotencyKey}) + if err != nil { + return nil, fmt.Errorf("encoding request: %w", err) + } + resp, err := c.doCtxHeaders(context.Background(), "POST", "/api/v1/billing/topups", bytes.NewReader(raw), nil) + if err != nil { + return nil, &SubmitNetworkError{Key: idempotencyKey, Err: err} + } + defer resp.Body.Close() + if err := checkHTTPStatus(resp); err != nil { + return nil, err + } + + var result apiEnvelope[TopUp] + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + t := &result.Data + if t.IdempotencyKey == "" { + t.IdempotencyKey = idempotencyKey + } + t.Replayed = resp.Header.Get("Idempotent-Replayed") == "true" + return t, nil +} + +// GetAutoRecharge returns the workspace's auto-recharge setting. +func (c *Client) GetAutoRecharge() (*AutoRecharge, error) { + return callJSON[AutoRecharge](c, "GET", "/api/v1/billing/auto-recharge", nil) +} + +// SetAutoRecharge turns auto-recharge on or off. The server needs threshold +// and target even to turn it off. +func (c *Client) SetAutoRecharge(enabled bool, threshold, target float64) (*AutoRecharge, error) { + body := map[string]any{"enabled": enabled, "threshold_usd": threshold, "target_usd": target} + return callJSON[AutoRecharge](c, "PUT", "/api/v1/billing/auto-recharge", body) +} + +// ListPaymentMethods returns the workspace's cards. +func (c *Client) ListPaymentMethods() ([]PaymentMethod, error) { + list, err := callJSON[[]PaymentMethod](c, "GET", "/api/v1/billing/payment-methods", nil) + if err != nil { + return nil, err + } + return *list, nil +} diff --git a/internal/api/jobs.go b/internal/api/jobs.go index a3836a8..c3c850c 100644 --- a/internal/api/jobs.go +++ b/internal/api/jobs.go @@ -235,9 +235,9 @@ const ( GroupCancelTimeout = 315 * time.Second ) -// SubmitNetworkError is a submit whose answer never came: the job may have -// been created. Sending the same request with Key within 24 hours answers that -// job instead of starting another. +// SubmitNetworkError is a submit (a job or a top-up) whose answer never came: +// it may have been made. Sending the same request with Key answers that one +// instead of making another. type SubmitNetworkError struct { Key string Err error diff --git a/internal/commands/billing.go b/internal/commands/billing.go index 2c16978..1273417 100644 --- a/internal/commands/billing.go +++ b/internal/commands/billing.go @@ -1,23 +1,40 @@ package commands import ( + "errors" "fmt" + "io" + "math" + "net/http" + "os" "strconv" "strings" "time" "github.com/charmbracelet/lipgloss" + "github.com/runcrate/cli/internal/api" "github.com/spf13/cobra" ) +// billingDashboardURL is where cards are added and top-ups confirmed: the API +// never takes card details. +const billingDashboardURL = "https://runcrate.ai/dashboard/billing" + +// Auto-recharge amounts sent when turning it off before it was ever set (the +// server needs both even to turn it off). +const ( + defaultRechargeThreshold = 20 + defaultRechargeTarget = 50 +) + var billingCmd = &cobra.Command{ Use: "billing", - Short: "View billing and usage", + Short: "View billing and usage, top up, and manage auto-recharge", } var billingBalanceCmd = &cobra.Command{ Use: "balance", - Short: "Show current credit balance", + Short: "Show current credit balance, burn per hour and runway", RunE: func(cmd *cobra.Command, args []string) error { client, err := loadClient() if err != nil { @@ -28,19 +45,50 @@ var billingBalanceCmd = &cobra.Command{ if err != nil { return fmt.Errorf("fetching balance: %w", err) } + if jsonOutput { + return printJSON(balance) + } + renderBalance(os.Stdout, balance) + return nil + }, +} - dim := lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - bold := lipgloss.NewStyle().Bold(true) +// renderBalance prints the balance, and what running machines burn and how +// long the balance lasts when the server reports them. +func renderBalance(w io.Writer, b *api.Balance) { + dim := lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + bold := lipgloss.NewStyle().Bold(true) - fmt.Println() - fmt.Printf(" %s %s\n", dim.Render("Credits"), bold.Render(fmt.Sprintf("$%.2f", balance.Credits))) - if balance.ActiveUsageCost > 0 { - fmt.Printf(" %s $%.2f\n", dim.Render("Active usage"), balance.ActiveUsageCost) + fmt.Fprintln(w) + fmt.Fprintf(w, " %s %s\n", dim.Render("Credits "), bold.Render(fmt.Sprintf("$%.2f", b.Credits))) + if b.ActiveUsageCost > 0 { + fmt.Fprintf(w, " %s $%.2f\n", dim.Render("Active usage"), b.ActiveUsageCost) + } + if b.FloatingBalanceUsd != nil { + fmt.Fprintf(w, " %s $%.2f %s\n", dim.Render("Floating "), *b.FloatingBalanceUsd, dim.Render("(net of running machines)")) + } + if b.BurnUsdPerHour != nil { + fmt.Fprintf(w, " %s $%.2f/hr\n", dim.Render("Burn "), *b.BurnUsdPerHour) + switch { + case b.RunwayHours != nil: + fmt.Fprintf(w, " %s %s\n", dim.Render("Runway "), formatRunway(*b.RunwayHours)) + case *b.BurnUsdPerHour == 0: + fmt.Fprintf(w, " %s %s\n", dim.Render("Runway "), dim.Render("nothing running")) } - fmt.Println() + } + fmt.Fprintln(w) +} - return nil - }, +// formatRunway writes hours of runway as minutes, hours or days. +func formatRunway(hours float64) string { + switch { + case hours < 1: + return fmt.Sprintf("%d min", int(math.Round(hours*60))) + case hours < 48: + return fmt.Sprintf("%.1f hours", hours) + default: + return fmt.Sprintf("%.1f days", hours/24) + } } // parsePeriodFrom converts a period like "7d", "24h", or "2w" into an RFC3339 @@ -85,6 +133,9 @@ var billingUsageCmd = &cobra.Command{ if err != nil { return fmt.Errorf("fetching usage: %w", err) } + if jsonOutput { + return printJSON(usage) + } dim := lipgloss.NewStyle().Foreground(lipgloss.Color("241")) bold := lipgloss.NewStyle().Bold(true) @@ -116,9 +167,326 @@ var billingUsageCmd = &cobra.Command{ }, } +var billingTopUpCmd = &cobra.Command{ + Use: "topup ", + Aliases: []string{"top-up"}, + Short: "Add credit in USD, charged to the workspace's default card", + Long: "Adds credit to the workspace balance, charged to its default card. Needs the billing\n" + + "permission on your API key or sign-in, turned on by a workspace owner or manager.\n\n" + + "Each top-up has an idempotency key (a new one unless you pass --idempotency-key). Sending\n" + + "the same key again answers that top-up's current status instead of charging again: that is\n" + + "how a processing top-up, or one waiting for confirmation, is checked.", + Example: " runcrate billing topup 50\n" + + " runcrate billing topup 50 --yes --json\n" + + " runcrate billing topup 50 --idempotency-key --yes # check an earlier top-up", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + amount, err := parseUSD(args[0]) + if err != nil { + return err + } + cmd.SilenceUsage = true + client, err := loadClient() + if err != nil { + return err + } + + key, _ := cmd.Flags().GetString("idempotency-key") + if key == "" { + key = api.NewIdempotencyKey() + } + if yes, _ := cmd.Flags().GetBool("yes"); !yes { + ok, err := confirm(os.Stdin, os.Stderr, fmt.Sprintf( + "Charge $%.2f to the workspace's default card and add it to the balance? [y/N] ", amount)) + if err != nil { + return errors.New("no answer to the prompt; pass --yes to top up without asking") + } + if !ok { + fmt.Println("Nothing charged.") + return nil + } + } + + t, err := client.TopUp(amount, key) + if err != nil { + return topUpError(err, key) + } + if jsonOutput { + if err := printJSON(t); err != nil { + return err + } + if t.Status == api.TopUpDeclined { + return declinedError(t) + } + return nil + } + return renderTopUp(os.Stdout, t) + }, +} + +// parseUSD reads a positive dollar amount such as 50, 49.99 or $50. The +// server checks the range. +func parseUSD(s string) (float64, error) { + v, err := strconv.ParseFloat(strings.TrimPrefix(strings.TrimSpace(s), "$"), 64) + if err != nil || v <= 0 || math.IsInf(v, 0) || math.IsNaN(v) { + return 0, fmt.Errorf("amount %q is not a dollar amount such as 50 or 49.99", s) + } + return v, nil +} + +// renderTopUp prints what came of a top-up and how to check it again. A +// declined top-up is an error, so the command exits non-zero. +func renderTopUp(w io.Writer, t *api.TopUp) error { + dim := lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + amount := fmt.Sprintf("$%.2f", t.AmountUsd) + again := fmt.Sprintf("runcrate billing topup %s --idempotency-key %s --yes", + strconv.FormatFloat(t.AmountUsd, 'f', -1, 64), t.IdempotencyKey) + + if t.Replayed { + fmt.Fprintln(w, "This key was sent before, so nothing new was charged. Its top-up now:") + } + switch t.Status { + case api.TopUpSucceeded: + fmt.Fprintf(w, "Added %s to the balance.", amount) + if t.BalanceUsd != nil { + fmt.Fprintf(w, " Balance: $%.2f.", *t.BalanceUsd) + } + if t.InvoiceNumber != nil && *t.InvoiceNumber != "" { + fmt.Fprintf(w, " Invoice %s.", *t.InvoiceNumber) + } + fmt.Fprintln(w) + case api.TopUpProcessing: + fmt.Fprintf(w, "The top-up of %s is processing. Check it with the same key:\n %s\n", amount, again) + case api.TopUpRequiresAction: + fmt.Fprintf(w, "The top-up of %s needs confirming. Nothing is charged until then.\n", amount) + fmt.Fprintf(w, "A workspace owner or manager must open this link, signed in to Runcrate:\n %s\n", t.ConfirmURL) + if t.ExpiresAt != nil && *t.ExpiresAt != "" { + fmt.Fprintf(w, "The link expires at %s.\n", *t.ExpiresAt) + } + fmt.Fprintf(w, "Then check it with the same key:\n %s\n", again) + case api.TopUpDeclined: + return declinedError(t) + default: + fmt.Fprintf(w, "The top-up of %s is %s. Check it with the same key:\n %s\n", amount, t.Status, again) + } + fmt.Fprintf(w, "%s\n", dim.Render("Idempotency key: "+t.IdempotencyKey)) + return nil +} + +// declinedError is a declined top-up, with the server's sentence. +func declinedError(t *api.TopUp) error { + why := t.Message + if why == "" { + why = t.Reason + } + return fmt.Errorf("top-up of $%.2f declined: %s", t.AmountUsd, why) +} + +// topUpError adds the key to send again when the top-up may have been made: +// no answer came, the server failed (5xx, possibly after the charge), or it is +// being charged right now (409 idempotency_in_progress). +func topUpError(err error, key string) error { + var ne *api.SubmitNetworkError + if errors.As(err, &ne) { + key = ne.Key + } + var ae *api.APIError + isAPI := errors.As(err, &ae) + switch { + case ne != nil, isAPI && ae.Status >= 500: + return fmt.Errorf("topping up: %w\n The top-up may have been made. Run the same command with --idempotency-key %s: "+ + "it answers that top-up instead of charging again", err, key) + case isAPI && ae.Status == http.StatusConflict && (ae.Code == "conflict" || ae.Details["reason"] == "idempotency_in_progress"): + return fmt.Errorf("topping up: %w\n It is being charged right now. Check it in a moment with --idempotency-key %s", err, key) + } + return fmt.Errorf("topping up: %w", err) +} + +var billingAutoRechargeCmd = &cobra.Command{ + Use: "auto-recharge", + Aliases: []string{"autorecharge"}, + Short: "Show auto-recharge: a top-up from the default card when the balance runs low", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := loadClient() + if err != nil { + return err + } + a, err := client.GetAutoRecharge() + if err != nil { + return fmt.Errorf("fetching auto-recharge: %w", err) + } + return showAutoRecharge(a) + }, +} + +var billingAutoRechargeSetCmd = &cobra.Command{ + Use: "set --threshold X --target Y", + Short: "Turn auto-recharge on: below the threshold, top up to the target (USD)", + Long: "Turns auto-recharge on. When the balance falls below --threshold, the default card is\n" + + "charged to bring it back to --target. Needs the billing permission, and the person behind the\n" + + "key or sign-in must be a workspace owner or manager.", + Example: " runcrate billing auto-recharge set --threshold 20 --target 100", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + threshold, _ := cmd.Flags().GetFloat64("threshold") + target, _ := cmd.Flags().GetFloat64("target") + cmd.SilenceUsage = true + client, err := loadClient() + if err != nil { + return err + } + a, err := client.SetAutoRecharge(true, threshold, target) + if err != nil { + return fmt.Errorf("turning on auto-recharge: %w", err) + } + return showAutoRecharge(a) + }, +} + +var billingAutoRechargeOffCmd = &cobra.Command{ + Use: "off", + Short: "Turn auto-recharge off, keeping its amounts", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + client, err := loadClient() + if err != nil { + return err + } + a, err := autoRechargeOff(client) + if err != nil { + return err + } + return showAutoRecharge(a) + }, +} + +// autoRechargeOff turns auto-recharge off. The server needs the amounts even +// then, so it sends back the current ones (or the defaults if none were set). +func autoRechargeOff(client *api.Client) (*api.AutoRecharge, error) { + cur, err := client.GetAutoRecharge() + if err != nil { + return nil, fmt.Errorf("fetching auto-recharge: %w", err) + } + threshold, target := float64(defaultRechargeThreshold), float64(defaultRechargeTarget) + if cur.ThresholdUsd != nil { + threshold = *cur.ThresholdUsd + } + if cur.TargetUsd != nil { + target = *cur.TargetUsd + } + a, err := client.SetAutoRecharge(false, threshold, target) + if err != nil { + return nil, fmt.Errorf("turning off auto-recharge: %w", err) + } + return a, nil +} + +func showAutoRecharge(a *api.AutoRecharge) error { + if jsonOutput { + return printJSON(a) + } + renderAutoRecharge(os.Stdout, a) + return nil +} + +func renderAutoRecharge(w io.Writer, a *api.AutoRecharge) { + dim := lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + bold := lipgloss.NewStyle().Bold(true) + + state := "off" + if a.Enabled { + state = "on" + } + fmt.Fprintln(w) + fmt.Fprintf(w, " %s %s\n", dim.Render("Auto-recharge"), bold.Render(state)) + if a.ThresholdUsd != nil { + fmt.Fprintf(w, " %s $%.2f\n", dim.Render("Below "), *a.ThresholdUsd) + } + if a.TargetUsd != nil { + fmt.Fprintf(w, " %s $%.2f\n", dim.Render("Top up to "), *a.TargetUsd) + } + if a.LastFailureReason != nil && *a.LastFailureReason != "" { + when := "" + if a.LastFailureAt != nil { + when = " " + dim.Render("("+*a.LastFailureAt+")") + } + fmt.Fprintf(w, " %s %s%s\n", dim.Render("Last failure "), *a.LastFailureReason, when) + } + if !a.Enabled { + threshold, target := float64(defaultRechargeThreshold), float64(defaultRechargeTarget) + if a.ThresholdUsd != nil && a.TargetUsd != nil { + threshold, target = *a.ThresholdUsd, *a.TargetUsd + } + fmt.Fprintf(w, "\n %s\n", dim.Render(fmt.Sprintf("Turn it on: runcrate billing auto-recharge set --threshold %s --target %s", + strconv.FormatFloat(threshold, 'f', -1, 64), strconv.FormatFloat(target, 'f', -1, 64)))) + } + fmt.Fprintln(w) +} + +var billingPaymentMethodsCmd = &cobra.Command{ + Use: "payment-methods", + Aliases: []string{"cards"}, + Short: "List the workspace's cards", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := loadClient() + if err != nil { + return err + } + list, err := client.ListPaymentMethods() + if err != nil { + return fmt.Errorf("fetching payment methods: %w", err) + } + if jsonOutput { + return printJSON(list) + } + return renderPaymentMethods(os.Stdout, list) + }, +} + +func renderPaymentMethods(w io.Writer, list []api.PaymentMethod) error { + if len(list) == 0 { + fmt.Fprintf(w, "No card on file. A workspace owner or manager adds one in the dashboard: %s\n", billingDashboardURL) + return nil + } + t := newTableTo(w) + fmt.Fprintf(t, "BRAND\tLAST4\tEXPIRES\tDEFAULT\n") + for _, pm := range list { + brand := pm.Type + if pm.Brand != nil && *pm.Brand != "" { + brand = *pm.Brand + } + last4, expires, def := "-", "-", "no" + if pm.Last4 != nil && *pm.Last4 != "" { + last4 = *pm.Last4 + } + if pm.ExpMonth != nil && pm.ExpYear != nil { + expires = fmt.Sprintf("%02d/%d", *pm.ExpMonth, *pm.ExpYear) + } + if pm.Default { + def = "yes" + } + fmt.Fprintf(t, "%s\t%s\t%s\t%s\n", brand, last4, expires, def) + } + return t.Flush() +} + func init() { + billingCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output as JSON") billingUsageCmd.Flags().String("period", "", "Usage period (e.g., 24h, 7d, 2w)") - billingCmd.AddCommand(billingBalanceCmd) - billingCmd.AddCommand(billingUsageCmd) + + billingTopUpCmd.Flags().String("idempotency-key", "", "Top up at most once per key; send it again to check that top-up (default: a new key)") + billingTopUpCmd.Flags().BoolP("yes", "y", false, "Top up without asking") + + billingAutoRechargeSetCmd.Flags().Float64("threshold", 0, "Top up when the balance falls below this many USD (required)") + billingAutoRechargeSetCmd.Flags().Float64("target", 0, "Bring the balance back to this many USD (required)") + for _, name := range []string{"threshold", "target"} { + _ = billingAutoRechargeSetCmd.MarkFlagRequired(name) + } + billingAutoRechargeCmd.AddCommand(billingAutoRechargeSetCmd, billingAutoRechargeOffCmd) + + billingCmd.AddCommand(billingBalanceCmd, billingUsageCmd, billingTopUpCmd, billingAutoRechargeCmd, billingPaymentMethodsCmd) rootCmd.AddCommand(billingCmd) } diff --git a/internal/commands/billing_test.go b/internal/commands/billing_test.go index ceeb43b..7cb0b5b 100644 --- a/internal/commands/billing_test.go +++ b/internal/commands/billing_test.go @@ -1,6 +1,16 @@ package commands -import "testing" +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + + "github.com/runcrate/cli/internal/api" +) func TestParsePeriodFrom(t *testing.T) { cases := []struct { @@ -30,3 +40,213 @@ func TestParsePeriodFrom(t *testing.T) { } } } + +// topUpServer answers POST /api/v1/billing/topups with reply, after recording +// the request body. +func topUpServer(t *testing.T, reply func(body map[string]any) string) (*httptest.Server, *map[string]any) { + t.Helper() + got := map[string]any{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/api/v1/billing/topups" { + t.Errorf("request = %s %s, want POST /api/v1/billing/topups", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Errorf("body is not JSON: %v", err) + } + w.Write([]byte(reply(got))) + })) + t.Cleanup(server.Close) + return server, &got +} + +func TestTopUpSendsTheAmountAndTheGivenKey(t *testing.T) { + server, body := topUpServer(t, func(map[string]any) string { + return `{"data":{"id":"t1","status":"succeeded","amount_usd":50,"idempotency_key":"k-1", + "invoice_number":"RC-000123","balance_usd":104.12,"created_at":"2026-10-26T10:00:00Z"}}` + }) + + res, err := api.NewClient("rc_live_x", server.URL).TopUp(50, "k-1") + if err != nil { + t.Fatalf("TopUp() error: %v", err) + } + if (*body)["amount_usd"] != 50.0 || (*body)["idempotency_key"] != "k-1" { + t.Fatalf("body = %v, want amount_usd 50 and idempotency_key k-1", *body) + } + + var out bytes.Buffer + if err := renderTopUp(&out, res); err != nil { + t.Fatalf("renderTopUp() error: %v", err) + } + for _, want := range []string{"Added $50.00", "Balance: $104.12", "RC-000123", "k-1"} { + if !strings.Contains(out.String(), want) { + t.Errorf("output missing %q:\n%s", want, out.String()) + } + } +} + +func TestTopUpMakesAKeyWhenNoneIsGiven(t *testing.T) { + server, body := topUpServer(t, func(b map[string]any) string { + return `{"data":{"id":"t2","status":"processing","amount_usd":25,"idempotency_key":"` + + b["idempotency_key"].(string) + `","created_at":"2026-10-26T10:00:00Z"}}` + }) + + res, err := api.NewClient("rc_live_x", server.URL).TopUp(25, "") + if err != nil { + t.Fatalf("TopUp() error: %v", err) + } + sent, _ := (*body)["idempotency_key"].(string) + uuidV4 := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + if !uuidV4.MatchString(sent) { + t.Fatalf("sent key %q, want a fresh UUIDv4", sent) + } + if res.IdempotencyKey != sent { + t.Errorf("result key %q, want the key that was sent (%q) so a retry can reuse it", res.IdempotencyKey, sent) + } + + var out bytes.Buffer + if err := renderTopUp(&out, res); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "runcrate billing topup 25 --idempotency-key "+sent) { + t.Errorf("processing top-up should say how to check it with the same key:\n%s", out.String()) + } +} + +func TestTopUpRequiresActionShowsTheConfirmLink(t *testing.T) { + const link = "https://www.runcrate.ai/dashboard/billing?action=confirm-top-up&t=abc" + server, _ := topUpServer(t, func(map[string]any) string { + return `{"data":{"id":"t3","status":"requires_action","amount_usd":200,"idempotency_key":"k-3", + "invoice_number":null,"confirm_url":"` + link + `","expires_at":"2026-10-27T10:00:00Z","created_at":"2026-10-26T10:00:00Z"}}` + }) + + res, err := api.NewClient("rc_live_x", server.URL).TopUp(200, "k-3") + if err != nil { + t.Fatalf("TopUp() error: %v", err) + } + var out bytes.Buffer + if err := renderTopUp(&out, res); err != nil { + t.Fatalf("requires_action is not a failure, got %v", err) + } + for _, want := range []string{link, "owner or manager", "Nothing is charged", "2026-10-27T10:00:00Z", "--idempotency-key k-3"} { + if !strings.Contains(out.String(), want) { + t.Errorf("output missing %q:\n%s", want, out.String()) + } + } +} + +func TestTopUpDeclinedIsAnErrorWithTheReason(t *testing.T) { + err := renderTopUp(&bytes.Buffer{}, &api.TopUp{Status: api.TopUpDeclined, AmountUsd: 50, Reason: "card_declined", + Message: "The card was declined. Add another card in the dashboard."}) + if err == nil || !strings.Contains(err.Error(), "The card was declined.") { + t.Errorf("err = %v, want the server's sentence so the command exits non-zero", err) + } +} + +func TestTopUpRefusalsShowTheServerMessage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusPaymentRequired) + w.Write([]byte(`{"error":{"code":"TOPUP_CAP_REACHED","message":"This key's daily top-up cap of $500 is reached.","details":{"cap":"daily","remaining_usd":0}}}`)) + })) + defer server.Close() + + _, err := api.NewClient("rc_live_x", server.URL).TopUp(50, "k-4") + if err = topUpError(err, "k-4"); err == nil || !strings.Contains(err.Error(), "daily top-up cap of $500 is reached") { + t.Errorf("err = %v, want the API's message", err) + } +} + +// A 5xx may come after the card was charged, and a 409 in progress is being +// charged right now: both say which key to send again. A refusal does not. +func TestTopUpServerErrorsTellTheKeyToSendAgain(t *testing.T) { + cases := []struct { + status int + body string + wantHint bool + }{ + {http.StatusBadGateway, `{"error":{"code":"internal_error","message":"Something went wrong."}}`, true}, + {http.StatusInternalServerError, ``, true}, + {http.StatusConflict, `{"error":{"code":"IDEMPOTENCY_IN_PROGRESS","message":"This top-up is being charged.","details":{"reason":"idempotency_in_progress"}}}`, true}, + {http.StatusForbidden, `{"error":{"code":"BILLING_PERMISSION_REQUIRED","message":"This key cannot top up."}}`, false}, + } + for _, c := range cases { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(c.status) + w.Write([]byte(c.body)) + })) + _, err := api.NewClient("rc_live_x", server.URL).TopUp(50, "k-6") + server.Close() + err = topUpError(err, "k-6") + if got := err != nil && strings.Contains(err.Error(), "--idempotency-key k-6"); got != c.wantHint { + t.Errorf("HTTP %d: err = %v, want key hint %v", c.status, err, c.wantHint) + } + } +} + +func TestTopUpWithNoAnswerTellsTheKeyToSendAgain(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + url := server.URL + server.Close() + + _, err := api.NewClient("rc_live_x", url).TopUp(50, "k-5") + if err = topUpError(err, "k-5"); err == nil || !strings.Contains(err.Error(), "--idempotency-key k-5") { + t.Errorf("err = %v, want the key to send again", err) + } +} + +// Off sends enabled=false with the amounts already set (the server needs them), +// or the defaults when none were ever set. +func TestAutoRechargeOffSendsTheCurrentAmounts(t *testing.T) { + cases := []struct { + current string + wantThreshold, wantTo float64 + }{ + {`{"enabled":true,"threshold_usd":30,"target_usd":120,"last_failure_reason":null,"last_failure_at":null}`, 30, 120}, + {`{"enabled":false,"threshold_usd":null,"target_usd":null,"last_failure_reason":null,"last_failure_at":null}`, 20, 50}, + } + for _, c := range cases { + var put map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/billing/auto-recharge" { + t.Errorf("path = %s", r.URL.Path) + } + switch r.Method { + case "GET": + w.Write([]byte(`{"data":` + c.current + `}`)) + case "PUT": + json.NewDecoder(r.Body).Decode(&put) + w.Write([]byte(`{"data":{"enabled":false,"threshold_usd":30,"target_usd":120}}`)) + default: + t.Errorf("method = %s", r.Method) + } + })) + + a, err := autoRechargeOff(api.NewClient("rc_live_x", server.URL)) + server.Close() + if err != nil { + t.Fatalf("autoRechargeOff() error: %v", err) + } + if put["enabled"] != false || put["threshold_usd"] != c.wantThreshold || put["target_usd"] != c.wantTo { + t.Errorf("PUT body = %v, want enabled false, threshold %v, target %v", put, c.wantThreshold, c.wantTo) + } + if a.Enabled { + t.Errorf("result = %+v, want off", a) + } + } +} + +func TestBalanceShowsBurnAndRunwayOnlyWhenSent(t *testing.T) { + floating, burn, runway := 52.5, 1.5, 35.0 + var full bytes.Buffer + renderBalance(&full, &api.Balance{Credits: 54, ActiveUsageCost: 1.5, FloatingBalanceUsd: &floating, BurnUsdPerHour: &burn, RunwayHours: &runway}) + for _, want := range []string{"$54.00", "$52.50", "$1.50/hr", "35.0 hours"} { + if !strings.Contains(full.String(), want) { + t.Errorf("balance missing %q:\n%s", want, full.String()) + } + } + + var old bytes.Buffer + renderBalance(&old, &api.Balance{Credits: 54}) + if strings.Contains(old.String(), "Burn") || strings.Contains(old.String(), "Runway") { + t.Errorf("a server without the fields should show the balance only:\n%s", old.String()) + } +} diff --git a/internal/commands/root.go b/internal/commands/root.go index bdb4ee5..36526ee 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -107,8 +107,11 @@ func printHelp() { fmt.Println(" ssh-keys delete ") fmt.Println() fmt.Println(" Billing:") - fmt.Println(" billing balance Current credit balance") + fmt.Println(" billing balance Credit balance, burn per hour, runway") fmt.Println(" billing usage Spending breakdown") + fmt.Println(" billing topup Add credit from the default card [--idempotency-key K]") + fmt.Println(" billing auto-recharge [set|off] Top up when the balance runs low") + fmt.Println(" billing payment-methods Cards on the workspace") fmt.Println() fmt.Println(" Workspaces: Separate projects, teams, billing") fmt.Println(" workspaces List all your workspaces")