From 48dd69714ab2a778535253983b8196738465527d Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Fri, 16 Oct 2026 03:25:55 +0100 Subject: [PATCH] fix(gcp): price tiered SKUs from the first paid tier The Cloud SQL, Memorystore and Cloud Storage extractors read only TieredRates[0], so a SKU whose first tier is a free allowance and whose later tier is paid came back as price 0 and was skipped entirely. Add skumatch.PaidRate, which returns the first tier with a non-zero unit price, and use it in all three extractors. SKUs whose tiers are all free are still skipped. Closes #242 Co-Authored-By: Claude Opus 5.5 --- .../gcp/internal/skumatch/paid_rate_test.go | 35 +++++++++++++++ providers/gcp/internal/skumatch/skumatch.go | 18 ++++++++ providers/gcp/services/cloudsql/client.go | 8 +--- .../services/cloudsql/tiered_rates_test.go | 42 ++++++++++++++++++ providers/gcp/services/cloudstorage/client.go | 8 +--- .../cloudstorage/tiered_rates_test.go | 43 +++++++++++++++++++ providers/gcp/services/memorystore/client.go | 8 +--- .../services/memorystore/tiered_rates_test.go | 42 ++++++++++++++++++ 8 files changed, 186 insertions(+), 18 deletions(-) create mode 100644 providers/gcp/internal/skumatch/paid_rate_test.go create mode 100644 providers/gcp/services/cloudsql/tiered_rates_test.go create mode 100644 providers/gcp/services/cloudstorage/tiered_rates_test.go create mode 100644 providers/gcp/services/memorystore/tiered_rates_test.go diff --git a/providers/gcp/internal/skumatch/paid_rate_test.go b/providers/gcp/internal/skumatch/paid_rate_test.go new file mode 100644 index 00000000..0fb82cea --- /dev/null +++ b/providers/gcp/internal/skumatch/paid_rate_test.go @@ -0,0 +1,35 @@ +package skumatch + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "google.golang.org/api/cloudbilling/v1" +) + +func tier(units, nanos int64) *cloudbilling.TierRate { + return &cloudbilling.TierRate{UnitPrice: &cloudbilling.Money{CurrencyCode: "USD", Units: units, Nanos: nanos}} +} + +func TestPaidRate(t *testing.T) { + free, paid, whole := tier(0, 0), tier(0, 50000000), tier(2, 0) + tests := []struct { + name string + expr *cloudbilling.PricingExpression + want *cloudbilling.TierRate + }{ + {"nil expression", nil, nil}, + {"no tiers", &cloudbilling.PricingExpression{}, nil}, + {"single paid tier", &cloudbilling.PricingExpression{TieredRates: []*cloudbilling.TierRate{paid}}, paid}, + {"free first tier then paid", &cloudbilling.PricingExpression{TieredRates: []*cloudbilling.TierRate{free, paid}}, paid}, + {"first paid tier wins over later ones", &cloudbilling.PricingExpression{TieredRates: []*cloudbilling.TierRate{free, paid, whole}}, paid}, + {"whole-unit price counts as paid", &cloudbilling.PricingExpression{TieredRates: []*cloudbilling.TierRate{free, whole}}, whole}, + {"nil tier and nil price are skipped", &cloudbilling.PricingExpression{TieredRates: []*cloudbilling.TierRate{nil, {}, paid}}, paid}, + {"all tiers free", &cloudbilling.PricingExpression{TieredRates: []*cloudbilling.TierRate{free, tier(0, 0)}}, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Same(t, tt.want, PaidRate(tt.expr)) + }) + } +} diff --git a/providers/gcp/internal/skumatch/skumatch.go b/providers/gcp/internal/skumatch/skumatch.go index c16abdbf..94509df7 100644 --- a/providers/gcp/internal/skumatch/skumatch.go +++ b/providers/gcp/internal/skumatch/skumatch.go @@ -97,3 +97,21 @@ func Slot(sku *cloudbilling.Sku) (PriceSlot, error) { } return 0, fmt.Errorf("sku %s has unrecognized usage type %q", sku.SkuId, sku.Category.UsageType) } + +// PaidRate returns the first tier whose unit price is non-zero, or nil when +// every tier is free or has no price. The catalog lists tiers by ascending +// StartUsageAmount, and a free allowance shows up as a leading zero-price +// tier; the first non-zero tier is the marginal rate a workload sized for a +// commitment pays, so it is the one to price from. Reading only the first +// tier would treat a [free, paid] SKU as free and drop it. +func PaidRate(expr *cloudbilling.PricingExpression) *cloudbilling.TierRate { + if expr == nil { + return nil + } + for _, rate := range expr.TieredRates { + if rate != nil && rate.UnitPrice != nil && (rate.UnitPrice.Units != 0 || rate.UnitPrice.Nanos != 0) { + return rate + } + } + return nil +} diff --git a/providers/gcp/services/cloudsql/client.go b/providers/gcp/services/cloudsql/client.go index d80ae5f1..52960164 100644 --- a/providers/gcp/services/cloudsql/client.go +++ b/providers/gcp/services/cloudsql/client.go @@ -459,12 +459,8 @@ func extractSQLPriceFromSKU(sku *cloudbilling.Sku) (price float64, currency stri } pricingInfo := sku.PricingInfo[0] - if pricingInfo.PricingExpression == nil || len(pricingInfo.PricingExpression.TieredRates) == 0 { - return 0, "" - } - - rate := pricingInfo.PricingExpression.TieredRates[0] - if rate.UnitPrice == nil { + rate := skumatch.PaidRate(pricingInfo.PricingExpression) + if rate == nil { return 0, "" } diff --git a/providers/gcp/services/cloudsql/tiered_rates_test.go b/providers/gcp/services/cloudsql/tiered_rates_test.go new file mode 100644 index 00000000..ab40066a --- /dev/null +++ b/providers/gcp/services/cloudsql/tiered_rates_test.go @@ -0,0 +1,42 @@ +package cloudsql + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/api/cloudbilling/v1" +) + +// freeFirstTierSKU returns a SKU whose first tier is a free allowance and whose +// second tier carries the paid rate (issue #242). +func freeFirstTierSKU(id, description, usageType, region string, paidNanos int64) *cloudbilling.Sku { + sku := currencySKU(id, description, usageType, region, "USD", 0) + sku.PricingInfo[0].PricingExpression.TieredRates = append(sku.PricingInfo[0].PricingExpression.TieredRates, + &cloudbilling.TierRate{StartUsageAmount: 100, UnitPrice: &cloudbilling.Money{CurrencyCode: "USD", Nanos: paidNanos}}) + return sku +} + +func TestExtractSQLPricing_FreeFirstTierUsesPaidTier(t *testing.T) { + const tier, region = "db-n1-standard-1", "us-central1" + od := freeFirstTierSKU("od", tier+" Cloud SQL", "OnDemand", region, 50000000) + + price, currency := extractSQLPriceFromSKU(od) + assert.InDelta(t, 0.05, price, 1e-12) + assert.Equal(t, "USD", currency) + + onDemand, commitment, currency, err := extractSQLPricingFromSKUs([]*cloudbilling.Sku{ + od, + freeFirstTierSKU("cud", tier+" Cloud SQL commitment 1yr", "Commit1Yr", region, 42000000), + }, tier, region) + require.NoError(t, err) + assert.InDelta(t, 0.05, onDemand, 1e-12) + assert.InDelta(t, 0.042, commitment, 1e-12) + assert.Equal(t, "USD", currency) +} + +func TestExtractSQLPricing_AllTiersFreeIsSkipped(t *testing.T) { + sku := freeFirstTierSKU("od", "db-n1-standard-1 Cloud SQL", "OnDemand", "us-central1", 0) + price, _ := extractSQLPriceFromSKU(sku) + assert.Zero(t, price) +} diff --git a/providers/gcp/services/cloudstorage/client.go b/providers/gcp/services/cloudstorage/client.go index 43e95b54..ea52972e 100644 --- a/providers/gcp/services/cloudstorage/client.go +++ b/providers/gcp/services/cloudstorage/client.go @@ -321,12 +321,8 @@ func extractStoragePriceFromSKU(sku *cloudbilling.Sku) (price float64, currency } pricingInfo := sku.PricingInfo[0] - if pricingInfo.PricingExpression == nil || len(pricingInfo.PricingExpression.TieredRates) == 0 { - return 0, "", nil - } - - rate := pricingInfo.PricingExpression.TieredRates[0] - if rate.UnitPrice == nil { + rate := skumatch.PaidRate(pricingInfo.PricingExpression) + if rate == nil { return 0, "", nil } diff --git a/providers/gcp/services/cloudstorage/tiered_rates_test.go b/providers/gcp/services/cloudstorage/tiered_rates_test.go new file mode 100644 index 00000000..7dd9f662 --- /dev/null +++ b/providers/gcp/services/cloudstorage/tiered_rates_test.go @@ -0,0 +1,43 @@ +package cloudstorage + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/api/cloudbilling/v1" +) + +// freeFirstTierStorageSKU returns a SKU whose first tier is a free allowance and +// whose second tier carries the paid rate (issue #242). +func freeFirstTierStorageSKU(description, usageType string, paidNanos int64) *cloudbilling.Sku { + sku := usageTypeSKU(description, usageType, 0) + sku.PricingInfo[0].PricingExpression.TieredRates = append(sku.PricingInfo[0].PricingExpression.TieredRates, + &cloudbilling.TierRate{StartUsageAmount: 5, UnitPrice: &cloudbilling.Money{CurrencyCode: "USD", Nanos: paidNanos}}) + return sku +} + +func TestExtractStoragePricing_FreeFirstTierUsesPaidTier(t *testing.T) { + od := freeFirstTierStorageSKU("STANDARD Storage in us-central1", "OnDemand", 20000000) + + price, currency, err := extractStoragePriceFromSKU(od) + require.NoError(t, err) + assert.InDelta(t, 0.02/averageHoursPerMonth, price, 1e-15) + assert.Equal(t, "USD", currency) + + onDemand, commitment, currency, err := extractStoragePricingFromSKUs([]*cloudbilling.Sku{ + od, + freeFirstTierStorageSKU("STANDARD Storage in us-central1 Committed Use Discount", "Commit1Yr", 16000000), + }, "STANDARD", "us-central1") + require.NoError(t, err) + assert.InDelta(t, 0.02/averageHoursPerMonth, onDemand, 1e-15) + assert.InDelta(t, 0.016/averageHoursPerMonth, commitment, 1e-15) + assert.Equal(t, "USD", currency) +} + +func TestExtractStoragePricing_AllTiersFreeIsSkipped(t *testing.T) { + sku := freeFirstTierStorageSKU("STANDARD Storage in us-central1", "OnDemand", 0) + price, _, err := extractStoragePriceFromSKU(sku) + require.NoError(t, err) + assert.Zero(t, price) +} diff --git a/providers/gcp/services/memorystore/client.go b/providers/gcp/services/memorystore/client.go index cddd7c8c..e7a78b2b 100644 --- a/providers/gcp/services/memorystore/client.go +++ b/providers/gcp/services/memorystore/client.go @@ -427,12 +427,8 @@ func extractPriceFromSKU(sku *cloudbilling.Sku) (price float64, currency string) } pricingInfo := sku.PricingInfo[0] - if pricingInfo.PricingExpression == nil || len(pricingInfo.PricingExpression.TieredRates) == 0 { - return 0, "" - } - - rate := pricingInfo.PricingExpression.TieredRates[0] - if rate.UnitPrice == nil { + rate := skumatch.PaidRate(pricingInfo.PricingExpression) + if rate == nil { return 0, "" } diff --git a/providers/gcp/services/memorystore/tiered_rates_test.go b/providers/gcp/services/memorystore/tiered_rates_test.go new file mode 100644 index 00000000..7f740697 --- /dev/null +++ b/providers/gcp/services/memorystore/tiered_rates_test.go @@ -0,0 +1,42 @@ +package memorystore + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/api/cloudbilling/v1" +) + +// freeFirstTierSKU returns a SKU whose first tier is a free allowance and whose +// second tier carries the paid rate (issue #242). +func freeFirstTierSKU(id, description, usageType, region string, paidNanos int64) *cloudbilling.Sku { + sku := currencySKU(id, description, usageType, region, "USD", 0) + sku.PricingInfo[0].PricingExpression.TieredRates = append(sku.PricingInfo[0].PricingExpression.TieredRates, + &cloudbilling.TierRate{StartUsageAmount: 100, UnitPrice: &cloudbilling.Money{CurrencyCode: "USD", Nanos: paidNanos}}) + return sku +} + +func TestExtractPricing_FreeFirstTierUsesPaidTier(t *testing.T) { + const tier, region = "STANDARD_HA", "us-central1" + od := freeFirstTierSKU("od", tier+" Memorystore", "OnDemand", region, 50000000) + + price, currency := extractPriceFromSKU(od) + assert.InDelta(t, 0.05, price, 1e-12) + assert.Equal(t, "USD", currency) + + onDemand, commitment, currency, err := extractPricingFromSKUs([]*cloudbilling.Sku{ + od, + freeFirstTierSKU("cud", tier+" Memorystore commitment 1yr", "Commit1Yr", region, 42000000), + }, tier, region) + require.NoError(t, err) + assert.InDelta(t, 0.05, onDemand, 1e-12) + assert.InDelta(t, 0.042, commitment, 1e-12) + assert.Equal(t, "USD", currency) +} + +func TestExtractPricing_AllTiersFreeIsSkipped(t *testing.T) { + sku := freeFirstTierSKU("od", "STANDARD_HA Memorystore", "OnDemand", "us-central1", 0) + price, _ := extractPriceFromSKU(sku) + assert.Zero(t, price) +}