Skip to content
This repository has been archived by the owner on Jan 2, 2024. It is now read-only.

Commit

Permalink
control: support division transform (#268)
Browse files Browse the repository at this point in the history
This commit adds support for transforming the quantities of per unit
prices using division and rounding.

A new 'divide' field and object is ready for use in pricing.json, like:

  plans["plan:pro@0"].features["feature:builds"].divide.by = 100

Rounding down is the default rounding behavior. To override, use:

  plans["plan:pro@0"].features["feature:builds"].divide.rounding = "up"
  • Loading branch information
bmizerany committed Mar 2, 2023
1 parent 53f58b0 commit f366a46
Show file tree
Hide file tree
Showing 6 changed files with 111 additions and 14 deletions.
7 changes: 6 additions & 1 deletion api/apitypes/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,18 @@ func (t *Tier) UnmarshalJSON(data []byte) error {
return nil
}

type Divide struct {
By int `json:"by,omitempty"`
Rounding string `json:"rounding,omitempty"`
}

type Feature struct {
Title string `json:"title,omitempty"`
Base float64 `json:"base,omitempty"`
Mode string `json:"mode,omitempty"`
Aggregate string `json:"aggregate,omitempty"`
Tiers []Tier `json:"tiers,omitempty"`
PermLink string `json:"permLink,omitempty"`
Divide *Divide `json:"divide,omitempty"`
}

type Plan struct {
Expand Down
18 changes: 17 additions & 1 deletion api/materialize/views.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ func FromPricingHuJSON(data []byte) (fs []control.Feature, err error) {
for plan, p := range m.Plans {
for feature, f := range p.Features {
fn := feature.WithPlan(plan)

divide := values.Coalesce(f.Divide, &apitypes.Divide{})
ff := control.Feature{
FeaturePlan: fn,

Expand All @@ -52,6 +54,9 @@ func FromPricingHuJSON(data []byte) (fs []control.Feature, err error) {

Mode: values.Coalesce(f.Mode, "graduated"),
Aggregate: values.Coalesce(f.Aggregate, "sum"),

TransformDenominator: divide.By,
TransformRoundUp: divide.Rounding == "up",
}

if len(f.Tiers) > 0 {
Expand Down Expand Up @@ -99,13 +104,24 @@ func ToPricingJSON(fs []control.Feature) ([]byte, error) {
}
}

p.Features[f.FeaturePlan.Name()] = apitypes.Feature{
af := apitypes.Feature{
Title: values.ZeroIf(f.Title, f.FeaturePlan.String()),
Base: f.Base,
Mode: values.ZeroIf(f.Mode, "graduated"),
Aggregate: values.ZeroIf(f.Aggregate, "sum"),
Tiers: tiers,
}
if f.TransformDenominator != 0 {
var round string
if f.TransformRoundUp {
round = "up"
}
af.Divide = &apitypes.Divide{
By: f.TransformDenominator,
Rounding: round,
}
}
p.Features[f.FeaturePlan.Name()] = af
m.Plans[f.Plan()] = p
}
return json.MarshalIndent(m, "", " ")
Expand Down
17 changes: 17 additions & 0 deletions api/materialize/views_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ func TestPricingHuJSON(t *testing.T) {
"feature:base": {
"base": 100,
},
"feature:xform": {
"divide": {"by": 100, "rounding": "up"},
}
},
},
}
Expand Down Expand Up @@ -74,6 +77,17 @@ func TestPricingHuJSON(t *testing.T) {
{Upto: tier.Inf, Price: 50, Base: 0},
},
},
{
PlanTitle: "Just an example plan to show off features part duex",
Title: "feature:xform@plan:example@2",
FeaturePlan: refs.MustParseFeaturePlan("feature:xform@plan:example@2"),
Currency: "usd",
Interval: "@monthly",
Mode: "graduated", // defaults
Aggregate: "sum",
TransformDenominator: 100,
TransformRoundUp: true,
},
}

diff.Test(t, t.Errorf, got, want)
Expand Down Expand Up @@ -104,6 +118,9 @@ func TestPricingHuJSON(t *testing.T) {
"features": {
"feature:base": {
"base": 100,
},
"feature:xform": {
"divide": {"by": 100, "rounding": "up"},
}
}
}
Expand Down
36 changes: 27 additions & 9 deletions control/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ type Feature struct {

// ReportID is the ID for reporting usage to the billing provider.
ReportID string

TransformDenominator int // the denominator for transforming usage
TransformRoundUp bool // whether to round up transformed usage; otherwise round down
}

// TODO(bmizerany): remove FQN and replace with simply adding the version to
Expand Down Expand Up @@ -276,6 +279,15 @@ func (c *Client) pushFeature(ctx context.Context, f Feature) (providerID string,
data.Set("recurring", "interval", interval)
data.Set("recurring", "interval_count", 1) // TODO: support user-defined interval count

if f.TransformDenominator != 0 {
round := "down"
if f.TransformRoundUp {
round = "up"
}
data.Set("transform_quantity", "divide_by", f.TransformDenominator)
data.Set("transform_quantity", "round", round)
}

numTiers := len(f.Tiers)
switch {
case numTiers == 0:
Expand Down Expand Up @@ -357,19 +369,25 @@ type stripePrice struct {
PriceDecimal float64 `json:"unit_amount_decimal,string"`
Base int `json:"flat_amount"`
}
Currency string
Currency string
TransformQuantity struct {
DivideBy int `json:"divide_by"`
Round string `json:"round"`
} `json:"transform_quantity"`
}

func stripePriceToFeature(p stripePrice) Feature {
f := Feature{
ProviderID: p.ProviderID(),
PlanTitle: p.Metadata.PlanTitle,
FeaturePlan: p.Metadata.Feature,
Title: p.Metadata.Title,
Currency: p.Currency,
Interval: intervalFromStripe[p.Recurring.Interval],
Mode: p.TiersMode,
Aggregate: aggregateFromStripe[p.Recurring.AggregateUsage],
ProviderID: p.ProviderID(),
PlanTitle: p.Metadata.PlanTitle,
FeaturePlan: p.Metadata.Feature,
Title: p.Metadata.Title,
Currency: p.Currency,
Interval: intervalFromStripe[p.Recurring.Interval],
Mode: p.TiersMode,
Aggregate: aggregateFromStripe[p.Recurring.AggregateUsage],
TransformDenominator: p.TransformQuantity.DivideBy,
TransformRoundUp: p.TransformQuantity.Round == "up",
}

if len(p.Tiers) == 0 && p.Recurring.UsageType == "metered" {
Expand Down
3 changes: 3 additions & 0 deletions control/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ func TestRoundTrip(t *testing.T) {
// tiers_mode isn't needed or even set
Mode: "",

TransformDenominator: 100,
TransformRoundUp: true,

Aggregate: "perpetual",
Tiers: []Tier{
{Upto: 1, Price: 100, Base: 0},
Expand Down
44 changes: 41 additions & 3 deletions control/schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,12 @@ func (s *scheduleTester) advanceTo(t time.Time) {
}
}

func (s *scheduleTester) advanceToNextPeriod() {
func (s *scheduleTester) advanceToNextPeriod(numPeriods int) {
// TODO(bmizerany): make Phase aware so that it jumps based on the
// start of the next phase if the current phase ends sooner than than 1
// interval of the current phase.
now := s.clock.Present()
eop := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, time.UTC)
eop := time.Date(now.Year(), now.Month()+time.Month(numPeriods), 1, 0, 0, 0, 0, time.UTC)
s.t.Logf("advancing to next period %s", eop)
s.advanceTo(eop)
}
Expand Down Expand Up @@ -398,7 +398,7 @@ func TestScheduleCancel(t *testing.T) {
s.report("org:paid", "feature:x", 99)
s.advance(10)
s.cancel("org:paid")
s.advanceToNextPeriod()
s.advanceToNextPeriod(1)

// check usage is billed
s.checkInvoices("org:paid", []Invoice{{
Expand Down Expand Up @@ -427,6 +427,44 @@ func TestScheduleCancel(t *testing.T) {
}})
}

func TestScheduleTransforms(t *testing.T) {
featureUp := mpf("feature:up@0")

s := newScheduleTester(t)
s.push([]Feature{{
FeaturePlan: featureUp,
Interval: "@monthly",
Currency: "usd",
Mode: "graduated",
Aggregate: "sum",
Tiers: []Tier{{Upto: Inf, Price: 2}},
TransformDenominator: 3,
TransformRoundUp: true,
}})

s.setPaymentMethod("org:paid", "pm_card_us")
s.schedule("org:paid", 0, "", featureUp)
s.report("org:paid", "feature:up", 100)
s.advanceToNextPeriod(2)

const wantAmount = 68 // 100 / 3 = 33.3333, rounded up to 34, 34 * 2 = 68

// check usage is billed
s.checkInvoices("org:paid", []Invoice{{
Lines: []InvoiceLineItem{
{Feature: featureUp, Quantity: 100, Amount: wantAmount}, // 100 / 3 = 33.3333, rounded up to 34, 34 * 2 = 68 },
},
SubtotalPreTax: wantAmount,
Subtotal: wantAmount,
TotalPreTax: wantAmount,
Total: wantAmount,
}, {
Lines: []InvoiceLineItem{
{Feature: featureUp},
},
}})
}

func TestScheduleCancelNothing(t *testing.T) {
s := newScheduleTester(t)
s.cancel("org:paid")
Expand Down

0 comments on commit f366a46

Please sign in to comment.