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

Commit

Permalink
api: breakout checkout into /v1/checkout
Browse files Browse the repository at this point in the history
  • Loading branch information
bmizerany committed Jan 20, 2023
1 parent ad87ba9 commit ad71d6b
Show file tree
Hide file tree
Showing 6 changed files with 117 additions and 111 deletions.
58 changes: 36 additions & 22 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"tier.run/api/apitypes"
"tier.run/api/materialize"
"tier.run/control"
"tier.run/refs"
"tier.run/stripe"
"tier.run/trweb"
"tier.run/values"
Expand Down Expand Up @@ -154,6 +155,8 @@ func (h *Handler) serve(w http.ResponseWriter, r *http.Request) error {
return h.serveReport(w, r)
case "/v1/subscribe":
return h.serveSubscribe(w, r)
case "/v1/checkout":
return h.serveCheckout(w, r)
case "/v1/phase":
return h.servePhase(w, r)
case "/v1/pull":
Expand All @@ -165,11 +168,44 @@ func (h *Handler) serve(w http.ResponseWriter, r *http.Request) error {
}
}

func (h *Handler) serveCheckout(w http.ResponseWriter, r *http.Request) error {
var cr apitypes.CheckoutRequest
if err := trweb.DecodeStrict(r, &cr); err != nil {
return err
}
fs, err := refs.ParseFeaturePlans(cr.Features...)
if err != nil {
return err
}

h.Logf("checkout: %# v", pretty.Formatter(cr))

link, err := h.c.Checkout(r.Context(), cr.Org, cr.SuccessURL, &control.CheckoutParams{
TrialDays: cr.TrialDays,
Features: fs,
CancelURL: cr.CancelURL,
})
if err != nil {
return err
}
return httpJSON(w, &apitypes.CheckoutResponse{URL: link})
}

func (h *Handler) serveSubscribe(w http.ResponseWriter, r *http.Request) error {
var sr apitypes.ScheduleRequest
if err := trweb.DecodeStrict(r, &sr); err != nil {
return err
}
if sr.Info != nil {
info := infoToOrgInfo(sr.Info)
if err := h.c.PutCustomer(r.Context(), sr.Org, info); err != nil {
return err
}
}
if len(sr.Phases) == 0 {
return nil
}

var phases []control.Phase
if len(sr.Phases) > 0 {
m, err := h.c.Pull(r.Context(), 0)
Expand All @@ -189,28 +225,6 @@ func (h *Handler) serveSubscribe(w http.ResponseWriter, r *http.Request) error {
})
}
}
if sr.Info != nil {
info := infoToOrgInfo(sr.Info)
if err := h.c.PutCustomer(r.Context(), sr.Org, info); err != nil {
return err
}
}

if sr.Checkout != nil {
cp := (*control.CheckoutParams)(sr.Checkout)
link, err := h.c.Checkout(r.Context(), sr.Org, phases, cp)
if err != nil {
return err
}
return httpJSON(w, &apitypes.ScheduleResponse{
CheckoutURL: link,
})
}

if len(phases) == 0 {
return nil
}

return h.c.Schedule(r.Context(), sr.Org, phases)
}

Expand Down
22 changes: 7 additions & 15 deletions api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,33 +52,25 @@ func TestAPICheckout(t *testing.T) {
}}
cc.Push(ctx, m, pushLogger(t))

cp := &apitypes.CheckoutParams{SuccessURL: "https://example.com/success"}

t.Run("card setup", func(t *testing.T) {
r, err := tc.Schedule(ctx, "org:test", &tier.ScheduleParams{
Checkout: cp,
// No phases == card setup.
})
r, err := tc.Checkout(ctx, "org:test", "https://example.com/success", nil)
if err != nil {
t.Fatal(err)
}
t.Logf("checkout: %s", r.CheckoutURL)
if r.CheckoutURL == "" {
t.Logf("checkout: %s", r.URL)
if r.URL == "" {
t.Error("unexpected empty checkout url")
}
})
t.Run("subscription", func(t *testing.T) {
r, err := tc.Schedule(ctx, "org:test", &tier.ScheduleParams{
Checkout: cp,
Phases: []tier.Phase{{
Features: []string{"feature:x@plan:test@0"},
}},
r, err := tc.Checkout(ctx, "org:test", "https://example.com/success", &tier.CheckoutParams{
Features: []string{"feature:x@plan:test@0"},
})
if err != nil {
t.Fatal(err)
}
t.Logf("checkout: %s", r.CheckoutURL)
if r.CheckoutURL == "" {
t.Logf("checkout: %s", r.URL)
if r.URL == "" {
t.Error("unexpected empty checkout url")
}
})
Expand Down
19 changes: 12 additions & 7 deletions api/apitypes/apitypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,26 @@ type OrgInfo struct {
InvoiceSettings InvoiceSettings `json:"invoice_settings"`
}

type CheckoutParams struct {
SuccessURL string `json:"success_url"`
CancelURL string `json:"cancel_url"`
type CheckoutRequest struct {
Org string
TrialDays int
Features []string
SuccessURL string
CancelURL string

This comment has been minimized.

Copy link
@isaacs

isaacs Jan 20, 2023

Contributor

Needs json:"cancel_url"?

This comment has been minimized.

Copy link
@isaacs

isaacs Jan 20, 2023

Contributor

(et al)

}

type ScheduleRequest struct {
Org string
Info *OrgInfo
Phases []Phase

Checkout *CheckoutParams
}

type ScheduleResponse struct {
CheckoutURL string `json:"checkout_url,omitempty"`
// ScheduleResponse is the expected response from a schedule request. It is
// currently empty, reserved for furture use.
type ScheduleResponse struct{}

type CheckoutResponse struct {
URL string
}

type ReportRequest struct {
Expand Down
44 changes: 28 additions & 16 deletions client/tier/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,33 +238,45 @@ func (c *Client) Subscribe(ctx context.Context, org string, featuresAndPlans ...
return err
}

// Checkout creates a new checkout link for the provided org and features, if
// any; otherwise, if no features are specified, and payment setup link is
// returned instead.
func (c *Client) Checkout(ctx context.Context, org string, successURL string, p *CheckoutParams) (*apitypes.CheckoutResponse, error) {
if p == nil {
p = &CheckoutParams{}
}
r := &apitypes.CheckoutRequest{
Org: org,
SuccessURL: successURL,
CancelURL: p.CancelURL,
TrialDays: p.TrialDays,
Features: p.Features,
}
return fetch.OK[*apitypes.CheckoutResponse, *apitypes.Error](ctx, c.client(), "POST", c.baseURL("/v1/checkout"), r)
}

type Phase = apitypes.Phase
type OrgInfo = apitypes.OrgInfo
type CheckoutParams = apitypes.CheckoutParams

type CheckoutParams struct {
TrialDays int
Features []string
CancelURL string
}

type ScheduleParams struct {
Info *OrgInfo
Phases []Phase
Checkout *CheckoutParams
Info *OrgInfo
Phases []Phase
}

func (c *Client) Schedule(ctx context.Context, org string, p *ScheduleParams) (*apitypes.ScheduleResponse, error) {
return fetch.OK[*apitypes.ScheduleResponse, *apitypes.Error](ctx, c.client(), "POST", c.baseURL("/v1/subscribe"), &apitypes.ScheduleRequest{
Org: org,
Info: (*apitypes.OrgInfo)(p.Info),
Phases: copyPhases(p.Phases),
Checkout: p.Checkout,
Org: org,
Info: (*apitypes.OrgInfo)(p.Info),
Phases: p.Phases,
})
}

func copyPhases(phases []Phase) []apitypes.Phase {
c := make([]apitypes.Phase, len(phases))
for i, p := range phases {
c[i] = apitypes.Phase(p)
}
return c
}

func (c *Client) WhoAmI(ctx context.Context) (apitypes.WhoAmIResponse, error) {
return fetch.OK[apitypes.WhoAmIResponse, *apitypes.Error](ctx, c.client(), "GET", c.baseURL("/v1/whoami"), nil)
}
50 changes: 25 additions & 25 deletions cmd/tier/tier.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,25 +283,38 @@ func runTier(cmd string, args []string) (err error) {
return errUsage
}
org := fs.Arg(0)
p := &tier.ScheduleParams{
Info: &tier.OrgInfo{
Email: *email,
},
}

// the cancel must be used without arguments
if *cancel && fs.NArg() > 1 {
fmt.Fprintln(stderr, "tier: the -cancel flag must be used without arguments")
return errUsage
}

if *cancel {
p.Phases = []tier.Phase{{}}
}

var refs []string
if fs.NArg() > 1 {
refs = fs.Args()[1:]
}

vlogf("subscribing %s to %v", org, refs)

useCheckout := *successURL != ""
if useCheckout {
cr, err := tc().Checkout(ctx, org, *successURL, &tier.CheckoutParams{
TrialDays: *trial,
Features: refs,
CancelURL: *cancelURL,
})
if err != nil {
return err
}
fmt.Fprintln(stdout, cr.URL)
return nil
} else {
p := &tier.ScheduleParams{
Info: &tier.OrgInfo{
Email: *email,
},
}
switch {
case *trial > 0:
p.Phases = []tier.Phase{{
Expand All @@ -320,25 +333,12 @@ func runTier(cmd string, args []string) (err error) {
default:
p.Phases = []tier.Phase{{Features: refs}}
}
}

vlogf("subscribing %s to %v", org, refs)

useCheckout := *successURL != ""
if useCheckout {
p.Checkout = &apitypes.CheckoutParams{
SuccessURL: *successURL,
CancelURL: *cancelURL,
if *cancel {
p.Phases = []tier.Phase{{}}
}
}
sr, err := tc().Schedule(ctx, org, p)
if err != nil {
_, err := tc().Schedule(ctx, org, p)
return err
}
if useCheckout {
fmt.Fprintln(stdout, sr.CheckoutURL)
}
return err
case "phases":
if len(args) < 1 {
return errUsage
Expand Down
35 changes: 9 additions & 26 deletions control/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,11 +355,12 @@ func addPhases(ctx context.Context, c *Client, f *stripe.Form, update bool, name
}

type CheckoutParams struct {
SuccessURL string
CancelURL string
TrialDays int
Features []refs.FeaturePlan
CancelURL string
}

func (c *Client) Checkout(ctx context.Context, org string, phases []Phase, p *CheckoutParams) (link string, err error) {
func (c *Client) Checkout(ctx context.Context, org string, successURL string, p *CheckoutParams) (link string, err error) {
defer errorfmt.Handlef("checkout: %w", &err)

cid, err := c.putCustomer(ctx, org, nil)
Expand All @@ -377,12 +378,11 @@ func (c *Client) Checkout(ctx context.Context, org string, phases []Phase, p *Ch

var f stripe.Form
f.Set("customer", cid)
f.Set("success_url", p.SuccessURL)
f.Set("success_url", successURL)
if p.CancelURL != "" {
f.Set("cancel_url", p.CancelURL)
}

if len(phases) == 0 {
if len(p.Features) == 0 {
f.Set("mode", "setup")
// TODO: support other payment methods:
// https://stripe.com/docs/api/checkout/sessions/create#create_checkout_session-payment_method_types
Expand All @@ -391,19 +391,16 @@ func (c *Client) Checkout(ctx context.Context, org string, phases []Phase, p *Ch
} else {
f.Set("mode", "subscription")
f.Set("subscription_data", "metadata", "tier.subscription", "default")

// checkout does not support schedules, so we need to compute trial
// days based at least two phases if a trial period is desired.
if trialDays := computeTrialDays(phases); trialDays > 0 {
f.Set("subscription_data", "trial_period_days", trialDays)
if p.TrialDays > 0 {
f.Set("subscription_data", "trial_period_days", p.TrialDays)
}

m, err := c.Pull(ctx, 0)
if err != nil {
return "", err
}

names := refs.FeaturePlanNames(phases[0].Features)
names := refs.FeaturePlanNames(p.Features)
fps, err := Expand(m, names...)
if err != nil {
return "", err
Expand All @@ -430,20 +427,6 @@ func (c *Client) Checkout(ctx context.Context, org string, phases []Phase, p *Ch
}
}

func computeTrialDays(ps []Phase) int {
switch {
case len(ps) == 0:
return 0
case len(ps) == 1 && ps[0].Trial:
return 35 * 365 // a long time into the future
case len(ps) == 1 && !ps[0].Trial:
return 0
default:
d := ps[1].Effective.Sub(ps[0].Effective)
return int(d.Hours() / 24)
}
}

func (c *Client) Schedule(ctx context.Context, org string, phases []Phase) error {
if len(phases) == 0 {
return errors.New("tier: schedule: at least one phase required")
Expand Down

0 comments on commit ad71d6b

Please sign in to comment.