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
163 changes: 126 additions & 37 deletions internal/tencent/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,67 +15,147 @@ import (
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
)

// BillByProductItem is one row of the cost-by-product breakdown.
// Mirrors the JSON shape consumed by the clanker-cloud Tencent cost
// provider — every monetary value is a string-encoded decimal because
// Tencent's billing API returns them that way (the SDK doesn't parse
// them and we don't want to lose precision in a float round-trip).
type BillByProductItem struct {
Product string `json:"product"`
RealCost string `json:"real_cost"`
Cash string `json:"cash"`
Incentive string `json:"incentive"`
Voucher string `json:"voucher"`
Pct string `json:"pct"`
}

// BillByProductReport is the top-level JSON envelope. Total is the
// sum of RealCost across every product, computed client-side so the
// downstream consumer doesn't have to re-tokenise the strings.
type BillByProductReport struct {
Month string `json:"month"`
Items []BillByProductItem `json:"items"`
Total float64 `json:"total"`
}

// listBillByProduct prints a per-service cost breakdown for a given month.
// Tencent's billing API treats month as both BeginTime and EndTime so we
// always pass the same value.
func listBillByProduct(c *Client, month string) error {
//
// Format dispatch: when format="json" the report is emitted as a JSON
// object; otherwise the historical tabwriter table is printed.
func listBillByProduct(c *Client, month string, format string) error {
report, err := buildBillByProductReport(c, month)
if err != nil {
return err
}
if strings.EqualFold(strings.TrimSpace(format), "json") {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(report)
}
return writeBillByProductTable(report)
}

func buildBillByProductReport(c *Client, month string) (BillByProductReport, error) {
if month == "" {
month = time.Now().Format("2006-01")
}
report := BillByProductReport{Month: month}
client, err := newBillingClient(c)
if err != nil {
return fmt.Errorf("init billing client: %w", err)
return report, fmt.Errorf("init billing client: %w", err)
}
req := billing.NewDescribeBillSummaryByProductRequest()
req.BeginTime = &month
req.EndTime = &month
resp, err := client.DescribeBillSummaryByProduct(req)
if err != nil {
return fmt.Errorf("DescribeBillSummaryByProduct: %w", friendlyError(err))
return report, fmt.Errorf("DescribeBillSummaryByProduct: %w", friendlyError(err))
}
if resp == nil || resp.Response == nil || resp.Response.SummaryOverview == nil {
return report, nil
}
for _, it := range resp.Response.SummaryOverview {
row := BillByProductItem{
Product: derefString(it.BusinessCodeName),
RealCost: derefString(it.RealTotalCost),
Cash: derefString(it.CashPayAmount),
Incentive: derefString(it.IncentivePayAmount),
Voucher: derefString(it.VoucherPayAmount),
Pct: derefString(it.RealTotalCostRatio),
}
report.Items = append(report.Items, row)
if v, err := strconv.ParseFloat(row.RealCost, 64); err == nil {
report.Total += v
}
}
return report, nil
}

fmt.Printf("Tencent Cloud Cost by Product — %s:\n\n", month)
if resp == nil || resp.Response == nil || resp.Response.SummaryOverview == nil || len(resp.Response.SummaryOverview) == 0 {
func writeBillByProductTable(report BillByProductReport) error {
fmt.Printf("Tencent Cloud Cost by Product — %s:\n\n", report.Month)
if len(report.Items) == 0 {
fmt.Println(" No billing data for this month")
return nil
}

tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "PRODUCT\tREAL_COST\tCASH\tINCENTIVE\tVOUCHER\tPCT")
var total float64
for _, it := range resp.Response.SummaryOverview {
for _, it := range report.Items {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s%%\n",
derefString(it.BusinessCodeName),
derefString(it.RealTotalCost),
derefString(it.CashPayAmount),
derefString(it.IncentivePayAmount),
derefString(it.VoucherPayAmount),
derefString(it.RealTotalCostRatio),
)
if it.RealTotalCost != nil {
if v, err := strconv.ParseFloat(*it.RealTotalCost, 64); err == nil {
total += v
}
}
it.Product, it.RealCost, it.Cash, it.Incentive, it.Voucher, it.Pct)
}
if err := tw.Flush(); err != nil {
return err
}
fmt.Printf("\nTotal: %.4f\n", total)
fmt.Printf("\nTotal: %.4f\n", report.Total)
return nil
}

// listBillResourceTop prints the most expensive resources for the month.
func listBillResourceTop(c *Client, month string, top int) error {
// BillTopResource is one row of the per-resource top-N report.
type BillTopResource struct {
Product string `json:"product"`
ResourceID string `json:"resource_id"`
Name string `json:"name"`
Region string `json:"region"`
PayMode string `json:"pay_mode"`
Action string `json:"action"`
Cost string `json:"cost"`
}

// BillTopResourceReport is the JSON envelope for `cost top --format json`.
type BillTopResourceReport struct {
Month string `json:"month"`
Top int `json:"top"`
Items []BillTopResource `json:"items"`
}

// listBillResourceTop prints (or emits as JSON) the most expensive
// resources for the month.
func listBillResourceTop(c *Client, month string, top int, format string) error {
report, err := buildBillTopReport(c, month, top)
if err != nil {
return err
}
if strings.EqualFold(strings.TrimSpace(format), "json") {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(report)
}
return writeBillTopTable(report)
}

func buildBillTopReport(c *Client, month string, top int) (BillTopResourceReport, error) {
if month == "" {
month = time.Now().Format("2006-01")
}
if top <= 0 {
top = 20
}
report := BillTopResourceReport{Month: month, Top: top}
client, err := newBillingClient(c)
if err != nil {
return fmt.Errorf("init billing client: %w", err)
return report, fmt.Errorf("init billing client: %w", err)
}
req := billing.NewDescribeBillResourceSummaryRequest()
var offset uint64 = 0
Expand All @@ -90,27 +170,36 @@ func listBillResourceTop(c *Client, month string, top int) error {
req.PeriodType = &period
resp, err := client.DescribeBillResourceSummary(req)
if err != nil {
return fmt.Errorf("DescribeBillResourceSummary: %w", friendlyError(err))
return report, fmt.Errorf("DescribeBillResourceSummary: %w", friendlyError(err))
}
if resp == nil || resp.Response == nil {
return report, nil
}
for _, r := range resp.Response.ResourceSummarySet {
report.Items = append(report.Items, BillTopResource{
Product: derefString(r.BusinessCodeName),
ResourceID: derefString(r.ResourceId),
Name: derefString(r.ResourceName),
Region: derefString(r.RegionName),
PayMode: derefString(r.PayModeName),
Action: derefString(r.ActionTypeName),
Cost: derefString(r.RealTotalCost),
})
}
return report, nil
}

fmt.Printf("Top %d Resources by Cost — %s:\n\n", top, month)
if resp == nil || resp.Response == nil || len(resp.Response.ResourceSummarySet) == 0 {
func writeBillTopTable(report BillTopResourceReport) error {
fmt.Printf("Top %d Resources by Cost — %s:\n\n", report.Top, report.Month)
if len(report.Items) == 0 {
fmt.Println(" No resource-level billing data for this month")
return nil
}

tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "PRODUCT\tRESOURCE_ID\tNAME\tREGION\tPAY_MODE\tACTION\tCOST")
for _, r := range resp.Response.ResourceSummarySet {
for _, r := range report.Items {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
derefString(r.BusinessCodeName),
derefString(r.ResourceId),
derefString(r.ResourceName),
derefString(r.RegionName),
derefString(r.PayModeName),
derefString(r.ActionTypeName),
derefString(r.RealTotalCost),
)
r.Product, r.ResourceID, r.Name, r.Region, r.PayMode, r.Action, r.Cost)
}
return tw.Flush()
}
Expand Down
64 changes: 64 additions & 0 deletions internal/tencent/billing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package tencent

import (
"encoding/json"
"testing"
)

// TestBillByProductReport_JSONRoundTrip pins the JSON envelope shape
// that clanker-cloud's cost.Provider parses. A reshape upstream would
// otherwise silently break the cloud integration.
func TestBillByProductReport_JSONRoundTrip(t *testing.T) {
in := BillByProductReport{
Month: "2026-05",
Items: []BillByProductItem{
{Product: "CVM", RealCost: "12.50", Cash: "10.00", Voucher: "2.50", Pct: "62.5"},
{Product: "COS", RealCost: "7.50", Cash: "7.50", Pct: "37.5"},
},
Total: 20.0,
}
raw, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal: %v", err)
}

var out BillByProductReport
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal: %v\nwire: %s", err, raw)
}
if out.Month != in.Month {
t.Errorf("month lost: got %q want %q", out.Month, in.Month)
}
if len(out.Items) != len(in.Items) {
t.Fatalf("items length lost: got %d want %d", len(out.Items), len(in.Items))
}
if out.Items[0].Product != "CVM" || out.Items[0].RealCost != "12.50" {
t.Errorf("first item lost: got %+v", out.Items[0])
}
if out.Total != 20.0 {
t.Errorf("total lost: got %v want 20", out.Total)
}
}

// TestBillTopResourceReport_JSONRoundTrip — same contract test for the
// top-N resource shape.
func TestBillTopResourceReport_JSONRoundTrip(t *testing.T) {
in := BillTopResourceReport{
Month: "2026-05",
Top: 5,
Items: []BillTopResource{
{Product: "CVM", ResourceID: "ins-1", Name: "web-a", Region: "ap-singapore", PayMode: "PREPAID", Action: "renew", Cost: "12.50"},
},
}
raw, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out BillTopResourceReport
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal: %v\nwire: %s", err, raw)
}
if out.Top != 5 || len(out.Items) != 1 || out.Items[0].ResourceID != "ins-1" {
t.Errorf("round-trip lost data: got %+v", out)
}
}
9 changes: 7 additions & 2 deletions internal/tencent/static_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ externally-routable endpoint when running from outside the cluster's VPC.`,
Use: "cost",
Short: "Tencent Cloud billing — cost commands",
}
var costByProductFormat string
costByProductCmd := &cobra.Command{
Use: "by-product",
Short: "Cost breakdown by Tencent service for a given month",
Expand All @@ -265,10 +266,13 @@ externally-routable endpoint when running from outside the cluster's VPC.`,
if err != nil {
return err
}
return listBillByProduct(client, costMonth)
return listBillByProduct(client, costMonth, costByProductFormat)
},
}
costByProductCmd.Flags().StringVar(&costMonth, "month", "", "YYYY-MM (default: current month)")
costByProductCmd.Flags().StringVar(&costByProductFormat, "format", "table", "Output format: table | json")

var costTopFormat string
costTopCmd := &cobra.Command{
Use: "top",
Short: "Top N resources by spend for a given month",
Expand All @@ -282,11 +286,12 @@ externally-routable endpoint when running from outside the cluster's VPC.`,
return err
}
topN, _ := cmd.Flags().GetInt("limit")
return listBillResourceTop(client, costMonth, topN)
return listBillResourceTop(client, costMonth, topN, costTopFormat)
},
}
costTopCmd.Flags().StringVar(&costMonth, "month", "", "YYYY-MM (default: current month)")
costTopCmd.Flags().Int("limit", 20, "Number of resources to return (max 200)")
costTopCmd.Flags().StringVar(&costTopFormat, "format", "table", "Output format: table | json")

var voucherStatus string
costVouchersCmd := &cobra.Command{
Expand Down
Loading