diff --git a/Makefile b/Makefile index d38469209e..b06e81b947 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ FPM_ARGS ?= \ NAME = scw SRC = cmd/scw -PACKAGES = pkg/api pkg/commands pkg/utils pkg/cli pkg/sshcommand pkg/config pkg/scwversion +PACKAGES = pkg/api pkg/commands pkg/utils pkg/cli pkg/sshcommand pkg/config pkg/scwversion pkg/pricing REV = $(shell git rev-parse HEAD || echo "nogit") TAG = $(shell git describe --tags --always || echo "nogit") BUILDER = scaleway-cli-builder @@ -72,7 +72,7 @@ $(INSTALL_LIST): %_install: $(IREF_LIST): %_iref: pkg/scwversion/version.go $(GOTEST) -i ./$* $(TEST_LIST): %_test: - $(GOTEST) ./$* + $(GOTEST) -v ./$* $(FMT_LIST): %_fmt: $(GOFMT) ./$* diff --git a/README.md b/README.md index 23155c2ead..c1bd8a2ec8 100644 --- a/README.md +++ b/README.md @@ -1130,10 +1130,11 @@ $ scw inspect myserver | jq '.[0].public_ip.address' #### Features -* `scw info` now prints user/organization info from the API ([#142](https://github.com/scaleway/scaleway-cli/issues/130) +* `scw info` now prints user/organization info from the API ([#130](https://github.com/scaleway/scaleway-cli/issues/130) * Added helpers to manipulate new `user_data` API ([#150](https://github.com/scaleway/scaleway-cli/issues/150)) * Support of `scw rm -f/--force` option ([#158](https://github.com/scaleway/scaleway-cli/issues/158)) * Added `scw _userdata local ...` option which interacts with the Metadata API without authentication ([#166](https://github.com/scaleway/scaleway-cli/issues/166)) +* Initial version of `scw _billing` (price estimation tool) ([#118](https://github.com/scaleway/scaleway-cli/issues/118) #### Fixes diff --git a/pkg/cli/commands.go b/pkg/cli/commands.go index 44eff4d41c..e291084f46 100644 --- a/pkg/cli/commands.go +++ b/pkg/cli/commands.go @@ -13,12 +13,10 @@ var Commands = []*Command{ cmdAttach, cmdCommit, - cmdCompletion, cmdCp, cmdCreate, cmdEvents, cmdExec, - cmdFlushCache, cmdHistory, cmdImages, cmdInfo, @@ -27,7 +25,6 @@ var Commands = []*Command{ cmdLogin, cmdLogout, cmdLogs, - cmdPatch, cmdPort, cmdPs, cmdRename, @@ -43,4 +40,9 @@ var Commands = []*Command{ cmdUserdata, cmdVersion, cmdWait, + + cmdBilling, + cmdCompletion, + cmdFlushCache, + cmdPatch, } diff --git a/pkg/cli/test.go b/pkg/cli/test.go index 23ad437b0a..771cda71f3 100644 --- a/pkg/cli/test.go +++ b/pkg/cli/test.go @@ -17,7 +17,7 @@ var ( "version", "wait", } secretCommands []string = []string{ - "_patch", "_completion", "_flush-cache", "_userdata", + "_patch", "_completion", "_flush-cache", "_userdata", "_billing", } publicOptions []string = []string{ "-h, --help=false", diff --git a/pkg/cli/x_billing.go b/pkg/cli/x_billing.go new file mode 100644 index 0000000000..9769f6fa9c --- /dev/null +++ b/pkg/cli/x_billing.go @@ -0,0 +1,94 @@ +// Copyright (C) 2015 Scaleway. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE.md file. + +package cli + +import ( + "fmt" + "math/big" + "text/tabwriter" + "time" + + "github.com/scaleway/scaleway-cli/pkg/commands" + "github.com/scaleway/scaleway-cli/pkg/pricing" + "github.com/scaleway/scaleway-cli/pkg/utils" + "github.com/scaleway/scaleway-cli/vendor/github.com/Sirupsen/logrus" + "github.com/scaleway/scaleway-cli/vendor/github.com/docker/docker/pkg/units" +) + +var cmdBilling = &Command{ + Exec: runBilling, + UsageLine: "_billing [OPTIONS]", + Description: "", + Hidden: true, + Help: "Get resources billing estimation", +} + +func init() { + cmdBilling.Flag.BoolVar(&billingHelp, []string{"h", "-help"}, false, "Print usage") + cmdBilling.Flag.BoolVar(&billingNoTrunc, []string{"-no-trunc"}, false, "Don't truncate output") +} + +// BillingArgs are flags for the `RunBilling` function +type BillingArgs struct { + NoTrunc bool +} + +// Flags +var billingHelp bool // -h, --help flag +var billingNoTrunc bool // --no-trunc flag + +func runBilling(cmd *Command, rawArgs []string) error { + if billingHelp { + return cmd.PrintUsage() + } + if len(rawArgs) > 0 { + return cmd.PrintShortUsage() + } + + // cli parsing + args := commands.PsArgs{ + NoTrunc: billingNoTrunc, + } + ctx := cmd.GetContext(rawArgs) + + logrus.Warn("") + logrus.Warn("Warning: 'scw _billing' is a work-in-progress price estimation tool") + logrus.Warn("For real usage, visit https://cloud.scaleway.com/#/billing") + logrus.Warn("") + + // table + w := tabwriter.NewWriter(ctx.Stdout, 20, 1, 3, ' ', 0) + defer w.Flush() + fmt.Fprintf(w, "ID\tNAME\tSTARTED\tMONTH PRICE\n") + + // servers + servers, err := cmd.API.GetServers(true, 0) + if err != nil { + return err + } + + totalMonthPrice := new(big.Rat) + + for _, server := range *servers { + if server.State != "running" { + continue + } + shortID := utils.TruncIf(server.Identifier, 8, !args.NoTrunc) + shortName := utils.TruncIf(utils.Wordify(server.Name), 25, !args.NoTrunc) + modificationTime, _ := time.Parse("2006-01-02T15:04:05.000000+00:00", server.ModificationDate) + modificationAgo := time.Now().UTC().Sub(modificationTime) + shortModificationDate := units.HumanDuration(modificationAgo) + usage := pricing.NewUsageByPath("/compute/c1/run") + usage.SetStartEnd(modificationTime, time.Now().UTC()) + + totalMonthPrice = totalMonthPrice.Add(totalMonthPrice, usage.Total()) + + fmt.Fprintf(w, "server/%s\t%s\t%s\t%s\n", shortID, shortName, shortModificationDate, usage.TotalString()) + } + + fmt.Fprintf(w, "TOTAL\t\t\t%s\n", pricing.PriceString(totalMonthPrice, "EUR")) + + return nil +} diff --git a/pkg/pricing/basket.go b/pkg/pricing/basket.go new file mode 100644 index 0000000000..73580fd0a1 --- /dev/null +++ b/pkg/pricing/basket.go @@ -0,0 +1,41 @@ +package pricing + +import ( + "math/big" + "time" +) + +type Basket []Usage + +func NewBasket() *Basket { + return &Basket{} +} + +func (b *Basket) Add(usage Usage) error { + *b = append(*b, usage) + return nil +} + +func (b *Basket) Length() int { + return len(*b) +} + +func (b *Basket) SetDuration(duration time.Duration) error { + var err error + for i, usage := range *b { + err = usage.SetDuration(duration) + if err != nil { + return err + } + (*b)[i] = usage + } + return nil +} + +func (b *Basket) Total() *big.Rat { + total := new(big.Rat) + for _, usage := range *b { + total = total.Add(total, usage.Total()) + } + return total +} diff --git a/pkg/pricing/basket_test.go b/pkg/pricing/basket_test.go new file mode 100644 index 0000000000..ea592aa15e --- /dev/null +++ b/pkg/pricing/basket_test.go @@ -0,0 +1,87 @@ +package pricing + +import ( + "math/big" + "testing" + "time" + + . "github.com/scaleway/scaleway-cli/vendor/github.com/smartystreets/goconvey/convey" +) + +func TestNewBasket(t *testing.T) { + Convey("Testing NewBasket()", t, func() { + basket := NewBasket() + So(basket, ShouldNotBeNil) + So(basket.Length(), ShouldEqual, 0) + }) +} + +func TestBasket_Add(t *testing.T) { + Convey("Testing Basket.Add", t, FailureContinues, func() { + basket := NewBasket() + So(basket, ShouldNotBeNil) + So(basket.Length(), ShouldEqual, 0) + + err := basket.Add(NewUsageByPathWithQuantity("/compute/c1/run", big.NewRat(1, 1))) + So(err, ShouldBeNil) + So(basket.Length(), ShouldEqual, 1) + + err = basket.Add(NewUsageByPathWithQuantity("/compute/c1/run", big.NewRat(42, 1))) + So(err, ShouldBeNil) + So(basket.Length(), ShouldEqual, 2) + + err = basket.Add(NewUsageByPathWithQuantity("/compute/c1/run", big.NewRat(600, 1))) + So(err, ShouldBeNil) + So(basket.Length(), ShouldEqual, 3) + }) +} + +func TestBasket_Total(t *testing.T) { + Convey("Testing Basket.Total", t, FailureContinues, func() { + Convey("3 compute instances", func() { + basket := NewBasket() + So(basket, ShouldNotBeNil) + So(basket.Total(), ShouldEqualBigRat, ratZero) + + err := basket.Add(NewUsageByPathWithQuantity("/compute/c1/run", big.NewRat(1, 1))) + So(err, ShouldBeNil) + So(basket.Total(), ShouldEqualBigRat, big.NewRat(2, 1000)) // 0.002 + + err = basket.Add(NewUsageByPathWithQuantity("/compute/c1/run", big.NewRat(42, 1))) + So(err, ShouldBeNil) + So(basket.Total(), ShouldEqualBigRat, big.NewRat(4, 1000)) // 0.004 + + err = basket.Add(NewUsageByPathWithQuantity("/compute/c1/run", big.NewRat(600, 1))) + So(err, ShouldBeNil) + So(basket.Total(), ShouldEqualBigRat, big.NewRat(24, 1000)) // 0.024 + }) + Convey("1 compute instance with 2 volumes and 1 ip", func() { + basket := NewBasket() + + basket.Add(NewUsageByPath("/compute/c1/run")) + basket.Add(NewUsageByPath("/ip/dynamic")) + basket.Add(NewUsageByPath("/storage/local/ssd/storage")) + basket.Add(NewUsageByPath("/storage/local/ssd/storage")) + So(basket.Length(), ShouldEqual, 4) + + basket.SetDuration(1 * time.Minute) + So(basket.Total(), ShouldEqualBigRat, big.NewRat(8, 1000)) // 0.008 + + basket.SetDuration(1 * time.Hour) + So(basket.Total(), ShouldEqualBigRat, big.NewRat(8, 1000)) // 0.008 + + basket.SetDuration(2 * time.Hour) + So(basket.Total(), ShouldEqualBigRat, big.NewRat(12, 1000)) // 0.012 + + basket.SetDuration(2 * 24 * time.Hour) + So(basket.Total(), ShouldEqualBigRat, big.NewRat(196, 1000)) // 0.196 + + basket.SetDuration(30 * 24 * time.Hour) + So(basket.Total(), ShouldEqualBigRat, big.NewRat(2050, 1000)) // 2.05 + + // FIXME: this test is false, the capacity is per month + basket.SetDuration(365 * 24 * time.Hour) + So(basket.Total(), ShouldEqualBigRat, big.NewRat(2694, 1000)) // 2.694 + }) + }) +} diff --git a/pkg/pricing/pricing.go b/pkg/pricing/pricing.go new file mode 100644 index 0000000000..f8b55a9c8a --- /dev/null +++ b/pkg/pricing/pricing.go @@ -0,0 +1,85 @@ +package pricing + +import ( + "math/big" + "time" +) + +type PricingObject struct { + Path string + Identifier string + Currency string + UsageUnit string + UnitPrice *big.Rat + UnitQuantity *big.Rat + UnitPriceCap *big.Rat + UsageGranularity time.Duration +} + +type PricingList []PricingObject + +// CurrentPricing tries to be up-to-date with the real pricing +// we cannot guarantee of these values since we hardcode values for now +// later, we should be able to call a dedicated pricing API +var CurrentPricing PricingList + +func init() { + CurrentPricing = PricingList{ + { + Path: "/compute/c1/run", + Identifier: "aaaaaaaa-aaaa-4aaa-8aaa-111111111112", + Currency: "EUR", + UnitPrice: big.NewRat(2, 1000), // 0.002 + UnitQuantity: big.NewRat(60000, 1000), // 60 + UnitPriceCap: big.NewRat(1000, 1000), // 1 + UsageGranularity: time.Minute, + }, + { + Path: "/ip/dynamic", + Identifier: "467116bf-4631-49fb-905b-e07701c21111", + Currency: "EUR", + UnitPrice: big.NewRat(2, 1000), // 0.002 + UnitQuantity: big.NewRat(60000, 1000), // 60 + UnitPriceCap: big.NewRat(990, 1000), // 0.99 + UsageGranularity: time.Minute, + }, + { + Path: "/ip/reserved", + Identifier: "467116bf-4631-49fb-905b-e07701c22222", + Currency: "EUR", + UnitPrice: big.NewRat(2, 1000), // 0.002 + UnitQuantity: big.NewRat(60000, 1000), // 60 + UnitPriceCap: big.NewRat(990, 1000), // 0.99 + UsageGranularity: time.Minute, + }, + { + Path: "/storage/local/ssd/storage", + Identifier: "bbbbbbbb-bbbb-4bbb-8bbb-111111111144", + Currency: "EUR", + UnitPrice: big.NewRat(2, 1000), // 0.002 + UnitQuantity: big.NewRat(50000, 1000), // 50 + UnitPriceCap: big.NewRat(1000, 1000), // 1 + UsageGranularity: time.Hour, + }, + } +} + +// GetByPath returns an object matching a path +func (pl *PricingList) GetByPath(path string) *PricingObject { + for _, object := range *pl { + if object.Path == path { + return &object + } + } + return nil +} + +// GetByIdentifier returns an object matching a identifier +func (pl *PricingList) GetByIdentifier(identifier string) *PricingObject { + for _, object := range *pl { + if object.Identifier == identifier { + return &object + } + } + return nil +} diff --git a/pkg/pricing/pricing_test.go b/pkg/pricing/pricing_test.go new file mode 100644 index 0000000000..6d46a87541 --- /dev/null +++ b/pkg/pricing/pricing_test.go @@ -0,0 +1,33 @@ +package pricing + +import ( + "testing" + + . "github.com/scaleway/scaleway-cli/vendor/github.com/smartystreets/goconvey/convey" +) + +func TestGetByPath(t *testing.T) { + Convey("Testing GetByPath", t, func() { + object := CurrentPricing.GetByPath("/compute/c1/run") + So(object, ShouldNotBeNil) + So(object.Path, ShouldEqual, "/compute/c1/run") + + object = CurrentPricing.GetByPath("/ip/dynamic") + So(object, ShouldNotBeNil) + So(object.Path, ShouldEqual, "/ip/dynamic") + + object = CurrentPricing.GetByPath("/dontexists") + So(object, ShouldBeNil) + }) +} + +func TestGetByIdentifier(t *testing.T) { + Convey("Testing GetByIdentifier", t, func() { + object := CurrentPricing.GetByIdentifier("aaaaaaaa-aaaa-4aaa-8aaa-111111111112") + So(object, ShouldNotBeNil) + So(object.Path, ShouldEqual, "/compute/c1/run") + + object = CurrentPricing.GetByIdentifier("dontexists") + So(object, ShouldBeNil) + }) +} diff --git a/pkg/pricing/usage.go b/pkg/pricing/usage.go new file mode 100644 index 0000000000..fe82ae0259 --- /dev/null +++ b/pkg/pricing/usage.go @@ -0,0 +1,84 @@ +package pricing + +import ( + "math/big" + "time" +) + +type Usage struct { + PricingObject *PricingObject + Quantity *big.Rat +} + +func NewUsageByPath(objectPath string) Usage { + return NewUsageByPathWithQuantity(objectPath, ratZero) +} + +func NewUsageByPathWithQuantity(objectPath string, quantity *big.Rat) Usage { + return NewUsageWithQuantity(CurrentPricing.GetByPath(objectPath), quantity) +} + +func NewUsageWithQuantity(object *PricingObject, quantity *big.Rat) Usage { + return Usage{ + PricingObject: object, + Quantity: quantity, + } +} + +func NewUsage(object *PricingObject) Usage { + return NewUsageWithQuantity(object, ratZero) +} + +func (u *Usage) SetQuantity(quantity *big.Rat) error { + u.Quantity = ratMax(quantity, ratZero) + return nil +} + +func (u *Usage) SetDuration(duration time.Duration) error { + minutes := new(big.Rat).SetFloat64(duration.Minutes()) + factor := new(big.Rat).SetInt64((u.PricingObject.UsageGranularity / time.Minute).Nanoseconds()) + quantity := new(big.Rat).Quo(minutes, factor) + ceil := new(big.Rat).SetInt(ratCeil(quantity)) + return u.SetQuantity(ceil) +} + +func (u *Usage) SetStartEnd(start, end time.Time) error { + roundedStart := start.Round(u.PricingObject.UsageGranularity) + if roundedStart.After(start) { + roundedStart = roundedStart.Add(-u.PricingObject.UsageGranularity) + } + roundedEnd := end.Round(u.PricingObject.UsageGranularity) + if roundedEnd.Before(end) { + roundedEnd = roundedEnd.Add(u.PricingObject.UsageGranularity) + } + return u.SetDuration(roundedEnd.Sub(roundedStart)) +} + +func (u *Usage) BillableQuantity() *big.Rat { + if u.Quantity.Cmp(ratZero) < 1 { + return big.NewRat(0, 1) + } + + //return math.Ceil(u.Quantity/u.PricingObject.UnitQuantity) * u.PricingObject.UnitQuantity + quantityQuotient := new(big.Rat).Quo(u.Quantity, u.PricingObject.UnitQuantity) + ceil := new(big.Rat).SetInt(ratCeil(quantityQuotient)) + return new(big.Rat).Mul(ceil, u.PricingObject.UnitQuantity) +} + +func (u *Usage) LostQuantity() *big.Rat { + //return u.BillableQuantity() - math.Max(u.Quantity, 0) + + return new(big.Rat).Sub(u.BillableQuantity(), ratMax(u.Quantity, ratZero)) +} + +func (u *Usage) Total() *big.Rat { + //return math.Min(u.PricingObject.UnitPrice * u.BillableQuantity(), u.PricingObject.UnitPriceCap) + + total := new(big.Rat).Mul(u.BillableQuantity(), u.PricingObject.UnitPrice) + total = total.Quo(total, u.PricingObject.UnitQuantity) + return ratMin(total, u.PricingObject.UnitPriceCap) +} + +func (u *Usage) TotalString() string { + return PriceString(u.Total(), u.PricingObject.Currency) +} diff --git a/pkg/pricing/usage_test.go b/pkg/pricing/usage_test.go new file mode 100644 index 0000000000..d3e3320643 --- /dev/null +++ b/pkg/pricing/usage_test.go @@ -0,0 +1,263 @@ +package pricing + +import ( + "math/big" + "testing" + "time" + + . "github.com/scaleway/scaleway-cli/vendor/github.com/smartystreets/goconvey/convey" +) + +func TestNewUsageByPathWithQuantity(t *testing.T) { + Convey("Testing NewUsageByPathWithQuantity()", t, func() { + usage := NewUsageByPathWithQuantity("/compute/c1/run", big.NewRat(1, 1)) + So(usage.PricingObject.Path, ShouldEqual, "/compute/c1/run") + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(1, 1)) + }) +} + +func TestNewUsageByPath(t *testing.T) { + Convey("Testing NewUsageByPath()", t, func() { + usage := NewUsageByPath("/compute/c1/run") + So(usage.PricingObject.Path, ShouldEqual, "/compute/c1/run") + So(usage.Quantity, ShouldEqualBigRat, ratZero) + }) +} + +func TestNewUsageWithQuantity(t *testing.T) { + Convey("Testing NewUsageWithQuantity()", t, func() { + object := CurrentPricing.GetByPath("/compute/c1/run") + usage := NewUsageWithQuantity(object, big.NewRat(1, 1)) + So(usage.PricingObject.Path, ShouldEqual, "/compute/c1/run") + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(1, 1)) + }) +} + +func TestUsage_SetStartEnd(t *testing.T) { + Convey("Testing Usage.SetStartEnd()", t, func() { + object := PricingObject{ + UsageGranularity: time.Minute, + } + usage := NewUsage(&object) + layout := "2006-Jan-02 15:04:05" + start, err := time.Parse(layout, "2015-Jan-25 13:15:42") + So(err, ShouldBeNil) + end, err := time.Parse(layout, "2015-Jan-25 13:16:10") + So(err, ShouldBeNil) + err = usage.SetStartEnd(start, end) + So(err, ShouldBeNil) + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(2, 1)) + }) +} + +func TestUsage_SetDuration(t *testing.T) { + Convey("Testing Usage.SetDuration()", t, FailureContinues, func() { + Convey("UsageGranularity=time.Minute", func() { + object := PricingObject{ + UsageGranularity: time.Minute, + } + usage := NewUsage(&object) + + err := usage.SetDuration(time.Minute * 10) + So(err, ShouldBeNil) + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(10, 1)) + + err = usage.SetDuration(time.Minute + time.Second) + So(err, ShouldBeNil) + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(2, 1)) + + err = usage.SetDuration(0 * time.Minute) + So(err, ShouldBeNil) + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(0, 1)) + + err = usage.SetDuration(-1 * time.Minute) + So(err, ShouldBeNil) + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(0, 1)) + + err = usage.SetDuration(10*time.Hour + 5*time.Minute + 10*time.Second) + So(err, ShouldBeNil) + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(60*10+5+1, 1)) + + err = usage.SetDuration(10 * time.Nanosecond) + So(err, ShouldBeNil) + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(1, 1)) + }) + + Convey("UsageGranularity=time.Hour", func() { + object := PricingObject{ + UsageGranularity: time.Hour, + } + usage := NewUsage(&object) + + err := usage.SetDuration(time.Minute * 10) + So(err, ShouldBeNil) + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(1, 1)) + + err = usage.SetDuration(time.Minute + time.Second) + So(err, ShouldBeNil) + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(1, 1)) + + err = usage.SetDuration(0 * time.Minute) + So(err, ShouldBeNil) + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(0, 1)) + + err = usage.SetDuration(-1 * time.Minute) + So(err, ShouldBeNil) + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(0, 1)) + + err = usage.SetDuration(10*time.Hour + 5*time.Minute + 10*time.Second) + So(err, ShouldBeNil) + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(11, 1)) + + err = usage.SetDuration(10 * time.Nanosecond) + So(err, ShouldBeNil) + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(1, 1)) + }) + + Convey("UsageGranularity=time.Hour*24", func() { + object := PricingObject{ + UsageGranularity: time.Hour * 24, + } + usage := NewUsage(&object) + + err := usage.SetDuration(time.Minute * 10) + So(err, ShouldBeNil) + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(1, 1)) + + err = usage.SetDuration(time.Minute + time.Second) + So(err, ShouldBeNil) + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(1, 1)) + + err = usage.SetDuration(0 * time.Minute) + So(err, ShouldBeNil) + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(0, 1)) + + err = usage.SetDuration(-1 * time.Minute) + So(err, ShouldBeNil) + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(0, 1)) + + err = usage.SetDuration(10*time.Hour + 5*time.Minute + 10*time.Second) + So(err, ShouldBeNil) + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(1, 1)) + + err = usage.SetDuration(3*24*time.Hour + 1*time.Minute) + So(err, ShouldBeNil) + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(4, 1)) + + err = usage.SetDuration(10 * time.Nanosecond) + So(err, ShouldBeNil) + So(usage.Quantity, ShouldEqualBigRat, big.NewRat(1, 1)) + }) + }) +} + +func TestUsage_BillableQuantity(t *testing.T) { + Convey("Testing Usage.BillableQuantity()", t, FailureContinues, func() { + object := &PricingObject{ + UnitQuantity: big.NewRat(60, 1), + } + usage := NewUsageWithQuantity(object, big.NewRat(-1, 1)) + So(usage.BillableQuantity(), ShouldEqualBigRat, big.NewRat(0, 1)) + + usage = NewUsageWithQuantity(object, big.NewRat(-1000, 1)) + So(usage.BillableQuantity(), ShouldEqualBigRat, big.NewRat(0, 1)) + + usage = NewUsageWithQuantity(object, big.NewRat(0, 1)) + So(usage.BillableQuantity(), ShouldEqualBigRat, big.NewRat(0, 1)) + + usage = NewUsageWithQuantity(object, big.NewRat(1, 1)) + So(usage.BillableQuantity(), ShouldEqualBigRat, big.NewRat(60, 1)) + + usage = NewUsageWithQuantity(object, big.NewRat(59, 1)) + So(usage.BillableQuantity(), ShouldEqualBigRat, big.NewRat(60, 1)) + + usage = NewUsageWithQuantity(object, big.NewRat(599999, 10000)) // 59.9999 + So(usage.BillableQuantity(), ShouldEqualBigRat, big.NewRat(60, 1)) + + usage = NewUsageWithQuantity(object, big.NewRat(60, 1)) + So(usage.BillableQuantity(), ShouldEqualBigRat, big.NewRat(60, 1)) + + usage = NewUsageWithQuantity(object, big.NewRat(6000001, 100000)) // 60.00001 + So(usage.BillableQuantity(), ShouldEqualBigRat, big.NewRat(60*2, 1)) + + usage = NewUsageWithQuantity(object, big.NewRat(61, 1)) + So(usage.BillableQuantity(), ShouldEqualBigRat, big.NewRat(60*2, 1)) + + usage = NewUsageWithQuantity(object, big.NewRat(119, 1)) + So(usage.BillableQuantity(), ShouldEqualBigRat, big.NewRat(60*2, 1)) + + usage = NewUsageWithQuantity(object, big.NewRat(121, 1)) + So(usage.BillableQuantity(), ShouldEqualBigRat, big.NewRat(60*3, 1)) + + usage = NewUsageWithQuantity(object, big.NewRat(1000, 1)) + So(usage.BillableQuantity(), ShouldEqualBigRat, big.NewRat(60*17, 1)) + }) +} + +func TestUsage_LostQuantity(t *testing.T) { + Convey("Testing Usage.LostQuantity()", t, FailureContinues, func() { + object := &PricingObject{ + UnitQuantity: big.NewRat(60, 1), + } + usage := NewUsageWithQuantity(object, big.NewRat(-1, 1)) + So(usage.LostQuantity(), ShouldEqualBigRat, big.NewRat(0, 1)) + + usage = NewUsageWithQuantity(object, big.NewRat(-1000, 1)) + So(usage.LostQuantity(), ShouldEqualBigRat, big.NewRat(0, 1)) + + usage = NewUsageWithQuantity(object, big.NewRat(0, 1)) + So(usage.LostQuantity(), ShouldEqualBigRat, big.NewRat(0, 1)) + + usage = NewUsageWithQuantity(object, big.NewRat(1, 1)) + So(usage.LostQuantity(), ShouldEqualBigRat, big.NewRat(60-1, 1)) + + usage = NewUsageWithQuantity(object, big.NewRat(59, 1)) + So(usage.LostQuantity(), ShouldEqualBigRat, big.NewRat(60-59, 1)) + + usage = NewUsageWithQuantity(object, big.NewRat(599999, 10000)) // 59.9999 + So(usage.LostQuantity(), ShouldEqualBigRat, new(big.Rat).Sub(big.NewRat(60, 1), big.NewRat(599999, 10000))) + + usage = NewUsageWithQuantity(object, big.NewRat(60, 1)) + So(usage.LostQuantity(), ShouldEqualBigRat, big.NewRat(0, 1)) + + usage = NewUsageWithQuantity(object, big.NewRat(6000001, 100000)) // 60.00001 + So(usage.LostQuantity(), ShouldEqualBigRat, big.NewRat(6000000*2-6000001, 100000)) + + usage = NewUsageWithQuantity(object, big.NewRat(61, 1)) + So(usage.LostQuantity(), ShouldEqualBigRat, big.NewRat(60*2-61, 1)) + + usage = NewUsageWithQuantity(object, big.NewRat(119, 1)) + So(usage.LostQuantity(), ShouldEqualBigRat, big.NewRat(60*2-119, 1)) + + usage = NewUsageWithQuantity(object, big.NewRat(121, 1)) + So(usage.LostQuantity(), ShouldEqualBigRat, big.NewRat(60*3-121, 1)) + + usage = NewUsageWithQuantity(object, big.NewRat(1000, 1)) + So(usage.LostQuantity(), ShouldEqualBigRat, big.NewRat(60*17-1000, 1)) + }) +} + +func TestUsage_Total(t *testing.T) { + Convey("Testing Usage.Total()", t, FailureContinues, func() { + object := PricingObject{ + UnitQuantity: big.NewRat(60, 1), + UnitPrice: big.NewRat(12, 1000), // 0.012 + UnitPriceCap: big.NewRat(6, 1), + } + + usage := NewUsageWithQuantity(&object, big.NewRat(-1, 1)) + So(usage.Total(), ShouldEqualBigRat, big.NewRat(0, 1)) + + usage = NewUsageWithQuantity(&object, big.NewRat(0, 1)) + So(usage.Total(), ShouldEqualBigRat, big.NewRat(0, 1)) + + usage = NewUsageWithQuantity(&object, big.NewRat(1, 1)) + So(usage.Total(), ShouldEqualBigRat, big.NewRat(12, 1000)) // 0.012 + + usage = NewUsageWithQuantity(&object, big.NewRat(61, 1)) + So(usage.Total(), ShouldEqualBigRat, big.NewRat(24, 1000)) // 0.024 + + usage = NewUsageWithQuantity(&object, big.NewRat(1000, 1)) + So(usage.Total(), ShouldEqualBigRat, big.NewRat(204, 1000)) // 0.204 + }) +} diff --git a/pkg/pricing/utils.go b/pkg/pricing/utils.go new file mode 100644 index 0000000000..4eb5b00c61 --- /dev/null +++ b/pkg/pricing/utils.go @@ -0,0 +1,47 @@ +package pricing + +import ( + "fmt" + "math/big" + + "github.com/scaleway/scaleway-cli/vendor/github.com/dustin/go-humanize" +) + +var ( + intZero = big.NewInt(0) + intOne = big.NewInt(1) + ratZero = big.NewRat(0, 1) + ratOne = big.NewRat(1, 1) +) + +// Returns a new big.Int set to the ceiling of x. +func ratCeil(x *big.Rat) *big.Int { + z := new(big.Int) + m := new(big.Int) + z.DivMod(x.Num(), x.Denom(), m) + if m.Cmp(intZero) == 1 { + z.Add(z, intOne) + } + return z +} + +// Returns a new big.Rat set to maximum of x and y +func ratMax(x, y *big.Rat) *big.Rat { + if x.Cmp(y) < 1 { + return y + } + return x +} + +// Returns a new big.Rat set to minimum of x and y +func ratMin(x, y *big.Rat) *big.Rat { + if x.Cmp(y) > 0 { + return y + } + return x +} + +func PriceString(price *big.Rat, currency string) string { + floatVal, _ := price.Float64() + return fmt.Sprintf("%s %s", humanize.Ftoa(floatVal), currency) +} diff --git a/pkg/pricing/utils_test.go b/pkg/pricing/utils_test.go new file mode 100644 index 0000000000..72d3de9561 --- /dev/null +++ b/pkg/pricing/utils_test.go @@ -0,0 +1,22 @@ +package pricing + +import ( + "fmt" + "math/big" +) + +func ShouldEqualBigRat(actual interface{}, expected ...interface{}) string { + actualRat := actual.(*big.Rat) + expectRat := expected[0].(*big.Rat) + cmp := actualRat.Cmp(expectRat) + if cmp == 0 { + return "" + } + + output := fmt.Sprintf("big.Rat are not matching: %q != %q\n", actualRat, expectRat) + + actualFloat64, _ := actualRat.Float64() + expectFloat64, _ := expectRat.Float64() + output += fmt.Sprintf(" %f != %f", actualFloat64, expectFloat64) + return output +}