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

control: return pseudo phases if no schedule #226

Merged
merged 1 commit into from
Jan 30, 2023
Merged
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
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