Repository: cycloidio/terracost (go)

Issue:
Return detailed prices per resource
In order to have a better idea of what is changing, we would like to have a detailed resource list.

We need to have it (at least, but could be interesting to have an ASCII table output) as a JSON oneliner.

The goal would be to expose for each resource: the price per hour, the price per month (to figure what will be the bill), and the delta change (if there's a change between runs).

From a structure point of view, here is a proposal:

```json
{
  "aws_instance": [
    { "hourly_cost": "xx$", "monthly_cost": "xx$", "monthly_delta": "xx$"},
    ...
  ],
  ...
}
```
This structure would allow to group resources by type also.

Pull request:
cost: return per-hour cost of resources
This PR adds a new Cost struct that wraps the Decimal in order to provide `Hourly` and `Monthly` methods returning per-hour and per-month cost of a component or resource.

Closes #33 

Patch:
diff --git a/cost/component.go b/cost/component.go
index 3bb2760..6822fc0 100644
--- a/cost/component.go
+++ b/cost/component.go
@@ -9,15 +9,18 @@ import (
 type Component struct {
 	Quantity decimal.Decimal
 	Unit     string
-	Rate     decimal.Decimal
+	Rate     Cost
 	Details  []string
 
 	Error error
 }
 
 // Cost returns the cost of this component (Rate multiplied by Quantity).
-func (c Component) Cost() decimal.Decimal {
-	return c.Rate.Mul(c.Quantity)
+func (c Component) Cost() Cost {
+	if c.Rate.IsZero() || c.Quantity.IsZero() {
+		return Zero
+	}
+	return c.Rate.MulDecimal(c.Quantity)
 }
 
 // ComponentDiff is a difference between the Prior and Planned Component.
@@ -26,17 +29,17 @@ type ComponentDiff struct {
 }
 
 // PriorCost returns the full cost of the Prior Component or decimal.Zero if it doesn't exist.
-func (cd ComponentDiff) PriorCost() decimal.Decimal {
+func (cd ComponentDiff) PriorCost() Cost {
 	if cd.Prior == nil {
-		return decimal.Zero
+		return Zero
 	}
 	return cd.Prior.Cost()
 }
 
 // PlannedCost returns the full cost of the Planned Component or decimal.Zero if it doesn't exist.
-func (cd ComponentDiff) PlannedCost() decimal.Decimal {
+func (cd ComponentDiff) PlannedCost() Cost {
 	if cd.Planned == nil {
-		return decimal.Zero
+		return Zero
 	}
 	return cd.Planned.Cost()
 }
diff --git a/cost/cost.go b/cost/cost.go
new file mode 100644
index 0000000..3c7fbfc
--- /dev/null
+++ b/cost/cost.go
@@ -0,0 +1,48 @@
+package cost
+
+import (
+	"github.com/shopspring/decimal"
+)
+
+// HoursPerMonth is an approximate number of hours in a month.
+// It is calculated as 365 days in a year x 24 hours in a day / 12 months in year.
+var HoursPerMonth = decimal.NewFromInt(730)
+
+// Cost represents a monthly or hourly cost of a cloud resource or its component.
+type Cost struct {
+	// Decimal is price per month.
+	decimal.Decimal
+}
+
+// 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}
+}
+
+// NewHourly returns a new Cost from price per hour.
+func NewHourly(hourly decimal.Decimal) Cost {
+	return Cost{Decimal: hourly.Mul(HoursPerMonth)}
+}
+
+// Monthly returns the cost per month.
+func (c Cost) Monthly() decimal.Decimal {
+	return c.Decimal
+}
+
+// Hourly returns the cost per hour.
+func (c Cost) Hourly() decimal.Decimal {
+	return c.DivRound(HoursPerMonth, 6)
+}
+
+// Add adds the values of two Cost structs.
+func (c Cost) Add(c2 Cost) Cost {
+	return Cost{Decimal: c.Decimal.Add(c2.Monthly())}
+}
+
+// MulDecimal multiplies the Cost by the given decimal.Decimal.
+func (c Cost) MulDecimal(d decimal.Decimal) Cost {
+	return Cost{Decimal: c.Decimal.Mul(d)}
+}
diff --git a/cost/plan.go b/cost/plan.go
index c715827..c3f2207 100644
--- a/cost/plan.go
+++ b/cost/plan.go
@@ -2,8 +2,6 @@ package cost
 
 import (
 	"sort"
-
-	"github.com/shopspring/decimal"
 )
 
 // Plan is the cost difference between two State instances. It is not tied to any specific cloud provider or IaC tool.
@@ -20,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() decimal.Decimal {
+func (p Plan) PriorCost() Cost {
 	if p.Prior == nil {
-		return decimal.Zero
+		return Zero
 	}
 	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() decimal.Decimal {
+func (p Plan) PlannedCost() Cost {
 	if p.Planned == nil {
-		return decimal.Zero
+		return Zero
 	}
 	return p.Planned.Cost()
 }
diff --git a/cost/resource.go b/cost/resource.go
index 587cb11..7096380 100644
--- a/cost/resource.go
+++ b/cost/resource.go
@@ -1,9 +1,5 @@
 package cost
 
-import (
-	"github.com/shopspring/decimal"
-)
-
 // Resource represents costs of a single cloud resource. Each Resource includes a Component map, keyed
 // by the label.
 type Resource struct {
@@ -12,8 +8,8 @@ type Resource struct {
 }
 
 // Cost returns the sum of costs of every Component of this Resource.
-func (re Resource) Cost() decimal.Decimal {
-	var total decimal.Decimal
+func (re Resource) Cost() Cost {
+	var total Cost
 	for _, comp := range re.Components {
 		total = total.Add(comp.Cost())
 	}
@@ -28,8 +24,8 @@ type ResourceDiff struct {
 }
 
 // PriorCost returns the sum of costs of every Component's PriorCost.
-func (rd ResourceDiff) PriorCost() decimal.Decimal {
-	var total decimal.Decimal
+func (rd ResourceDiff) PriorCost() Cost {
+	total := Zero
 	for _, cd := range rd.ComponentDiffs {
 		total = total.Add(cd.PriorCost())
 	}
@@ -37,8 +33,8 @@ func (rd ResourceDiff) PriorCost() decimal.Decimal {
 }
 
 // PlannedCost returns the sum of costs of every Component's PlannedCost.
-func (rd ResourceDiff) PlannedCost() decimal.Decimal {
-	var total decimal.Decimal
+func (rd ResourceDiff) PlannedCost() Cost {
+	total := Zero
 	for _, cd := range rd.ComponentDiffs {
 		total = total.Add(cd.PlannedCost())
 	}
diff --git a/cost/state.go b/cost/state.go
index 50a567c..c36c953 100644
--- a/cost/state.go
+++ b/cost/state.go
@@ -4,8 +4,6 @@ import (
 	"context"
 	"fmt"
 
-	"github.com/shopspring/decimal"
-
 	"github.com/cycloidio/terracost/price"
 	"github.com/cycloidio/terracost/product"
 	"github.com/cycloidio/terracost/query"
@@ -25,10 +23,6 @@ type State struct {
 	Resources map[string]Resource
 }
 
-// HoursPerMonth is an approximate number of hours in a month.
-// It is calculated as 365 days in a year x 24 hours in a day / 12 months in year.
-const HoursPerMonth = 730
-
 // Errors that might be returned from NewState if either a product or a price are not found.
 var (
 	ErrProductNotFound = fmt.Errorf("product not found")
@@ -66,10 +60,11 @@ func NewState(ctx context.Context, backend Backend, queries []query.Resource) (*
 			}
 
 			quantity := comp.MonthlyQuantity
-			rate := prices[0].Value
-			if quantity.Equals(decimal.Zero) {
+			rate := NewMonthly(prices[0].Value)
+
+			if quantity.IsZero() {
 				quantity = comp.HourlyQuantity
-				rate = rate.Mul(decimal.NewFromInt(HoursPerMonth))
+				rate = NewHourly(prices[0].Value)
 			}
 
 			component := Component{
@@ -87,8 +82,8 @@ func NewState(ctx context.Context, backend Backend, queries []query.Resource) (*
 }
 
 // Cost returns the sum of the costs of every Resource included in this State.
-func (s *State) Cost() decimal.Decimal {
-	total := decimal.Zero
+func (s *State) Cost() Cost {
+	var total Cost
 	for _, re := range s.Resources {
 		total = total.Add(re.Cost())
 	}


Test patch:
diff --git a/cost/component_test.go b/cost/component_test.go
index 7254e41..49790ce 100644
--- a/cost/component_test.go
+++ b/cost/component_test.go
@@ -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: decimal.NewFromFloat(1.5)}}
+		cd := cost.ComponentDiff{Prior: &cost.Component{Quantity: decimal.NewFromInt(5), Rate: cost.NewMonthly(decimal.NewFromFloat(1.5))}}
 		actual := cd.PriorCost()
 		assert.True(t, actual.Equal(decimal.NewFromFloat(7.5)))
 	})
@@ -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: decimal.NewFromFloat(1.5)}}
+		cd := cost.ComponentDiff{Planned: &cost.Component{Quantity: decimal.NewFromInt(5), Rate: cost.NewMonthly(decimal.NewFromFloat(1.5))}}
 		actual := cd.PlannedCost()
 		assert.True(t, actual.Equal(decimal.NewFromFloat(7.5)))
 	})
diff --git a/cost/cost_test.go b/cost/cost_test.go
new file mode 100644
index 0000000..1e35365
--- /dev/null
+++ b/cost/cost_test.go
@@ -0,0 +1,49 @@
+package cost_test
+
+import (
+	"testing"
+
+	"github.com/cycloidio/terracost/cost"
+	"github.com/shopspring/decimal"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNewHourly(t *testing.T) {
+	val := decimal.NewFromFloat(1.23)
+	c := cost.NewHourly(val)
+	assertDecimalEqual(t, val.Mul(cost.HoursPerMonth), c.Decimal)
+}
+
+func TestNewMonthly(t *testing.T) {
+	val := decimal.NewFromFloat(1.23)
+	c := cost.NewMonthly(val)
+	assertDecimalEqual(t, val, c.Decimal)
+}
+
+func TestCost_Hourly(t *testing.T) {
+	val := decimal.NewFromFloat(1.23)
+	c := cost.NewMonthly(val.Mul(cost.HoursPerMonth))
+	assertDecimalEqual(t, val, c.Hourly())
+}
+
+func TestCost_Monthly(t *testing.T) {
+	val := decimal.NewFromFloat(1.23)
+	c := cost.NewHourly(val)
+	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)
+}
+
+func TestCost_MulDecimal(t *testing.T) {
+	c := cost.NewMonthly(decimal.NewFromFloat(1.23))
+	d := decimal.NewFromInt(3)
+	assertDecimalEqual(t, decimal.NewFromFloat(3.69), c.MulDecimal(d).Decimal)
+}
+
+func assertDecimalEqual(t *testing.T, expected, actual decimal.Decimal) {
+	assert.Truef(t, expected.Equal(actual), "Not equal:\nexpected: %s\nactual  : %s", expected, actual)
+}
diff --git a/cost/plan_test.go b/cost/plan_test.go
index 5fac6fc..1ddf401 100644
--- a/cost/plan_test.go
+++ b/cost/plan_test.go
@@ -19,7 +19,7 @@ func TestPlan_ResourceDifferences(t *testing.T) {
 						"EC2 instance hours": {
 							Quantity: decimal.NewFromInt(730),
 							Unit:     "Hrs",
-							Rate:     decimal.NewFromFloat(1.23),
+							Rate:     cost.NewMonthly(decimal.NewFromFloat(1.23)),
 						},
 					},
 				},
@@ -36,7 +36,7 @@ func TestPlan_ResourceDifferences(t *testing.T) {
 					Prior: &cost.Component{
 						Quantity: decimal.NewFromInt(730),
 						Unit:     "Hrs",
-						Rate:     decimal.NewFromFloat(1.23),
+						Rate:     cost.NewMonthly(decimal.NewFromFloat(1.23)),
 					},
 				},
 			},
@@ -51,7 +51,7 @@ func TestPlan_ResourceDifferences(t *testing.T) {
 						"EC2 instance hours": {
 							Quantity: decimal.NewFromInt(730),
 							Unit:     "Hrs",
-							Rate:     decimal.NewFromFloat(1.23),
+							Rate:     cost.NewMonthly(decimal.NewFromFloat(1.23)),
 						},
 					},
 				},
@@ -68,7 +68,7 @@ func TestPlan_ResourceDifferences(t *testing.T) {
 					Planned: &cost.Component{
 						Quantity: decimal.NewFromInt(730),
 						Unit:     "Hrs",
-						Rate:     decimal.NewFromFloat(1.23),
+						Rate:     cost.NewMonthly(decimal.NewFromFloat(1.23)),
 					},
 				},
 			},
@@ -83,7 +83,7 @@ func TestPlan_ResourceDifferences(t *testing.T) {
 						"EC2 instance hours": {
 							Quantity: decimal.NewFromInt(730),
 							Unit:     "Hrs",
-							Rate:     decimal.NewFromFloat(1.50),
+							Rate:     cost.NewMonthly(decimal.NewFromFloat(1.50)),
 						},
 					},
 				},
@@ -92,7 +92,7 @@ func TestPlan_ResourceDifferences(t *testing.T) {
 						"EC2 instance hours": {
 							Quantity: decimal.NewFromInt(730),
 							Unit:     "Hrs",
-							Rate:     decimal.NewFromFloat(1.23),
+							Rate:     cost.NewMonthly(decimal.NewFromFloat(1.23)),
 						},
 					},
 				},
@@ -105,7 +105,7 @@ func TestPlan_ResourceDifferences(t *testing.T) {
 						"EC2 instance hours": {
 							Quantity: decimal.NewFromInt(730),
 							Unit:     "Hrs",
-							Rate:     decimal.NewFromFloat(2.50),
+							Rate:     cost.NewMonthly(decimal.NewFromFloat(2.50)),
 						},
 					},
 				},
@@ -114,7 +114,7 @@ func TestPlan_ResourceDifferences(t *testing.T) {
 						"EC2 instance hours": {
 							Quantity: decimal.NewFromInt(730),
 							Unit:     "Hrs",
-							Rate:     decimal.NewFromFloat(3.21),
+							Rate:     cost.NewMonthly(decimal.NewFromFloat(3.21)),
 						},
 					},
 				},
@@ -131,12 +131,12 @@ func TestPlan_ResourceDifferences(t *testing.T) {
 					Prior: &cost.Component{
 						Quantity: decimal.NewFromInt(730),
 						Unit:     "Hrs",
-						Rate:     decimal.NewFromFloat(1.50),
+						Rate:     cost.NewMonthly(decimal.NewFromFloat(1.50)),
 					},
 					Planned: &cost.Component{
 						Quantity: decimal.NewFromInt(730),
 						Unit:     "Hrs",
-						Rate:     decimal.NewFromFloat(2.50),
+						Rate:     cost.NewMonthly(decimal.NewFromFloat(2.50)),
 					},
 				},
 			},
@@ -148,7 +148,7 @@ func TestPlan_ResourceDifferences(t *testing.T) {
 					Planned: &cost.Component{
 						Quantity: decimal.NewFromInt(730),
 						Unit:     "Hrs",
-						Rate:     decimal.NewFromFloat(3.21),
+						Rate:     cost.NewMonthly(decimal.NewFromFloat(3.21)),
 					},
 				},
 			},
@@ -160,7 +160,7 @@ func TestPlan_ResourceDifferences(t *testing.T) {
 					Prior: &cost.Component{
 						Quantity: decimal.NewFromInt(730),
 						Unit:     "Hrs",
-						Rate:     decimal.NewFromFloat(1.23),
+						Rate:     cost.NewMonthly(decimal.NewFromFloat(1.23)),
 					},
 				},
 			},
@@ -177,7 +177,7 @@ func TestPlan_SkippedAddresses(t *testing.T) {
 						"EC2 instance hours": {
 							Quantity: decimal.NewFromInt(730),
 							Unit:     "Hrs",
-							Rate:     decimal.NewFromFloat(2.50),
+							Rate:     cost.NewMonthly(decimal.NewFromFloat(2.50)),
 						},
 					},
 				},
@@ -200,7 +200,7 @@ func TestPlan_SkippedAddresses(t *testing.T) {
 						"EC2 instance hours": {
 							Quantity: decimal.NewFromInt(730),
 							Unit:     "Hrs",
-							Rate:     decimal.NewFromFloat(2.50),
+							Rate:     cost.NewMonthly(decimal.NewFromFloat(2.50)),
 						},
 					},
 				},
@@ -223,7 +223,7 @@ func TestPlan_SkippedAddresses(t *testing.T) {
 						"EC2 instance hours": {
 							Quantity: decimal.NewFromInt(730),
 							Unit:     "Hrs",
-							Rate:     decimal.NewFromFloat(2.50),
+							Rate:     cost.NewMonthly(decimal.NewFromFloat(2.50)),
 						},
 					},
 				},
@@ -238,7 +238,7 @@ func TestPlan_SkippedAddresses(t *testing.T) {
 						"EC2 instance hours": {
 							Quantity: decimal.NewFromInt(730),
 							Unit:     "Hrs",
-							Rate:     decimal.NewFromFloat(2.50),
+							Rate:     cost.NewMonthly(decimal.NewFromFloat(2.50)),
 						},
 					},
 				},
diff --git a/cost/state_test.go b/cost/state_test.go
index df32d95..0b04b56 100644
--- a/cost/state_test.go
+++ b/cost/state_test.go
@@ -65,7 +65,7 @@ func TestNewState(t *testing.T) {
 				"aws_instance.test1": {
 					Components: map[string]cost.Component{
 						"Compute": {
-							Rate:     decimal.New(89790, -2),
+							Rate:     cost.NewMonthly(decimal.New(89790, -2)),
 							Quantity: decimal.NewFromInt(1),
 						},
 					},
@@ -126,7 +126,7 @@ func TestState_Cost(t *testing.T) {
 			"aws_instance.test1": {
 				Components: map[string]cost.Component{
 					"Compute": {
-						Rate:     decimal.NewFromFloat(1.23),
+						Rate:     cost.NewMonthly(decimal.NewFromFloat(1.23)),
 						Quantity: decimal.NewFromInt(730),
 					},
 				},
@@ -135,5 +135,5 @@ func TestState_Cost(t *testing.T) {
 	}
 
 	expected := decimal.NewFromFloat(897.9)
-	assert.True(t, expected.Equal(state.Cost()))
+	assert.True(t, expected.Equal(state.Cost().Monthly()))
 }
diff --git a/e2e/aws_estimation_test.go b/e2e/aws_estimation_test.go
index 32e6bb3..699c263 100644
--- a/e2e/aws_estimation_test.go
+++ b/e2e/aws_estimation_test.go
@@ -135,8 +135,8 @@ func TestAWSEstimation(t *testing.T) {
 		plan, err := costestimation.EstimateTerraformPlan(ctx, backend, f, terraformProviderInitializer)
 		require.NoError(t, err)
 
-		assertDecimalEqual(t, decimal.NewFromFloat(91.2), plan.PriorCost())
-		assertDecimalEqual(t, decimal.NewFromFloat(901.5), plan.PlannedCost())
+		assertDecimalEqual(t, decimal.NewFromFloat(91.2), plan.PriorCost().Monthly())
+		assertDecimalEqual(t, decimal.NewFromFloat(901.5), plan.PlannedCost().Monthly())
 
 		diffs := plan.ResourceDifferences()
 		require.Len(t, diffs, 2)
@@ -148,19 +148,19 @@ func TestAWSEstimation(t *testing.T) {
 				require.NotNil(t, compute)
 				assert.Equal(t, []string{"Linux", "on-demand", "t2.micro"}, compute.Prior.Details)
 				assert.Equal(t, []string{"Linux", "on-demand", "t2.xlarge"}, compute.Planned.Details)
-				assertDecimalEqual(t, decimal.NewFromFloat(87.6), compute.PriorCost())
-				assertDecimalEqual(t, decimal.NewFromFloat(897.9), compute.PlannedCost())
+				assertDecimalEqual(t, decimal.NewFromFloat(87.6), compute.PriorCost().Monthly())
+				assertDecimalEqual(t, decimal.NewFromFloat(897.9), compute.PlannedCost().Monthly())
 
 				rootVol := diff.ComponentDiffs["Root volume: Storage"]
 				require.NotNil(t, rootVol)
-				assertDecimalEqual(t, decimal.NewFromFloat(3.6), rootVol.PriorCost())
-				assertDecimalEqual(t, decimal.NewFromFloat(3.6), rootVol.PlannedCost())
+				assertDecimalEqual(t, decimal.NewFromFloat(3.6), rootVol.PriorCost().Monthly())
+				assertDecimalEqual(t, decimal.NewFromFloat(3.6), rootVol.PlannedCost().Monthly())
 
 			case "aws_lb.example":
 				lb := diff.ComponentDiffs["Application Load Balancer"]
 				require.NotNil(t, lb)
 				assert.False(t, diff.Valid())
-				assertDecimalEqual(t, decimal.NewFromFloat(0), lb.Planned.Cost())
+				assertDecimalEqual(t, decimal.NewFromFloat(0), lb.Planned.Cost().Monthly())
 			}
 		}
 	})
@@ -179,8 +179,8 @@ func TestAWSEstimation(t *testing.T) {
 
 		rootVol := diffs[0].ComponentDiffs["Root volume: Storage"]
 		require.NotNil(t, rootVol)
-		assertDecimalEqual(t, decimal.NewFromFloat(3.6), rootVol.PriorCost())
-		assertDecimalEqual(t, decimal.NewFromFloat(3.6), rootVol.PlannedCost())
+		assertDecimalEqual(t, decimal.NewFromFloat(3.6), rootVol.PriorCost().Monthly())
+		assertDecimalEqual(t, decimal.NewFromFloat(3.6), rootVol.PlannedCost().Monthly())
 
 		expected := map[string]error{
 			"Compute": cost.ErrProductNotFound,