Skip to content

Commit 97a4bf0

Browse files
committed
feat: convert package and dynamic price to unitconfig translation
1 parent bf09b93 commit 97a4bf0

7 files changed

Lines changed: 1295 additions & 644 deletions

File tree

api/spec/packages/aip/src/productcatalog/ratecard.tsp

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import "../billing/tax.tsp";
33
import "../features/index.tsp";
44
import "../tax/codes.tsp";
55
import "./price.tsp";
6+
import "./unitconfig.tsp";
67

78
namespace ProductCatalog;
89

@@ -52,6 +53,18 @@ model RateCard {
5253
@summary("Price")
5354
price: Price;
5455

56+
/**
57+
* Unit conversion configuration for the rate card.
58+
*
59+
* Synthesized on read for plans authored with v1 dynamic or package prices:
60+
* dynamic prices map to a unit price with a multiply unit config, and
61+
* package prices map to a unit price with a divide unit config.
62+
* Not yet accepted on create or update.
63+
*/
64+
@visibility(Lifecycle.Read)
65+
@summary("Unit config")
66+
unit_config?: UnitConfig;
67+
5568
/**
5669
* The payment term of the rate card.
5770
* In advance payment term can only be used for flat prices.

api/v3/api.gen.go

Lines changed: 734 additions & 603 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/v3/handlers/plans/convert.go

Lines changed: 66 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,28 +16,6 @@ import (
1616
"github.com/openmeterio/openmeter/pkg/models"
1717
)
1818

19-
var unsupportedV3PriceTypes = map[productcatalog.PriceType]struct{}{
20-
productcatalog.DynamicPriceType: {},
21-
productcatalog.PackagePriceType: {},
22-
}
23-
24-
func hasUnsupportedV3Price(p plan.Plan) bool {
25-
for _, phase := range p.Phases {
26-
for _, rc := range phase.RateCards {
27-
price := rc.AsMeta().Price
28-
if price == nil {
29-
continue
30-
}
31-
32-
if _, unsupported := unsupportedV3PriceTypes[price.Type()]; unsupported {
33-
return true
34-
}
35-
}
36-
}
37-
38-
return false
39-
}
40-
4119
func ToAPIBillingPlan(p plan.Plan) (api.BillingPlan, error) {
4220
validationIssues, _ := p.AsProductCatalogPlan().ValidationErrors()
4321

@@ -163,9 +141,54 @@ func ToAPIBillingRateCard(rc productcatalog.RateCard) (api.BillingRateCard, erro
163141

164142
result.Price = price
165143

144+
unitConfig, err := ToAPIBillingRateCardUnitConfig(meta.Price)
145+
if err != nil {
146+
return result, fmt.Errorf("failed to convert unit config: %w", err)
147+
}
148+
149+
result.UnitConfig = unitConfig
150+
166151
return result, nil
167152
}
168153

154+
// ToAPIBillingRateCardUnitConfig synthesizes a v3 unit config from a v1 dynamic
155+
// or package price. v3 does not surface dynamic or package prices directly;
156+
// instead they are rendered as a unit price paired with a unit config that
157+
// describes the conversion that v1 applied implicitly.
158+
func ToAPIBillingRateCardUnitConfig(p *productcatalog.Price) (*api.BillingUnitConfig, error) {
159+
if p == nil {
160+
return nil, nil
161+
}
162+
163+
switch p.Type() {
164+
case productcatalog.DynamicPriceType:
165+
dynamic, err := p.AsDynamic()
166+
if err != nil {
167+
return nil, fmt.Errorf("failed to read dynamic price: %w", err)
168+
}
169+
170+
return &api.BillingUnitConfig{
171+
Operation: api.BillingUnitConfigOperationMultiply,
172+
ConversionFactor: dynamic.Multiplier.String(),
173+
}, nil
174+
175+
case productcatalog.PackagePriceType:
176+
pkg, err := p.AsPackage()
177+
if err != nil {
178+
return nil, fmt.Errorf("failed to read package price: %w", err)
179+
}
180+
181+
return &api.BillingUnitConfig{
182+
Operation: api.BillingUnitConfigOperationDivide,
183+
ConversionFactor: pkg.QuantityPerPackage.String(),
184+
Rounding: lo.ToPtr(api.BillingUnitConfigRoundingModeCeiling),
185+
}, nil
186+
187+
default:
188+
return nil, nil
189+
}
190+
}
191+
169192
func ToAPIBillingPrice(p *productcatalog.Price) (api.BillingPrice, error) {
170193
var result api.BillingPrice
171194

@@ -236,10 +259,29 @@ func ToAPIBillingPrice(p *productcatalog.Price) (api.BillingPrice, error) {
236259
}
237260

238261
case productcatalog.DynamicPriceType:
239-
return result, models.NewGenericConflictError(fmt.Errorf("dynamic price is not supported in v3 API"))
262+
// Dynamic prices are surfaced in v3 as a unit price of amount 1; the
263+
// multiplier is carried separately on the rate card's unit config.
264+
if err := result.FromBillingPriceUnit(api.BillingPriceUnit{
265+
Amount: "1",
266+
Type: api.BillingPriceUnitType("unit"),
267+
}); err != nil {
268+
return result, fmt.Errorf("failed to set unit price for dynamic price: %w", err)
269+
}
240270

241271
case productcatalog.PackagePriceType:
242-
return result, models.NewGenericConflictError(fmt.Errorf("package price is not supported in v3 API"))
272+
// Package prices are surfaced in v3 as a unit price; the package size
273+
// is carried separately on the rate card's unit config.
274+
pkg, err := p.AsPackage()
275+
if err != nil {
276+
return result, fmt.Errorf("failed to read package price: %w", err)
277+
}
278+
279+
if err = result.FromBillingPriceUnit(api.BillingPriceUnit{
280+
Amount: pkg.Amount.String(),
281+
Type: api.BillingPriceUnitType("unit"),
282+
}); err != nil {
283+
return result, fmt.Errorf("failed to set unit price for package price: %w", err)
284+
}
243285

244286
default:
245287
return result, fmt.Errorf("unknown price type: %s", p.Type())

api/v3/handlers/plans/convert_test.go

Lines changed: 186 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -529,25 +529,199 @@ func TestFromBillingPrice(t *testing.T) {
529529
assert.Equal(t, "volume", disc)
530530
})
531531

532-
t.Run("dynamic price returns conflict error", func(t *testing.T) {
533-
p := productcatalog.NewPriceFrom(productcatalog.DynamicPrice{})
532+
t.Run("dynamic price translates to unit price of amount 1", func(t *testing.T) {
533+
p := productcatalog.NewPriceFrom(productcatalog.DynamicPrice{
534+
Multiplier: decimal.NewFromFloat(1.2),
535+
})
534536

535-
_, err := ToAPIBillingPrice(p)
536-
require.Error(t, err)
537-
assert.True(t, models.IsGenericConflictError(err))
538-
assert.Contains(t, err.Error(), "dynamic price is not supported in v3 API")
537+
result, err := ToAPIBillingPrice(p)
538+
require.NoError(t, err)
539+
540+
disc, err := result.Discriminator()
541+
require.NoError(t, err)
542+
assert.Equal(t, "unit", disc)
543+
544+
unit, err := result.AsBillingPriceUnit()
545+
require.NoError(t, err)
546+
assert.Equal(t, api.Numeric("1"), unit.Amount)
547+
})
548+
549+
t.Run("package price translates to unit price with package amount", func(t *testing.T) {
550+
p := productcatalog.NewPriceFrom(productcatalog.PackagePrice{
551+
Amount: decimal.NewFromFloat(0.5),
552+
QuantityPerPackage: decimal.NewFromInt(1000),
553+
})
554+
555+
result, err := ToAPIBillingPrice(p)
556+
require.NoError(t, err)
557+
558+
disc, err := result.Discriminator()
559+
require.NoError(t, err)
560+
assert.Equal(t, "unit", disc)
561+
562+
unit, err := result.AsBillingPriceUnit()
563+
require.NoError(t, err)
564+
assert.Equal(t, api.Numeric("0.5"), unit.Amount)
565+
})
566+
}
567+
568+
func TestToAPIBillingRateCardUnitConfig(t *testing.T) {
569+
t.Run("nil price has no unit config", func(t *testing.T) {
570+
result, err := ToAPIBillingRateCardUnitConfig(nil)
571+
require.NoError(t, err)
572+
assert.Nil(t, result)
573+
})
574+
575+
t.Run("flat price has no unit config", func(t *testing.T) {
576+
p := productcatalog.NewPriceFrom(productcatalog.FlatPrice{Amount: decimal.NewFromFloat(5)})
577+
578+
result, err := ToAPIBillingRateCardUnitConfig(p)
579+
require.NoError(t, err)
580+
assert.Nil(t, result)
581+
})
582+
583+
t.Run("unit price has no unit config", func(t *testing.T) {
584+
p := productcatalog.NewPriceFrom(productcatalog.UnitPrice{Amount: decimal.NewFromFloat(0.05)})
585+
586+
result, err := ToAPIBillingRateCardUnitConfig(p)
587+
require.NoError(t, err)
588+
assert.Nil(t, result)
539589
})
540590

541-
t.Run("package price returns conflict error", func(t *testing.T) {
591+
t.Run("dynamic price produces multiply unit config", func(t *testing.T) {
592+
p := productcatalog.NewPriceFrom(productcatalog.DynamicPrice{
593+
Multiplier: decimal.NewFromFloat(1.2),
594+
})
595+
596+
result, err := ToAPIBillingRateCardUnitConfig(p)
597+
require.NoError(t, err)
598+
require.NotNil(t, result)
599+
assert.Equal(t, api.BillingUnitConfigOperationMultiply, result.Operation)
600+
assert.Equal(t, api.Numeric("1.2"), result.ConversionFactor)
601+
assert.Nil(t, result.Rounding)
602+
assert.Nil(t, result.Precision)
603+
assert.Nil(t, result.DisplayUnit)
604+
})
605+
606+
t.Run("package price produces divide unit config with ceiling rounding", func(t *testing.T) {
542607
p := productcatalog.NewPriceFrom(productcatalog.PackagePrice{
543608
Amount: decimal.NewFromFloat(10),
544-
QuantityPerPackage: decimal.NewFromInt(100),
609+
QuantityPerPackage: decimal.NewFromInt(1000),
545610
})
546611

547-
_, err := ToAPIBillingPrice(p)
548-
require.Error(t, err)
549-
assert.True(t, models.IsGenericConflictError(err))
550-
assert.Contains(t, err.Error(), "package price is not supported in v3 API")
612+
result, err := ToAPIBillingRateCardUnitConfig(p)
613+
require.NoError(t, err)
614+
require.NotNil(t, result)
615+
assert.Equal(t, api.BillingUnitConfigOperationDivide, result.Operation)
616+
assert.Equal(t, api.Numeric("1000"), result.ConversionFactor)
617+
require.NotNil(t, result.Rounding)
618+
assert.Equal(t, api.BillingUnitConfigRoundingModeCeiling, *result.Rounding)
619+
})
620+
}
621+
622+
func TestFromRateCard_DynamicAndPackagePrices(t *testing.T) {
623+
cadence, err := datetime.ISODurationString("P1M").Parse()
624+
require.NoError(t, err)
625+
626+
t.Run("dynamic price renders as unit price plus multiply unit config and preserves commitments", func(t *testing.T) {
627+
minAmt := decimal.NewFromFloat(10)
628+
maxAmt := decimal.NewFromFloat(100)
629+
price := productcatalog.NewPriceFrom(productcatalog.DynamicPrice{
630+
Multiplier: decimal.NewFromFloat(1.2),
631+
Commitments: productcatalog.Commitments{
632+
MinimumAmount: &minAmt,
633+
MaximumAmount: &maxAmt,
634+
},
635+
})
636+
637+
rc := &productcatalog.UsageBasedRateCard{
638+
RateCardMeta: productcatalog.RateCardMeta{
639+
Key: "tokens",
640+
Name: "Tokens",
641+
Price: price,
642+
},
643+
BillingCadence: cadence,
644+
}
645+
646+
result, err := ToAPIBillingRateCard(rc)
647+
require.NoError(t, err)
648+
649+
disc, err := result.Price.Discriminator()
650+
require.NoError(t, err)
651+
assert.Equal(t, "unit", disc)
652+
653+
unit, err := result.Price.AsBillingPriceUnit()
654+
require.NoError(t, err)
655+
assert.Equal(t, api.Numeric("1"), unit.Amount)
656+
657+
require.NotNil(t, result.UnitConfig)
658+
assert.Equal(t, api.BillingUnitConfigOperationMultiply, result.UnitConfig.Operation)
659+
assert.Equal(t, api.Numeric("1.2"), result.UnitConfig.ConversionFactor)
660+
assert.Nil(t, result.UnitConfig.Rounding)
661+
662+
require.NotNil(t, result.Commitments)
663+
assert.Equal(t, lo.ToPtr(api.Numeric("10")), result.Commitments.MinimumAmount)
664+
assert.Equal(t, lo.ToPtr(api.Numeric("100")), result.Commitments.MaximumAmount)
665+
})
666+
667+
t.Run("package price renders as unit price plus divide unit config and preserves commitments", func(t *testing.T) {
668+
minAmt := decimal.NewFromFloat(5)
669+
price := productcatalog.NewPriceFrom(productcatalog.PackagePrice{
670+
Amount: decimal.NewFromFloat(0.5),
671+
QuantityPerPackage: decimal.NewFromInt(1000),
672+
Commitments: productcatalog.Commitments{
673+
MinimumAmount: &minAmt,
674+
},
675+
})
676+
677+
rc := &productcatalog.UsageBasedRateCard{
678+
RateCardMeta: productcatalog.RateCardMeta{
679+
Key: "api-calls",
680+
Name: "API Calls",
681+
Price: price,
682+
},
683+
BillingCadence: cadence,
684+
}
685+
686+
result, err := ToAPIBillingRateCard(rc)
687+
require.NoError(t, err)
688+
689+
disc, err := result.Price.Discriminator()
690+
require.NoError(t, err)
691+
assert.Equal(t, "unit", disc)
692+
693+
unit, err := result.Price.AsBillingPriceUnit()
694+
require.NoError(t, err)
695+
assert.Equal(t, api.Numeric("0.5"), unit.Amount)
696+
697+
require.NotNil(t, result.UnitConfig)
698+
assert.Equal(t, api.BillingUnitConfigOperationDivide, result.UnitConfig.Operation)
699+
assert.Equal(t, api.Numeric("1000"), result.UnitConfig.ConversionFactor)
700+
require.NotNil(t, result.UnitConfig.Rounding)
701+
assert.Equal(t, api.BillingUnitConfigRoundingModeCeiling, *result.UnitConfig.Rounding)
702+
703+
require.NotNil(t, result.Commitments)
704+
assert.Equal(t, lo.ToPtr(api.Numeric("5")), result.Commitments.MinimumAmount)
705+
assert.Nil(t, result.Commitments.MaximumAmount)
706+
})
707+
708+
t.Run("unit price has no unit config on rate card", func(t *testing.T) {
709+
price := productcatalog.NewPriceFrom(productcatalog.UnitPrice{
710+
Amount: decimal.NewFromFloat(0.05),
711+
})
712+
713+
rc := &productcatalog.UsageBasedRateCard{
714+
RateCardMeta: productcatalog.RateCardMeta{
715+
Key: "api-calls",
716+
Name: "API Calls",
717+
Price: price,
718+
},
719+
BillingCadence: cadence,
720+
}
721+
722+
result, err := ToAPIBillingRateCard(rc)
723+
require.NoError(t, err)
724+
assert.Nil(t, result.UnitConfig)
551725
})
552726
}
553727

api/v3/handlers/plans/list.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,6 @@ func (h *handler) ListPlans() ListPlansHandler {
111111

112112
items := make([]api.BillingPlan, 0, len(result.Items))
113113
for _, p := range result.Items {
114-
// FIXME: For now we skip plans containing price types not representable in v3 (e.g., package, dynamic). We'll add full bidirectional transform later on.
115-
if hasUnsupportedV3Price(p) {
116-
continue
117-
}
118-
119114
billingPlan, err := ToAPIBillingPlan(p)
120115
if err != nil {
121116
return ListPlansResponse{}, err

0 commit comments

Comments
 (0)