ducky/subscriptions
ducky/subscriptions/api/subscription_handlers.go
Make it possible to create a user without payment details. We want a new subscription flow, in which the system (not the user) is responsible for creating a subscription when a new profile is created. This is to prevent issues where a user has an account, but no subscription. This is bad because the free trial starts ticking from the day the subscription is created, so we should try to make the subscription and account creation get created as close to each other as possible. Our plan is to instead have the authd service fire off an NSQ event when an auth.Profile is created, which a subscription listener will be listening for. When that happens, the listener will use the subscription API to create a subscription. Then the user will update the subscription with their payment info and the plan they want to use. To accomplish this, we changed the way things were handled. The SubscriptionRequest type, along with its Validate method, were removed. Instead, we get the SubscriptionChange type which handles both the creation of a subscription and the updating of a subscription. We also added an endpoint for patching subscriptions, useful for adding the StripeSubscription or updating the plan. By default, every subscription is created with a "Pending" plan which has a 31 day free trial. This is so we can detect users that haven't actually set up their subscription yet, but their free trial is still timed correctly. We changed the way we handle scopes, creating actual auth.Scope instances instead of just declaring an ID for them. This is useful when we have a client, for example. With this change, we lose all the validation we had on creating a Subscription, and we need to rewrite that validation logic. This is because we no longer have a specific type for "creating a subscription", so we can't just call a validate method. We should have a helper method validateCreateRequest(change SubscriptionChange) that will return the API errors we want, so it's easier to unit test. We should really be restricting the CreateSubscriptionHandler to ScopeSubscriptionAdmin, anyways, since Subscriptions should only ever be created by the system tools or administrators. We created a PatchSubscriptionHandler that exposes an interface to updating properties of a Subscription. It allows users to update their own Subscriptions or requires the ScopeSubscriptionAdmin scope before allowing you to update another user's Subscription. It, likewise, needs validation still. We also added the concept of "system-controlled properties" of the SubscriptionChange type, which only admins or the system tools can update. We updated our planOptions to distinguish between plans that do and do not need administrative credentials to be chosen. Our free and pending plans are available to administrators only. We updated our StripeChange object to be better organised (separating out the system and user-controlled properties), and we added a StripeSource and Email property, so the Stripe part can be better managed, and all our requests can be made using just this type. This required updating our SubscriptionChange.IsEmpty helper, which has been updated (along with its tests) and it passes all tests. To replace our SubscriptionRequest.Validate helper, we created a ChangingSystemProperties helper (which returns the system-controlled properties being changed as a slice of JSON pointers, fit for use in error messages) and an IsAcceptablePlan helper, which returns true if the plan exists and the user has the authority to select it. We also updated our stripe helpers to remove the CreateStripeSubscription (we create one when we create the customer) and create an UpdateStripeSubscription instead. It does what you'd think it does. We also added some comments to New, so it at least has some notes about how it's meant to be used and why. Now it just creates the customer in stripe, then creates a Subscription based on that customer. We also updated our StripeSubscriptionChange helper to detect when the StripeSubscription property changed.
| paddy@4 | 1 package api |
| paddy@4 | 2 |
| paddy@4 | 3 import ( |
| paddy@4 | 4 "net/http" |
| paddy@4 | 5 |
| paddy@4 | 6 "code.secondbit.org/api.hg" |
| paddy@6 | 7 "code.secondbit.org/auth.hg" |
| paddy@4 | 8 "code.secondbit.org/trout.hg" |
| paddy@4 | 9 "code.secondbit.org/uuid.hg" |
| paddy@4 | 10 |
| paddy@6 | 11 "code.secondbit.org/ducky/subscriptions.hg" |
| paddy@6 | 12 |
| paddy@4 | 13 "golang.org/x/net/context" |
| paddy@4 | 14 ) |
| paddy@4 | 15 |
| paddy@6 | 16 var ( |
| paddy@6 | 17 ScopeSubscription = auth.Scope{ID: "subscriptions", Name: "Manage Subscriptions", Description: "Read and update your subscription information."} |
| paddy@6 | 18 ScopeSubscriptionAdmin = auth.Scope{ID: "subscriptions_admin", Name: "Administer Subscriptions", Description: "Read and update subscription information, bypassing ACL."} |
| paddy@4 | 19 ) |
| paddy@4 | 20 |
| paddy@4 | 21 func HandleSubscriptions(router *trout.Router, c context.Context) { |
| paddy@4 | 22 router.Endpoint("/subscriptions").Methods("POST", "OPTIONS").Handler( |
| paddy@4 | 23 api.CORSMiddleware(api.NegotiateMiddleware(api.ContextWrapper(c, CreateSubscriptionHandler)))) |
| paddy@4 | 24 router.Endpoint("/subscriptions/{id}").Methods("GET", "OPTIONS").Handler( |
| paddy@4 | 25 api.CORSMiddleware(api.NegotiateMiddleware(api.ContextWrapper(c, GetSubscriptionHandler)))) |
| paddy@6 | 26 router.Endpoint("/subscriptions/{id}").Methods("PATCH", "OPTIONS").Handler( |
| paddy@6 | 27 api.CORSMiddleware(api.NegotiateMiddleware(api.ContextWrapper(c, PatchSubscriptionHandler)))) |
| paddy@4 | 28 } |
| paddy@4 | 29 |
| paddy@4 | 30 func CreateSubscriptionHandler(w http.ResponseWriter, r *http.Request, c context.Context) { |
| paddy@4 | 31 store, err := getSubscriptionStore(c) |
| paddy@4 | 32 if err != nil { |
| paddy@4 | 33 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError}) |
| paddy@4 | 34 return |
| paddy@4 | 35 } |
| paddy@4 | 36 stripe, err := getStripeClient(c) |
| paddy@4 | 37 if err != nil { |
| paddy@4 | 38 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError}) |
| paddy@4 | 39 return |
| paddy@4 | 40 } |
| paddy@6 | 41 if !api.CheckScopes(r, ScopeSubscriptionAdmin.ID) { |
| paddy@4 | 42 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: []api.RequestError{{Slug: api.RequestErrAccessDenied}}}) |
| paddy@4 | 43 return |
| paddy@4 | 44 } |
| paddy@6 | 45 var req subscriptions.SubscriptionChange |
| paddy@4 | 46 err = api.Decode(r, &req) |
| paddy@4 | 47 if err != nil { |
| paddy@4 | 48 api.Encode(w, r, http.StatusBadRequest, Response{Errors: api.InvalidFormatError}) |
| paddy@4 | 49 return |
| paddy@4 | 50 } |
| paddy@6 | 51 // BUG(paddy): Need to validate the request when creating a subscription |
| paddy@4 | 52 sub, err := subscriptions.New(req, stripe, store) |
| paddy@4 | 53 if err != nil { |
| paddy@4 | 54 var rErr api.RequestError |
| paddy@4 | 55 var code int |
| paddy@4 | 56 switch err { |
| paddy@4 | 57 case subscriptions.ErrSubscriptionAlreadyExists: |
| paddy@4 | 58 rErr = api.RequestError{Slug: api.RequestErrConflict, Field: "/user_id"} |
| paddy@4 | 59 code = http.StatusBadRequest |
| paddy@4 | 60 case subscriptions.ErrStripeSubscriptionAlreadyExists: |
| paddy@4 | 61 rErr = api.RequestError{Slug: api.RequestErrConflict, Field: "/stripe_token"} |
| paddy@4 | 62 code = http.StatusBadRequest |
| paddy@4 | 63 default: |
| paddy@4 | 64 rErr = api.RequestError{Slug: api.RequestErrActOfGod} |
| paddy@4 | 65 code = http.StatusInternalServerError |
| paddy@4 | 66 } |
| paddy@4 | 67 api.Encode(w, r, code, Response{Errors: []api.RequestError{rErr}}) |
| paddy@4 | 68 return |
| paddy@4 | 69 } |
| paddy@4 | 70 resp := Response{Subscriptions: []subscriptions.Subscription{sub}} |
| paddy@4 | 71 api.Encode(w, r, http.StatusCreated, resp) |
| paddy@4 | 72 } |
| paddy@4 | 73 |
| paddy@4 | 74 func GetSubscriptionHandler(w http.ResponseWriter, r *http.Request, c context.Context) { |
| paddy@4 | 75 store, err := getSubscriptionStore(c) |
| paddy@4 | 76 if err != nil { |
| paddy@4 | 77 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError}) |
| paddy@4 | 78 return |
| paddy@4 | 79 } |
| paddy@4 | 80 vars := trout.RequestVars(r) |
| paddy@4 | 81 rawID := vars.Get("id") |
| paddy@4 | 82 if rawID == "" { |
| paddy@4 | 83 api.Encode(w, r, http.StatusBadRequest, Response{Errors: []api.RequestError{{Slug: api.RequestErrMissing, Param: "id"}}}) |
| paddy@4 | 84 return |
| paddy@4 | 85 } |
| paddy@4 | 86 id, err := uuid.Parse(rawID) |
| paddy@4 | 87 if err != nil { |
| paddy@4 | 88 api.Encode(w, r, http.StatusBadRequest, Response{Errors: []api.RequestError{{Slug: api.RequestErrInvalidFormat, Param: "id"}}}) |
| paddy@4 | 89 return |
| paddy@4 | 90 } |
| paddy@6 | 91 if !api.CheckScopes(r, ScopeSubscription.ID) { |
| paddy@4 | 92 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: []api.RequestError{{Slug: api.RequestErrAccessDenied}}}) |
| paddy@4 | 93 return |
| paddy@4 | 94 } |
| paddy@4 | 95 userID, err := api.AuthUser(r) |
| paddy@4 | 96 if err != nil { |
| paddy@4 | 97 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: []api.RequestError{{Slug: api.RequestErrAccessDenied}}}) |
| paddy@4 | 98 return |
| paddy@4 | 99 } |
| paddy@6 | 100 if !id.Equal(userID) && !api.CheckScopes(r, ScopeSubscriptionAdmin.ID) { |
| paddy@4 | 101 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: []api.RequestError{{Slug: api.RequestErrAccessDenied}}}) |
| paddy@4 | 102 return |
| paddy@4 | 103 } |
| paddy@4 | 104 subs, err := store.GetSubscriptions([]uuid.ID{id}) |
| paddy@4 | 105 if err != nil { |
| paddy@4 | 106 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError}) |
| paddy@4 | 107 return |
| paddy@4 | 108 } |
| paddy@4 | 109 sub, ok := subs[id.String()] |
| paddy@4 | 110 if !ok { |
| paddy@4 | 111 api.Encode(w, r, http.StatusNotFound, Response{Errors: []api.RequestError{{Slug: api.RequestErrNotFound, Param: "id"}}}) |
| paddy@4 | 112 return |
| paddy@4 | 113 } |
| paddy@4 | 114 resp := Response{Subscriptions: []subscriptions.Subscription{sub}} |
| paddy@4 | 115 api.Encode(w, r, http.StatusOK, resp) |
| paddy@4 | 116 } |
| paddy@6 | 117 |
| paddy@6 | 118 func PatchSubscriptionHandler(w http.ResponseWriter, r *http.Request, c context.Context) { |
| paddy@6 | 119 store, err := getSubscriptionStore(c) |
| paddy@6 | 120 if err != nil { |
| paddy@6 | 121 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError}) |
| paddy@6 | 122 return |
| paddy@6 | 123 } |
| paddy@6 | 124 stripe, err := getStripeClient(c) |
| paddy@6 | 125 if err != nil { |
| paddy@6 | 126 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError}) |
| paddy@6 | 127 return |
| paddy@6 | 128 } |
| paddy@6 | 129 if !api.CheckScopes(r, ScopeSubscription.ID) { |
| paddy@6 | 130 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: []api.RequestError{{Slug: api.RequestErrAccessDenied}}}) |
| paddy@6 | 131 return |
| paddy@6 | 132 } |
| paddy@6 | 133 userID, err := api.AuthUser(r) |
| paddy@6 | 134 if err != nil { |
| paddy@6 | 135 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: []api.RequestError{{Slug: api.RequestErrAccessDenied}}}) |
| paddy@6 | 136 return |
| paddy@6 | 137 } |
| paddy@6 | 138 vars := trout.RequestVars(r) |
| paddy@6 | 139 rawID := vars.Get("id") |
| paddy@6 | 140 if rawID == "" { |
| paddy@6 | 141 api.Encode(w, r, http.StatusBadRequest, Response{Errors: []api.RequestError{{Slug: api.RequestErrMissing, Param: "id"}}}) |
| paddy@6 | 142 return |
| paddy@6 | 143 } |
| paddy@6 | 144 id, err := uuid.Parse(rawID) |
| paddy@6 | 145 if err != nil { |
| paddy@6 | 146 api.Encode(w, r, http.StatusBadRequest, Response{Errors: []api.RequestError{{Slug: api.RequestErrInvalidFormat, Param: "id"}}}) |
| paddy@6 | 147 return |
| paddy@6 | 148 } |
| paddy@6 | 149 |
| paddy@6 | 150 var req subscriptions.SubscriptionChange |
| paddy@6 | 151 err = api.Decode(r, &req) |
| paddy@6 | 152 if err != nil { |
| paddy@6 | 153 api.Encode(w, r, http.StatusBadRequest, Response{Errors: api.InvalidFormatError}) |
| paddy@6 | 154 return |
| paddy@6 | 155 } |
| paddy@6 | 156 // BUG(paddy): Need to validate the request when updating a subscription |
| paddy@6 | 157 |
| paddy@6 | 158 // only admin users can update the system-controlled properties |
| paddy@6 | 159 changedSysProps := subscriptions.ChangingSystemProperties(req) |
| paddy@6 | 160 if len(changedSysProps) > 0 && !api.CheckScopes(r, ScopeSubscriptionAdmin.ID) { |
| paddy@6 | 161 errs := make([]api.RequestError, len(changedSysProps)) |
| paddy@6 | 162 for pos, prop := range changedSysProps { |
| paddy@6 | 163 errs[pos] = api.RequestError{Slug: api.RequestErrAccessDenied, Field: prop} |
| paddy@6 | 164 } |
| paddy@6 | 165 api.Encode(w, r, http.StatusBadRequest, Response{Errors: errs}) |
| paddy@6 | 166 return |
| paddy@6 | 167 } |
| paddy@6 | 168 |
| paddy@6 | 169 subs, err := store.GetSubscriptions([]uuid.ID{userID}) |
| paddy@6 | 170 if err != nil { |
| paddy@6 | 171 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError}) |
| paddy@6 | 172 return |
| paddy@6 | 173 } |
| paddy@6 | 174 sub, ok := subs[userID.String()] |
| paddy@6 | 175 if !ok { |
| paddy@6 | 176 api.Encode(w, r, http.StatusNotFound, Response{Errors: []api.RequestError{{Slug: api.RequestErrNotFound, Param: "id"}}}) |
| paddy@6 | 177 return |
| paddy@6 | 178 } |
| paddy@6 | 179 stripeSub, err := subscriptions.UpdateStripeSubscription(sub.StripeSubscription, req.Plan, req.StripeSource, stripe) |
| paddy@6 | 180 if err != nil { |
| paddy@6 | 181 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError}) |
| paddy@6 | 182 return |
| paddy@6 | 183 } |
| paddy@6 | 184 change := subscriptions.StripeSubscriptionChange(sub, *stripeSub) |
| paddy@6 | 185 if change.IsEmpty() { |
| paddy@6 | 186 resp := Response{Subscriptions: []subscriptions.Subscription{sub}} |
| paddy@6 | 187 api.Encode(w, r, http.StatusOK, resp) |
| paddy@6 | 188 return |
| paddy@6 | 189 } |
| paddy@6 | 190 err = store.UpdateSubscription(id, change) |
| paddy@6 | 191 if err != nil { |
| paddy@6 | 192 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError}) |
| paddy@6 | 193 return |
| paddy@6 | 194 } |
| paddy@6 | 195 sub.ApplyChange(change) |
| paddy@6 | 196 resp := Response{Subscriptions: []subscriptions.Subscription{sub}} |
| paddy@6 | 197 api.Encode(w, r, http.StatusOK, resp) |
| paddy@6 | 198 } |