ducky/subscriptions
ducky/subscriptions/subscription.go
Export all subscriptionStore methods. We're not going to wrap all our subscriptionStore interactions in a Context type, so we need to expose all the functions so other packages can call them. Also, it's now SubscriptionStore. Also creates a SubscriptionRequest type that is used to create a new Subscription instance, along with a Validate method on it, to detect errors when creating a SubscriptionRequest. Update our CreateSubscription function to be New, and have it take a SubscriptionRequest, a Stripe instance, and a SubscriptionStore as arguments. It'll create the Subscription in the SubscriptionStore, create a customer in Stripe, and create the subscription in Stripe, then associate the Stripe subscription with the created Subscription. Also, fix our StripeSubscriptionChange function to correctly equate a missing/zero Unix timestamp from Stripe with a missing/zero time.Time.
| paddy@0 | 1 package subscriptions |
| paddy@0 | 2 |
| paddy@0 | 3 import ( |
| paddy@0 | 4 "errors" |
| paddy@0 | 5 "time" |
| paddy@0 | 6 |
| paddy@3 | 7 "code.secondbit.org/api.hg" |
| paddy@0 | 8 "code.secondbit.org/uuid.hg" |
| paddy@0 | 9 ) |
| paddy@0 | 10 |
| paddy@0 | 11 var ( |
| paddy@0 | 12 // ErrSubscriptionAlreadyExists is returned when a Subscription |
| paddy@0 | 13 // with an identical ID already exists in the subscriptionStore. |
| paddy@0 | 14 ErrSubscriptionAlreadyExists = errors.New("Subscription already exists") |
| paddy@0 | 15 // ErrSubscriptionNotFound is returned when a single Subscription |
| paddy@0 | 16 // is acted upon or requested, but cannot be found. |
| paddy@0 | 17 ErrSubscriptionNotFound = errors.New("Subscription not found") |
| paddy@2 | 18 // ErrStripeSubscriptionAlreadyExists is returned when a Subscription |
| paddy@2 | 19 // is created or updates its StripeSubscription property, but that |
| paddy@2 | 20 // StripeSubscription is already associated with another Subscription. |
| paddy@2 | 21 ErrStripeSubscriptionAlreadyExists = errors.New("Stripe subscription already assigned to another Subscription") |
| paddy@0 | 22 // ErrSubscriptionChangeEmpty is returned when a SubscriptionChange |
| paddy@0 | 23 // is empty but is passed to subscriptionStore.UpdateSubscription |
| paddy@0 | 24 // anyways. |
| paddy@0 | 25 ErrSubscriptionChangeEmpty = errors.New("SubscriptionChange is empty") |
| paddy@0 | 26 // ErrNoSubscriptionID is returned when one or more Subscription IDs |
| paddy@0 | 27 // are required, but none are provided. |
| paddy@0 | 28 ErrNoSubscriptionID = errors.New("no Subscription ID provided") |
| paddy@3 | 29 |
| paddy@3 | 30 planOptions = []string{"basic_monthly", "basic_yearly", "supporter_monthly", "supporter_yearly"} |
| paddy@3 | 31 |
| paddy@3 | 32 Version string |
| paddy@0 | 33 ) |
| paddy@0 | 34 |
| paddy@0 | 35 // Subscription represents the state of a user's payments. It holds |
| paddy@0 | 36 // metadata about the last time a user was charged, how much a user |
| paddy@0 | 37 // should be charged, how to charge a user and how much to charge |
| paddy@0 | 38 // the user. |
| paddy@0 | 39 type Subscription struct { |
| paddy@3 | 40 UserID uuid.ID `json:"user_id"` |
| paddy@3 | 41 StripeSubscription string `json:"stripe_subscription"` |
| paddy@3 | 42 Plan string `json:"plan"` |
| paddy@3 | 43 Status string `json:"status"` |
| paddy@3 | 44 Canceling bool `json:"canceling"` |
| paddy@3 | 45 Created time.Time `json:"created"` |
| paddy@3 | 46 TrialStart time.Time `json:"trial_start,omitempty"` |
| paddy@3 | 47 TrialEnd time.Time `json:"trial_end,omitempty"` |
| paddy@3 | 48 PeriodStart time.Time `json:"period_start,omitempty"` |
| paddy@3 | 49 PeriodEnd time.Time `json:"period_end,omitempty"` |
| paddy@3 | 50 CanceledAt time.Time `json:"canceled_at,omitempty"` |
| paddy@3 | 51 FailedChargeAttempts int `json:"failed_charge_attempts"` |
| paddy@3 | 52 LastFailedCharge time.Time `json:"last_failed_charge,omitempty"` |
| paddy@3 | 53 LastNotified time.Time `json:"last_notified,omitempty"` |
| paddy@0 | 54 } |
| paddy@0 | 55 |
| paddy@0 | 56 // SubscriptionChange represents desired changes to a Subscription |
| paddy@0 | 57 // object. A nil value means that property should remain unchanged. |
| paddy@0 | 58 type SubscriptionChange struct { |
| paddy@2 | 59 StripeSubscription *string |
| paddy@2 | 60 Plan *string |
| paddy@2 | 61 Status *string |
| paddy@2 | 62 Canceling *bool |
| paddy@2 | 63 TrialStart *time.Time |
| paddy@2 | 64 TrialEnd *time.Time |
| paddy@2 | 65 PeriodStart *time.Time |
| paddy@2 | 66 PeriodEnd *time.Time |
| paddy@2 | 67 CanceledAt *time.Time |
| paddy@2 | 68 FailedChargeAttempts *int |
| paddy@2 | 69 LastFailedCharge *time.Time |
| paddy@2 | 70 LastNotified *time.Time |
| paddy@0 | 71 } |
| paddy@0 | 72 |
| paddy@0 | 73 // IsEmpty returns true if the SubscriptionChange doesn't request |
| paddy@0 | 74 // a change to any property of the Subscription. |
| paddy@0 | 75 func (change SubscriptionChange) IsEmpty() bool { |
| paddy@2 | 76 if change.StripeSubscription != nil { |
| paddy@0 | 77 return false |
| paddy@0 | 78 } |
| paddy@2 | 79 if change.Plan != nil { |
| paddy@0 | 80 return false |
| paddy@0 | 81 } |
| paddy@2 | 82 if change.Status != nil { |
| paddy@0 | 83 return false |
| paddy@0 | 84 } |
| paddy@2 | 85 if change.Canceling != nil { |
| paddy@0 | 86 return false |
| paddy@0 | 87 } |
| paddy@2 | 88 if change.TrialStart != nil { |
| paddy@2 | 89 return false |
| paddy@2 | 90 } |
| paddy@2 | 91 if change.TrialEnd != nil { |
| paddy@2 | 92 return false |
| paddy@2 | 93 } |
| paddy@2 | 94 if change.PeriodStart != nil { |
| paddy@2 | 95 return false |
| paddy@2 | 96 } |
| paddy@2 | 97 if change.PeriodEnd != nil { |
| paddy@2 | 98 return false |
| paddy@2 | 99 } |
| paddy@2 | 100 if change.CanceledAt != nil { |
| paddy@0 | 101 return false |
| paddy@0 | 102 } |
| paddy@0 | 103 if change.LastNotified != nil { |
| paddy@0 | 104 return false |
| paddy@0 | 105 } |
| paddy@2 | 106 if change.LastFailedCharge != nil { |
| paddy@2 | 107 return false |
| paddy@2 | 108 } |
| paddy@2 | 109 if change.FailedChargeAttempts != nil { |
| paddy@0 | 110 return false |
| paddy@0 | 111 } |
| paddy@0 | 112 return true |
| paddy@0 | 113 } |
| paddy@0 | 114 |
| paddy@0 | 115 // ApplyChange updates a Subscription based on the changes requested |
| paddy@0 | 116 // by a SubscriptionChange. |
| paddy@0 | 117 func (s *Subscription) ApplyChange(change SubscriptionChange) { |
| paddy@2 | 118 if change.StripeSubscription != nil { |
| paddy@2 | 119 s.StripeSubscription = *change.StripeSubscription |
| paddy@0 | 120 } |
| paddy@2 | 121 if change.Plan != nil { |
| paddy@2 | 122 s.Plan = *change.Plan |
| paddy@0 | 123 } |
| paddy@2 | 124 if change.Status != nil { |
| paddy@2 | 125 s.Status = *change.Status |
| paddy@0 | 126 } |
| paddy@2 | 127 if change.Canceling != nil { |
| paddy@2 | 128 s.Canceling = *change.Canceling |
| paddy@0 | 129 } |
| paddy@2 | 130 if change.TrialStart != nil { |
| paddy@2 | 131 s.TrialStart = *change.TrialStart |
| paddy@2 | 132 } |
| paddy@2 | 133 if change.TrialEnd != nil { |
| paddy@2 | 134 s.TrialEnd = *change.TrialEnd |
| paddy@2 | 135 } |
| paddy@2 | 136 if change.PeriodStart != nil { |
| paddy@2 | 137 s.PeriodStart = *change.PeriodStart |
| paddy@2 | 138 } |
| paddy@2 | 139 if change.PeriodEnd != nil { |
| paddy@2 | 140 s.PeriodEnd = *change.PeriodEnd |
| paddy@2 | 141 } |
| paddy@2 | 142 if change.CanceledAt != nil { |
| paddy@2 | 143 s.CanceledAt = *change.CanceledAt |
| paddy@2 | 144 } |
| paddy@2 | 145 if change.LastFailedCharge != nil { |
| paddy@2 | 146 s.LastFailedCharge = *change.LastFailedCharge |
| paddy@0 | 147 } |
| paddy@0 | 148 if change.LastNotified != nil { |
| paddy@0 | 149 s.LastNotified = *change.LastNotified |
| paddy@0 | 150 } |
| paddy@2 | 151 if change.FailedChargeAttempts != nil { |
| paddy@2 | 152 s.FailedChargeAttempts = *change.FailedChargeAttempts |
| paddy@0 | 153 } |
| paddy@0 | 154 } |
| paddy@0 | 155 |
| paddy@3 | 156 type SubscriptionRequest struct { |
| paddy@3 | 157 UserID uuid.ID `json:"user_id"` |
| paddy@3 | 158 Email string `json:"email"` |
| paddy@3 | 159 StripeToken string `json:"stripe_token"` |
| paddy@3 | 160 Plan string `json:"plan"` |
| paddy@3 | 161 } |
| paddy@3 | 162 |
| paddy@3 | 163 func (req SubscriptionRequest) Validate(user uuid.ID, admin bool) []api.RequestError { |
| paddy@3 | 164 var errors []api.RequestError |
| paddy@3 | 165 if req.UserID == nil { |
| paddy@3 | 166 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/user_id"}) |
| paddy@3 | 167 } else if !req.UserID.Equal(user) && !admin { |
| paddy@3 | 168 errors = append(errors, api.RequestError{Slug: api.RequestErrAccessDenied, Field: "/user_id"}) |
| paddy@3 | 169 } |
| paddy@3 | 170 if req.Email == "" { |
| paddy@3 | 171 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/email"}) |
| paddy@3 | 172 } |
| paddy@3 | 173 if req.StripeToken == "" { |
| paddy@3 | 174 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/stripe_token"}) |
| paddy@3 | 175 } |
| paddy@3 | 176 if req.Plan == "" { |
| paddy@3 | 177 errors = append(errors, api.RequestError{Slug: api.RequestErrMissing, Field: "/plan"}) |
| paddy@3 | 178 } else { |
| paddy@3 | 179 var acceptablePlan bool |
| paddy@3 | 180 for _, p := range planOptions { |
| paddy@3 | 181 if req.Plan == p { |
| paddy@3 | 182 acceptablePlan = true |
| paddy@3 | 183 break |
| paddy@3 | 184 } |
| paddy@3 | 185 } |
| paddy@3 | 186 if !acceptablePlan { |
| paddy@3 | 187 errors = append(errors, api.RequestError{Slug: api.RequestErrInvalidValue, Field: "/plan"}) |
| paddy@3 | 188 } |
| paddy@3 | 189 } |
| paddy@3 | 190 return errors |
| paddy@3 | 191 } |
| paddy@3 | 192 |
| paddy@3 | 193 func SubscriptionFromRequest(req SubscriptionRequest) Subscription { |
| paddy@3 | 194 return Subscription{ |
| paddy@3 | 195 UserID: req.UserID, |
| paddy@3 | 196 StripeSubscription: req.StripeToken, |
| paddy@3 | 197 Plan: req.Plan, |
| paddy@3 | 198 Created: time.Now(), |
| paddy@3 | 199 TrialStart: time.Now(), |
| paddy@3 | 200 } |
| paddy@3 | 201 } |
| paddy@3 | 202 |
| paddy@1 | 203 // SubscriptionStats represents a set of statistics about our Subscription |
| paddy@1 | 204 // data that will be exposed to the Prometheus scraper. |
| paddy@1 | 205 type SubscriptionStats struct { |
| paddy@2 | 206 Number int64 |
| paddy@2 | 207 Canceling int64 |
| paddy@2 | 208 Failing int64 |
| paddy@2 | 209 Plans map[string]int64 |
| paddy@1 | 210 // BUG(paddy): Currently, Kubernetes doesn't offer any way to contact _all_ nodes in a service. |
| paddy@1 | 211 // Because of this, we can only report stats that will be identical across nodes, e.g. stats |
| paddy@1 | 212 // that come from the database. More info here: https://github.com/GoogleCloudPlatform/kubernetes/issues/6666 |
| paddy@1 | 213 // In the future, we'll need per-node metrics. For now, we'll make do. |
| paddy@1 | 214 // |
| paddy@1 | 215 // Actually, as of https://github.com/GoogleCloudPlatform/kubernetes/pull/9073, we may be all set: |
| paddy@1 | 216 // "SRV Records are created for named ports that are part of normal or Headless Services. For each named port, |
| paddy@1 | 217 // the SRV record would have the form _my-port-name._my-port-protocol.my-svc.my-namespace.svc.cluster.local. |
| paddy@1 | 218 // For a regular service, this resolves to the port number and the CNAME: my-svc.my-namespace.svc.cluster.local. |
| paddy@1 | 219 // For a headless service, this resolves to multiple answers, one for each pod that is backing the service, and |
| paddy@1 | 220 // 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 | 221 // SRV records always contain the 'svc' segment in them and are not supported for old-style CNAMEs where the 'svc' segment |
| paddy@1 | 222 // was omitted." |
| paddy@1 | 223 } |
| paddy@1 | 224 |
| paddy@3 | 225 type SubscriptionStore interface { |
| paddy@3 | 226 Reset() error |
| paddy@3 | 227 CreateSubscription(sub Subscription) error |
| paddy@3 | 228 UpdateSubscription(id uuid.ID, change SubscriptionChange) error |
| paddy@3 | 229 DeleteSubscription(id uuid.ID) error |
| paddy@3 | 230 GetSubscriptions(ids []uuid.ID) (map[string]Subscription, error) |
| paddy@3 | 231 GetSubscriptionStats() (SubscriptionStats, error) |
| paddy@0 | 232 } |