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

Commit

Permalink
api: add /v1/phases with coupon information (#296)
Browse files Browse the repository at this point in the history
  • Loading branch information
bmizerany committed May 20, 2023
1 parent 844f3cf commit bf5b350
Show file tree
Hide file tree
Showing 6 changed files with 192 additions and 30 deletions.
47 changes: 42 additions & 5 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"strings"
"time"

"github.com/kr/pretty"
"golang.org/x/exp/slices"
"tier.run/api/apitypes"
"tier.run/api/materialize"
Expand Down Expand Up @@ -181,6 +180,8 @@ func (h *Handler) serve(w http.ResponseWriter, r *http.Request) error {
return h.serveSubscribe(w, r)
case "/v1/checkout":
return h.serveCheckout(w, r)
case "/v1/phases":
return h.servePhases(w, r)
case "/v1/phase":
return h.servePhase(w, r)
case "/v1/pull":
Expand Down Expand Up @@ -323,16 +324,51 @@ func (h *Handler) serveWhoAmI(w http.ResponseWriter, r *http.Request) error {
})
}

func (h *Handler) servePhase(w http.ResponseWriter, r *http.Request) error {
// EXPERIMENTAL (undocumented)
func (h *Handler) servePhases(w http.ResponseWriter, r *http.Request) error {
org := r.FormValue("org")
s, err := h.c.LookupPhases(r.Context(), org)
if err != nil {
return err
}

var pr apitypes.PhasesResponse
ps := s.Phases
h.Logf("lookup phases: %# v", pretty.Formatter(ps))
for i, p := range ps {
if p.Current {
pr.Current = apitypes.Period(s.Current)
}
var end time.Time
if i+1 < len(ps) {
end = ps[i+1].Effective
}
pr.Phases = append(pr.Phases, apitypes.PhaseResponse{
Effective: p.Effective,
End: end,
Features: p.Features,
Plans: p.Plans,
Fragments: p.Fragments(),
Trial: p.Trial,
Tax: apitypes.Taxation{
Automatic: p.AutomaticTax,
},
Current: apitypes.Period(s.Current),
Coupon: p.Coupon,
CouponData: (*apitypes.Coupon)(p.CouponData),
})
}

return httpJSON(w, pr)
}

func (h *Handler) servePhase(w http.ResponseWriter, r *http.Request) error {
org := r.FormValue("org")
s, err := h.c.LookupPhases(r.Context(), org)
if err != nil {
return err
}

ps := s.Phases
for i, p := range ps {
if p.Current {
var end time.Time
Expand All @@ -349,8 +385,9 @@ func (h *Handler) servePhase(w http.ResponseWriter, r *http.Request) error {
Tax: apitypes.Taxation{
Automatic: p.AutomaticTax,
},
Current: apitypes.Period(s.Current),
Coupon: p.Coupon,
Current: apitypes.Period(s.Current),
Coupon: p.Coupon,
CouponData: (*apitypes.Coupon)(p.CouponData),
})
}
}
Expand Down
8 changes: 7 additions & 1 deletion api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,9 @@ func TestPhase(t *testing.T) {
Trial: true,
Features: []string{"plan:test@0"},
Coupon: "coupon_test",
CouponData: &apitypes.Coupon{
ID: "coupon_test",
},
}, {
Effective: now.AddDate(0, 0, 14),
Trial: false,
Expand All @@ -459,6 +462,9 @@ func TestPhase(t *testing.T) {
Plans: mpps("plan:test@0"),
Trial: true,
Coupon: "coupon_test",
CouponData: &apitypes.Coupon{
ID: "coupon_test",
},
},
},
{
Expand Down Expand Up @@ -508,7 +514,7 @@ func TestPhase(t *testing.T) {
}

got.Current = apitypes.Period{}
diff.Test(t, t.Errorf, got, tt.want)
diff.Test(t, t.Errorf, got, tt.want, diff.KeepFields[apitypes.Coupon]("ID"))
})
}
}
Expand Down
52 changes: 39 additions & 13 deletions api/apitypes/apitypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,28 @@ func (e *Error) Error() string {
e.Status, e.Code, e.Message)
}

type Coupon struct {
ID string `json:"id"`
Metadata map[string]string
Created time.Time
AmountOff int `json:"amount_off,omitempty"`
Currency string `json:"currency,omitempty"`
Duration string `json:"duration,omitempty"`
DurationInMonths int `json:"duration_in_months,omitempty"`
MaxRedemptions int `json:"max_redemptions,omitempty"`
Name string `json:"name,omitempty"`
PercentOff float64 `json:"percent_off,omitempty"`
RedeemBy time.Time
TimesRedeemed int `json:"times_redeemed,omitempty"`
Valid bool `json:"valid,omitempty"`
}

type Phase struct {
Trial bool `json:"trial,omitempty"`
Effective time.Time `json:"effective,omitempty"`
Features []string `json:"features,omitempty"`
Coupon string `json:"coupon,omitempty"`
Trial bool `json:"trial,omitempty"`
Effective time.Time `json:"effective,omitempty"`
Features []string `json:"features,omitempty"`
Coupon string `json:"coupon,omitempty"`
CouponData *Coupon `json:"coupon_data,omitempty"`
}

type Taxation struct {
Expand All @@ -41,16 +58,25 @@ func (p *Period) IsZero() bool {
return p.Effective.IsZero() && p.End.IsZero()
}

type PhasesResponse struct {
Current Period `json:"current,omitempty"`
Phases []PhaseResponse `json:"phases"`
}

// The PhaseResponse is a response with all current phase fields exposed as
// top-level fields. Clients that need all phase data should use the Phases
// field.
type PhaseResponse struct {
Effective time.Time `json:"effective,omitempty"`
End time.Time `json:"end,omitempty"`
Features []refs.FeaturePlan `json:"features,omitempty"`
Plans []refs.Plan `json:"plans,omitempty"`
Fragments []refs.FeaturePlan `json:"fragments,omitempty"`
Trial bool `json:"trial,omitempty"`
Tax Taxation `json:"tax,omitempty"`
Current Period `json:"current,omitempty"`
Coupon string `json:"coupon,omitempty"`
Effective time.Time `json:"effective,omitempty"`
End time.Time `json:"end,omitempty"`
Features []refs.FeaturePlan `json:"features,omitempty"`
Plans []refs.Plan `json:"plans,omitempty"`
Fragments []refs.FeaturePlan `json:"fragments,omitempty"`
Trial bool `json:"trial,omitempty"`
Tax Taxation `json:"tax,omitempty"`
Current Period `json:"current,omitempty"` // not set on PhasesResponse
Coupon string `json:"coupon,omitempty"`
CouponData *Coupon `json:"coupon_data,omitempty"`
}

func (pr PhaseResponse) MarshalJSON() ([]byte, error) {
Expand Down
7 changes: 7 additions & 0 deletions client/tier/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,13 @@ func (c *Client) LookupPhase(ctx context.Context, org string) (apitypes.PhaseRes
return fetchOK[apitypes.PhaseResponse, *apitypes.Error](ctx, c, "GET", "/v1/phase?org="+org, nil)
}

// LookupPhases reports information about all recent, current, and future phases for org.
//
// EXPERIMENTAL: This API is subject to change.
func (c *Client) LookupPhases(ctx context.Context, org string) (apitypes.PhasesResponse, error) {
return fetchOK[apitypes.PhasesResponse, *apitypes.Error](ctx, c, "GET", "/v1/phases?org="+org, nil)
}

// LookupLimits reports the current usage and limits for the provided org.
func (c *Client) LookupLimits(ctx context.Context, org string) (apitypes.UsageResponse, error) {
return fetchOK[apitypes.UsageResponse, *apitypes.Error](ctx, c, "GET", "/v1/limits?org="+org, nil)
Expand Down
58 changes: 48 additions & 10 deletions control/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,11 @@ type Phase struct {

AutomaticTax bool

Coupon string
Coupon string // deprecated

// CouponData is the coupon that was applied to the subscription. It is
// nil if no coupon was applied.
CouponData *Coupon
}

// Valid reports if the Phase is one that would be retured from the Stripe API.
Expand Down Expand Up @@ -119,7 +123,7 @@ type subscription struct {
Features []Feature
AutomaticTax bool // TODO(bmizerany): cheange to tax.Applied
Current Period
Coupon string
Coupon *Coupon
}

func (c *Client) lookupSubscription(ctx context.Context, org, name string) (sub subscription, err error) {
Expand All @@ -132,6 +136,7 @@ func (c *Client) lookupSubscription(ctx context.Context, org, name string) (sub
var f stripe.Form
f.Set("customer", cid)
f.Add("expand[]", "data.schedule")
f.Add("expand[]", "data.discount.coupon")

type T struct {
stripe.ID
Expand All @@ -158,9 +163,7 @@ func (c *Client) lookupSubscription(ctx context.Context, org, name string) (sub
CurrentPeriodStart int64 `json:"current_period_start"`
CurrentPeriodEnd int64 `json:"current_period_end"`
Discount struct {
Coupon struct {
ID string
}
Coupon stripeCoupon
}
}

Expand Down Expand Up @@ -203,7 +206,7 @@ func (c *Client) lookupSubscription(ctx context.Context, org, name string) (sub
Effective: timeUnix(v.CurrentPeriodStart),
End: timeUnix(v.CurrentPeriodEnd),
},
Coupon: v.Discount.Coupon.ID,
Coupon: stripeCouponToCoupon(v.Discount.Coupon),
}
if v.TrialEnd > 0 {
s.TrialEnd = time.Unix(v.TrialEnd, 0)
Expand Down Expand Up @@ -293,7 +296,7 @@ type stripeSubSchedule struct {
Items []struct {
Price stripePrice
}
Coupon string
Coupon stripeCoupon
}
}

Expand Down Expand Up @@ -345,6 +348,7 @@ func (c *Client) lookupPhases(ctx context.Context, org string, s subscription, n

var ss stripeSubSchedule
var f stripe.Form
f.Add("expand[]", "phases.coupon")
f.Add("expand[]", "phases.items.price")
if err := c.Stripe.Do(ctx, "GET", "/v1/subscription_schedules/"+s.ScheduleID, f, &ss); err != nil {
return Phase{}, nil, err
Expand Down Expand Up @@ -382,7 +386,8 @@ func (c *Client) lookupPhases(ctx context.Context, org string, s subscription, n

AutomaticTax: s.AutomaticTax,

Coupon: p.Coupon,
Coupon: p.Coupon.ID,
CouponData: stripeCouponToCoupon(p.Coupon),
}
all = append(all, p)
if p.Current {
Expand Down Expand Up @@ -434,8 +439,9 @@ func subscriptionToPhases(org string, s subscription) []Phase {
}

for i := range ps {
if ps[i].Current {
ps[i].Coupon = s.Coupon
if ps[i].Current && s.Coupon != nil {
ps[i].Coupon = s.Coupon.ID
ps[i].CouponData = s.Coupon
}
}

Expand Down Expand Up @@ -768,6 +774,38 @@ func (c *Client) LookupStatus(ctx context.Context, org string) (string, error) {
return s.Status, nil
}

type Coupon struct {
ID string `json:"id"`
Metadata map[string]string
Created time.Time
AmountOff int `json:"amount_off"`
Currency string `json:"currency"`
Duration string `json:"duration"`
DurationInMonths int `json:"duration_in_months"`
MaxRedemptions int `json:"max_redemptions"`
Name string `json:"name"`
PercentOff float64 `json:"percent_off"`
RedeemBy time.Time
TimesRedeemed int `json:"times_redeemed"`
Valid bool `json:"valid"`
}

type stripeCoupon struct {
Coupon
Created int64
RedeemBy int64 `json:"redeem_by"`
}

func stripeCouponToCoupon(sc stripeCoupon) *Coupon {
if sc.ID == "" {
return nil
}
c := &sc.Coupon
c.Created = timeUnix(sc.Created)
c.RedeemBy = timeUnix(sc.RedeemBy)
return c
}

type Schedule struct {
Current Period
Phases []Phase
Expand Down
50 changes: 49 additions & 1 deletion control/schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -904,7 +904,13 @@ func TestLookupPhases(t *testing.T) {

s := newScheduleTester(t)
s.push(fs0)
s.schedule("org:example", 0, "", FeaturePlans(fs0)...)

p := ScheduleParams{
Phases: []Phase{{Features: FeaturePlans(fs0)}},
}
if err := s.cc.Schedule(s.ctx, "org:example", p); err != nil {
s.t.Fatalf("error subscribing: %v", err)
}

got, err := s.cc.LookupPhases(s.ctx, "org:example")
if err != nil {
Expand Down Expand Up @@ -1147,6 +1153,48 @@ func TestLookupPhasesNoSchedule(t *testing.T) {
Plans: []refs.Plan{mpp("plan:test@0")},
}},
},
{
s: `
"start_date": 123123123,
"discount": {
"coupon": {
"id": "coupon:test",
"created": 123123123,
"percent_off": 50,
"metadata": {"test": "meta"},
"redeem_by": 123123123,
"max_redemptions": 1,
"times_redeemed": 1,
"amount_off": 1,
"currency": "usd",
"duration": "forever",
"duration_in_months": 1,
}
},
`,
want: []Phase{{
Org: "org:test",
Effective: time.Unix(123123123, 0),
Current: true,
Trial: false,
Features: fs,
Plans: []refs.Plan{mpp("plan:test@0")},
Coupon: "coupon:test",
CouponData: &Coupon{
ID: "coupon:test",
Created: time.Unix(123123123, 0),
PercentOff: 50,
AmountOff: 1,
Currency: "usd",
RedeemBy: time.Unix(123123123, 0),
MaxRedemptions: 1,
TimesRedeemed: 1,
Metadata: map[string]string{"test": "meta"},
Duration: "forever",
DurationInMonths: 1,
},
}},
},
{
s: `
"start_date": 123123123,
Expand Down

0 comments on commit bf5b350

Please sign in to comment.