ducky/subscriptions

Paddy 2015-07-13 Parent:c4cfceb2f2fb Child:0ae1ff0ee306

7:9e138933e4ce Go to Latest

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.

History
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 }