ducky/subscriptions

Paddy 2015-06-16 Parent:f1a22fc2321d Child:b240b6123548

2:61c4ce5850da Go to Latest

ducky/subscriptions/subscription.go

Use stripe's built-in subscriptions. We're going to use Stripe's built-in subscriptions to manage our subscriptions, which required us to change a lot of stuff. We're now tracking stripe_subscription instead of stripe_customer, and we need to track the plan, status, and if the user is canceling after this month. We also don't need to know when to begin charging them (Stripe will do it), but we should track when their trial starts and ends, when the current pay period they're in starts and ends, when they canceled (if they've canceled), the number of failed charge attempts they've had, and the last time we notified them about billing (To avoid spamming users). We get to delete all the stuff about periods, which is nice. We updated our SubscriptionChange type to match. Notably, there are a lot of non-user modifiable things now, but our Stripe webhook will need to use them to update our database records and keep them in sync. We no longer need to deal with sorting stuff, which is also nice. Our SubscriptionStats have been updated to be... useful? Now we can track how many users we have, and how many of them have failing credit cards, how many are canceling at the end of their current payment period, and how many users are on each plan. We also switched around how the TestUpdateSubscription loops were written, to avoid resetting more than we needed to. Before, we had to call store.reset() after every single change iteration. Now we get to call it only when switching stores. This makes a significant difference in the amount of time it takes to run tests. Finally, we added a test case for retrieving subscription stats. It's minimal, but it works.

History
     1.1 --- a/subscription.go	Sun Jun 14 02:48:08 2015 -0400
     1.2 +++ b/subscription.go	Tue Jun 16 23:09:59 2015 -0400
     1.3 @@ -1,18 +1,12 @@
     1.4  package subscriptions
     1.5  
     1.6  import (
     1.7 -	"database/sql/driver"
     1.8  	"errors"
     1.9  	"time"
    1.10  
    1.11  	"code.secondbit.org/uuid.hg"
    1.12  )
    1.13  
    1.14 -const (
    1.15 -	// MonthlyPeriod represents a period of once a month.
    1.16 -	MonthlyPeriod period = "monthly"
    1.17 -)
    1.18 -
    1.19  var (
    1.20  	// ErrSubscriptionAlreadyExists is returned when a Subscription
    1.21  	// with an identical ID already exists in the subscriptionStore.
    1.22 @@ -20,10 +14,10 @@
    1.23  	// ErrSubscriptionNotFound is returned when a single Subscription
    1.24  	// is acted upon or requested, but cannot be found.
    1.25  	ErrSubscriptionNotFound = errors.New("Subscription not found")
    1.26 -	// ErrStripeCustomerAlreadyExists is returned when a Subscription
    1.27 -	// is created or updates its StripeCustomer property, but that
    1.28 -	// StripeCustomer is already associated with another Subscription.
    1.29 -	ErrStripeCustomerAlreadyExists = errors.New("Stripe customer already assigned to another Subscription")
    1.30 +	// ErrStripeSubscriptionAlreadyExists is returned when a Subscription
    1.31 +	// is created or updates its StripeSubscription property, but that
    1.32 +	// StripeSubscription is already associated with another Subscription.
    1.33 +	ErrStripeSubscriptionAlreadyExists = errors.New("Stripe subscription already assigned to another Subscription")
    1.34  	// ErrSubscriptionChangeEmpty is returned when a SubscriptionChange
    1.35  	// is empty but is passed to subscriptionStore.UpdateSubscription
    1.36  	// anyways.
    1.37 @@ -31,84 +25,83 @@
    1.38  	// ErrNoSubscriptionID is returned when one or more Subscription IDs
    1.39  	// are required, but none are provided.
    1.40  	ErrNoSubscriptionID = errors.New("no Subscription ID provided")
    1.41 -	// ErrPeriodInvalid is returned when attempting to use a period that
    1.42 -	// is not a valid period.
    1.43 -	ErrPeriodInvalid = errors.New("invalid period")
    1.44  )
    1.45  
    1.46 -type period string
    1.47 -
    1.48 -func (p period) Value() (driver.Value, error) {
    1.49 -	return string(p), nil
    1.50 -}
    1.51 -
    1.52 -func (p *period) Scan(src interface{}) error {
    1.53 -	if src == nil {
    1.54 -		*p = period("")
    1.55 -		return nil
    1.56 -	}
    1.57 -	switch src.(type) {
    1.58 -	case []byte:
    1.59 -		*p = period(string(src.([]byte)))
    1.60 -		return nil
    1.61 -	case string:
    1.62 -		*p = period(src.(string))
    1.63 -		return nil
    1.64 -	default:
    1.65 -		return ErrPeriodInvalid
    1.66 -	}
    1.67 -}
    1.68 -
    1.69  // Subscription represents the state of a user's payments. It holds
    1.70  // metadata about the last time a user was charged, how much a user
    1.71  // should be charged, how to charge a user and how much to charge
    1.72  // the user.
    1.73  type Subscription struct {
    1.74 -	UserID         uuid.ID
    1.75 -	StripeCustomer string
    1.76 -	Amount         int
    1.77 -	Period         period
    1.78 -	Created        time.Time
    1.79 -	BeginCharging  time.Time
    1.80 -	LastCharged    time.Time
    1.81 -	LastNotified   time.Time
    1.82 -	InLockout      bool
    1.83 +	UserID               uuid.ID
    1.84 +	StripeSubscription   string
    1.85 +	Plan                 string
    1.86 +	Status               string
    1.87 +	Canceling            bool
    1.88 +	Created              time.Time
    1.89 +	TrialStart           time.Time
    1.90 +	TrialEnd             time.Time
    1.91 +	PeriodStart          time.Time
    1.92 +	PeriodEnd            time.Time
    1.93 +	CanceledAt           time.Time
    1.94 +	FailedChargeAttempts int
    1.95 +	LastFailedCharge     time.Time
    1.96 +	LastNotified         time.Time
    1.97  }
    1.98  
    1.99  // SubscriptionChange represents desired changes to a Subscription
   1.100  // object. A nil value means that property should remain unchanged.
   1.101  type SubscriptionChange struct {
   1.102 -	StripeCustomer *string
   1.103 -	Amount         *int
   1.104 -	Period         *period
   1.105 -	BeginCharging  *time.Time
   1.106 -	LastCharged    *time.Time
   1.107 -	LastNotified   *time.Time
   1.108 -	InLockout      *bool
   1.109 +	StripeSubscription   *string
   1.110 +	Plan                 *string
   1.111 +	Status               *string
   1.112 +	Canceling            *bool
   1.113 +	TrialStart           *time.Time
   1.114 +	TrialEnd             *time.Time
   1.115 +	PeriodStart          *time.Time
   1.116 +	PeriodEnd            *time.Time
   1.117 +	CanceledAt           *time.Time
   1.118 +	FailedChargeAttempts *int
   1.119 +	LastFailedCharge     *time.Time
   1.120 +	LastNotified         *time.Time
   1.121  }
   1.122  
   1.123  // IsEmpty returns true if the SubscriptionChange doesn't request
   1.124  // a change to any property of the Subscription.
   1.125  func (change SubscriptionChange) IsEmpty() bool {
   1.126 -	if change.StripeCustomer != nil {
   1.127 +	if change.StripeSubscription != nil {
   1.128  		return false
   1.129  	}
   1.130 -	if change.Amount != nil {
   1.131 +	if change.Plan != nil {
   1.132  		return false
   1.133  	}
   1.134 -	if change.Period != nil {
   1.135 +	if change.Status != nil {
   1.136  		return false
   1.137  	}
   1.138 -	if change.BeginCharging != nil {
   1.139 +	if change.Canceling != nil {
   1.140  		return false
   1.141  	}
   1.142 -	if change.LastCharged != nil {
   1.143 +	if change.TrialStart != nil {
   1.144 +		return false
   1.145 +	}
   1.146 +	if change.TrialEnd != nil {
   1.147 +		return false
   1.148 +	}
   1.149 +	if change.PeriodStart != nil {
   1.150 +		return false
   1.151 +	}
   1.152 +	if change.PeriodEnd != nil {
   1.153 +		return false
   1.154 +	}
   1.155 +	if change.CanceledAt != nil {
   1.156  		return false
   1.157  	}
   1.158  	if change.LastNotified != nil {
   1.159  		return false
   1.160  	}
   1.161 -	if change.InLockout != nil {
   1.162 +	if change.LastFailedCharge != nil {
   1.163 +		return false
   1.164 +	}
   1.165 +	if change.FailedChargeAttempts != nil {
   1.166  		return false
   1.167  	}
   1.168  	return true
   1.169 @@ -117,57 +110,51 @@
   1.170  // ApplyChange updates a Subscription based on the changes requested
   1.171  // by a SubscriptionChange.
   1.172  func (s *Subscription) ApplyChange(change SubscriptionChange) {
   1.173 -	if change.StripeCustomer != nil {
   1.174 -		s.StripeCustomer = *change.StripeCustomer
   1.175 +	if change.StripeSubscription != nil {
   1.176 +		s.StripeSubscription = *change.StripeSubscription
   1.177  	}
   1.178 -	if change.Amount != nil {
   1.179 -		s.Amount = *change.Amount
   1.180 +	if change.Plan != nil {
   1.181 +		s.Plan = *change.Plan
   1.182  	}
   1.183 -	if change.Period != nil {
   1.184 -		s.Period = *change.Period
   1.185 +	if change.Status != nil {
   1.186 +		s.Status = *change.Status
   1.187  	}
   1.188 -	if change.BeginCharging != nil {
   1.189 -		s.BeginCharging = *change.BeginCharging
   1.190 +	if change.Canceling != nil {
   1.191 +		s.Canceling = *change.Canceling
   1.192  	}
   1.193 -	if change.LastCharged != nil {
   1.194 -		s.LastCharged = *change.LastCharged
   1.195 +	if change.TrialStart != nil {
   1.196 +		s.TrialStart = *change.TrialStart
   1.197 +	}
   1.198 +	if change.TrialEnd != nil {
   1.199 +		s.TrialEnd = *change.TrialEnd
   1.200 +	}
   1.201 +	if change.PeriodStart != nil {
   1.202 +		s.PeriodStart = *change.PeriodStart
   1.203 +	}
   1.204 +	if change.PeriodEnd != nil {
   1.205 +		s.PeriodEnd = *change.PeriodEnd
   1.206 +	}
   1.207 +	if change.CanceledAt != nil {
   1.208 +		s.CanceledAt = *change.CanceledAt
   1.209 +	}
   1.210 +	if change.LastFailedCharge != nil {
   1.211 +		s.LastFailedCharge = *change.LastFailedCharge
   1.212  	}
   1.213  	if change.LastNotified != nil {
   1.214  		s.LastNotified = *change.LastNotified
   1.215  	}
   1.216 -	if change.InLockout != nil {
   1.217 -		s.InLockout = *change.InLockout
   1.218 +	if change.FailedChargeAttempts != nil {
   1.219 +		s.FailedChargeAttempts = *change.FailedChargeAttempts
   1.220  	}
   1.221  }
   1.222  
   1.223 -// ByLastChargeDate allows us to sort a []Subscription by the LastCharged
   1.224 -// property, with the lowest LastCharged date first.
   1.225 -type ByLastChargeDate []Subscription
   1.226 -
   1.227 -// Len returns the length the SubscriptionsByLastChargeDate. It fulfills
   1.228 -// the sort.Interface interface.
   1.229 -func (s ByLastChargeDate) Len() int {
   1.230 -	return len(s)
   1.231 -}
   1.232 -
   1.233 -// Swap puts the item in position i in position j, and the item in position
   1.234 -// j in position i. It fulfills the sort.Interface interface.
   1.235 -func (s ByLastChargeDate) Swap(i, j int) {
   1.236 -	s[i], s[j] = s[j], s[i]
   1.237 -}
   1.238 -
   1.239 -// Less returns true if the item in position i should be sorted before the
   1.240 -// item in position j.
   1.241 -func (s ByLastChargeDate) Less(i, j int) bool {
   1.242 -	return s[i].LastCharged.Before(s[j].LastCharged)
   1.243 -}
   1.244 -
   1.245  // SubscriptionStats represents a set of statistics about our Subscription
   1.246  // data that will be exposed to the Prometheus scraper.
   1.247  type SubscriptionStats struct {
   1.248 -	Number      int64
   1.249 -	TotalAmount int64
   1.250 -	MeanAmount  float64
   1.251 +	Number    int64
   1.252 +	Canceling int64
   1.253 +	Failing   int64
   1.254 +	Plans     map[string]int64
   1.255  	// BUG(paddy): Currently, Kubernetes doesn't offer any way to contact _all_ nodes in a service.
   1.256  	// Because of this, we can only report stats that will be identical across nodes, e.g. stats
   1.257  	// that come from the database. More info here: https://github.com/GoogleCloudPlatform/kubernetes/issues/6666
   1.258 @@ -188,7 +175,6 @@
   1.259  	createSubscription(sub Subscription) error
   1.260  	updateSubscription(id uuid.ID, change SubscriptionChange) error
   1.261  	deleteSubscription(id uuid.ID) error
   1.262 -	listSubscriptionsLastChargedBefore(time.Time) ([]Subscription, error)
   1.263  	getSubscriptions(ids []uuid.ID) (map[string]Subscription, error)
   1.264  	getSubscriptionStats() (SubscriptionStats, error)
   1.265  }