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