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