Skip to content

Commit

Permalink
Merge pull request #49 from cycloidio/vs-cost-currency
Browse files Browse the repository at this point in the history
cost: include currency in cost calculations
  • Loading branch information
Vladimir authored Jul 13, 2021
2 parents fcaedcb + 978fc15 commit 6fd5fc8
Show file tree
Hide file tree
Showing 14 changed files with 178 additions and 82 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
## [Unreleased]

### Added

- Include currency in resource component cost estimation
([Issue #48](https://github.com/cycloidio/terracost/issues/48))

## [0.3.0] _2021-06-01_

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ backend := mysql.NewBackend(db)

// service can be "AmazonEC2" or "AmazonRDS"
// region is any AWS region, e.g. "us-east-1" or "eu-west-3"
ingester := aws.NewIngester(service, region)
ingester, err := aws.NewIngester(service, region)
err = terracost.IngestPricing(ctx, backend, ingester)
```

Expand All @@ -67,7 +67,7 @@ go func() {
2. Initialize an ingester capable of tracking progress (in this example the channel will receive an update every 5 seconds):

```go
ingester := aws.NewIngester(service, region, aws.WithProgress(progressCh, 5*time.Second))
ingester, err := aws.NewIngester(service, region, aws.WithProgress(progressCh, 5*time.Second))
```

3. Use the ingester as in the previous section.
Expand Down
4 changes: 2 additions & 2 deletions cost/component_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func TestComponentDiff_PriorCost(t *testing.T) {
})

t.Run("WithValue", func(t *testing.T) {
cd := cost.ComponentDiff{Prior: &cost.Component{Quantity: decimal.NewFromInt(5), Rate: cost.NewMonthly(decimal.NewFromFloat(1.5))}}
cd := cost.ComponentDiff{Prior: &cost.Component{Quantity: decimal.NewFromInt(5), Rate: cost.NewMonthly(decimal.NewFromFloat(1.5), "USD")}}
actual := cd.PriorCost()
assert.True(t, actual.Equal(decimal.NewFromFloat(7.5)))
})
Expand All @@ -32,7 +32,7 @@ func TestComponentDiff_PlannedCost(t *testing.T) {
})

t.Run("WithValue", func(t *testing.T) {
cd := cost.ComponentDiff{Planned: &cost.Component{Quantity: decimal.NewFromInt(5), Rate: cost.NewMonthly(decimal.NewFromFloat(1.5))}}
cd := cost.ComponentDiff{Planned: &cost.Component{Quantity: decimal.NewFromInt(5), Rate: cost.NewMonthly(decimal.NewFromFloat(1.5), "USD")}}
actual := cd.PlannedCost()
assert.True(t, actual.Equal(decimal.NewFromFloat(7.5)))
})
Expand Down
32 changes: 23 additions & 9 deletions cost/cost.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cost

import (
"fmt"

"github.com/shopspring/decimal"
)

Expand All @@ -12,19 +14,21 @@ var HoursPerMonth = decimal.NewFromInt(730)
type Cost struct {
// Decimal is price per month.
decimal.Decimal
// Currency of the cost.
Currency string
}

// Zero is Cost with zero value.
var Zero = Cost{}

// NewMonthly returns a new Cost from price per month.
func NewMonthly(monthly decimal.Decimal) Cost {
return Cost{Decimal: monthly}
// NewMonthly returns a new Cost from price per month with currency.
func NewMonthly(monthly decimal.Decimal, currency string) Cost {
return Cost{Decimal: monthly, Currency: currency}
}

// NewHourly returns a new Cost from price per hour.
func NewHourly(hourly decimal.Decimal) Cost {
return Cost{Decimal: hourly.Mul(HoursPerMonth)}
// NewHourly returns a new Cost from price per hour with currency.
func NewHourly(hourly decimal.Decimal, currency string) Cost {
return Cost{Decimal: hourly.Mul(HoursPerMonth), Currency: currency}
}

// Monthly returns the cost per month.
Expand All @@ -38,11 +42,21 @@ func (c Cost) Hourly() decimal.Decimal {
}

// Add adds the values of two Cost structs.
func (c Cost) Add(c2 Cost) Cost {
return Cost{Decimal: c.Decimal.Add(c2.Monthly())}
// If the currency of both costs doesn't match, error is returned.
func (c Cost) Add(c2 Cost) (Cost, error) {
// If there is no currency, use the currency of the addition
if c.Currency == "" {
c.Currency = c2.Currency
}

if c.Currency != c2.Currency {
return Zero, fmt.Errorf("currency mismatch: expected %s, got %s", c.Currency, c2.Currency)
}

return Cost{Decimal: c.Decimal.Add(c2.Monthly()), Currency: c.Currency}, nil
}

// MulDecimal multiplies the Cost by the given decimal.Decimal.
func (c Cost) MulDecimal(d decimal.Decimal) Cost {
return Cost{Decimal: c.Decimal.Mul(d)}
return Cost{Decimal: c.Decimal.Mul(d), Currency: c.Currency}
}
26 changes: 18 additions & 8 deletions cost/cost_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,36 +10,46 @@ import (

func TestNewHourly(t *testing.T) {
val := decimal.NewFromFloat(1.23)
c := cost.NewHourly(val)
c := cost.NewHourly(val, "USD")
assertDecimalEqual(t, val.Mul(cost.HoursPerMonth), c.Decimal)
}

func TestNewMonthly(t *testing.T) {
val := decimal.NewFromFloat(1.23)
c := cost.NewMonthly(val)
c := cost.NewMonthly(val, "USD")
assertDecimalEqual(t, val, c.Decimal)
}

func TestCost_Hourly(t *testing.T) {
val := decimal.NewFromFloat(1.23)
c := cost.NewMonthly(val.Mul(cost.HoursPerMonth))
c := cost.NewMonthly(val.Mul(cost.HoursPerMonth), "USD")
assertDecimalEqual(t, val, c.Hourly())
}

func TestCost_Monthly(t *testing.T) {
val := decimal.NewFromFloat(1.23)
c := cost.NewHourly(val)
c := cost.NewHourly(val, "USD")
assertDecimalEqual(t, val.Mul(cost.HoursPerMonth), c.Monthly())
}

func TestCost_Add(t *testing.T) {
c1 := cost.NewMonthly(decimal.NewFromFloat(1.23))
c2 := cost.NewMonthly(decimal.NewFromFloat(3.21))
assertDecimalEqual(t, decimal.NewFromFloat(4.44), c1.Add(c2).Decimal)
t.Run("Success", func(t *testing.T) {
c1 := cost.NewMonthly(decimal.NewFromFloat(1.23), "USD")
c2 := cost.NewMonthly(decimal.NewFromFloat(3.21), "USD")
ac, err := c1.Add(c2)
assert.NoError(t, err)
assertDecimalEqual(t, decimal.NewFromFloat(4.44), ac.Decimal)
})
t.Run("CurrencyMismatch", func(t *testing.T) {
c1 := cost.NewMonthly(decimal.NewFromFloat(1.23), "USD")
c2 := cost.NewMonthly(decimal.NewFromFloat(3.21), "EUR")
_, err := c1.Add(c2)
assert.EqualError(t, err, "currency mismatch: expected USD, got EUR")
})
}

func TestCost_MulDecimal(t *testing.T) {
c := cost.NewMonthly(decimal.NewFromFloat(1.23))
c := cost.NewMonthly(decimal.NewFromFloat(1.23), "USD")
d := decimal.NewFromInt(3)
assertDecimalEqual(t, decimal.NewFromFloat(3.69), c.MulDecimal(d).Decimal)
}
Expand Down
8 changes: 4 additions & 4 deletions cost/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,17 @@ func NewPlan(prior, planned *State) *Plan {
}

// PriorCost returns the total cost of the Prior State or decimal.Zero if it isn't included in the plan.
func (p Plan) PriorCost() Cost {
func (p Plan) PriorCost() (Cost, error) {
if p.Prior == nil {
return Zero
return Zero, nil
}
return p.Prior.Cost()
}

// PlannedCost returns the total cost of the Planned State or decimal.Zero if it isn't included in the plan.
func (p Plan) PlannedCost() Cost {
func (p Plan) PlannedCost() (Cost, error) {
if p.Planned == nil {
return Zero
return Zero, nil
}
return p.Planned.Cost()
}
Expand Down
32 changes: 16 additions & 16 deletions cost/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func TestPlan_ResourceDifferences(t *testing.T) {
"EC2 instance hours": {
Quantity: decimal.NewFromInt(730),
Unit: "Hrs",
Rate: cost.NewMonthly(decimal.NewFromFloat(1.23)),
Rate: cost.NewMonthly(decimal.NewFromFloat(1.23), "USD"),
},
},
},
Expand All @@ -36,7 +36,7 @@ func TestPlan_ResourceDifferences(t *testing.T) {
Prior: &cost.Component{
Quantity: decimal.NewFromInt(730),
Unit: "Hrs",
Rate: cost.NewMonthly(decimal.NewFromFloat(1.23)),
Rate: cost.NewMonthly(decimal.NewFromFloat(1.23), "USD"),
},
},
},
Expand All @@ -51,7 +51,7 @@ func TestPlan_ResourceDifferences(t *testing.T) {
"EC2 instance hours": {
Quantity: decimal.NewFromInt(730),
Unit: "Hrs",
Rate: cost.NewMonthly(decimal.NewFromFloat(1.23)),
Rate: cost.NewMonthly(decimal.NewFromFloat(1.23), "USD"),
},
},
},
Expand All @@ -68,7 +68,7 @@ func TestPlan_ResourceDifferences(t *testing.T) {
Planned: &cost.Component{
Quantity: decimal.NewFromInt(730),
Unit: "Hrs",
Rate: cost.NewMonthly(decimal.NewFromFloat(1.23)),
Rate: cost.NewMonthly(decimal.NewFromFloat(1.23), "USD"),
},
},
},
Expand All @@ -83,7 +83,7 @@ func TestPlan_ResourceDifferences(t *testing.T) {
"EC2 instance hours": {
Quantity: decimal.NewFromInt(730),
Unit: "Hrs",
Rate: cost.NewMonthly(decimal.NewFromFloat(1.50)),
Rate: cost.NewMonthly(decimal.NewFromFloat(1.50), "USD"),
},
},
},
Expand All @@ -92,7 +92,7 @@ func TestPlan_ResourceDifferences(t *testing.T) {
"EC2 instance hours": {
Quantity: decimal.NewFromInt(730),
Unit: "Hrs",
Rate: cost.NewMonthly(decimal.NewFromFloat(1.23)),
Rate: cost.NewMonthly(decimal.NewFromFloat(1.23), "USD"),
},
},
},
Expand All @@ -105,7 +105,7 @@ func TestPlan_ResourceDifferences(t *testing.T) {
"EC2 instance hours": {
Quantity: decimal.NewFromInt(730),
Unit: "Hrs",
Rate: cost.NewMonthly(decimal.NewFromFloat(2.50)),
Rate: cost.NewMonthly(decimal.NewFromFloat(2.50), "USD"),
},
},
},
Expand All @@ -114,7 +114,7 @@ func TestPlan_ResourceDifferences(t *testing.T) {
"EC2 instance hours": {
Quantity: decimal.NewFromInt(730),
Unit: "Hrs",
Rate: cost.NewMonthly(decimal.NewFromFloat(3.21)),
Rate: cost.NewMonthly(decimal.NewFromFloat(3.21), "USD"),
},
},
},
Expand All @@ -131,12 +131,12 @@ func TestPlan_ResourceDifferences(t *testing.T) {
Prior: &cost.Component{
Quantity: decimal.NewFromInt(730),
Unit: "Hrs",
Rate: cost.NewMonthly(decimal.NewFromFloat(1.50)),
Rate: cost.NewMonthly(decimal.NewFromFloat(1.50), "USD"),
},
Planned: &cost.Component{
Quantity: decimal.NewFromInt(730),
Unit: "Hrs",
Rate: cost.NewMonthly(decimal.NewFromFloat(2.50)),
Rate: cost.NewMonthly(decimal.NewFromFloat(2.50), "USD"),
},
},
},
Expand All @@ -148,7 +148,7 @@ func TestPlan_ResourceDifferences(t *testing.T) {
Planned: &cost.Component{
Quantity: decimal.NewFromInt(730),
Unit: "Hrs",
Rate: cost.NewMonthly(decimal.NewFromFloat(3.21)),
Rate: cost.NewMonthly(decimal.NewFromFloat(3.21), "USD"),
},
},
},
Expand All @@ -160,7 +160,7 @@ func TestPlan_ResourceDifferences(t *testing.T) {
Prior: &cost.Component{
Quantity: decimal.NewFromInt(730),
Unit: "Hrs",
Rate: cost.NewMonthly(decimal.NewFromFloat(1.23)),
Rate: cost.NewMonthly(decimal.NewFromFloat(1.23), "USD"),
},
},
},
Expand All @@ -177,7 +177,7 @@ func TestPlan_SkippedAddresses(t *testing.T) {
"EC2 instance hours": {
Quantity: decimal.NewFromInt(730),
Unit: "Hrs",
Rate: cost.NewMonthly(decimal.NewFromFloat(2.50)),
Rate: cost.NewMonthly(decimal.NewFromFloat(2.50), "USD"),
},
},
},
Expand All @@ -200,7 +200,7 @@ func TestPlan_SkippedAddresses(t *testing.T) {
"EC2 instance hours": {
Quantity: decimal.NewFromInt(730),
Unit: "Hrs",
Rate: cost.NewMonthly(decimal.NewFromFloat(2.50)),
Rate: cost.NewMonthly(decimal.NewFromFloat(2.50), "USD"),
},
},
},
Expand All @@ -223,7 +223,7 @@ func TestPlan_SkippedAddresses(t *testing.T) {
"EC2 instance hours": {
Quantity: decimal.NewFromInt(730),
Unit: "Hrs",
Rate: cost.NewMonthly(decimal.NewFromFloat(2.50)),
Rate: cost.NewMonthly(decimal.NewFromFloat(2.50), "USD"),
},
},
},
Expand All @@ -238,7 +238,7 @@ func TestPlan_SkippedAddresses(t *testing.T) {
"EC2 instance hours": {
Quantity: decimal.NewFromInt(730),
Unit: "Hrs",
Rate: cost.NewMonthly(decimal.NewFromFloat(2.50)),
Rate: cost.NewMonthly(decimal.NewFromFloat(2.50), "USD"),
},
},
},
Expand Down
37 changes: 27 additions & 10 deletions cost/resource.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package cost

import "fmt"

// Resource represents costs of a single cloud resource. Each Resource includes a Component map, keyed
// by the label.
type Resource struct {
Expand All @@ -10,12 +12,17 @@ type Resource struct {
}

// Cost returns the sum of costs of every Component of this Resource.
func (re Resource) Cost() Cost {
// Error is returned if there is a mismatch in Component currency.
func (re Resource) Cost() (Cost, error) {
var total Cost
for _, comp := range re.Components {
total = total.Add(comp.Cost())
var err error
for name, comp := range re.Components {
total, err = total.Add(comp.Cost())
if err != nil {
return Zero, fmt.Errorf("failed to add cost of component %s: %w", name, err)
}
}
return total
return total, nil
}

// ResourceDiff is the difference in costs between prior and planned Resource. It contains a ComponentDiff
Expand All @@ -28,21 +35,31 @@ type ResourceDiff struct {
}

// PriorCost returns the sum of costs of every Component's PriorCost.
func (rd ResourceDiff) PriorCost() Cost {
// Error is returned if there is a mismatch between currencies of the Components.
func (rd ResourceDiff) PriorCost() (Cost, error) {
total := Zero
var err error
for _, cd := range rd.ComponentDiffs {
total = total.Add(cd.PriorCost())
total, err = total.Add(cd.PriorCost())
if err != nil {
return Zero, fmt.Errorf("failed calculating prior cost : %w", err)
}
}
return total
return total, nil
}

// PlannedCost returns the sum of costs of every Component's PlannedCost.
func (rd ResourceDiff) PlannedCost() Cost {
// Error is returned if there is a mismatch between currencies of the Components.
func (rd ResourceDiff) PlannedCost() (Cost, error) {
total := Zero
var err error
for _, cd := range rd.ComponentDiffs {
total = total.Add(cd.PlannedCost())
total, err = total.Add(cd.PriorCost())
if err != nil {
return Zero, fmt.Errorf("failed calculating planned cost: %w", err)
}
}
return total
return total, nil
}

// Errors returns a map of Component errors keyed by the Component label.
Expand Down
Loading

0 comments on commit 6fd5fc8

Please sign in to comment.