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

Commit

Permalink
control: return pseudo phases if no schedule (#226)
Browse files Browse the repository at this point in the history
This prevents lookupPhases from passing empty schedule IDs to Stripe,
which causes an API error that bubbles up to Tier clients.

The lookupPhases method will now return pseudo Phases in place of
schedule phases if no schedule exists for a subscription at the time of
calling. If a trialing, the first phase will be the trial phase. If a
cancel is scheduled, a final "cancel" phase will be appended after the
paid phase, if any.

This allows callers to think in Phases/Schedules only and not
worry about what a subscription is as well.

Fixes #221
  • Loading branch information
bmizerany committed Jan 30, 2023
1 parent 592b1a5 commit 1b05cfd
Show file tree
Hide file tree
Showing 6 changed files with 213 additions and 14 deletions.
3 changes: 3 additions & 0 deletions control/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ var (
)

func FeaturePlans(fs []Feature) []refs.FeaturePlan {
if fs == nil {
return nil // preserve nil
}
ns := make([]refs.FeaturePlan, len(fs))
for i, f := range fs {
ns[i] = f.FeaturePlan
Expand Down
57 changes: 48 additions & 9 deletions control/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ type subscription struct {
ScheduleID string
Status string
Name string
Effective time.Time
TrialEnd time.Time
EndDate time.Time
Features []Feature
}

Expand All @@ -107,8 +110,11 @@ func (c *Client) lookupSubscription(ctx context.Context, org, name string) (sub

type T struct {
stripe.ID
Status string
Items struct {
Status string
StartDate int64 `json:"start_date"`
CancelAt int64 `json:"cancel_at"`
TrialEnd int64 `json:"trial_end"`
Items struct {
Data []struct {
ID string
Price stripePrice
Expand Down Expand Up @@ -153,9 +159,16 @@ func (c *Client) lookupSubscription(ctx context.Context, org, name string) (sub
s := subscription{
ID: v.ProviderID(),
ScheduleID: v.Schedule.ID,
Effective: time.Unix(v.StartDate, 0),
Status: v.Status,
Features: fs,
}
if v.TrialEnd > 0 {
s.TrialEnd = time.Unix(v.TrialEnd, 0)
}
if v.CancelAt > 0 {
s.EndDate = time.Unix(v.CancelAt, 0)
}
return s, nil
}

Expand Down Expand Up @@ -199,9 +212,35 @@ func (c *Client) createSchedule(ctx context.Context, org, name string, fromSub s
}
}

func (c *Client) lookupPhases(ctx context.Context, org, schedID, name string) (current Phase, all []Phase, err error) {
func (c *Client) lookupPhases(ctx context.Context, org string, s subscription, name string) (current Phase, all []Phase, err error) {
defer errorfmt.Handlef("lookupPhases: %w", &err)

if s.ScheduleID == "" {
ps := []Phase{{
Org: org,
Effective: s.Effective,
Features: FeaturePlans(s.Features),
Current: true,
Trial: !s.TrialEnd.IsZero(),
}}
if !s.TrialEnd.IsZero() {
ps = append(ps, Phase{
Org: org,
Effective: s.TrialEnd,
Features: FeaturePlans(s.Features),
Current: false,
Trial: false,
})
}
if !s.EndDate.IsZero() {
ps = append(ps, Phase{
Org: org,
Effective: s.EndDate,
})
}
return ps[0], ps, nil
}

type T struct {
stripe.ID
Current struct {
Expand All @@ -221,11 +260,11 @@ func (c *Client) lookupPhases(ctx context.Context, org, schedID, name string) (c

g, ctx := errgroup.WithContext(ctx)

var s T
var ss T
g.Go(func() error {
var f stripe.Form
f.Add("expand[]", "phases.items.price")
return c.Stripe.Do(ctx, "GET", "/v1/subscription_schedules/"+schedID, f, &s)
return c.Stripe.Do(ctx, "GET", "/v1/subscription_schedules/"+s.ScheduleID, f, &ss)
})

var m []refs.FeaturePlan
Expand All @@ -246,7 +285,7 @@ func (c *Client) lookupPhases(ctx context.Context, org, schedID, name string) (c
return Phase{}, nil, err
}

for _, p := range s.Phases {
for _, p := range ss.Phases {
fs := make([]refs.FeaturePlan, 0, len(p.Items))
for _, pi := range p.Items {
fs = append(fs, featureByProviderID[pi.Price.ProviderID()])
Expand All @@ -269,7 +308,7 @@ func (c *Client) lookupPhases(ctx context.Context, org, schedID, name string) (c
Org: org,
Effective: time.Unix(p.Start, 0),
Features: fs,
Current: p.Start == s.Current.Start,
Current: p.Start == ss.Current.Start,

Plans: plans,
}
Expand Down Expand Up @@ -470,7 +509,7 @@ func (c *Client) schedule(ctx context.Context, org string, phases []Phase) (err
// We have a subscription, but it is has no active schedule, so start a new one.
return c.createSchedule(ctx, org, defaultScheduleName, s.ID, phases)
} else {
cp, _, err := c.lookupPhases(ctx, org, s.ScheduleID, defaultScheduleName)
cp, _, err := c.lookupPhases(ctx, org, s, defaultScheduleName)
if err != nil {
return err
}
Expand Down Expand Up @@ -591,7 +630,7 @@ func (c *Client) LookupPhases(ctx context.Context, org string) (ps []Phase, err
if err != nil {
return nil, err
}
_, all, err := c.lookupPhases(ctx, org, s.ScheduleID, defaultScheduleName)
_, all, err := c.lookupPhases(ctx, org, s, defaultScheduleName)
return all, err
}

Expand Down
159 changes: 159 additions & 0 deletions control/schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,22 @@ import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"regexp"
"testing"
"time"

"github.com/kr/pretty"
"github.com/tailscale/hujson"
"golang.org/x/exp/slices"
"golang.org/x/sync/errgroup"
"kr.dev/diff"
"kr.dev/errorfmt"
"tier.run/refs"
"tier.run/stripe"
"tier.run/stripe/stroke"
)

Expand Down Expand Up @@ -689,6 +695,141 @@ func TestLookupPhases(t *testing.T) {
diff.Test(t, t.Errorf, got, want, ignoreProviderIDs)
}

func TestLookupPhasesNoSchedule(t *testing.T) {
// TODO(bmizerany): This tests assumptions, but we need an integration
// test provin fields "like" trial actually fall off / go to zero when
// the trial is over. This needs a test clock with stripe.
newHandler := func(s string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case wants(r, "GET", "/v1/customers"):
writeHuJSON(w, `
{"data": [
{
"metadata": {"tier.org": "org:test"},
"id": "cus_test",
},
]}
`)
case wants(r, "GET", "/v1/subscriptions"):
writeHuJSON(w, `
{"data": [
{
%s
"metadata": {
"tier.subscription": "default",
},
"items": {"data": [{"price": {
"metadata": {"tier.feature": "feature:x@0"},
}}]},
},
]}
`, s)
default:
panic(fmt.Errorf("unknown request: %s %s", r.Method, r.URL.Path))
}
})
}

fs := []refs.FeaturePlan{
mpf("feature:x@0"),
}

cases := []struct {
s string
want []Phase
}{
{
s: `
"start_date": 123123123,
`,
want: []Phase{{
Org: "org:test",
Effective: time.Unix(123123123, 0),
Current: true,
Trial: false,
Features: fs,
}},
},
{
s: `
"start_date": 123123123,
"cancel_at": 223123123,
`,
want: []Phase{{
Org: "org:test",
Effective: time.Unix(123123123, 0),
Current: true,
Features: fs,
}, {
Org: "org:test",
Effective: time.Unix(223123123, 0),
}},
},
{
s: `
"start_date": 100000000,
"trial_end": 200000000,
"cancel_at": 300000000,
`,
want: []Phase{{
Org: "org:test",
Effective: time.Unix(100000000, 0),
Current: true,
Trial: true,
Features: fs,
}, {
Org: "org:test",
Effective: time.Unix(200000000, 0),
Features: fs,
}, {
Org: "org:test",
Effective: time.Unix(300000000, 0),
Features: nil, // cancel plan
}},
},
{
s: `
"start_date": 100000000,
"trial_end": 200000000,
`,
want: []Phase{{
Org: "org:test",
Effective: time.Unix(100000000, 0),
Current: true,
Trial: true,
Features: fs,
}, {
Org: "org:test",
Effective: time.Unix(200000000, 0),
Features: fs,
}},
},
}

for _, tt := range cases {
t.Run("", func(t *testing.T) {
s := httptest.NewServer(newHandler(tt.s))
t.Cleanup(s.Close)

cc := &Client{
Logf: t.Logf,
Stripe: &stripe.Client{
BaseURL: s.URL,
},
}

ctx := context.Background()
got, err := cc.LookupPhases(ctx, "org:test")
if err != nil {
t.Fatal(err)
}

diff.Test(t, t.Errorf, got, tt.want)
})
}
}

func TestReportUsage(t *testing.T) {
fs := []Feature{
{
Expand Down Expand Up @@ -934,3 +1075,21 @@ func plans(ss ...string) []refs.Plan {
}
return ps
}

func wants(r *http.Request, method, pattern string) bool {
pattern = "^" + pattern + "$"
rx := regexp.MustCompile(pattern)
return r.Method == method && rx.MatchString(r.URL.Path)
}

func writeHuJSON(w io.Writer, s string, args ...any) {
s = fmt.Sprintf(s, args...)
b, err := hujson.Standardize([]byte(s))
if err != nil {
panic(err)
}
_, err = w.Write(b)
if err != nil {
panic(err)
}
}
2 changes: 0 additions & 2 deletions control/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,6 @@ func (c *Client) ReportUsage(ctx context.Context, org string, feature refs.Name,
f.Set("action", "increment")
}

// TODO(bmizerany): take idempotency key from context or use random
// string. if in context then upstream client supplied their own.
f.SetIdempotencyKey(randomString())

ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ go 1.19
require (
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da
github.com/kr/pretty v0.3.0
github.com/tailscale/hujson v0.0.0-20220630195928-54599719472f
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a
golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,8 @@ github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/tailscale/hujson v0.0.0-20220630195928-54599719472f h1:n4r/sJ92cBSBHK8n9lR1XLFr0OiTVeGfN5TR+9LaN7E=
github.com/tailscale/hujson v0.0.0-20220630195928-54599719472f/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
Expand Down

0 comments on commit 1b05cfd

Please sign in to comment.