diff --git a/cmd/billing/billing.go b/cmd/billing/billing.go new file mode 100644 index 0000000..dc52646 --- /dev/null +++ b/cmd/billing/billing.go @@ -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.") +} diff --git a/cmd/billing_test.go b/cmd/billing_test.go new file mode 100644 index 0000000..5e8e5f4 --- /dev/null +++ b/cmd/billing_test.go @@ -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") + }) + + 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() + }) + }) + }) +}) diff --git a/cmd/root.go b/cmd/root.go index 546d4bb..aaad64c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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" @@ -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) diff --git a/cmd/test/fixtures/billing-estimate-empty-accounts.json b/cmd/test/fixtures/billing-estimate-empty-accounts.json new file mode 100644 index 0000000..7fd7f4f --- /dev/null +++ b/cmd/test/fixtures/billing-estimate-empty-accounts.json @@ -0,0 +1,8 @@ +{ + "data": { + "start_date": "2025-08-01", + "end_date": "2025-08-11", + "total_amount": 0.0, + "accounts": [] + } +} diff --git a/cmd/test/fixtures/billing-estimate-response.json b/cmd/test/fixtures/billing-estimate-response.json new file mode 100644 index 0000000..0963725 --- /dev/null +++ b/cmd/test/fixtures/billing-estimate-response.json @@ -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 + } + ] + } +} diff --git a/cmd/util/feature_flags.go b/cmd/util/feature_flags.go index aa6c2af..764ba1d 100644 --- a/cmd/util/feature_flags.go +++ b/cmd/util/feature_flags.go @@ -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" diff --git a/go.mod b/go.mod index a1dca6f..c00bdd7 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 029bae3..03d36bf 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/client/client.go b/internal/client/client.go index 9d7065e..ffa01fa 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -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) } diff --git a/internal/formatter/billing_estimate.go b/internal/formatter/billing_estimate.go new file mode 100644 index 0000000..519eacd --- /dev/null +++ b/internal/formatter/billing_estimate.go @@ -0,0 +1,158 @@ +package formatter + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/sirupsen/logrus" + "github.com/spf13/viper" + ybmclient "github.com/yugabyte/yugabytedb-managed-go-client-internal" +) + +const ( + defaultBillingEstimateListing = "table {{.AccountName}}\t{{.TotalAmount}}" + billingSummaryListing = "table {{.StartDate}}\t{{.EndDate}}\t{{.TotalAmount}}" + accountNameHeader = "Account Name" + totalAmountHeader = "Amount" +) + +type BillingEstimateContext struct { + HeaderContext + Context + c ybmclient.BillingEstimateAccountInfo +} + +type BillingSummaryContext struct { + HeaderContext + Context + data ybmclient.BillingEstimateData +} + +func NewBillingEstimateFormat() Format { + source := viper.GetString("output") + switch source { + case "table", "": + format := defaultBillingEstimateListing + return Format(format) + default: // custom format or json or pretty + return Format(source) + } +} + +func NewBillingSummaryFormat() Format { + source := viper.GetString("output") + switch source { + case "table", "": + format := billingSummaryListing + return Format(format) + default: // custom format or json or pretty + return Format(source) + } +} + +func NewBillingEstimateContext() *BillingEstimateContext { + billingEstimateCtx := BillingEstimateContext{} + billingEstimateCtx.Header = SubHeaderContext{ + "AccountName": accountNameHeader, + "TotalAmount": totalAmountHeader, + } + return &billingEstimateCtx +} + +func NewBillingSummaryContext() *BillingSummaryContext { + billingSummaryCtx := BillingSummaryContext{} + billingSummaryCtx.Header = SubHeaderContext{ + "StartDate": "Start Date", + "EndDate": "End Date", + "TotalAmount": "Total Amount", + } + return &billingSummaryCtx +} + +func billingSummaryWrite(ctx Context, billingEstimateData ybmclient.BillingEstimateData) error { + render := func(format func(subContext SubContext) error) error { + err := format(&BillingSummaryContext{data: billingEstimateData}) + if err != nil { + logrus.Debugf("Error rendering billing summary: %v", err) + return err + } + return nil + } + return ctx.Write(NewBillingSummaryContext(), render) +} + +func billingEstimateAccountsWrite(ctx Context, accounts []ybmclient.BillingEstimateAccountInfo) error { + render := func(format func(subContext SubContext) error) error { + for _, account := range accounts { + err := format(&BillingEstimateContext{c: account}) + if err != nil { + logrus.Debugf("Error rendering billing estimate account: %v", err) + return err + } + } + return nil + } + return ctx.Write(NewBillingEstimateContext(), render) +} + +func BillingEstimateWriteFull(billingEstimateData ybmclient.BillingEstimateData) { + ctx := Context{ + Output: os.Stdout, + Format: NewBillingSummaryFormat(), + } + + err := billingSummaryWrite(ctx, billingEstimateData) + if err != nil { + logrus.Fatal(err.Error()) + } + ctx.Output.Write([]byte("\n")) + + accounts := billingEstimateData.GetAccounts() + if len(accounts) == 0 { + if viper.GetString("output") == "table" { + fmt.Fprintf(ctx.Output, "No account data available.\n") + } + return + } + + if viper.GetString("output") == "table" { + ctx = Context{ + Output: os.Stdout, + Format: NewBillingEstimateFormat(), + } + + err = billingEstimateAccountsWrite(ctx, accounts) + if err != nil { + logrus.Fatal(err.Error()) + } + } +} + +func (c *BillingEstimateContext) AccountName() string { + return c.c.GetAccountName() +} + +func (c *BillingEstimateContext) TotalAmount() string { + return fmt.Sprintf("$%.2f", c.c.GetTotalAmount()) +} + +func (c *BillingEstimateContext) MarshalJSON() ([]byte, error) { + return json.Marshal(c.c) +} + +func (c *BillingSummaryContext) StartDate() string { + return c.data.GetStartDate() +} + +func (c *BillingSummaryContext) EndDate() string { + return c.data.GetEndDate() +} + +func (c *BillingSummaryContext) TotalAmount() string { + return fmt.Sprintf("$%.2f", c.data.GetTotalAmount()) +} + +func (c *BillingSummaryContext) MarshalJSON() ([]byte, error) { + return json.Marshal(c.data) +}