Skip to content

Commit 16615a2

Browse files
MarinXkangasta
andauthored
feat(account): add new command to get billing summary (#606)
Closes #339 Co-authored-by: Toni Kangas <toni.kangas@upcloud.com>
1 parent 1b8e7a5 commit 16615a2

File tree

6 files changed

+184
-4
lines changed

6 files changed

+184
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- In `kubernetes create`, allow waiting for cluster and its node-groups to reach running state with `--wait=all` flag. When using `--wait` or `--wait=cluster`, the command will wait only for the cluster to reach running state.
13+
- Add `account billing` command for listing billing details.
1314

1415
## [3.26.0] - 2025-11-26
1516

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ require (
66
dario.cat/mergo v1.0.2
77
github.com/UpCloudLtd/progress v1.0.3
88
github.com/UpCloudLtd/upcloud-go-api/credentials v0.1.1
9-
github.com/UpCloudLtd/upcloud-go-api/v8 v8.32.0
9+
github.com/UpCloudLtd/upcloud-go-api/v8 v8.33.0
1010
github.com/adrg/xdg v0.5.3
1111
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2
1212
github.com/gemalto/flume v1.0.0

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -423,8 +423,8 @@ github.com/UpCloudLtd/progress v1.0.3 h1:8SfntHkBPyQc5BL3946Bgi9KYnQOxa5RR2EKdad
423423
github.com/UpCloudLtd/progress v1.0.3/go.mod h1:iGxOnb9HvHW0yrLGUjHr0lxHhn7TehgWwh7a8NqK6iQ=
424424
github.com/UpCloudLtd/upcloud-go-api/credentials v0.1.1 h1:eTfQsv58ufALOk9BZ7WbS/i7pMUD11RnYYpRPsz0LdI=
425425
github.com/UpCloudLtd/upcloud-go-api/credentials v0.1.1/go.mod h1:7OtVs2UqtfvjkC1HfE+Oud0MnbMv7qUWnbEgxnTAqts=
426-
github.com/UpCloudLtd/upcloud-go-api/v8 v8.32.0 h1:mtxrTUWr7vB2lcv0KEpHpXAdQMl4ol024I+P+qfEIRc=
427-
github.com/UpCloudLtd/upcloud-go-api/v8 v8.32.0/go.mod h1:NBh1d/ip1bhdAIhuPWbyPme7tbLzDTV7dhutUmU1vg8=
426+
github.com/UpCloudLtd/upcloud-go-api/v8 v8.33.0 h1:18MDgUePzdapCSKwLWVL+WRawyT25yMxmV9TQ32KQJQ=
427+
github.com/UpCloudLtd/upcloud-go-api/v8 v8.33.0/go.mod h1:NBh1d/ip1bhdAIhuPWbyPme7tbLzDTV7dhutUmU1vg8=
428428
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
429429
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
430430
github.com/ansel1/merry v1.5.0/go.mod h1:wUy/yW0JX0ix9GYvUbciq+bi3jW/vlKPlbpI7qdZpOw=
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package account
2+
3+
import (
4+
"fmt"
5+
"sort"
6+
7+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands"
8+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/output"
9+
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud"
10+
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request"
11+
"github.com/spf13/pflag"
12+
)
13+
14+
// BillingCommand creates the 'account billing' command
15+
func BillingCommand() commands.Command {
16+
return &billingCommand{
17+
BaseCommand: commands.New(
18+
"billing",
19+
"Show billing information",
20+
"upctl account billing --year 2025 --month 7",
21+
),
22+
}
23+
}
24+
25+
type billingCommand struct {
26+
*commands.BaseCommand
27+
year int
28+
month int
29+
resourceID string
30+
username string
31+
}
32+
33+
// InitCommand implements Command.InitCommand
34+
func (s *billingCommand) InitCommand() {
35+
flagSet := &pflag.FlagSet{}
36+
37+
flagSet.IntVar(&s.year, "year", 0, "Year for billing information.")
38+
flagSet.IntVar(&s.month, "month", 0, "Month for billing information.")
39+
flagSet.StringVar(&s.resourceID, "resource-id", "", "For IP addresses: the address itself, others, resource UUID")
40+
flagSet.StringVar(&s.username, "username", "", "Valid username")
41+
42+
s.AddFlags(flagSet)
43+
44+
commands.Must(s.Cobra().MarkFlagRequired("year"))
45+
commands.Must(s.Cobra().MarkFlagRequired("month"))
46+
}
47+
48+
func firstElementAsString(row output.TableRow) string {
49+
s, ok := row[0].(string)
50+
if !ok {
51+
return ""
52+
}
53+
return s
54+
}
55+
56+
// ExecuteWithoutArguments implements commands.NoArgumentCommand
57+
func (s *billingCommand) ExecuteWithoutArguments(exec commands.Executor) (output.Output, error) {
58+
if s.year < 1900 || s.year > 9999 {
59+
return nil, fmt.Errorf("invalid year: %d", s.year)
60+
}
61+
if s.month < 1 || s.month > 12 {
62+
return nil, fmt.Errorf("invalid month: %d", s.month)
63+
}
64+
65+
svc := exec.Account()
66+
summary, err := svc.GetBillingSummary(exec.Context(), &request.GetBillingSummaryRequest{
67+
YearMonth: fmt.Sprintf("%d-%02d", s.year, s.month),
68+
ResourceID: s.resourceID,
69+
Username: s.username,
70+
})
71+
if err != nil {
72+
return nil, err
73+
}
74+
75+
createCategorySections := func() []output.CombinedSection {
76+
var sections []output.CombinedSection
77+
var summaryRows []output.TableRow
78+
79+
categories := map[string]*upcloud.BillingCategory{
80+
"Servers": summary.Servers,
81+
"Managed Databases": summary.ManagedDatabases,
82+
"Managed Object Storages": summary.ManagedObjectStorages,
83+
"Managed Load Balancers": summary.ManagedLoadbalancers,
84+
"Managed Kubernetes": summary.ManagedKubernetes,
85+
"Network Gateways": summary.NetworkGateways,
86+
"Networks": summary.Networks,
87+
"Storages": summary.Storages,
88+
}
89+
90+
for categoryName, category := range categories {
91+
if category != nil {
92+
summaryRows = append(summaryRows, output.TableRow{categoryName, category.TotalAmount})
93+
resourceGroups := map[string]*upcloud.BillingResourceGroup{
94+
"Server": category.Server,
95+
"Managed Database": category.ManagedDatabase,
96+
"Managed Object Storage": category.ManagedObjectStorage,
97+
"Managed Load Balancer": category.ManagedLoadbalancer,
98+
"Managed Kubernetes": category.ManagedKubernetes,
99+
"Network Gateway": category.NetworkGateway,
100+
"IPv4 Address": category.IPv4Address,
101+
"Backup": category.Backup,
102+
"Storage": category.Storage,
103+
"Template": category.Template,
104+
}
105+
106+
for groupName, group := range resourceGroups {
107+
if group != nil && len(group.Resources) > 0 {
108+
var resourceRows []output.TableRow
109+
for _, resource := range group.Resources {
110+
resourceRows = append(resourceRows, output.TableRow{
111+
resource.ResourceID,
112+
resource.Amount,
113+
resource.Hours,
114+
})
115+
}
116+
117+
sections = append(sections, output.CombinedSection{
118+
Key: fmt.Sprintf("%s_%s_resources", categoryName, groupName),
119+
Title: fmt.Sprintf("%s - %s Resources:", categoryName, groupName),
120+
Contents: output.Table{
121+
Columns: []output.TableColumn{
122+
{Key: "resource_id", Header: "Resource ID"},
123+
{Key: "amount", Header: "Amount"},
124+
{Key: "hours", Header: "Hours"},
125+
},
126+
Rows: resourceRows,
127+
EmptyMessage: fmt.Sprintf("No resources for %s.", groupName),
128+
},
129+
})
130+
}
131+
}
132+
}
133+
}
134+
135+
sort.Slice(summaryRows, func(i, j int) bool {
136+
return firstElementAsString(summaryRows[i]) < firstElementAsString(summaryRows[j])
137+
})
138+
summaryRows = append(summaryRows, output.TableRow{"Total", summary.TotalAmount})
139+
140+
sort.Slice(sections, func(i, j int) bool {
141+
return sections[i].Title < sections[j].Title
142+
})
143+
sections = append([]output.CombinedSection{{
144+
Key: "summary",
145+
Title: "Summary:",
146+
Contents: output.Table{
147+
Columns: []output.TableColumn{
148+
{Key: "resource", Header: "Resource"},
149+
{Key: "total_amount", Header: "Amount"},
150+
},
151+
Rows: summaryRows,
152+
},
153+
}}, sections...)
154+
return sections
155+
}
156+
157+
combined := output.Combined(createCategorySections())
158+
159+
return output.MarshaledWithHumanOutput{
160+
Value: summary,
161+
Output: combined,
162+
}, nil
163+
}

internal/commands/base/base.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ func BuildCommands(rootCmd *cobra.Command, conf *config.Config) {
132132
commands.BuildCommand(account.ShowCommand(), accountCommand.Cobra(), conf)
133133
commands.BuildCommand(account.ListCommand(), accountCommand.Cobra(), conf)
134134
commands.BuildCommand(account.DeleteCommand(), accountCommand.Cobra(), conf)
135+
commands.BuildCommand(account.BillingCommand(), accountCommand.Cobra(), conf)
135136

136137
// Account permissions
137138
permissionsCommand := commands.BuildCommand(permissions.BasePermissionsCommand(), accountCommand.Cobra(), conf)
@@ -282,7 +283,6 @@ func BuildCommands(rootCmd *cobra.Command, conf *config.Config) {
282283
allCommand := commands.BuildCommand(all.BaseAllCommand(), rootCmd, conf)
283284
commands.BuildCommand(all.PurgeCommand(), allCommand.Cobra(), conf)
284285
commands.BuildCommand(all.ListCommand(), allCommand.Cobra(), conf)
285-
286286
// Stack operations
287287
stackCommand := commands.BuildCommand(stack.BaseStackCommand(), rootCmd, conf)
288288
stackDeployCommand := commands.BuildCommand(stack.DeployCommand(), stackCommand.Cobra(), conf)

internal/mock/mock.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1312,6 +1312,22 @@ func (m *Service) DetachManagedObjectStorageUserPolicy(ctx context.Context, r *r
13121312
return nil
13131313
}
13141314

1315+
func (m *Service) CreateManagedObjectStoragePolicyVersion(ctx context.Context, r *request.CreateManagedObjectStoragePolicyVersionRequest) (*upcloud.ManagedObjectStoragePolicyVersion, error) {
1316+
return nil, nil
1317+
}
1318+
1319+
func (m *Service) GetManagedObjectStoragePolicyVersion(ctx context.Context, r *request.GetManagedObjectStoragePolicyVersionRequest) (*upcloud.ManagedObjectStoragePolicyVersion, error) {
1320+
return nil, nil
1321+
}
1322+
1323+
func (m *Service) GetManagedObjectStoragePolicyVersions(ctx context.Context, r *request.GetManagedObjectStoragePolicyVersionsRequest) ([]upcloud.ManagedObjectStoragePolicyVersion, error) {
1324+
return nil, nil
1325+
}
1326+
1327+
func (m *Service) DeleteManagedObjectStoragePolicyVersion(ctx context.Context, r *request.DeleteManagedObjectStoragePolicyVersionRequest) error {
1328+
return nil
1329+
}
1330+
13151331
func (m *Service) CreateManagedObjectStorageCustomDomain(ctx context.Context, r *request.CreateManagedObjectStorageCustomDomainRequest) error {
13161332
return m.Called(r).Error(0)
13171333
}

0 commit comments

Comments
 (0)