ducky/subscriptions
ducky/subscriptions/subscription.go
Add comments, move ChangingSystemProperties to the api package. Add comments to all our exported types and variables in subscription.go, both to make golint happy and because it's good to have comments. Move the subscriptions.ChangingSystemProperties helper to api.changingSystemProperties, because it returns API-specific strings and there's no real reason it has to be in the subscriptions package--everything it needs to work on is exported.
| 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@6 | 69 // User-controlled |
| paddy@6 | 70 StripeSource *string `json:"stripe_source,omitempty"` |
| paddy@6 | 71 Email *string `json:"email,omitempty"` |
| paddy@6 | 72 Plan *string `json:"plan,omitempty"` |
| paddy@6 | 73 Canceling *bool `json:"cenceling,omitempty"` |
| paddy@6 | 74 |
| paddy@6 | 75 // System-controlled |
| paddy@6 | 76 StripeSubscription *string `json:"stripe_subscription,omitempty"` |
| paddy@6 | 77 Status *string `json:"status,omitempty"` |
| paddy@6 | 78 TrialStart *time.Time `json:"trial_start,omitempty"` |
| paddy@6 | 79 TrialEnd *time.Time `json:"trial_end,omitempty"` |
| paddy@6 | 80 PeriodStart *time.Time `json:"period_start,omitempty"` |
| paddy@6 | 81 PeriodEnd *time.Time `json:"period_end,omitempty"` |
| paddy@6 | 82 CanceledAt *time.Time `json:"canceled_at,omitempty"` |
| paddy@6 | 83 FailedChargeAttempts *int `json:"failed_charge_attempts,omitempty"` |
| paddy@6 | 84 LastFailedCharge *time.Time `json:"last_failed_charge,omitempty"` |
| paddy@6 | 85 LastNotified *time.Time `json:"last_notified,omitempty"` |
| paddy@0 | 86 } |
| paddy@0 | 87 |
| paddy@0 | 88 // IsEmpty returns true if the SubscriptionChange doesn't request |
| paddy@0 | 89 // a change to any property of the Subscription. |
| paddy@0 | 90 func (change SubscriptionChange) IsEmpty() bool { |
| paddy@6 | 91 if change.StripeSource != nil { |
| paddy@6 | 92 return false |
| paddy@6 | 93 } |
| paddy@6 | 94 if change.Email != nil { |
| paddy@0 | 95 return false |
| paddy@0 | 96 } |
| paddy@2 | 97 if change.Plan != nil { |
| paddy@0 | 98 return false |
| paddy@0 | 99 } |
| paddy@6 | 100 if change.Canceling != nil { |
| paddy@0 | 101 return false |
| paddy@0 | 102 } |
| paddy@6 | 103 if change.StripeSubscription != nil { |
| paddy@6 | 104 return false |
| paddy@6 | 105 } |
| paddy@6 | 106 if change.Status != nil { |
| paddy@0 | 107 return false |
| paddy@0 | 108 } |
| paddy@2 | 109 if change.TrialStart != nil { |
| paddy@2 | 110 return false |
| paddy@2 | 111 } |
| paddy@2 | 112 if change.TrialEnd != nil { |
| paddy@2 | 113 return false |
| paddy@2 | 114 } |
| paddy@2 | 115 if change.PeriodStart != nil { |
| paddy@2 | 116 return false |
| paddy@2 | 117 } |
| paddy@2 | 118 if change.PeriodEnd != nil { |
| paddy@2 | 119 return false |
| paddy@2 | 120 } |
| paddy@2 | 121 if change.CanceledAt != nil { |
| paddy@0 | 122 return false |
| paddy@0 | 123 } |
| paddy@0 | 124 if change.LastNotified != nil { |
| paddy@0 | 125 return false |
| paddy@0 | 126 } |
| paddy@2 | 127 if change.LastFailedCharge != nil { |
| paddy@2 | 128 return false |
| paddy@2 | 129 } |
| paddy@2 | 130 if change.FailedChargeAttempts != nil { |
| paddy@0 | 131 return false |
| paddy@0 | 132 } |
| paddy@0 | 133 return true |
| paddy@0 | 134 } |
| paddy@0 | 135 |
| paddy@0 | 136 // ApplyChange updates a Subscription based on the changes requested |
| paddy@0 | 137 // by a SubscriptionChange. |
| paddy@0 | 138 func (s *Subscription) ApplyChange(change SubscriptionChange) { |
| paddy@2 | 139 if change.StripeSubscription != nil { |
| paddy@2 | 140 s.StripeSubscription = *change.StripeSubscription |
| paddy@0 | 141 } |
| paddy@2 | 142 if change.Plan != nil { |
| paddy@2 | 143 s.Plan = *change.Plan |
| paddy@0 | 144 } |
| paddy@2 | 145 if change.Status != nil { |
| paddy@2 | 146 s.Status = *change.Status |
| paddy@0 | 147 } |
| paddy@2 | 148 if change.Canceling != nil { |
| paddy@2 | 149 s.Canceling = *change.Canceling |
| paddy@0 | 150 } |
| paddy@2 | 151 if change.TrialStart != nil { |
| paddy@2 | 152 s.TrialStart = *change.TrialStart |
| paddy@2 | 153 } |
| paddy@2 | 154 if change.TrialEnd != nil { |
| paddy@2 | 155 s.TrialEnd = *change.TrialEnd |
| paddy@2 | 156 } |
| paddy@2 | 157 if change.PeriodStart != nil { |
| paddy@2 | 158 s.PeriodStart = *change.PeriodStart |
| paddy@2 | 159 } |
| paddy@2 | 160 if change.PeriodEnd != nil { |
| paddy@2 | 161 s.PeriodEnd = *change.PeriodEnd |
| paddy@2 | 162 } |
| paddy@2 | 163 if change.CanceledAt != nil { |
| paddy@2 | 164 s.CanceledAt = *change.CanceledAt |
| paddy@2 | 165 } |
| paddy@2 | 166 if change.LastFailedCharge != nil { |
| paddy@2 | 167 s.LastFailedCharge = *change.LastFailedCharge |
| paddy@0 | 168 } |
| paddy@0 | 169 if change.LastNotified != nil { |
| paddy@0 | 170 s.LastNotified = *change.LastNotified |
| paddy@0 | 171 } |
| paddy@2 | 172 if change.FailedChargeAttempts != nil { |
| paddy@2 | 173 s.FailedChargeAttempts = *change.FailedChargeAttempts |
| paddy@0 | 174 } |
| paddy@0 | 175 } |
| paddy@0 | 176 |
| paddy@11 | 177 // IsAcceptablePlan returns true if the user can select the specified |
| paddy@11 | 178 // plan, taking into account their admin status. If a plan exists, and |
| paddy@11 | 179 // is not designated as an admin-only plan, any user selecting it will |
| paddy@11 | 180 // return true. If a plan exists, but is designated as admin-only, |
| paddy@11 | 181 // IsAcceptablePlan will only return true if admin is true. If the plan |
| paddy@11 | 182 // doesn't exist, IsAcceptablePlan always returns false. |
| paddy@6 | 183 func IsAcceptablePlan(plan string, admin bool) bool { |
| paddy@6 | 184 for p, adminOnly := range planOptions { |
| paddy@6 | 185 if plan == p { |
| paddy@6 | 186 return admin || adminOnly == false |
| paddy@3 | 187 } |
| paddy@3 | 188 } |
| paddy@6 | 189 return false |
| paddy@3 | 190 } |
| paddy@3 | 191 |
| paddy@1 | 192 // SubscriptionStats represents a set of statistics about our Subscription |
| paddy@1 | 193 // data that will be exposed to the Prometheus scraper. |
| paddy@1 | 194 type SubscriptionStats struct { |
| paddy@2 | 195 Number int64 |
| paddy@2 | 196 Canceling int64 |
| paddy@2 | 197 Failing int64 |
| paddy@2 | 198 Plans map[string]int64 |
| paddy@1 | 199 // BUG(paddy): Currently, Kubernetes doesn't offer any way to contact _all_ nodes in a service. |
| paddy@1 | 200 // Because of this, we can only report stats that will be identical across nodes, e.g. stats |
| paddy@1 | 201 // that come from the database. More info here: https://github.com/GoogleCloudPlatform/kubernetes/issues/6666 |
| paddy@1 | 202 // In the future, we'll need per-node metrics. For now, we'll make do. |
| paddy@1 | 203 // |
| paddy@1 | 204 // Actually, as of https://github.com/GoogleCloudPlatform/kubernetes/pull/9073, we may be all set: |
| paddy@1 | 205 // "SRV Records are created for named ports that are part of normal or Headless Services. For each named port, |
| paddy@1 | 206 // the SRV record would have the form _my-port-name._my-port-protocol.my-svc.my-namespace.svc.cluster.local. |
| paddy@1 | 207 // For a regular service, this resolves to the port number and the CNAME: my-svc.my-namespace.svc.cluster.local. |
| paddy@1 | 208 // For a headless service, this resolves to multiple answers, one for each pod that is backing the service, and |
| paddy@1 | 209 // 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 | 210 // SRV records always contain the 'svc' segment in them and are not supported for old-style CNAMEs where the 'svc' segment |
| paddy@1 | 211 // was omitted." |
| paddy@1 | 212 } |
| paddy@1 | 213 |
| paddy@11 | 214 // SubscriptionStore is an interface describing datastore interactions for |
| paddy@11 | 215 // Subscriptions. |
| paddy@3 | 216 type SubscriptionStore interface { |
| paddy@3 | 217 Reset() error |
| paddy@3 | 218 CreateSubscription(sub Subscription) error |
| paddy@3 | 219 UpdateSubscription(id uuid.ID, change SubscriptionChange) error |
| paddy@3 | 220 DeleteSubscription(id uuid.ID) error |
| paddy@3 | 221 GetSubscriptions(ids []uuid.ID) (map[string]Subscription, error) |
| paddy@3 | 222 GetSubscriptionStats() (SubscriptionStats, error) |
| paddy@0 | 223 } |