ducky/subscriptions
2015-10-04
Parent:aab6ba5ae392
ducky/subscriptions/subscription.go
Make api subpackage golint-passing. Add comments to all the exported functions, methods, and variables in the api subpackage, to make golint happy. Also, make the individual endpoints in the api subpackage unexported, as there's no real use case for exporting them. The handlers depend on the placeholders in the endpoint, so we need them to be controlled in unison, which means it's probably a bad idea to declare the route outside of the API package. And the only reason to expose the Handler is so people can declare custom endpoints.
| 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@11 | 38 // Version tracks the build ID of the binary, set using |
| paddy@11 | 39 // ldflags. |
| paddy@3 | 40 Version string |
| paddy@0 | 41 ) |
| paddy@0 | 42 |
| paddy@0 | 43 // Subscription represents the state of a user's payments. It holds |
| paddy@0 | 44 // metadata about the last time a user was charged, how much a user |
| paddy@0 | 45 // should be charged, how to charge a user and how much to charge |
| paddy@0 | 46 // the user. |
| paddy@0 | 47 type Subscription struct { |
| paddy@3 | 48 UserID uuid.ID `json:"user_id"` |
| paddy@3 | 49 StripeSubscription string `json:"stripe_subscription"` |
| paddy@3 | 50 Plan string `json:"plan"` |
| paddy@3 | 51 Status string `json:"status"` |
| paddy@3 | 52 Canceling bool `json:"canceling"` |
| paddy@3 | 53 Created time.Time `json:"created"` |
| paddy@3 | 54 TrialStart time.Time `json:"trial_start,omitempty"` |
| paddy@3 | 55 TrialEnd time.Time `json:"trial_end,omitempty"` |
| paddy@3 | 56 PeriodStart time.Time `json:"period_start,omitempty"` |
| paddy@3 | 57 PeriodEnd time.Time `json:"period_end,omitempty"` |
| paddy@3 | 58 CanceledAt time.Time `json:"canceled_at,omitempty"` |
| paddy@3 | 59 FailedChargeAttempts int `json:"failed_charge_attempts"` |
| paddy@3 | 60 LastFailedCharge time.Time `json:"last_failed_charge,omitempty"` |
| paddy@3 | 61 LastNotified time.Time `json:"last_notified,omitempty"` |
| paddy@0 | 62 } |
| paddy@0 | 63 |
| paddy@0 | 64 // SubscriptionChange represents desired changes to a Subscription |
| paddy@0 | 65 // object. A nil value means that property should remain unchanged. |
| paddy@0 | 66 type SubscriptionChange struct { |
| paddy@6 | 67 UserID uuid.ID `json:"user_id"` |
| paddy@6 | 68 |
| paddy@15 | 69 // User-controlled and not stored in DB (helper properties for the API) |
| paddy@6 | 70 StripeSource *string `json:"stripe_source,omitempty"` |
| paddy@6 | 71 Email *string `json:"email,omitempty"` |
| paddy@15 | 72 |
| paddy@15 | 73 // User-controlled and stored in DB |
| paddy@15 | 74 Plan *string `json:"plan,omitempty"` |
| paddy@15 | 75 Canceling *bool `json:"cenceling,omitempty"` |
| paddy@6 | 76 |
| paddy@6 | 77 // System-controlled |
| paddy@6 | 78 StripeSubscription *string `json:"stripe_subscription,omitempty"` |
| paddy@6 | 79 Status *string `json:"status,omitempty"` |
| paddy@6 | 80 TrialStart *time.Time `json:"trial_start,omitempty"` |
| paddy@6 | 81 TrialEnd *time.Time `json:"trial_end,omitempty"` |
| paddy@6 | 82 PeriodStart *time.Time `json:"period_start,omitempty"` |
| paddy@6 | 83 PeriodEnd *time.Time `json:"period_end,omitempty"` |
| paddy@6 | 84 CanceledAt *time.Time `json:"canceled_at,omitempty"` |
| paddy@6 | 85 FailedChargeAttempts *int `json:"failed_charge_attempts,omitempty"` |
| paddy@6 | 86 LastFailedCharge *time.Time `json:"last_failed_charge,omitempty"` |
| paddy@6 | 87 LastNotified *time.Time `json:"last_notified,omitempty"` |
| paddy@0 | 88 } |
| paddy@0 | 89 |
| paddy@0 | 90 // IsEmpty returns true if the SubscriptionChange doesn't request |
| paddy@0 | 91 // a change to any property of the Subscription. |
| paddy@0 | 92 func (change SubscriptionChange) IsEmpty() bool { |
| paddy@2 | 93 if change.Plan != nil { |
| paddy@0 | 94 return false |
| paddy@0 | 95 } |
| paddy@6 | 96 if change.Canceling != nil { |
| paddy@0 | 97 return false |
| paddy@0 | 98 } |
| paddy@6 | 99 if change.StripeSubscription != nil { |
| paddy@6 | 100 return false |
| paddy@6 | 101 } |
| paddy@6 | 102 if change.Status != nil { |
| paddy@0 | 103 return false |
| paddy@0 | 104 } |
| paddy@2 | 105 if change.TrialStart != nil { |
| paddy@2 | 106 return false |
| paddy@2 | 107 } |
| paddy@2 | 108 if change.TrialEnd != nil { |
| paddy@2 | 109 return false |
| paddy@2 | 110 } |
| paddy@2 | 111 if change.PeriodStart != nil { |
| paddy@2 | 112 return false |
| paddy@2 | 113 } |
| paddy@2 | 114 if change.PeriodEnd != nil { |
| paddy@2 | 115 return false |
| paddy@2 | 116 } |
| paddy@2 | 117 if change.CanceledAt != nil { |
| paddy@0 | 118 return false |
| paddy@0 | 119 } |
| paddy@0 | 120 if change.LastNotified != nil { |
| paddy@0 | 121 return false |
| paddy@0 | 122 } |
| paddy@2 | 123 if change.LastFailedCharge != nil { |
| paddy@2 | 124 return false |
| paddy@2 | 125 } |
| paddy@2 | 126 if change.FailedChargeAttempts != nil { |
| paddy@0 | 127 return false |
| paddy@0 | 128 } |
| paddy@0 | 129 return true |
| paddy@0 | 130 } |
| paddy@0 | 131 |
| paddy@0 | 132 // ApplyChange updates a Subscription based on the changes requested |
| paddy@0 | 133 // by a SubscriptionChange. |
| paddy@0 | 134 func (s *Subscription) ApplyChange(change SubscriptionChange) { |
| paddy@2 | 135 if change.StripeSubscription != nil { |
| paddy@2 | 136 s.StripeSubscription = *change.StripeSubscription |
| paddy@0 | 137 } |
| paddy@2 | 138 if change.Plan != nil { |
| paddy@2 | 139 s.Plan = *change.Plan |
| paddy@0 | 140 } |
| paddy@2 | 141 if change.Status != nil { |
| paddy@2 | 142 s.Status = *change.Status |
| paddy@0 | 143 } |
| paddy@2 | 144 if change.Canceling != nil { |
| paddy@2 | 145 s.Canceling = *change.Canceling |
| paddy@0 | 146 } |
| paddy@2 | 147 if change.TrialStart != nil { |
| paddy@2 | 148 s.TrialStart = *change.TrialStart |
| paddy@2 | 149 } |
| paddy@2 | 150 if change.TrialEnd != nil { |
| paddy@2 | 151 s.TrialEnd = *change.TrialEnd |
| paddy@2 | 152 } |
| paddy@2 | 153 if change.PeriodStart != nil { |
| paddy@2 | 154 s.PeriodStart = *change.PeriodStart |
| paddy@2 | 155 } |
| paddy@2 | 156 if change.PeriodEnd != nil { |
| paddy@2 | 157 s.PeriodEnd = *change.PeriodEnd |
| paddy@2 | 158 } |
| paddy@2 | 159 if change.CanceledAt != nil { |
| paddy@2 | 160 s.CanceledAt = *change.CanceledAt |
| paddy@2 | 161 } |
| paddy@2 | 162 if change.LastFailedCharge != nil { |
| paddy@2 | 163 s.LastFailedCharge = *change.LastFailedCharge |
| paddy@0 | 164 } |
| paddy@0 | 165 if change.LastNotified != nil { |
| paddy@0 | 166 s.LastNotified = *change.LastNotified |
| paddy@0 | 167 } |
| paddy@2 | 168 if change.FailedChargeAttempts != nil { |
| paddy@2 | 169 s.FailedChargeAttempts = *change.FailedChargeAttempts |
| paddy@0 | 170 } |
| paddy@0 | 171 } |
| paddy@0 | 172 |
| paddy@11 | 173 // IsAcceptablePlan returns true if the user can select the specified |
| paddy@11 | 174 // plan, taking into account their admin status. If a plan exists, and |
| paddy@11 | 175 // is not designated as an admin-only plan, any user selecting it will |
| paddy@11 | 176 // return true. If a plan exists, but is designated as admin-only, |
| paddy@11 | 177 // IsAcceptablePlan will only return true if admin is true. If the plan |
| paddy@11 | 178 // doesn't exist, IsAcceptablePlan always returns false. |
| paddy@6 | 179 func IsAcceptablePlan(plan string, admin bool) bool { |
| paddy@6 | 180 for p, adminOnly := range planOptions { |
| paddy@6 | 181 if plan == p { |
| paddy@6 | 182 return admin || adminOnly == false |
| paddy@3 | 183 } |
| paddy@3 | 184 } |
| paddy@6 | 185 return false |
| paddy@3 | 186 } |
| paddy@3 | 187 |
| paddy@1 | 188 // SubscriptionStats represents a set of statistics about our Subscription |
| paddy@1 | 189 // data that will be exposed to the Prometheus scraper. |
| paddy@1 | 190 type SubscriptionStats struct { |
| paddy@2 | 191 Number int64 |
| paddy@2 | 192 Canceling int64 |
| paddy@2 | 193 Failing int64 |
| paddy@2 | 194 Plans map[string]int64 |
| paddy@1 | 195 // BUG(paddy): Currently, Kubernetes doesn't offer any way to contact _all_ nodes in a service. |
| paddy@1 | 196 // Because of this, we can only report stats that will be identical across nodes, e.g. stats |
| paddy@1 | 197 // that come from the database. More info here: https://github.com/GoogleCloudPlatform/kubernetes/issues/6666 |
| paddy@1 | 198 // In the future, we'll need per-node metrics. For now, we'll make do. |
| paddy@1 | 199 // |
| paddy@1 | 200 // Actually, as of https://github.com/GoogleCloudPlatform/kubernetes/pull/9073, we may be all set: |
| paddy@1 | 201 // "SRV Records are created for named ports that are part of normal or Headless Services. For each named port, |
| paddy@1 | 202 // the SRV record would have the form _my-port-name._my-port-protocol.my-svc.my-namespace.svc.cluster.local. |
| paddy@1 | 203 // For a regular service, this resolves to the port number and the CNAME: my-svc.my-namespace.svc.cluster.local. |
| paddy@1 | 204 // For a headless service, this resolves to multiple answers, one for each pod that is backing the service, and |
| paddy@1 | 205 // 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 | 206 // SRV records always contain the 'svc' segment in them and are not supported for old-style CNAMEs where the 'svc' segment |
| paddy@1 | 207 // was omitted." |
| paddy@1 | 208 } |
| paddy@1 | 209 |
| paddy@11 | 210 // SubscriptionStore is an interface describing datastore interactions for |
| paddy@11 | 211 // Subscriptions. |
| paddy@3 | 212 type SubscriptionStore interface { |
| paddy@3 | 213 Reset() error |
| paddy@3 | 214 CreateSubscription(sub Subscription) error |
| paddy@3 | 215 UpdateSubscription(id uuid.ID, change SubscriptionChange) error |
| paddy@3 | 216 DeleteSubscription(id uuid.ID) error |
| paddy@3 | 217 GetSubscriptions(ids []uuid.ID) (map[string]Subscription, error) |
| paddy@3 | 218 GetSubscriptionStats() (SubscriptionStats, error) |
| paddy@0 | 219 } |