Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions api/spec/packages/aip/src/productcatalog/ratecard.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import "../billing/tax.tsp";
import "../features/index.tsp";
import "../tax/codes.tsp";
import "./price.tsp";
import "./unitconfig.tsp";

namespace ProductCatalog;

Expand Down Expand Up @@ -54,6 +55,20 @@ model RateCard {
@summary("Price")
price: Price;

/**
* Unit conversion configuration for the rate card.
*
* When set, transforms the raw metered quantity into a billing quantity before
* pricing. Valid only with unit, graduated, or volume prices.
*
* For plans authored with v1 dynamic or package prices, the unit config is
* synthesized on read: dynamic prices map to a unit price with a multiply unit
* config, and package prices map to a unit price with a divide unit config.
*/
@visibility(Lifecycle.Read, Lifecycle.Create, Lifecycle.Update)
@summary("Unit config")
unit_config?: UnitConfig;

/**
* The payment term of the rate card. In advance payment term can only be used for
* flat prices.
Expand Down
1,432 changes: 788 additions & 644 deletions api/v3/api.gen.go

Large diffs are not rendered by default.

139 changes: 115 additions & 24 deletions api/v3/handlers/plans/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,28 +16,6 @@ import (
"github.com/openmeterio/openmeter/pkg/models"
)

var unsupportedV3PriceTypes = map[productcatalog.PriceType]struct{}{
productcatalog.DynamicPriceType: {},
productcatalog.PackagePriceType: {},
}

func hasUnsupportedV3Price(p plan.Plan) bool {
for _, phase := range p.Phases {
for _, rc := range phase.RateCards {
price := rc.AsMeta().Price
if price == nil {
continue
}

if _, unsupported := unsupportedV3PriceTypes[price.Type()]; unsupported {
return true
}
}
}

return false
}

func ToAPIBillingPlan(p plan.Plan) (api.BillingPlan, error) {
validationIssues, _ := p.AsProductCatalogPlan().ValidationErrors()

Expand Down Expand Up @@ -163,9 +141,97 @@ func ToAPIBillingRateCard(rc productcatalog.RateCard) (api.BillingRateCard, erro

result.Price = price

if meta.UnitConfig != nil {
result.UnitConfig = ToAPIBillingUnitConfig(meta.UnitConfig)
} else {
unitConfig, err := ToAPIBillingRateCardUnitConfig(meta.Price)
if err != nil {
return result, fmt.Errorf("failed to convert unit config: %w", err)
}

result.UnitConfig = unitConfig
}

return result, nil
}

func ToAPIBillingUnitConfig(uc *productcatalog.UnitConfig) *api.BillingUnitConfig {
if uc == nil {
return nil
}

out := &api.BillingUnitConfig{
Operation: api.BillingUnitConfigOperation(uc.Operation),
ConversionFactor: uc.ConversionFactor.String(),
DisplayUnit: uc.DisplayUnit,
Precision: uc.Precision,
}

if uc.Rounding != nil {
out.Rounding = lo.ToPtr(api.BillingUnitConfigRoundingMode(*uc.Rounding))
}

return out
}

func FromAPIBillingUnitConfig(a *api.BillingUnitConfig) (*productcatalog.UnitConfig, error) {
if a == nil {
return nil, nil
}

factor, err := decimal.NewFromString(a.ConversionFactor)
if err != nil {
return nil, fmt.Errorf("invalid unit config conversion factor: %w", err)
}

uc := &productcatalog.UnitConfig{
Operation: productcatalog.UnitConfigOperation(a.Operation),
ConversionFactor: factor,
DisplayUnit: a.DisplayUnit,
Precision: a.Precision,
}

if a.Rounding != nil {
uc.Rounding = lo.ToPtr(productcatalog.UnitConfigRoundingMode(*a.Rounding))
}

return uc, nil
}

func ToAPIBillingRateCardUnitConfig(p *productcatalog.Price) (*api.BillingUnitConfig, error) {
if p == nil {
return nil, nil
}

switch p.Type() {
case productcatalog.DynamicPriceType:
dynamic, err := p.AsDynamic()
if err != nil {
return nil, fmt.Errorf("failed to read dynamic price: %w", err)
}

return &api.BillingUnitConfig{
Operation: api.BillingUnitConfigOperationMultiply,
ConversionFactor: dynamic.Multiplier.String(),
}, nil

case productcatalog.PackagePriceType:
pkg, err := p.AsPackage()
if err != nil {
return nil, fmt.Errorf("failed to read package price: %w", err)
}

return &api.BillingUnitConfig{
Operation: api.BillingUnitConfigOperationDivide,
ConversionFactor: pkg.QuantityPerPackage.String(),
Rounding: lo.ToPtr(api.BillingUnitConfigRoundingModeCeiling),
}, nil

default:
return nil, nil
}
}

func ToAPIBillingPrice(p *productcatalog.Price) (api.BillingPrice, error) {
var result api.BillingPrice

Expand Down Expand Up @@ -236,10 +302,29 @@ func ToAPIBillingPrice(p *productcatalog.Price) (api.BillingPrice, error) {
}

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

case productcatalog.PackagePriceType:
return result, models.NewGenericConflictError(fmt.Errorf("package price is not supported in v3 API"))
// Package prices are surfaced in v3 as a unit price; the package size
// is carried separately on the rate card's unit config.
pkg, err := p.AsPackage()
if err != nil {
return result, fmt.Errorf("failed to read package price: %w", err)
}

if err = result.FromBillingPriceUnit(api.BillingPriceUnit{
Amount: pkg.Amount.String(),
Type: api.BillingPriceUnitType("unit"),
}); err != nil {
return result, fmt.Errorf("failed to set unit price for package price: %w", err)
}

default:
return result, fmt.Errorf("unknown price type: %s", p.Type())
Expand Down Expand Up @@ -485,6 +570,11 @@ func FromAPIBillingPlanPhase(p api.BillingPlanPhase) (productcatalog.Phase, erro
}

func FromAPIBillingRateCard(rc api.BillingRateCard) (productcatalog.RateCard, error) {
unitConfig, err := FromAPIBillingUnitConfig(rc.UnitConfig)
if err != nil {
return nil, fmt.Errorf("failed to convert unit config: %w", err)
}

priceType, err := rc.Price.Discriminator()
if err != nil {
return nil, fmt.Errorf("failed to read price type: %w", err)
Expand All @@ -500,6 +590,7 @@ func FromAPIBillingRateCard(rc api.BillingRateCard) (productcatalog.RateCard, er
Name: rc.Name,
Description: rc.Description,
Metadata: labelMeta,
UnitConfig: unitConfig,
}

if rc.Feature != nil {
Expand Down
Loading