ducky/subscriptions

Paddy 2015-06-22 Parent:b240b6123548 Child:c4cfceb2f2fb

4:36e90e828dd0 Go to Latest

ducky/subscriptions/subscription.go

Add an API and subscriptionsd . Create a barebones implementation of the API, including only methods to create a Subscription and retrieve the Subscription associated with a user. Also create a subscriptiond service that will bootstrap the service and stores, and get everything stood up.

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