Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions cmd/billing/billing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package billing

import (
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
ybmAuthClient "github.com/yugabyte/ybm-cli/internal/client"
"github.com/yugabyte/ybm-cli/internal/formatter"
)

var BillingCmd = &cobra.Command{
Use: "billing",
Short: "Billing operations for YugabyteDB Aeon",
Long: "Billing operations for YugabyteDB Aeon",
Run: func(cmd *cobra.Command, args []string) {
cmd.Help()
},
}

var billingEstimateCmd = &cobra.Command{
Use: "estimate",
Short: "Get billing estimate for accounts",
Long: "Get billing estimate for one or more accounts within a specified date range. Results may not reflect real-time usage, please verify against your end-of-month invoice for accurate billing.",
Run: func(cmd *cobra.Command, args []string) {
authApi, err := ybmAuthClient.NewAuthApiClient()
if err != nil {
logrus.Fatalf(ybmAuthClient.GetApiErrorDetails(err))
}
authApi.GetInfo("", "")

startDate, _ := cmd.Flags().GetString("start-date")
endDate, _ := cmd.Flags().GetString("end-date")
accountNames, _ := cmd.Flags().GetStringSlice("account-names")

resp, r, err := authApi.GetBillingEstimate(startDate, endDate, accountNames).Execute()
if err != nil {
logrus.Debugf("Full HTTP response: %v", r)
logrus.Fatalf(ybmAuthClient.GetApiErrorDetails(err))
}

billingEstimateData := resp.GetData()
formatter.BillingEstimateWriteFull(billingEstimateData)
},
}

func init() {
BillingCmd.AddCommand(billingEstimateCmd)

billingEstimateCmd.Flags().StringSlice("account-names", []string{}, "[OPTIONAL] Comma-separated list of account names to fetch billing information for. Defaults to all accounts of user.")
billingEstimateCmd.Flags().String("start-date", "", "[OPTIONAL] Start date(format yyyy-MM-dd) for billing estimate (inclusive). Defaults to 1st day of current month if not provided.")
billingEstimateCmd.Flags().String("end-date", "", "[OPTIONAL] End date(format yyyy-MM-dd) for billing estimate (inclusive). Defaults to current date if not provided.")
}
181 changes: 181 additions & 0 deletions cmd/billing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package cmd_test

import (
"fmt"
"net/http"
"os"
"os/exec"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/onsi/gomega/gbytes"
"github.com/onsi/gomega/gexec"
"github.com/onsi/gomega/ghttp"
openapi "github.com/yugabyte/yugabytedb-managed-go-client-internal"
)

var _ = Describe("Billing", func() {

var (
server *ghttp.Server
statusCode int
args []string
responseAccount openapi.AccountResponse
responseProject openapi.AccountResponse
billingEstimateResponse openapi.BillingEstimateResponse
)

BeforeEach(func() {
args = os.Args
os.Args = []string{}
var err error
server, err = newGhttpServer(responseAccount, responseProject)
Expect(err).ToNot(HaveOccurred())
os.Setenv("YBM_HOST", fmt.Sprintf("http://%s", server.Addr()))
os.Setenv("YBM_APIKEY", "test-token")
statusCode = 200
})

AfterEach(func() {
os.Args = args
server.Close()
os.Unsetenv("YBM_FF_BILLING")
})

Comment thread
bhupendray-yb marked this conversation as resolved.
Context("When BILLING feature flag is disabled", func() {
It("should not recognize billing command", func() {
cmd := exec.Command(compiledCLIPath, "billing")
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
session.Wait(2)
Expect(session.Err).Should(gbytes.Say("unknown command \"billing\""))
session.Kill()
})

It("should not recognize billing estimate command", func() {
cmd := exec.Command(compiledCLIPath, "billing", "estimate")
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
session.Wait(2)
Expect(session.Err).Should(gbytes.Say("unknown command \"billing\""))
session.Kill()
})
})

Context("When BILLING feature flag is enabled", func() {
BeforeEach(func() {
os.Setenv("YBM_FF_BILLING", "true")
})

Describe("When running billing help", func() {
It("should show billing help", func() {
cmd := exec.Command(compiledCLIPath, "billing")
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
session.Wait(2)
Expect(session.Out).Should(gbytes.Say("Billing operations for YugabyteDB Aeon"))
Expect(session.Out).Should(gbytes.Say("estimate"))
session.Kill()
})
})

Describe("When running billing estimate", func() {
BeforeEach(func() {
err := loadJson("./test/fixtures/billing-estimate-response.json", &billingEstimateResponse)
Expect(err).ToNot(HaveOccurred())

server.AppendHandlers(
ghttp.CombineHandlers(
ghttp.VerifyRequest(http.MethodGet, "/api/public/v1/billing-estimate"),
ghttp.RespondWithJSONEncodedPtr(&statusCode, billingEstimateResponse),
),
)
})

It("with no params should get billing estimate", func() {
cmd := exec.Command(compiledCLIPath, "billing", "estimate")
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
session.Wait(2)
Expect(session.Out).Should(gbytes.Say(`Start Date End Date Total Amount
2025-01-01 2025-01-31 \$245.67

Account Name Amount
account-1 \$123.45
account-2 \$122.22`))
session.Kill()
})

It("should get billing estimate with all parameters", func() {
cmd := exec.Command(compiledCLIPath, "billing", "estimate",
"--start-date", "2025-01-01",
"--end-date", "2025-01-31",
"--account-names", "account-1,account-2")
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
session.Wait(2)
Expect(session.Out).Should(gbytes.Say(`Start Date End Date Total Amount
2025-01-01 2025-01-31 \$245.67

Account Name Amount
account-1 \$123.45
account-2 \$122.22`))
session.Kill()
})
})

Describe("When running billing estimate with no billing account", func() {
BeforeEach(func() {
err := loadJson("./test/fixtures/billing-estimate-empty-accounts.json", &billingEstimateResponse)
Expect(err).ToNot(HaveOccurred())

server.AppendHandlers(
ghttp.CombineHandlers(
ghttp.VerifyRequest(http.MethodGet, "/api/public/v1/billing-estimate"),
ghttp.RespondWithJSONEncodedPtr(&statusCode, billingEstimateResponse),
),
)
})

It("should show no account data message when accounts are empty", func() {
cmd := exec.Command(compiledCLIPath, "billing", "estimate")
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
session.Wait(2)
Expect(session.Out).Should(gbytes.Say(`Start Date End Date Total Amount
2025-08-01 2025-08-11 \$0.00

No account data available.`))
session.Kill()
})
})

Describe("When running billing estimate with account not belonging to the user", func() {
BeforeEach(func() {
errorResponse := map[string]interface{}{
"error": map[string]interface{}{
"detail": "Cannot find account with name 'account-10'",
"status": 400,
},
}

statusCode = 404
server.AppendHandlers(
ghttp.CombineHandlers(
ghttp.VerifyRequest(http.MethodGet, "/api/public/v1/billing-estimate"),
ghttp.RespondWithJSONEncodedPtr(&statusCode, errorResponse),
),
)
})

It("should show error message when account not found", func() {
cmd := exec.Command(compiledCLIPath, "billing", "estimate", "--account-names", "account-10")
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
session.Wait(2)
Expect(session.Err).Should(gbytes.Say("Cannot find account with name 'account-10'"))
session.Kill()
})
})
})
})
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/spf13/viper"
"github.com/yugabyte/ybm-cli/cmd/api_key"
"github.com/yugabyte/ybm-cli/cmd/backup"
"github.com/yugabyte/ybm-cli/cmd/billing"
"github.com/yugabyte/ybm-cli/cmd/cdc"
"github.com/yugabyte/ybm-cli/cmd/cluster"
"github.com/yugabyte/ybm-cli/cmd/dr"
Expand Down Expand Up @@ -138,6 +139,7 @@ func init() {
rootCmd.AddCommand(user.UserCmd)
rootCmd.AddCommand(metrics_exporter.MetricsExporterCmd)
rootCmd.AddCommand(integration.IntegrationCmd)
util.AddCommandIfFeatureFlag(rootCmd, billing.BillingCmd, util.BILLING)
util.AddCommandIfFeatureFlag(rootCmd, dr.DrCmd, util.DR)
util.AddCommandIfFeatureFlag(rootCmd, tools.ToolsCmd, util.TOOLS)
util.AddCommandIfFeatureFlag(rootCmd, cdc.CdcCmd, util.CDC)
Expand Down
8 changes: 8 additions & 0 deletions cmd/test/fixtures/billing-estimate-empty-accounts.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"data": {
"start_date": "2025-08-01",
"end_date": "2025-08-11",
"total_amount": 0.0,
"accounts": []
}
}
19 changes: 19 additions & 0 deletions cmd/test/fixtures/billing-estimate-response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"data": {
"start_date": "2025-01-01",
"end_date": "2025-01-31",
"total_amount": 245.67,
"accounts": [
{
"account_id": "3b98d883-1b63-40ce-a4d7-8dc2d6e0ba53",
"account_name": "account-1",
"total_amount": 123.45
},
{
"account_id": "cad5c492-f477-42e8-b597-e74376fa2257",
"account_name": "account-2",
"total_amount": 122.22
}
]
}
}
1 change: 1 addition & 0 deletions cmd/util/feature_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
type FeatureFlag string

const (
BILLING FeatureFlag = "BILLING"
CDC FeatureFlag = "CDC"
CONFIGURE_URL FeatureFlag = "CONFIGURE_URL"
NODE_OP FeatureFlag = "NODE_OPS"
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ require (
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.17.0
github.com/t-tomalak/logrus-easy-formatter v0.0.0-20190827215021-c074f06c5816
github.com/yugabyte/yugabytedb-managed-go-client-internal v0.0.0-20250717123335-2a5793d7df23
github.com/yugabyte/yugabytedb-managed-go-client-internal v0.0.0-20250809052019-ecd3dfbae333
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
golang.org/x/mod v0.20.0
golang.org/x/term v0.25.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -282,8 +282,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/t-tomalak/logrus-easy-formatter v0.0.0-20190827215021-c074f06c5816 h1:J6v8awz+me+xeb/cUTotKgceAYouhIB3pjzgRd6IlGk=
github.com/t-tomalak/logrus-easy-formatter v0.0.0-20190827215021-c074f06c5816/go.mod h1:tzym/CEb5jnFI+Q0k4Qq3+LvRF4gO3E2pxS8fHP8jcA=
github.com/yugabyte/yugabytedb-managed-go-client-internal v0.0.0-20250717123335-2a5793d7df23 h1:X/2j3y+YThiEihFWnAbEKSdnKQVkFU/hSm2TQShVI6A=
github.com/yugabyte/yugabytedb-managed-go-client-internal v0.0.0-20250717123335-2a5793d7df23/go.mod h1:5vW0xIzIZw+1djkiWKx0qqNmqbRBSf4mjc4qw8lIMik=
github.com/yugabyte/yugabytedb-managed-go-client-internal v0.0.0-20250809052019-ecd3dfbae333 h1:h6QDVQtff2itBwi1TXbLyzI/r09FgBuLrmEyHiJ1C9Q=
github.com/yugabyte/yugabytedb-managed-go-client-internal v0.0.0-20250809052019-ecd3dfbae333/go.mod h1:5vW0xIzIZw+1djkiWKx0qqNmqbRBSf4mjc4qw8lIMik=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
Expand Down
14 changes: 14 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,20 @@ func (a *AuthApiClient) GetBillingUsage(startTimestamp string, endTimestamp stri
return a.ApiClient.BillingApi.GetBillingUsage(a.ctx, a.AccountID).StartTimestamp(startTimestamp).EndTimestamp(endTimestamp).Granularity(ybmclient.GRANULARITYENUM_DAILY).ClusterIds(clusterIds)
}

func (a *AuthApiClient) GetBillingEstimate(startDate string, endDate string, accountNames []string) ybmclient.ApiGetBillingEstimateRequest {
request := a.ApiClient.BillingApi.GetBillingEstimate(a.ctx)
if startDate != "" {
request = request.StartDate(startDate)
}
if endDate != "" {
request = request.EndDate(endDate)
}
if len(accountNames) > 0 {
request = request.AccountNames(accountNames)
}
return request
}

func (a *AuthApiClient) ListClustersByDateRange(startTimestamp string, endTimestamp string) ybmclient.ApiListClustersByDateRangeRequest {
return a.ApiClient.BillingApi.ListClustersByDateRange(a.ctx, a.AccountID).StartTimestamp(startTimestamp).EndTimestamp(endTimestamp).Tier(ybmclient.CLUSTERTIER_PAID)
}
Expand Down
Loading