diff --git a/api/api.go b/api/api.go index 3f984c1..f999b5e 100644 --- a/api/api.go +++ b/api/api.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "strings" + "time" "github.com/kr/pretty" "golang.org/x/exp/slices" @@ -327,13 +328,19 @@ func (h *Handler) servePhase(w http.ResponseWriter, r *http.Request) error { h.Logf("lookup phases: %# v", pretty.Formatter(ps)) - for _, p := range ps { + for i, p := range ps { if p.Current { + var end time.Time + if i+1 < len(ps) { + end = ps[i+1].Effective + } return httpJSON(w, apitypes.PhaseResponse{ Effective: p.Effective, + End: end, Features: p.Features, Plans: p.Plans, Fragments: p.Fragments(), + Trial: p.Trial, }) } } diff --git a/api/api_test.go b/api/api_test.go index da2a29b..f8c8a6c 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -306,6 +306,87 @@ func TestPhaseBadOrg(t *testing.T) { }) } +func TestPhase(t *testing.T) { + t.Parallel() + + now := time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC) + + ctx := context.Background() + tc := newTestClient(t) + + ctx, err := tc.WithClock(ctx, t.Name(), now) + if err != nil { + t.Fatal(err) + } + + m := []byte(` + {"plans": {"plan:test@0": {"features": {"feature:x": {}}}}} + `) + + _, err = tc.PushJSON(ctx, m) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + phases []tier.Phase + want apitypes.PhaseResponse + }{ + { + phases: []tier.Phase{{ + Trial: true, + Features: []string{"plan:test@0"}, + }, { + Effective: now.AddDate(0, 0, 14), + Trial: false, + Features: []string{"plan:test@0"}, + }}, + want: apitypes.PhaseResponse{ + Effective: now, + End: now.AddDate(0, 0, 14), + Features: mpfs("feature:x@plan:test@0"), + Plans: mpps("plan:test@0"), + Trial: true, + }, + }, + { + phases: []tier.Phase{{ + Features: []string{"plan:test@0"}, + }}, + want: apitypes.PhaseResponse{ + Effective: now, + Features: mpfs("feature:x@plan:test@0"), + Plans: mpps("plan:test@0"), + }, + }, + { + phases: []tier.Phase{{ + Trial: true, + Features: []string{"plan:test@0"}, + }}, + want: apitypes.PhaseResponse{ + Trial: true, + Effective: now, + Features: mpfs("feature:x@plan:test@0"), + Plans: mpps("plan:test@0"), + }, + }, + } + + for _, tt := range cases { + t.Run("", func(t *testing.T) { + if _, err := tc.Schedule(ctx, "org:test", &tier.ScheduleParams{Phases: tt.phases}); err != nil { + t.Fatal(err) + } + got, err := tc.LookupPhase(ctx, "org:test") + if err != nil { + t.Fatal(err) + } + diff.Test(t, t.Errorf, got, tt.want) + }) + } +} + func TestPhaseFragments(t *testing.T) { t.Parallel() diff --git a/api/apitypes/apitypes.go b/api/apitypes/apitypes.go index f5f650d..73df120 100644 --- a/api/apitypes/apitypes.go +++ b/api/apitypes/apitypes.go @@ -27,9 +27,11 @@ type Phase struct { 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"` } type PaymentMethodsResponse struct { diff --git a/control/schedule.go b/control/schedule.go index aab840c..834f74c 100644 --- a/control/schedule.go +++ b/control/schedule.go @@ -249,8 +249,9 @@ type stripeSubSchedule struct { Metadata struct { Name string `json:"tier.subscription"` } - Start int64 `json:"start_date"` - Items []struct { + Start int64 `json:"start_date"` + TrialEnd int64 `json:"trial_end"` + Items []struct { Price stripePrice } } @@ -317,6 +318,8 @@ func (c *Client) lookupPhases(ctx context.Context, org string, s subscription, n Current: p.Start == ss.Current.Start, Plans: plans, + + Trial: p.TrialEnd > 0, } all = append(all, p) if p.Current { diff --git a/control/schedule_test.go b/control/schedule_test.go index 0f55be7..f02050f 100644 --- a/control/schedule_test.go +++ b/control/schedule_test.go @@ -118,6 +118,51 @@ func TestSchedule(t *testing.T) { }) } +func TestScheduleTrial(t *testing.T) { + s := newScheduleTester(t) + + model := []Feature{{ + FeaturePlan: mpf("feature:x@plan:free@0"), + Interval: "@monthly", + Currency: "usd", + }} + + fps := FeaturePlans(model) + s.push(model) + + s.schedule("org:example", 14, "", fps...) + s.checkPhases("org:example", []Phase{ + { + Org: "org:example", + Current: true, + Effective: t0, // unchanged by advanced clock + Features: FeaturePlans(model), + Plans: plans("plan:free@0"), + Trial: true, + }, + { + Org: "org:example", + Current: false, + Effective: t0.AddDate(0, 0, 14), // unchanged by advanced clock + Features: FeaturePlans(model), + Plans: plans("plan:free@0"), + Trial: false, + }, + }) + + s.schedule("org:example", -1, "", fps...) + s.checkPhases("org:example", []Phase{ + { + Org: "org:example", + Current: true, + Effective: t0, // unchanged by advanced clock + Features: FeaturePlans(model), + Plans: plans("plan:free@0"), + Trial: true, + }, + }) +} + type scheduleTester struct { ctx context.Context t *testing.T @@ -203,7 +248,7 @@ func (s *scheduleTester) schedule(org string, trialDays int, payment string, fs Trial: true, Features: fs, }, { - Effective: t0.AddDate(0, 1, 0), + Effective: t0.AddDate(0, 0, trialDays), Features: fs, }} } @@ -216,6 +261,16 @@ func (s *scheduleTester) schedule(org string, trialDays int, payment string, fs } } +func (s *scheduleTester) checkPhases(org string, want []Phase) { + s.t.Helper() + got, err := s.cc.LookupPhases(s.ctx, org) + if err != nil { + s.t.Fatal(err) + } + s.t.Logf("got phases %# v", pretty.Formatter(got)) + diff.Test(s.t, s.t.Errorf, got, want, ignoreProviderIDs) +} + func (s *scheduleTester) report(org, name string, n int) { s.t.Helper() if err := s.cc.ReportUsage(s.ctx, org, mpn(name), Report{ @@ -315,7 +370,15 @@ func TestScheduleFreeTrials(t *testing.T) { s.checkInvoices("org:trial", []Invoice{{ Lines: []InvoiceLineItem{ - lineItem(featureX, 2, 0), + lineItem(featureX, 1, 1), + }, + SubtotalPreTax: 1, + Subtotal: 1, + TotalPreTax: 1, + Total: 1, + }, { + Lines: []InvoiceLineItem{ + lineItem(featureX, 1, 0), }, }, { Lines: []InvoiceLineItem{