ducky/subscriptions

Paddy 2015-06-30 Parent:b240b6123548 Child:c4cfceb2f2fb

5:fe8f092cc149 Go to Latest

ducky/subscriptions/subscription.go

Make subscriptions Kubernetes-ready. Update our .hgignore file to include the docker-ready subscripionsd binary (which differs from the local subscriptionsd binary only in that it's statically-compiled for linux). Add a replication controller for subscriptionsd that will spin up the appropriate pods for us and make sure our Stripe and Subscriptionsd secret volumes are attached, so the pods may configure themselves with that private info. Create a Stripe secret volume with a placeholder for the stripe secret key. Create a Subscriptionsd secret volume with the DSN sent to the base64 encoding of an empty string right now (which means subscriptionsd will store data in memory, but whatever.) Create a subscriptionsd service that will route traffic to the subscriptionsd pods created by the replication controller. Create a minimal Dockerfile to get the subscriptionsd binary running on kubernetes. Add a build-docker helper script that will compile a Docker-ready subscriptionsd binary (by compiling it in a Docker container). Copy a Ubuntu ca-certificates.crt file that we'll inject into our Dockerfile so the Stripe SSL can be resolved. Busybox doesn't have any certificates, by default. Create a wrapper script that will be run in the Docker container to read the secrets from our secret volumes and inject them as environment variables, which is what subscriptionsd understands.

History
1 package subscriptions
3 import (
4 "errors"
5 "time"
7 "code.secondbit.org/api.hg"
8 "code.secondbit.org/uuid.hg"
9 )
11 var (
12 // ErrSubscriptionAlreadyExists is returned when a Subscription
13 // with an identical ID already exists in the subscriptionStore.
14 ErrSubscriptionAlreadyExists = errors.New("Subscription already exists")
15 // ErrSubscriptionNotFound is returned when a single Subscription
16 // is acted upon or requested, but cannot be found.
17 ErrSubscriptionNotFound = errors.New("Subscription not found")
18 // ErrStripeSubscriptionAlreadyExists is returned when a Subscription
19 // is created or updates its StripeSubscription property, but that
20 // StripeSubscription is already associated with another Subscription.
21 ErrStripeSubscriptionAlreadyExists = errors.New("Stripe subscription already assigned to another Subscription")
22 // ErrSubscriptionChangeEmpty is returned when a SubscriptionChange
23 // is empty but is passed to subscriptionStore.UpdateSubscription
24 // anyways.
25 ErrSubscriptionChangeEmpty = errors.New("SubscriptionChange is empty")
26 // ErrNoSubscriptionID is returned when one or more Subscription IDs
27 // are required, but none are provided.
28 ErrNoSubscriptionID = errors.New("no Subscription ID provided")
30 planOptions = []string{"basic_monthly", "basic_yearly", "supporter_monthly", "supporter_yearly"}
32 Version string
33 )
35 // Subscription represents the state of a user's payments. It holds
36 // metadata about the last time a user was charged, how much a user
37 // should be charged, how to charge a user and how much to charge
38 // the user.
39 type Subscription struct {
40 UserID uuid.ID `json:"user_id"`
41 StripeSubscription string `json:"stripe_subscription"`
42 Plan string `json:"plan"`
43 Status string `json:"status"`
44 Canceling bool `json:"canceling"`
45 Created time.Time `json:"created"`
46 TrialStart time.Time `json:"trial_start,omitempty"`
47 TrialEnd time.Time `json:"trial_end,omitempty"`
48 PeriodStart time.Time `json:"period_start,omitempty"`
49 PeriodEnd time.Time `json:"period_end,omitempty"`
50 CanceledAt time.Time `json:"canceled_at,omitempty"`
51 FailedChargeAttempts int `json:"failed_charge_attempts"`
52 LastFailedCharge time.Time `json:"last_failed_charge,omitempty"`
53 LastNotified time.Time `json:"last_notified,omitempty"`
54 }
56 // SubscriptionChange represents desired changes to a Subscription
57 // object. A nil value means that property should remain unchanged.
58 type SubscriptionChange struct {
59 StripeSubscription *string
60 Plan *string
61 Status *string
62 Canceling *bool
63 TrialStart *time.Time
64 TrialEnd *time.Time
65 PeriodStart *time.Time
66 PeriodEnd *time.Time
67 CanceledAt *time.Time
68 FailedChargeAttempts *int
69 LastFailedCharge *time.Time
70 LastNotified *time.Time
71 }
73 // IsEmpty returns true if the SubscriptionChange doesn't request
74 // a change to any property of the Subscription.
75 func (change SubscriptionChange) IsEmpty() bool {
76 if change.StripeSubscription != nil {
77 return false
78 }
79 if change.Plan != nil {
80 return false
81 }
82 if change.Status != nil {
83 return false
84 }
85 if change.Canceling != nil {
86 return false
87 }
88 if change.TrialStart != nil {
89 return false
90 }
91 if change.TrialEnd != nil {
92 return false
93 }
94 if change.PeriodStart != nil {
95 return false
96 }
97 if change.PeriodEnd != nil {
98 return false
99 }
100 if change.CanceledAt != nil {
101 return false
102 }
103 if change.LastNotified != nil {
104 return false
105 }
106 if change.LastFailedCharge != nil {
107 return false
108 }
109 if change.FailedChargeAttempts != nil {
110 return false
111 }
112 return true
113 }
115 // ApplyChange updates a Subscription based on the changes requested
116 // by a SubscriptionChange.
117 func (s *Subscription) ApplyChange(change SubscriptionChange) {
118 if change.StripeSubscription != nil {
119 s.StripeSubscription = *change.StripeSubscription
120 }
121 if change.Plan != nil {
122 s.Plan = *change.Plan
123 }
124 if change.Status != nil {
125 s.Status = *change.Status
126 }
127 if change.Canceling != nil {
128 s.Canceling = *change.Canceling
129 }
130 if change.TrialStart != nil {
131 s.TrialStart = *change.TrialStart
132 }
133 if change.TrialEnd != nil {
134 s.TrialEnd = *change.TrialEnd
135 }
136 if change.PeriodStart != nil {
137 s.PeriodStart = *change.PeriodStart
138 }
139 if change.PeriodEnd != nil {
140 s.PeriodEnd = *change.PeriodEnd
141 }
142 if change.CanceledAt != nil {
143 s.CanceledAt = *change.CanceledAt
144 }
145 if change.LastFailedCharge != nil {
146 s.LastFailedCharge = *change.LastFailedCharge
147 }
148 if change.LastNotified != nil {
149 s.LastNotified = *change.LastNotified
150 }
151 if change.FailedChargeAttempts != nil {
152 s.FailedChargeAttempts = *change.FailedChargeAttempts
153 }
154 }
156 type SubscriptionRequest struct {
157 UserID uuid.ID `json:"user_id"`
158 Email string `json:"email"`
159 StripeToken string `json:"stripe_token"`
160 Plan string `json:"plan"`
161 }
163 func (req SubscriptionRequest) Validate(user uuid.ID, admin bool) []api.RequestError {
164 var errors []api.RequestError
165 if req.UserID == nil {
166 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/user_id"})
167 } else if !req.UserID.Equal(user) && !admin {
168 errors = append(errors, api.RequestError{Slug: api.RequestErrAccessDenied, Field: "/user_id"})
169 }
170 if req.Email == "" {
171 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/email"})
172 }
173 if req.StripeToken == "" {
174 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/stripe_token"})
175 }
176 if req.Plan == "" {
177 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/plan"})
178 } else {
179 var acceptablePlan bool
180 for _, p := range planOptions {
181 if req.Plan == p {
182 acceptablePlan = true
183 break
184 }
185 }
186 if !acceptablePlan {
187 errors = append(errors, api.RequestError{Slug: api.RequestErrInvalidValue, Field: "/plan"})
188 }
189 }
190 return errors
191 }
193 func SubscriptionFromRequest(req SubscriptionRequest) Subscription {
194 return Subscription{
195 UserID: req.UserID,
196 StripeSubscription: req.StripeToken,
197 Plan: req.Plan,
198 Created: time.Now(),
199 TrialStart: time.Now(),
200 }
201 }
203 // SubscriptionStats represents a set of statistics about our Subscription
204 // data that will be exposed to the Prometheus scraper.
205 type SubscriptionStats struct {
206 Number int64
207 Canceling int64
208 Failing int64
209 Plans map[string]int64
210 // BUG(paddy): Currently, Kubernetes doesn't offer any way to contact _all_ nodes in a service.
211 // Because of this, we can only report stats that will be identical across nodes, e.g. stats
212 // that come from the database. More info here: https://github.com/GoogleCloudPlatform/kubernetes/issues/6666
213 // In the future, we'll need per-node metrics. For now, we'll make do.
214 //
215 // Actually, as of https://github.com/GoogleCloudPlatform/kubernetes/pull/9073, we may be all set:
216 // "SRV Records are created for named ports that are part of normal or Headless Services. For each named port,
217 // the SRV record would have the form _my-port-name._my-port-protocol.my-svc.my-namespace.svc.cluster.local.
218 // For a regular service, this resolves to the port number and the CNAME: my-svc.my-namespace.svc.cluster.local.
219 // For a headless service, this resolves to multiple answers, one for each pod that is backing the service, and
220 // contains the port number and a CNAME of the pod with the format auto-generated-name.my-svc.my-namespace.svc.cluster.local
221 // SRV records always contain the 'svc' segment in them and are not supported for old-style CNAMEs where the 'svc' segment
222 // was omitted."
223 }
225 type SubscriptionStore interface {
226 Reset() error
227 CreateSubscription(sub Subscription) error
228 UpdateSubscription(id uuid.ID, change SubscriptionChange) error
229 DeleteSubscription(id uuid.ID) error
230 GetSubscriptions(ids []uuid.ID) (map[string]Subscription, error)
231 GetSubscriptionStats() (SubscriptionStats, error)
232 }