ducky/subscriptions
ducky/subscriptions/subscription.go
Create a client for working with subscriptions. We mostly copied our code.secondbit.org/auth.hg/client package to create a simple client library for communicating with our Subscriptions API. Right now, the client only has support for creating a subscription. It remains untested, but it builds.
| 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 } |