ducky/subscriptions

Paddy 2015-09-30 Parent:0ae1ff0ee306 Child:b063bc0a6e84

15:aab6ba5ae392 Go to Latest

ducky/subscriptions/api/subscription_handlers.go

Log Postgres test failures more verbosely, fix SubscriptionChange.IsEmpty. SubscriptionChange.IsEmpty() would return false even if no actual database operations are going to be performed. This is because we allow information we _don't_ store in the database (Stripe source, Stripe email) to be specified in a SubscriptionChange object, just so we can easily access them. Then we use the Stripe API to store them in Stripe's databases, and turn them into data _we_ store in our database. Think of them as pre-processed values that are never stored raw. The problem is, we were treating these properties the same as the properties we actually stored in the database, and (worse) were running database tests for combinations of these properties, which was causing test failures because we were trying to update no columns in the database. Whoops. I removed these properties from the IsEmpty helper, and removed them from the code that generates the SubscriptionChange permutations for testing. This allows tests to pass, but also stays closer to what the system was designed to do. In tracking down this bug, I discovered that the logging we had for errors when running Postgres tests was inadequate, so I updated the logs when that failure occurs while testing Postgres to help surface future failures faster.

History
1 package api
3 import (
4 "net/http"
6 "code.secondbit.org/api.hg"
7 "code.secondbit.org/auth.hg"
8 "code.secondbit.org/trout.hg"
9 "code.secondbit.org/uuid.hg"
11 "code.secondbit.org/ducky/subscriptions.hg"
13 "golang.org/x/net/context"
14 )
16 var (
17 ScopeSubscription = auth.Scope{ID: "subscriptions", Name: "Manage Subscriptions", Description: "Read and update your subscription information."}
18 ScopeSubscriptionAdmin = auth.Scope{ID: "subscriptions_admin", Name: "Administer Subscriptions", Description: "Read and update subscription information, bypassing ACL."}
19 )
21 // changingSystemProperties takes a SubscriptionChange and returns a
22 // slice of JSON pointers to the properties that are changing but are
23 // also designated as "system properties" in the API. If no properties
24 // meet these criteria, an empty slice is returned.
25 func changingSystemProperties(change subscriptions.SubscriptionChange) []string {
26 var changes []string
27 if change.StripeSubscription != nil {
28 changes = append(changes, "/stripe_subscription")
29 }
30 if change.Status != nil {
31 changes = append(changes, "/status")
32 }
33 if change.TrialStart != nil {
34 changes = append(changes, "/trial_start")
35 }
36 if change.TrialEnd != nil {
37 changes = append(changes, "/trial_end")
38 }
39 if change.PeriodStart != nil {
40 changes = append(changes, "/period_start")
41 }
42 if change.PeriodEnd != nil {
43 changes = append(changes, "/period_end")
44 }
45 if change.CanceledAt != nil {
46 changes = append(changes, "/canceled_at")
47 }
48 if change.FailedChargeAttempts != nil {
49 changes = append(changes, "/failed_charge_attempts")
50 }
51 if change.LastFailedCharge != nil {
52 changes = append(changes, "/last_failed_charge")
53 }
54 if change.LastNotified != nil {
55 changes = append(changes, "/last_notified")
56 }
57 return changes
58 }
60 func HandleSubscriptions(router *trout.Router, c context.Context) {
61 router.Endpoint("/subscriptions").Methods("POST", "OPTIONS").Handler(
62 api.CORSMiddleware(api.NegotiateMiddleware(api.ContextWrapper(c, CreateSubscriptionHandler))))
63 router.Endpoint("/subscriptions/{id}").Methods("GET", "OPTIONS").Handler(
64 api.CORSMiddleware(api.NegotiateMiddleware(api.ContextWrapper(c, GetSubscriptionHandler))))
65 router.Endpoint("/subscriptions/{id}").Methods("PATCH", "OPTIONS").Handler(
66 api.CORSMiddleware(api.NegotiateMiddleware(api.ContextWrapper(c, PatchSubscriptionHandler))))
67 }
69 func CreateSubscriptionHandler(w http.ResponseWriter, r *http.Request, c context.Context) {
70 store, err := getSubscriptionStore(c)
71 if err != nil {
72 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
73 return
74 }
75 stripe, err := getStripeClient(c)
76 if err != nil {
77 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
78 return
79 }
80 if !api.CheckScopes(r, ScopeSubscriptionAdmin.ID) {
81 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: []api.RequestError{{Slug: api.RequestErrAccessDenied}}})
82 return
83 }
84 var req subscriptions.SubscriptionChange
85 err = api.Decode(r, &req)
86 if err != nil {
87 api.Encode(w, r, http.StatusBadRequest, Response{Errors: api.InvalidFormatError})
88 return
89 }
90 // BUG(paddy): Need to validate the request when creating a subscription
91 sub, err := subscriptions.New(req, stripe, store)
92 if err != nil {
93 var rErr api.RequestError
94 var code int
95 switch err {
96 case subscriptions.ErrSubscriptionAlreadyExists:
97 rErr = api.RequestError{Slug: api.RequestErrConflict, Field: "/user_id"}
98 code = http.StatusBadRequest
99 case subscriptions.ErrStripeSubscriptionAlreadyExists:
100 rErr = api.RequestError{Slug: api.RequestErrConflict, Field: "/stripe_token"}
101 code = http.StatusBadRequest
102 default:
103 rErr = api.RequestError{Slug: api.RequestErrActOfGod}
104 code = http.StatusInternalServerError
105 }
106 api.Encode(w, r, code, Response{Errors: []api.RequestError{rErr}})
107 return
108 }
109 resp := Response{Subscriptions: []subscriptions.Subscription{sub}}
110 api.Encode(w, r, http.StatusCreated, resp)
111 }
113 func GetSubscriptionHandler(w http.ResponseWriter, r *http.Request, c context.Context) {
114 store, err := getSubscriptionStore(c)
115 if err != nil {
116 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
117 return
118 }
119 vars := trout.RequestVars(r)
120 rawID := vars.Get("id")
121 if rawID == "" {
122 api.Encode(w, r, http.StatusBadRequest, Response{Errors: []api.RequestError{{Slug: api.RequestErrMissing, Param: "id"}}})
123 return
124 }
125 id, err := uuid.Parse(rawID)
126 if err != nil {
127 api.Encode(w, r, http.StatusBadRequest, Response{Errors: []api.RequestError{{Slug: api.RequestErrInvalidFormat, Param: "id"}}})
128 return
129 }
130 if !api.CheckScopes(r, ScopeSubscription.ID) {
131 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: []api.RequestError{{Slug: api.RequestErrAccessDenied}}})
132 return
133 }
134 userID, err := api.AuthUser(r)
135 if err != nil {
136 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: []api.RequestError{{Slug: api.RequestErrAccessDenied}}})
137 return
138 }
139 if !id.Equal(userID) && !api.CheckScopes(r, ScopeSubscriptionAdmin.ID) {
140 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: []api.RequestError{{Slug: api.RequestErrAccessDenied}}})
141 return
142 }
143 subs, err := store.GetSubscriptions([]uuid.ID{id})
144 if err != nil {
145 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
146 return
147 }
148 sub, ok := subs[id.String()]
149 if !ok {
150 api.Encode(w, r, http.StatusNotFound, Response{Errors: []api.RequestError{{Slug: api.RequestErrNotFound, Param: "id"}}})
151 return
152 }
153 resp := Response{Subscriptions: []subscriptions.Subscription{sub}}
154 api.Encode(w, r, http.StatusOK, resp)
155 }
157 func PatchSubscriptionHandler(w http.ResponseWriter, r *http.Request, c context.Context) {
158 store, err := getSubscriptionStore(c)
159 if err != nil {
160 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
161 return
162 }
163 stripe, err := getStripeClient(c)
164 if err != nil {
165 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
166 return
167 }
168 if !api.CheckScopes(r, ScopeSubscription.ID) {
169 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: []api.RequestError{{Slug: api.RequestErrAccessDenied}}})
170 return
171 }
172 userID, err := api.AuthUser(r)
173 if err != nil {
174 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: []api.RequestError{{Slug: api.RequestErrAccessDenied}}})
175 return
176 }
177 vars := trout.RequestVars(r)
178 rawID := vars.Get("id")
179 if rawID == "" {
180 api.Encode(w, r, http.StatusBadRequest, Response{Errors: []api.RequestError{{Slug: api.RequestErrMissing, Param: "id"}}})
181 return
182 }
183 id, err := uuid.Parse(rawID)
184 if err != nil {
185 api.Encode(w, r, http.StatusBadRequest, Response{Errors: []api.RequestError{{Slug: api.RequestErrInvalidFormat, Param: "id"}}})
186 return
187 }
189 var req subscriptions.SubscriptionChange
190 err = api.Decode(r, &req)
191 if err != nil {
192 api.Encode(w, r, http.StatusBadRequest, Response{Errors: api.InvalidFormatError})
193 return
194 }
195 // BUG(paddy): Need to validate the request when updating a subscription
197 // only admin users can update the system-controlled properties
198 changedSysProps := changingSystemProperties(req)
199 if len(changedSysProps) > 0 && !api.CheckScopes(r, ScopeSubscriptionAdmin.ID) {
200 errs := make([]api.RequestError, len(changedSysProps))
201 for pos, prop := range changedSysProps {
202 errs[pos] = api.RequestError{Slug: api.RequestErrAccessDenied, Field: prop}
203 }
204 api.Encode(w, r, http.StatusBadRequest, Response{Errors: errs})
205 return
206 }
208 subs, err := store.GetSubscriptions([]uuid.ID{userID})
209 if err != nil {
210 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
211 return
212 }
213 sub, ok := subs[userID.String()]
214 if !ok {
215 api.Encode(w, r, http.StatusNotFound, Response{Errors: []api.RequestError{{Slug: api.RequestErrNotFound, Param: "id"}}})
216 return
217 }
218 stripeSub, err := subscriptions.UpdateStripeSubscription(sub.StripeSubscription, req.Plan, req.StripeSource, stripe)
219 if err != nil {
220 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
221 return
222 }
223 change := subscriptions.StripeSubscriptionChange(sub, *stripeSub)
224 if change.IsEmpty() {
225 resp := Response{Subscriptions: []subscriptions.Subscription{sub}}
226 api.Encode(w, r, http.StatusOK, resp)
227 return
228 }
229 err = store.UpdateSubscription(id, change)
230 if err != nil {
231 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
232 return
233 }
234 sub.ApplyChange(change)
235 resp := Response{Subscriptions: []subscriptions.Subscription{sub}}
236 api.Encode(w, r, http.StatusOK, resp)
237 }