From c511f361bee66f2858ca96280b27e2ff77eb30ae Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 13 Oct 2026 20:00:12 +0200 Subject: [PATCH 1/2] feat(pkg): add recurring monthly cost to the audit record and recommendation table estimated_cost and the table's Est.Cost are the upfront payment, so they read 0 for no-upfront commitments. Keep them and add recurring_monthly_cost (USD per month, omitted when unknown) to AuditRecord and a Recurring$/mo column to RenderTable. Closes #358 Co-Authored-By: Claude Opus 5.5 --- pkg/common/audit.go | 49 +++++++++++++++++++++-------------- pkg/common/audit_test.go | 18 +++++++++++++ pkg/common/types.go | 43 ++++++++++++++++-------------- pkg/reporter/reporter.go | 13 +++++++--- pkg/reporter/reporter_test.go | 19 +++++++++++++- 5 files changed, 99 insertions(+), 43 deletions(-) diff --git a/pkg/common/audit.go b/pkg/common/audit.go index 0133e3f0..2c8fee35 100644 --- a/pkg/common/audit.go +++ b/pkg/common/audit.go @@ -288,28 +288,39 @@ func NewAuditRecord(runID string, rec Recommendation, result PurchaseResult, sta errMsg = result.Error.Error() } return AuditRecord{ - RunID: runID, - Provider: rec.Provider, - AccountID: rec.Account, - AccountName: rec.AccountName, - Region: rec.Region, - Service: string(rec.Service), - ResourceType: rec.ResourceType, - CommitmentType: rec.CommitmentType, - Term: termMonths(rec.Term), - Count: rec.Count, - EstimatedCost: rec.CommitmentCost, - EstimatedSavings: rec.EstimatedSavings, - CommitmentID: result.CommitmentID, - Status: status, - ErrorMessage: errMsg, - Timestamp: time.Now().UTC(), - DryRun: dryRun, - RawRecommendation: rec.RawRecommendation, - Source: source, + RunID: runID, + Provider: rec.Provider, + AccountID: rec.Account, + AccountName: rec.AccountName, + Region: rec.Region, + Service: string(rec.Service), + ResourceType: rec.ResourceType, + CommitmentType: rec.CommitmentType, + Term: termMonths(rec.Term), + Count: rec.Count, + EstimatedCost: rec.CommitmentCost, + RecurringMonthlyCost: copyFloatPtr(rec.RecurringMonthlyCost), + EstimatedSavings: rec.EstimatedSavings, + CommitmentID: result.CommitmentID, + Status: status, + ErrorMessage: errMsg, + Timestamp: time.Now().UTC(), + DryRun: dryRun, + RawRecommendation: rec.RawRecommendation, + Source: source, } } +// copyFloatPtr returns a pointer to a copy of *p, or nil, so the audit record +// does not alias the recommendation's value. +func copyFloatPtr(p *float64) *float64 { + if p == nil { + return nil + } + v := *p + return &v +} + // termMonths converts a term string ("1yr", "3yr") to months. // Returns 0 for unrecognized strings. func termMonths(t string) int { diff --git a/pkg/common/audit_test.go b/pkg/common/audit_test.go index f5e94b4b..7ad22548 100644 --- a/pkg/common/audit_test.go +++ b/pkg/common/audit_test.go @@ -235,3 +235,21 @@ func TestTermMonths(t *testing.T) { }) } } + +func TestNewAuditRecord_RecurringMonthlyCost(t *testing.T) { + recurring := 1968.81 + rec := Recommendation{CommitmentCost: 0, RecurringMonthlyCost: &recurring} + ar := NewAuditRecord("run", rec, PurchaseResult{}, "skipped", true, PurchaseSourceCLI) + if assert.NotNil(t, ar.RecurringMonthlyCost) { + assert.Equal(t, 1968.81, *ar.RecurringMonthlyCost) + } + assert.NotSame(t, rec.RecurringMonthlyCost, ar.RecurringMonthlyCost) + line, err := json.Marshal(ar) + assert.NoError(t, err) + assert.Contains(t, string(line), `"recurring_monthly_cost":1968.81`) + assert.Contains(t, string(line), `"estimated_cost":0`) + + unknown, err := json.Marshal(NewAuditRecord("run", Recommendation{}, PurchaseResult{}, "skipped", true, PurchaseSourceCLI)) + assert.NoError(t, err) + assert.NotContains(t, string(unknown), "recurring_monthly_cost") +} diff --git a/pkg/common/types.go b/pkg/common/types.go index 8b62a9d0..c936ec67 100644 --- a/pkg/common/types.go +++ b/pkg/common/types.go @@ -744,25 +744,30 @@ type RIUtilization struct { // AuditRecord is one line in the JSON-lines audit log. // Status values: "success", "error", "skipped" (dry-run), "skipped_covered" (idempotency). type AuditRecord struct { - RunID string `json:"run_id"` - Provider ProviderType `json:"provider"` - AccountID string `json:"account_id"` - AccountName string `json:"account_name"` - CredentialScope string `json:"credential_scope,omitempty"` // Routing identifier, not a verified account ID. - Region string `json:"region"` - Service string `json:"service"` - ResourceType string `json:"resource_type"` - CommitmentType CommitmentType `json:"commitment_type"` - Term int `json:"term_months"` - Count int `json:"count"` - EstimatedCost float64 `json:"estimated_cost"` - EstimatedSavings float64 `json:"estimated_savings"` - CommitmentID string `json:"commitment_id"` - Status string `json:"status"` - ErrorMessage string `json:"error_message"` - Timestamp time.Time `json:"timestamp"` - DryRun bool `json:"dry_run"` - RawRecommendation json.RawMessage `json:"raw_recommendation,omitempty"` + RunID string `json:"run_id"` + Provider ProviderType `json:"provider"` + AccountID string `json:"account_id"` + AccountName string `json:"account_name"` + CredentialScope string `json:"credential_scope,omitempty"` // Routing identifier, not a verified account ID. + Region string `json:"region"` + Service string `json:"service"` + ResourceType string `json:"resource_type"` + CommitmentType CommitmentType `json:"commitment_type"` + Term int `json:"term_months"` + Count int `json:"count"` + EstimatedCost float64 `json:"estimated_cost"` // Upfront payment (Recommendation.CommitmentCost); 0 for no-upfront commitments. + // RecurringMonthlyCost is the recurring charge in USD per month (the part + // paid every month after any upfront payment), copied from + // Recommendation.RecurringMonthlyCost. nil (omitted) means the provider did + // not report it, which is not the same as a known 0 (all-upfront). + RecurringMonthlyCost *float64 `json:"recurring_monthly_cost,omitempty"` + EstimatedSavings float64 `json:"estimated_savings"` + CommitmentID string `json:"commitment_id"` + Status string `json:"status"` + ErrorMessage string `json:"error_message"` + Timestamp time.Time `json:"timestamp"` + DryRun bool `json:"dry_run"` + RawRecommendation json.RawMessage `json:"raw_recommendation,omitempty"` // Source identifies the CUDly surface (cudly-cli / cudly-web) that // triggered the purchase. Mirrors the value stamped on the commitment // itself via purchase-automation tag/label. diff --git a/pkg/reporter/reporter.go b/pkg/reporter/reporter.go index fe630f20..31f821f3 100644 --- a/pkg/reporter/reporter.go +++ b/pkg/reporter/reporter.go @@ -16,7 +16,7 @@ const ( ) // RenderTable returns a formatted table of recommendations that passed the scorer. -// Columns: Cloud, Account, Region, Service, Type, Term, Count, Est. Cost, Est. Savings, Savings%, Break-even, Commitment. +// Columns: Cloud, Account, Region, Service, Type, Term, Count, Est. Cost (upfront), Recurring$/mo (USD per month, - when unknown), Est. Savings, Savings%, Break-even, Commitment. func RenderTable(result scorer.ScoredResult) string { if len(result.Passed) == 0 { return "No recommendations passed the filters.\n" @@ -24,8 +24,8 @@ func RenderTable(result scorer.ScoredResult) string { var buf bytes.Buffer w := tabwriter.NewWriter(&buf, tabMinWidth, 0, tabPadding, ' ', 0) - fmt.Fprintln(w, "Cloud\tAccount\tRegion\tService\tType\tTerm\tCount\tEst.Cost\tEst.Savings\tSavings%\tBreak-even\tCommitment") - fmt.Fprintln(w, "-----\t-------\t------\t-------\t----\t----\t-----\t--------\t-----------\t---------\t----------\t----------") + fmt.Fprintln(w, "Cloud\tAccount\tRegion\tService\tType\tTerm\tCount\tEst.Cost\tRecurring$/mo\tEst.Savings\tSavings%\tBreak-even\tCommitment") + fmt.Fprintln(w, "-----\t-------\t------\t-------\t----\t----\t-----\t--------\t-------------\t-----------\t---------\t----------\t----------") for _rvc := range result.Passed { rec := result.Passed[_rvc] @@ -33,7 +33,11 @@ func RenderTable(result scorer.ScoredResult) string { if rec.BreakEvenMonths > 0 { breakEven = fmt.Sprintf("%.1f mo", rec.BreakEvenMonths) } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%d\t$%.2f\t$%.2f\t%.1f%%\t%s\t%s\n", + recurring := "-" + if rec.RecurringMonthlyCost != nil { + recurring = fmt.Sprintf("$%.2f", *rec.RecurringMonthlyCost) + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%d\t$%.2f\t%s\t$%.2f\t%.1f%%\t%s\t%s\n", rec.Provider, rec.AccountName, rec.Region, @@ -42,6 +46,7 @@ func RenderTable(result scorer.ScoredResult) string { rec.Term, rec.Count, rec.CommitmentCost, + recurring, rec.EstimatedSavings, rec.SavingsPercentage, breakEven, diff --git a/pkg/reporter/reporter_test.go b/pkg/reporter/reporter_test.go index 96e1c4dd..dd67f644 100644 --- a/pkg/reporter/reporter_test.go +++ b/pkg/reporter/reporter_test.go @@ -27,7 +27,7 @@ func TestRenderTable_ColumnCount(t *testing.T) { header := lines[0] // Split on 2+ spaces (tabwriter aligns with spaces, not tabs after flush) cols := strings.Fields(header) - assert.Equal(t, 12, len(cols), "expected 12 columns in header, got: %v", cols) + assert.Equal(t, 13, len(cols), "expected 13 columns in header, got: %v", cols) } func TestRenderTable_Empty(t *testing.T) { @@ -86,3 +86,20 @@ func TestRenderSummary_WithFiltered(t *testing.T) { output := RenderSummary(result) assert.Contains(t, output, "Filtered: 2") } + +func TestRenderTable_RecurringMonthlyCost(t *testing.T) { + recurring := 1968.81 + zero := 0.0 + rows := []common.Recommendation{ + {Provider: "aws", Service: "savings-plans-ec2instance", Term: "1yr", Count: 1, RecurringMonthlyCost: &recurring, CommitmentType: common.CommitmentSavingsPlan}, + {Provider: "aws", Service: "savings-plans-compute", Term: "1yr", Count: 1, RecurringMonthlyCost: &zero, CommitmentType: common.CommitmentSavingsPlan}, + {Provider: "aws", Service: "rds", Term: "1yr", Count: 1, CommitmentType: common.CommitmentReservedInstance}, + } + output := RenderTable(makeResult(rows, nil)) + lines := strings.Split(strings.TrimRight(output, "\n"), "\n") + assert.Contains(t, lines[0], "Recurring$/mo") + assert.Contains(t, lines[2], "$1968.81") + assert.Contains(t, lines[3], "$0.00") + fields := strings.Fields(lines[4]) + assert.Equal(t, "-", fields[len(fields)-5], "an unknown recurring cost renders as -, not a known $0.00: %v", fields) +} From 9e7d6d5bcaf989ceed9e8cf929ea8de3ef62dff3 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 13 Oct 2026 20:23:21 +0200 Subject: [PATCH 2/2] test(pkg): pin the known-zero recurring cost in the table and the audit JSON The table test used Contains("$0.00") on a row whose Est.Cost and Est.Savings are also $0.00, so printing a known 0 as - still passed. Assert the Recurring cell itself, and add a known-zero audit JSON case. Refs #358 Co-Authored-By: Claude Opus 5.5 --- pkg/common/audit_test.go | 12 ++++++++++++ pkg/reporter/reporter_test.go | 15 +++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/pkg/common/audit_test.go b/pkg/common/audit_test.go index 7ad22548..9c5b707b 100644 --- a/pkg/common/audit_test.go +++ b/pkg/common/audit_test.go @@ -253,3 +253,15 @@ func TestNewAuditRecord_RecurringMonthlyCost(t *testing.T) { assert.NoError(t, err) assert.NotContains(t, string(unknown), "recurring_monthly_cost") } + +func TestNewAuditRecord_RecurringMonthlyCostKnownZero(t *testing.T) { + zero := 0.0 + rec := Recommendation{RecurringMonthlyCost: &zero} + ar := NewAuditRecord("run", rec, PurchaseResult{}, "skipped", true, PurchaseSourceCLI) + if assert.NotNil(t, ar.RecurringMonthlyCost, "a known 0 must stay distinguishable from unknown") { + assert.Equal(t, 0.0, *ar.RecurringMonthlyCost) + } + line, err := json.Marshal(ar) + assert.NoError(t, err) + assert.Contains(t, string(line), `"recurring_monthly_cost":0`) +} diff --git a/pkg/reporter/reporter_test.go b/pkg/reporter/reporter_test.go index dd67f644..25de03e8 100644 --- a/pkg/reporter/reporter_test.go +++ b/pkg/reporter/reporter_test.go @@ -98,8 +98,15 @@ func TestRenderTable_RecurringMonthlyCost(t *testing.T) { output := RenderTable(makeResult(rows, nil)) lines := strings.Split(strings.TrimRight(output, "\n"), "\n") assert.Contains(t, lines[0], "Recurring$/mo") - assert.Contains(t, lines[2], "$1968.81") - assert.Contains(t, lines[3], "$0.00") - fields := strings.Fields(lines[4]) - assert.Equal(t, "-", fields[len(fields)-5], "an unknown recurring cost renders as -, not a known $0.00: %v", fields) + + // Est.Cost and Est.Savings are also $0.00 on these rows, so look at the + // Recurring$/mo cell itself: fifth field from the end + // (Recurring, Est.Savings, Savings%, Break-even, Commitment). + recurringCell := func(line string) string { + fields := strings.Fields(line) + return fields[len(fields)-5] + } + assert.Equal(t, "$1968.81", recurringCell(lines[2])) + assert.Equal(t, "$0.00", recurringCell(lines[3]), "a known zero recurring cost (all-upfront) renders as $0.00") + assert.Equal(t, "-", recurringCell(lines[4]), "an unknown recurring cost renders as -, not a known $0.00") }