ducky/subscriptions

Paddy 2015-09-30 Parent:0ae1ff0ee306

15:aab6ba5ae392 Go to Latest

ducky/subscriptions/subscription.go

Log Postgres test failures more verbosely, fix SubscriptionChange.IsEmpty. SubscriptionChange.IsEmpty() would return false even if no actual database operations are going to be performed. This is because we allow information we _don't_ store in the database (Stripe source, Stripe email) to be specified in a SubscriptionChange object, just so we can easily access them. Then we use the Stripe API to store them in Stripe's databases, and turn them into data _we_ store in our database. Think of them as pre-processed values that are never stored raw. The problem is, we were treating these properties the same as the properties we actually stored in the database, and (worse) were running database tests for combinations of these properties, which was causing test failures because we were trying to update no columns in the database. Whoops. I removed these properties from the IsEmpty helper, and removed them from the code that generates the SubscriptionChange permutations for testing. This allows tests to pass, but also stays closer to what the system was designed to do. In tracking down this bug, I discovered that the logging we had for errors when running Postgres tests was inadequate, so I updated the logs when that failure occurs while testing Postgres to help surface future failures faster.

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