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

Commit

Permalink
control: convert single tier to metered+per_unit (#267)
Browse files Browse the repository at this point in the history
This changes how Tier interprets and converts tiers to Stripe prices.

Previously, a single tier in a pricing.json would convert to a metered
price with one or two tiers depending on if the tier had an inf limit or
not. If it did not have an inf limit, a catch-all was created to satisfy
Stripe. This produced awkward invoices.

This commit changes Tier to convert a feature that has a single tier
without a base price to be a metered+per_unit price in Stripe, which
shows up less awkwardly on invoices.

This also opens us up to support pricing transforms in Stripe for these
types of prices.
  • Loading branch information
bmizerany committed Mar 1, 2023
1 parent 10455f9 commit 53f58b0
Show file tree
Hide file tree
Showing 3 changed files with 49 additions and 24 deletions.
34 changes: 28 additions & 6 deletions control/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,11 @@ type Feature struct {

// IsMetered reports if the feature is metered.
func (f *Feature) IsMetered() bool {
// checking the mode is more reliable than checking the existence of
// tiers because not all responses from stripe containing prices
// checking the aggregate is more reliable than checking the existence
// of tiers because not all responses from stripe containing prices
// include tiers, but they all include the mode, which is empty for
// license prices.
return f.Mode != ""
return f.Aggregate != ""
}

func (f *Feature) ID() string {
Expand Down Expand Up @@ -276,11 +276,24 @@ 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 len(f.Tiers) == 0 {
numTiers := len(f.Tiers)
switch {
case numTiers == 0:
data.Set("recurring", "usage_type", "licensed")
data.Set("billing_scheme", "per_unit")
data.Set("unit_amount_decimal", f.Base)
} else {
case numTiers == 1 && f.Tiers[0].Base == 0:
t := f.Tiers[0]
data.Set("recurring", "usage_type", "metered")
data.Set("billing_scheme", "per_unit")
aggregate := aggregateToStripe[f.Aggregate]
if aggregate == "" {
return "", fmt.Errorf("unknown aggregate: %q", f.Aggregate)
}
data.Set("recurring", "aggregate_usage", aggregate)
data.Set("unit_amount_decimal", t.Price)
data.Set("metadata", "tier.limit", t.Upto)
default:
data.Set("recurring", "usage_type", "metered")
data.Set("billing_scheme", "tiered")
data.Set("tiers_mode", f.Mode)
Expand Down Expand Up @@ -357,8 +370,17 @@ func stripePriceToFeature(p stripePrice) Feature {
Interval: intervalFromStripe[p.Recurring.Interval],
Mode: p.TiersMode,
Aggregate: aggregateFromStripe[p.Recurring.AggregateUsage],
Base: p.UnitAmount,
}

if len(p.Tiers) == 0 && p.Recurring.UsageType == "metered" {
f.Tiers = append(f.Tiers, Tier{
Upto: parseLimit(p.Metadata.Limit),
Price: p.UnitAmount,
})
} else {
f.Base = p.UnitAmount
}

for i, t := range p.Tiers {
f.Tiers = append(f.Tiers, Tier{
Upto: t.Upto,
Expand Down
38 changes: 21 additions & 17 deletions control/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"golang.org/x/exp/slices"
"kr.dev/diff"
"tier.run/refs"
"tier.run/stripe"
"tier.run/stripe/stroke"
)

Expand All @@ -36,20 +35,20 @@ func TestRoundTrip(t *testing.T) {

want := []Feature{
{
FeaturePlan: refs.MustParseFeaturePlan("feature:decimal@fractionalBase"),
FeaturePlan: refs.MustParseFeaturePlan("feature:licensed:base:decimal@0"),
Interval: "@daily",
Currency: "eur",
Base: 0.1,
},
{
FeaturePlan: refs.MustParseFeaturePlan("feature:test@plan:free@3"),
FeaturePlan: refs.MustParseFeaturePlan("feature:licensed:base:int@0"),
Interval: "@daily",
Currency: "eur",
Title: "Test2",
Base: 1000,
},
{
FeaturePlan: refs.MustParseFeaturePlan("feature:test@plan:free@theVersion"),
FeaturePlan: refs.MustParseFeaturePlan("feature:metered:tiers:many@0"),
PlanTitle: "PlanTitle",
Interval: "@yearly",
Currency: "usd",
Expand All @@ -62,6 +61,22 @@ func TestRoundTrip(t *testing.T) {
{Upto: 3, Price: 300, Base: 3},
},
},
{
FeaturePlan: refs.MustParseFeaturePlan("feature:metered:tiers:one@0"),
PlanTitle: "PlanTitle",
Interval: "@yearly",
Currency: "usd",
Title: "FeatureTitle",

// a single tier is turned into per_unit + metered, so
// tiers_mode isn't needed or even set
Mode: "",

Aggregate: "perpetual",
Tiers: []Tier{
{Upto: 1, Price: 100, Base: 0},
},
},
}

if !slices.IsSortedFunc(want, func(a, b Feature) bool {
Expand All @@ -86,21 +101,10 @@ func TestRoundTrip(t *testing.T) {

diff.Test(t, t.Errorf, got, want,
diff.ZeroFields[Feature]("ProviderID"))

t.Run("product title", func(t *testing.T) {
var got struct {
Name string
}
if err := cc.Stripe.Do(ctx, "GET", "/v1/products/tier__feature-test-plan-free-theVersion", stripe.Form{}, &got); err != nil {
t.Fatal(err)
}
const want = "PlanTitle - FeatureTitle"
if got.Name != want {
t.Errorf("got %q, want %q", got.Name, want)
}
})
}

// TODO(bmizerany): add TestTitle

func TestPushPlanInvalidDecimal(t *testing.T) {
cc := newTestClient(t) // TODO(bmizerany): use a client without creating an account
ctx := context.Background()
Expand Down
1 change: 0 additions & 1 deletion control/schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,6 @@ func TestScheduleFreeTrials(t *testing.T) {
s.checkInvoices("org:paid", []Invoice{{
Lines: []InvoiceLineItem{
lineItem(featureX, 2, 2),
zero,
},
SubtotalPreTax: 2,
Subtotal: 2,
Expand Down

0 comments on commit 53f58b0

Please sign in to comment.