ducky/subscriptions

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

2:61c4ce5850da Browse Files

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.

sql/postgres_init.sql stripe.go subscription.go subscription_memstore.go subscription_postgres.go subscription_store_test.go

     1.1 --- a/sql/postgres_init.sql	Sun Jun 14 02:48:08 2015 -0400
     1.2 +++ b/sql/postgres_init.sql	Tue Jun 16 23:09:59 2015 -0400
     1.3 @@ -1,11 +1,16 @@
     1.4  CREATE TABLE IF NOT EXISTS subscriptions (
     1.5  	user_id VARCHAR(36) PRIMARY KEY,
     1.6 -	stripe_customer VARCHAR(36) UNIQUE NOT NULL,
     1.7 -	amount INTEGER NOT NULL,
     1.8 -	period VARCHAR(16) NOT NULL,
     1.9 +	stripe_subscription VARCHAR(36) UNIQUE NOT NULL,
    1.10 +	plan VARCHAR(36) NOT NULL,
    1.11 +	status VARCHAR(16) NOT NULL,
    1.12 +	canceling BOOLEAN NOT NULL,
    1.13  	created TIMESTAMPTZ NOT NULL,
    1.14 -	begin_charging TIMESTAMPTZ NOT NULL,
    1.15 -	last_charged TIMESTAMPTZ NOT NULL,
    1.16 -	last_notified TIMESTAMPTZ NOT NULL,
    1.17 -	in_lockout BOOLEAN NOT NULL
    1.18 +	trial_start TIMESTAMPTZ NOT NULL,
    1.19 +	trial_end TIMESTAMPTZ NOT NULL,
    1.20 +	period_start TIMESTAMPTZ NOT NULL,
    1.21 +	period_end TIMESTAMPTZ NOT NULL,
    1.22 +	canceled_at TIMESTAMPTZ NOT NULL,
    1.23 +	failed_charge_attempts INTEGER NOT NULL,
    1.24 +	last_failed_charge TIMESTAMPTZ NOT NULL,
    1.25 +	last_notified TIMESTAMPTZ NOT NULL
    1.26  );
     2.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     2.2 +++ b/stripe.go	Tue Jun 16 23:09:59 2015 -0400
     2.3 @@ -0,0 +1,127 @@
     2.4 +package subscriptions
     2.5 +
     2.6 +import (
     2.7 +	"time"
     2.8 +
     2.9 +	"code.secondbit.org/uuid.hg"
    2.10 +
    2.11 +	"github.com/stripe/stripe-go"
    2.12 +	"github.com/stripe/stripe-go/customer"
    2.13 +	"github.com/stripe/stripe-go/sub"
    2.14 +)
    2.15 +
    2.16 +type Stripe struct {
    2.17 +	apiKey        string
    2.18 +	customers     customer.Client
    2.19 +	subscriptions sub.Client
    2.20 +}
    2.21 +
    2.22 +func NewStripe(apiKey string, backend stripe.Backend) Stripe {
    2.23 +	return Stripe{
    2.24 +		apiKey: apiKey,
    2.25 +		customers: customer.Client{
    2.26 +			B:   backend,
    2.27 +			Key: apiKey,
    2.28 +		},
    2.29 +		subscriptions: sub.Client{
    2.30 +			B:   backend,
    2.31 +			Key: apiKey,
    2.32 +		},
    2.33 +	}
    2.34 +}
    2.35 +
    2.36 +func CreateStripeCustomer(token, email string, userID uuid.ID, s Stripe) (*stripe.Customer, error) {
    2.37 +	customerParams := &stripe.CustomerParams{
    2.38 +		Desc:  "Customer for user " + userID.String(),
    2.39 +		Email: email,
    2.40 +	}
    2.41 +	customerParams.AddMeta("UserID", userID.String())
    2.42 +	customerParams.SetSource(token)
    2.43 +	c, err := s.customers.New(customerParams)
    2.44 +	if err != nil {
    2.45 +		return nil, err
    2.46 +	}
    2.47 +	return c, nil
    2.48 +}
    2.49 +
    2.50 +func CreateStripeSubscription(customer, plan string, userID uuid.ID, s Stripe) (*stripe.Sub, error) {
    2.51 +	subParams := &stripe.SubParams{
    2.52 +		Plan:     plan,
    2.53 +		Customer: customer,
    2.54 +	}
    2.55 +	subParams.AddMeta("UserID", userID.String())
    2.56 +
    2.57 +	resp, err := s.subscriptions.New(subParams)
    2.58 +	if err != nil {
    2.59 +		return nil, err
    2.60 +	}
    2.61 +	return resp, nil
    2.62 +}
    2.63 +
    2.64 +func CreateSubscription(token, email string, subscription Subscription, s Stripe, store subscriptionStore) error {
    2.65 +	// create the subscription in our datastore
    2.66 +	// this will fail if they already have a subscription, which prevents duplicate/orphaned Stripe customers being created
    2.67 +	err := store.createSubscription(subscription)
    2.68 +	if err != nil {
    2.69 +		return err
    2.70 +	}
    2.71 +
    2.72 +	// create the customer in Stripe, storing the token for reuse
    2.73 +	customer, err := CreateStripeCustomer(token, email, subscription.UserID, s)
    2.74 +	if err != nil {
    2.75 +		// TODO: delete subscription object
    2.76 +		return err
    2.77 +	}
    2.78 +
    2.79 +	// create the subscription in Stripe, storing the ID for tracking and associating purposes
    2.80 +	stripeSub, err := CreateStripeSubscription(customer.ID, subscription.Plan, subscription.UserID, s)
    2.81 +	if err != nil {
    2.82 +		// TODO: delete customer
    2.83 +		// TODO: delete subscription object
    2.84 +		return err
    2.85 +	}
    2.86 +
    2.87 +	// update our subscription in the datastore with the latest information from Stripe
    2.88 +	change := StripeSubscriptionChange(subscription, *stripeSub)
    2.89 +	err = store.updateSubscription(subscription.UserID, change)
    2.90 +	if err != nil {
    2.91 +		// TODO: log an error, manually retry later?
    2.92 +		return err
    2.93 +	}
    2.94 +	return nil
    2.95 +}
    2.96 +
    2.97 +func StripeSubscriptionChange(orig Subscription, subscription stripe.Sub) SubscriptionChange {
    2.98 +	var change SubscriptionChange
    2.99 +	if subscription.Plan != nil && orig.Plan != subscription.Plan.ID {
   2.100 +		change.Plan = &subscription.Plan.ID
   2.101 +	}
   2.102 +	if string(subscription.Status) != orig.Status {
   2.103 +		status := string(subscription.Status)
   2.104 +		change.Status = &status
   2.105 +	}
   2.106 +	if subscription.EndCancel != orig.Canceling {
   2.107 +		change.Canceling = &subscription.EndCancel
   2.108 +	}
   2.109 +	if !time.Unix(subscription.TrialStart, 0).Equal(orig.TrialStart) {
   2.110 +		trialStart := time.Unix(subscription.TrialStart, 0)
   2.111 +		change.TrialStart = &trialStart
   2.112 +	}
   2.113 +	if !time.Unix(subscription.TrialEnd, 0).Equal(orig.TrialEnd) {
   2.114 +		trialEnd := time.Unix(subscription.TrialEnd, 0)
   2.115 +		change.TrialEnd = &trialEnd
   2.116 +	}
   2.117 +	if !time.Unix(subscription.PeriodStart, 0).Equal(orig.PeriodStart) {
   2.118 +		periodStart := time.Unix(subscription.PeriodStart, 0)
   2.119 +		change.PeriodStart = &periodStart
   2.120 +	}
   2.121 +	if !time.Unix(subscription.PeriodEnd, 0).Equal(orig.PeriodEnd) {
   2.122 +		periodEnd := time.Unix(subscription.PeriodEnd, 0)
   2.123 +		change.PeriodEnd = &periodEnd
   2.124 +	}
   2.125 +	if !time.Unix(subscription.Canceled, 0).Equal(orig.CanceledAt) {
   2.126 +		canceledAt := time.Unix(subscription.Canceled, 0)
   2.127 +		change.CanceledAt = &canceledAt
   2.128 +	}
   2.129 +	return change
   2.130 +}
     3.1 --- a/subscription.go	Sun Jun 14 02:48:08 2015 -0400
     3.2 +++ b/subscription.go	Tue Jun 16 23:09:59 2015 -0400
     3.3 @@ -1,18 +1,12 @@
     3.4  package subscriptions
     3.5  
     3.6  import (
     3.7 -	"database/sql/driver"
     3.8  	"errors"
     3.9  	"time"
    3.10  
    3.11  	"code.secondbit.org/uuid.hg"
    3.12  )
    3.13  
    3.14 -const (
    3.15 -	// MonthlyPeriod represents a period of once a month.
    3.16 -	MonthlyPeriod period = "monthly"
    3.17 -)
    3.18 -
    3.19  var (
    3.20  	// ErrSubscriptionAlreadyExists is returned when a Subscription
    3.21  	// with an identical ID already exists in the subscriptionStore.
    3.22 @@ -20,10 +14,10 @@
    3.23  	// ErrSubscriptionNotFound is returned when a single Subscription
    3.24  	// is acted upon or requested, but cannot be found.
    3.25  	ErrSubscriptionNotFound = errors.New("Subscription not found")
    3.26 -	// ErrStripeCustomerAlreadyExists is returned when a Subscription
    3.27 -	// is created or updates its StripeCustomer property, but that
    3.28 -	// StripeCustomer is already associated with another Subscription.
    3.29 -	ErrStripeCustomerAlreadyExists = errors.New("Stripe customer already assigned to another Subscription")
    3.30 +	// ErrStripeSubscriptionAlreadyExists is returned when a Subscription
    3.31 +	// is created or updates its StripeSubscription property, but that
    3.32 +	// StripeSubscription is already associated with another Subscription.
    3.33 +	ErrStripeSubscriptionAlreadyExists = errors.New("Stripe subscription already assigned to another Subscription")
    3.34  	// ErrSubscriptionChangeEmpty is returned when a SubscriptionChange
    3.35  	// is empty but is passed to subscriptionStore.UpdateSubscription
    3.36  	// anyways.
    3.37 @@ -31,84 +25,83 @@
    3.38  	// ErrNoSubscriptionID is returned when one or more Subscription IDs
    3.39  	// are required, but none are provided.
    3.40  	ErrNoSubscriptionID = errors.New("no Subscription ID provided")
    3.41 -	// ErrPeriodInvalid is returned when attempting to use a period that
    3.42 -	// is not a valid period.
    3.43 -	ErrPeriodInvalid = errors.New("invalid period")
    3.44  )
    3.45  
    3.46 -type period string
    3.47 -
    3.48 -func (p period) Value() (driver.Value, error) {
    3.49 -	return string(p), nil
    3.50 -}
    3.51 -
    3.52 -func (p *period) Scan(src interface{}) error {
    3.53 -	if src == nil {
    3.54 -		*p = period("")
    3.55 -		return nil
    3.56 -	}
    3.57 -	switch src.(type) {
    3.58 -	case []byte:
    3.59 -		*p = period(string(src.([]byte)))
    3.60 -		return nil
    3.61 -	case string:
    3.62 -		*p = period(src.(string))
    3.63 -		return nil
    3.64 -	default:
    3.65 -		return ErrPeriodInvalid
    3.66 -	}
    3.67 -}
    3.68 -
    3.69  // Subscription represents the state of a user's payments. It holds
    3.70  // metadata about the last time a user was charged, how much a user
    3.71  // should be charged, how to charge a user and how much to charge
    3.72  // the user.
    3.73  type Subscription struct {
    3.74 -	UserID         uuid.ID
    3.75 -	StripeCustomer string
    3.76 -	Amount         int
    3.77 -	Period         period
    3.78 -	Created        time.Time
    3.79 -	BeginCharging  time.Time
    3.80 -	LastCharged    time.Time
    3.81 -	LastNotified   time.Time
    3.82 -	InLockout      bool
    3.83 +	UserID               uuid.ID
    3.84 +	StripeSubscription   string
    3.85 +	Plan                 string
    3.86 +	Status               string
    3.87 +	Canceling            bool
    3.88 +	Created              time.Time
    3.89 +	TrialStart           time.Time
    3.90 +	TrialEnd             time.Time
    3.91 +	PeriodStart          time.Time
    3.92 +	PeriodEnd            time.Time
    3.93 +	CanceledAt           time.Time
    3.94 +	FailedChargeAttempts int
    3.95 +	LastFailedCharge     time.Time
    3.96 +	LastNotified         time.Time
    3.97  }
    3.98  
    3.99  // SubscriptionChange represents desired changes to a Subscription
   3.100  // object. A nil value means that property should remain unchanged.
   3.101  type SubscriptionChange struct {
   3.102 -	StripeCustomer *string
   3.103 -	Amount         *int
   3.104 -	Period         *period
   3.105 -	BeginCharging  *time.Time
   3.106 -	LastCharged    *time.Time
   3.107 -	LastNotified   *time.Time
   3.108 -	InLockout      *bool
   3.109 +	StripeSubscription   *string
   3.110 +	Plan                 *string
   3.111 +	Status               *string
   3.112 +	Canceling            *bool
   3.113 +	TrialStart           *time.Time
   3.114 +	TrialEnd             *time.Time
   3.115 +	PeriodStart          *time.Time
   3.116 +	PeriodEnd            *time.Time
   3.117 +	CanceledAt           *time.Time
   3.118 +	FailedChargeAttempts *int
   3.119 +	LastFailedCharge     *time.Time
   3.120 +	LastNotified         *time.Time
   3.121  }
   3.122  
   3.123  // IsEmpty returns true if the SubscriptionChange doesn't request
   3.124  // a change to any property of the Subscription.
   3.125  func (change SubscriptionChange) IsEmpty() bool {
   3.126 -	if change.StripeCustomer != nil {
   3.127 +	if change.StripeSubscription != nil {
   3.128  		return false
   3.129  	}
   3.130 -	if change.Amount != nil {
   3.131 +	if change.Plan != nil {
   3.132  		return false
   3.133  	}
   3.134 -	if change.Period != nil {
   3.135 +	if change.Status != nil {
   3.136  		return false
   3.137  	}
   3.138 -	if change.BeginCharging != nil {
   3.139 +	if change.Canceling != nil {
   3.140  		return false
   3.141  	}
   3.142 -	if change.LastCharged != nil {
   3.143 +	if change.TrialStart != nil {
   3.144 +		return false
   3.145 +	}
   3.146 +	if change.TrialEnd != nil {
   3.147 +		return false
   3.148 +	}
   3.149 +	if change.PeriodStart != nil {
   3.150 +		return false
   3.151 +	}
   3.152 +	if change.PeriodEnd != nil {
   3.153 +		return false
   3.154 +	}
   3.155 +	if change.CanceledAt != nil {
   3.156  		return false
   3.157  	}
   3.158  	if change.LastNotified != nil {
   3.159  		return false
   3.160  	}
   3.161 -	if change.InLockout != nil {
   3.162 +	if change.LastFailedCharge != nil {
   3.163 +		return false
   3.164 +	}
   3.165 +	if change.FailedChargeAttempts != nil {
   3.166  		return false
   3.167  	}
   3.168  	return true
   3.169 @@ -117,57 +110,51 @@
   3.170  // ApplyChange updates a Subscription based on the changes requested
   3.171  // by a SubscriptionChange.
   3.172  func (s *Subscription) ApplyChange(change SubscriptionChange) {
   3.173 -	if change.StripeCustomer != nil {
   3.174 -		s.StripeCustomer = *change.StripeCustomer
   3.175 +	if change.StripeSubscription != nil {
   3.176 +		s.StripeSubscription = *change.StripeSubscription
   3.177  	}
   3.178 -	if change.Amount != nil {
   3.179 -		s.Amount = *change.Amount
   3.180 +	if change.Plan != nil {
   3.181 +		s.Plan = *change.Plan
   3.182  	}
   3.183 -	if change.Period != nil {
   3.184 -		s.Period = *change.Period
   3.185 +	if change.Status != nil {
   3.186 +		s.Status = *change.Status
   3.187  	}
   3.188 -	if change.BeginCharging != nil {
   3.189 -		s.BeginCharging = *change.BeginCharging
   3.190 +	if change.Canceling != nil {
   3.191 +		s.Canceling = *change.Canceling
   3.192  	}
   3.193 -	if change.LastCharged != nil {
   3.194 -		s.LastCharged = *change.LastCharged
   3.195 +	if change.TrialStart != nil {
   3.196 +		s.TrialStart = *change.TrialStart
   3.197 +	}
   3.198 +	if change.TrialEnd != nil {
   3.199 +		s.TrialEnd = *change.TrialEnd
   3.200 +	}
   3.201 +	if change.PeriodStart != nil {
   3.202 +		s.PeriodStart = *change.PeriodStart
   3.203 +	}
   3.204 +	if change.PeriodEnd != nil {
   3.205 +		s.PeriodEnd = *change.PeriodEnd
   3.206 +	}
   3.207 +	if change.CanceledAt != nil {
   3.208 +		s.CanceledAt = *change.CanceledAt
   3.209 +	}
   3.210 +	if change.LastFailedCharge != nil {
   3.211 +		s.LastFailedCharge = *change.LastFailedCharge
   3.212  	}
   3.213  	if change.LastNotified != nil {
   3.214  		s.LastNotified = *change.LastNotified
   3.215  	}
   3.216 -	if change.InLockout != nil {
   3.217 -		s.InLockout = *change.InLockout
   3.218 +	if change.FailedChargeAttempts != nil {
   3.219 +		s.FailedChargeAttempts = *change.FailedChargeAttempts
   3.220  	}
   3.221  }
   3.222  
   3.223 -// ByLastChargeDate allows us to sort a []Subscription by the LastCharged
   3.224 -// property, with the lowest LastCharged date first.
   3.225 -type ByLastChargeDate []Subscription
   3.226 -
   3.227 -// Len returns the length the SubscriptionsByLastChargeDate. It fulfills
   3.228 -// the sort.Interface interface.
   3.229 -func (s ByLastChargeDate) Len() int {
   3.230 -	return len(s)
   3.231 -}
   3.232 -
   3.233 -// Swap puts the item in position i in position j, and the item in position
   3.234 -// j in position i. It fulfills the sort.Interface interface.
   3.235 -func (s ByLastChargeDate) Swap(i, j int) {
   3.236 -	s[i], s[j] = s[j], s[i]
   3.237 -}
   3.238 -
   3.239 -// Less returns true if the item in position i should be sorted before the
   3.240 -// item in position j.
   3.241 -func (s ByLastChargeDate) Less(i, j int) bool {
   3.242 -	return s[i].LastCharged.Before(s[j].LastCharged)
   3.243 -}
   3.244 -
   3.245  // SubscriptionStats represents a set of statistics about our Subscription
   3.246  // data that will be exposed to the Prometheus scraper.
   3.247  type SubscriptionStats struct {
   3.248 -	Number      int64
   3.249 -	TotalAmount int64
   3.250 -	MeanAmount  float64
   3.251 +	Number    int64
   3.252 +	Canceling int64
   3.253 +	Failing   int64
   3.254 +	Plans     map[string]int64
   3.255  	// BUG(paddy): Currently, Kubernetes doesn't offer any way to contact _all_ nodes in a service.
   3.256  	// Because of this, we can only report stats that will be identical across nodes, e.g. stats
   3.257  	// that come from the database. More info here: https://github.com/GoogleCloudPlatform/kubernetes/issues/6666
   3.258 @@ -188,7 +175,6 @@
   3.259  	createSubscription(sub Subscription) error
   3.260  	updateSubscription(id uuid.ID, change SubscriptionChange) error
   3.261  	deleteSubscription(id uuid.ID) error
   3.262 -	listSubscriptionsLastChargedBefore(time.Time) ([]Subscription, error)
   3.263  	getSubscriptions(ids []uuid.ID) (map[string]Subscription, error)
   3.264  	getSubscriptionStats() (SubscriptionStats, error)
   3.265  }
     4.1 --- a/subscription_memstore.go	Sun Jun 14 02:48:08 2015 -0400
     4.2 +++ b/subscription_memstore.go	Tue Jun 16 23:09:59 2015 -0400
     4.3 @@ -1,15 +1,12 @@
     4.4  package subscriptions
     4.5  
     4.6  import (
     4.7 -	"sort"
     4.8 -	"time"
     4.9 -
    4.10  	"code.secondbit.org/uuid.hg"
    4.11  )
    4.12  
    4.13 -func stripeCustomerInMemstore(stripeCustomer string, m *Memstore) bool {
    4.14 +func stripeSubscriptionInMemstore(stripeSubscription string, m *Memstore) bool {
    4.15  	for _, sub := range m.subscriptions {
    4.16 -		if sub.StripeCustomer == stripeCustomer {
    4.17 +		if sub.StripeSubscription == stripeSubscription {
    4.18  			return true
    4.19  		}
    4.20  	}
    4.21 @@ -23,8 +20,8 @@
    4.22  	if _, ok := m.subscriptions[sub.UserID.String()]; ok {
    4.23  		return ErrSubscriptionAlreadyExists
    4.24  	}
    4.25 -	if stripeCustomerInMemstore(sub.StripeCustomer, m) {
    4.26 -		return ErrStripeCustomerAlreadyExists
    4.27 +	if stripeSubscriptionInMemstore(sub.StripeSubscription, m) {
    4.28 +		return ErrStripeSubscriptionAlreadyExists
    4.29  	}
    4.30  	m.subscriptions[sub.UserID.String()] = sub
    4.31  	return nil
    4.32 @@ -42,9 +39,9 @@
    4.33  	if !ok {
    4.34  		return ErrSubscriptionNotFound
    4.35  	}
    4.36 -	if change.StripeCustomer != nil {
    4.37 -		if stripeCustomerInMemstore(*change.StripeCustomer, m) {
    4.38 -			return ErrStripeCustomerAlreadyExists
    4.39 +	if change.StripeSubscription != nil {
    4.40 +		if stripeSubscriptionInMemstore(*change.StripeSubscription, m) {
    4.41 +			return ErrStripeSubscriptionAlreadyExists
    4.42  		}
    4.43  	}
    4.44  	s.ApplyChange(change)
    4.45 @@ -64,25 +61,6 @@
    4.46  	return nil
    4.47  }
    4.48  
    4.49 -func (m *Memstore) listSubscriptionsLastChargedBefore(cutoff time.Time) ([]Subscription, error) {
    4.50 -	m.subscriptionLock.RLock()
    4.51 -	defer m.subscriptionLock.RUnlock()
    4.52 -
    4.53 -	var result []Subscription
    4.54 -	for _, s := range m.subscriptions {
    4.55 -		if cutoff.Before(s.LastCharged) {
    4.56 -			continue
    4.57 -		}
    4.58 -		result = append(result, s)
    4.59 -	}
    4.60 -
    4.61 -	sorted := ByLastChargeDate(result)
    4.62 -	sort.Sort(sorted)
    4.63 -	result = []Subscription(sorted)
    4.64 -
    4.65 -	return result, nil
    4.66 -}
    4.67 -
    4.68  func (m *Memstore) getSubscriptions(ids []uuid.ID) (map[string]Subscription, error) {
    4.69  	if len(ids) < 1 {
    4.70  		return map[string]Subscription{}, ErrNoSubscriptionID
    4.71 @@ -106,15 +84,20 @@
    4.72  	m.subscriptionLock.RLock()
    4.73  	defer m.subscriptionLock.RUnlock()
    4.74  
    4.75 -	stats := SubscriptionStats{}
    4.76 +	stats := SubscriptionStats{
    4.77 +		Plans: map[string]int64{},
    4.78 +	}
    4.79  
    4.80  	for _, subscription := range m.subscriptions {
    4.81  		stats.Number++
    4.82 -		stats.TotalAmount += int64(subscription.Amount)
    4.83 -	}
    4.84 +		stats.Plans[subscription.Plan]++
    4.85  
    4.86 -	if stats.Number > 0 {
    4.87 -		stats.MeanAmount = float64(stats.TotalAmount) / float64(stats.Number)
    4.88 +		if subscription.Canceling {
    4.89 +			stats.Canceling++
    4.90 +		}
    4.91 +		if subscription.Status == "past_due" || subscription.Status == "unpaid" {
    4.92 +			stats.Failing++
    4.93 +		}
    4.94  	}
    4.95  	return stats, nil
    4.96  }
     5.1 --- a/subscription_postgres.go	Sun Jun 14 02:48:08 2015 -0400
     5.2 +++ b/subscription_postgres.go	Tue Jun 16 23:09:59 2015 -0400
     5.3 @@ -1,10 +1,10 @@
     5.4  package subscriptions
     5.5  
     5.6  import (
     5.7 -	"database/sql"
     5.8 -	"time"
     5.9 +	"log"
    5.10  
    5.11  	"code.secondbit.org/uuid.hg"
    5.12 +
    5.13  	"github.com/lib/pq"
    5.14  	"github.com/secondbit/pan"
    5.15  )
    5.16 @@ -16,8 +16,8 @@
    5.17  }
    5.18  
    5.19  func (p Postgres) resetSQL() *pan.Query {
    5.20 -	var sub Subscription
    5.21 -	query := pan.New(pan.POSTGRES, "TRUNCATE "+pan.GetTableName(sub))
    5.22 +	var subscription Subscription
    5.23 +	query := pan.New(pan.POSTGRES, "TRUNCATE "+pan.GetTableName(subscription))
    5.24  	return query.FlushExpressions(" ")
    5.25  }
    5.26  
    5.27 @@ -30,9 +30,9 @@
    5.28  	return nil
    5.29  }
    5.30  
    5.31 -func (p Postgres) createSubscriptionSQL(sub Subscription) *pan.Query {
    5.32 -	fields, values := pan.GetFields(sub)
    5.33 -	query := pan.New(pan.POSTGRES, "INSERT INTO "+pan.GetTableName(sub))
    5.34 +func (p Postgres) createSubscriptionSQL(subscription Subscription) *pan.Query {
    5.35 +	fields, values := pan.GetFields(subscription)
    5.36 +	query := pan.New(pan.POSTGRES, "INSERT INTO "+pan.GetTableName(subscription))
    5.37  	query.Include("(" + pan.QueryList(fields) + ")")
    5.38  	query.Include("VALUES")
    5.39  	query.Include("("+pan.VariableList(len(values))+")", values...)
    5.40 @@ -44,25 +44,30 @@
    5.41  	_, err := p.Exec(query.String(), query.Args...)
    5.42  	if e, ok := err.(*pq.Error); ok && e.Constraint == "subscriptions_pkey" {
    5.43  		err = ErrSubscriptionAlreadyExists
    5.44 -	} else if e, ok := err.(*pq.Error); ok && e.Constraint == "subscriptions_stripe_customer_key" {
    5.45 -		err = ErrStripeCustomerAlreadyExists
    5.46 +	} else if e, ok := err.(*pq.Error); ok && e.Constraint == "subscriptions_stripe_subscription_key" {
    5.47 +		err = ErrStripeSubscriptionAlreadyExists
    5.48  	}
    5.49  	return err
    5.50  }
    5.51  
    5.52  func (p Postgres) updateSubscriptionSQL(id uuid.ID, change SubscriptionChange) *pan.Query {
    5.53 -	var sub Subscription
    5.54 -	query := pan.New(pan.POSTGRES, "UPDATE "+pan.GetTableName(sub)+" SET")
    5.55 -	query.IncludeIfNotNil(pan.GetUnquotedColumn(sub, "StripeCustomer")+" = ?", change.StripeCustomer)
    5.56 -	query.IncludeIfNotNil(pan.GetUnquotedColumn(sub, "Amount")+" = ?", change.Amount)
    5.57 -	query.IncludeIfNotNil(pan.GetUnquotedColumn(sub, "Period")+" = ?", change.Period)
    5.58 -	query.IncludeIfNotNil(pan.GetUnquotedColumn(sub, "BeginCharging")+" = ?", change.BeginCharging)
    5.59 -	query.IncludeIfNotNil(pan.GetUnquotedColumn(sub, "LastCharged")+" = ?", change.LastCharged)
    5.60 -	query.IncludeIfNotNil(pan.GetUnquotedColumn(sub, "LastNotified")+" = ?", change.LastNotified)
    5.61 -	query.IncludeIfNotNil(pan.GetUnquotedColumn(sub, "InLockout")+" = ?", change.InLockout)
    5.62 +	var subscription Subscription
    5.63 +	query := pan.New(pan.POSTGRES, "UPDATE "+pan.GetTableName(subscription)+" SET")
    5.64 +	query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "StripeSubscription")+" = ?", change.StripeSubscription)
    5.65 +	query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "Plan")+" = ?", change.Plan)
    5.66 +	query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "Status")+" = ?", change.Status)
    5.67 +	query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "Canceling")+" = ?", change.Canceling)
    5.68 +	query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "TrialStart")+" = ?", change.TrialStart)
    5.69 +	query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "TrialEnd")+" = ?", change.TrialEnd)
    5.70 +	query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "PeriodStart")+" = ?", change.PeriodStart)
    5.71 +	query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "PeriodEnd")+" = ?", change.PeriodEnd)
    5.72 +	query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "CanceledAt")+" = ?", change.CanceledAt)
    5.73 +	query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "FailedChargeAttempts")+" = ?", change.FailedChargeAttempts)
    5.74 +	query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "LastFailedCharge")+" = ?", change.LastFailedCharge)
    5.75 +	query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "LastNotified")+" = ?", change.LastNotified)
    5.76  	query.FlushExpressions(", ")
    5.77  	query.IncludeWhere()
    5.78 -	query.Include(pan.GetUnquotedColumn(sub, "UserID")+" = ?", id)
    5.79 +	query.Include(pan.GetUnquotedColumn(subscription, "UserID")+" = ?", id)
    5.80  	return query.FlushExpressions(" ")
    5.81  }
    5.82  
    5.83 @@ -73,8 +78,8 @@
    5.84  
    5.85  	query := p.updateSubscriptionSQL(id, change)
    5.86  	res, err := p.Exec(query.String(), query.Args...)
    5.87 -	if e, ok := err.(*pq.Error); ok && e.Constraint == "subscriptions_stripe_customer_key" {
    5.88 -		return ErrStripeCustomerAlreadyExists
    5.89 +	if e, ok := err.(*pq.Error); ok && e.Constraint == "subscriptions_stripe_subscription_key" {
    5.90 +		return ErrStripeSubscriptionAlreadyExists
    5.91  	} else if err != nil {
    5.92  		return err
    5.93  	}
    5.94 @@ -89,10 +94,10 @@
    5.95  }
    5.96  
    5.97  func (p Postgres) deleteSubscriptionSQL(id uuid.ID) *pan.Query {
    5.98 -	var sub Subscription
    5.99 -	query := pan.New(pan.POSTGRES, "DELETE FROM "+pan.GetTableName(sub))
   5.100 +	var subscription Subscription
   5.101 +	query := pan.New(pan.POSTGRES, "DELETE FROM "+pan.GetTableName(subscription))
   5.102  	query.IncludeWhere()
   5.103 -	query.Include(pan.GetUnquotedColumn(sub, "UserID")+" = ?", id)
   5.104 +	query.Include(pan.GetUnquotedColumn(subscription, "UserID")+" = ?", id)
   5.105  	return query.FlushExpressions(" ")
   5.106  }
   5.107  
   5.108 @@ -112,47 +117,16 @@
   5.109  	return nil
   5.110  }
   5.111  
   5.112 -func (p Postgres) listSubscriptionsLastChargedBeforeSQL(cutoff time.Time) *pan.Query {
   5.113 -	var sub Subscription
   5.114 -	fields, _ := pan.GetFields(sub)
   5.115 -	query := pan.New(pan.POSTGRES, "SELECT "+pan.QueryList(fields)+" FROM "+pan.GetTableName(sub))
   5.116 -	query.IncludeWhere()
   5.117 -	query.Include(pan.GetUnquotedColumn(sub, "LastCharged")+" < ?", cutoff)
   5.118 -	query.IncludeOrder(pan.GetUnquotedColumn(sub, "LastCharged") + " ASC")
   5.119 -	return query.FlushExpressions(" ")
   5.120 -}
   5.121 -
   5.122 -func (p Postgres) listSubscriptionsLastChargedBefore(cutoff time.Time) ([]Subscription, error) {
   5.123 -	var results []Subscription
   5.124 -	query := p.listSubscriptionsLastChargedBeforeSQL(cutoff)
   5.125 -	rows, err := p.Query(query.String(), query.Args...)
   5.126 -	if err != nil {
   5.127 -		return results, err
   5.128 -	}
   5.129 -	for rows.Next() {
   5.130 -		var sub Subscription
   5.131 -		err := pan.Unmarshal(rows, &sub)
   5.132 -		if err != nil {
   5.133 -			return results, err
   5.134 -		}
   5.135 -		results = append(results, sub)
   5.136 -	}
   5.137 -	if err := rows.Err(); err != nil {
   5.138 -		return results, err
   5.139 -	}
   5.140 -	return results, nil
   5.141 -}
   5.142 -
   5.143  func (p Postgres) getSubscriptionsSQL(ids []uuid.ID) *pan.Query {
   5.144 -	var sub Subscription
   5.145 -	fields, _ := pan.GetFields(sub)
   5.146 +	var subscription Subscription
   5.147 +	fields, _ := pan.GetFields(subscription)
   5.148  	intIDs := make([]interface{}, len(ids))
   5.149  	for pos, id := range ids {
   5.150  		intIDs[pos] = id
   5.151  	}
   5.152 -	query := pan.New(pan.POSTGRES, "SELECT "+pan.QueryList(fields)+" FROM "+pan.GetTableName(sub))
   5.153 +	query := pan.New(pan.POSTGRES, "SELECT "+pan.QueryList(fields)+" FROM "+pan.GetTableName(subscription))
   5.154  	query.IncludeWhere()
   5.155 -	query.Include(pan.GetUnquotedColumn(sub, "UserID") + " IN")
   5.156 +	query.Include(pan.GetUnquotedColumn(subscription, "UserID") + " IN")
   5.157  	query.Include("("+pan.VariableList(len(intIDs))+")", intIDs...)
   5.158  	return query.FlushExpressions(" ")
   5.159  }
   5.160 @@ -168,12 +142,12 @@
   5.161  		return results, err
   5.162  	}
   5.163  	for rows.Next() {
   5.164 -		var sub Subscription
   5.165 -		err := pan.Unmarshal(rows, &sub)
   5.166 +		var subscription Subscription
   5.167 +		err := pan.Unmarshal(rows, &subscription)
   5.168  		if err != nil {
   5.169  			return results, err
   5.170  		}
   5.171 -		results[sub.UserID.String()] = sub
   5.172 +		results[subscription.UserID.String()] = subscription
   5.173  	}
   5.174  	if err := rows.Err(); err != nil {
   5.175  		return results, err
   5.176 @@ -181,39 +155,81 @@
   5.177  	return results, nil
   5.178  }
   5.179  
   5.180 -func (p Postgres) getSubscriptionStatsSQL() *pan.Query {
   5.181 -	var sub Subscription
   5.182 -	amountColumn := pan.GetUnquotedColumn(sub, "Amount")
   5.183 -	query := pan.New(pan.POSTGRES, "SELECT")
   5.184 -	query.Include("COUNT(*), SUM(" + amountColumn + "), AVG(" + amountColumn + ")")
   5.185 -	query.Include("FROM " + pan.GetTableName(sub))
   5.186 +func (p Postgres) getSubscriptionStatsCountSQL() *pan.Query {
   5.187 +	var subscription Subscription
   5.188 +	query := pan.New(pan.POSTGRES, "SELECT COUNT(*) FROM")
   5.189 +	query.Include(pan.GetTableName(subscription))
   5.190 +	return query.FlushExpressions(" ")
   5.191 +}
   5.192 +
   5.193 +func (p Postgres) getSubscriptionStatsCancelingSQL() *pan.Query {
   5.194 +	var subscription Subscription
   5.195 +	query := pan.New(pan.POSTGRES, "SELECT COUNT(*) FROM")
   5.196 +	query.Include(pan.GetTableName(subscription))
   5.197 +	query.IncludeWhere()
   5.198 +	query.Include(pan.GetUnquotedColumn(subscription, "Canceling")+" = ?", true)
   5.199 +	return query.FlushExpressions(" ")
   5.200 +}
   5.201 +
   5.202 +func (p Postgres) getSubscriptionStatsFailingSQL() *pan.Query {
   5.203 +	var subscription Subscription
   5.204 +	query := pan.New(pan.POSTGRES, "SELECT COUNT(*) FROM")
   5.205 +	query.Include(pan.GetTableName(subscription))
   5.206 +	query.IncludeWhere()
   5.207 +	statuses := []interface{}{"past_due", "unpaid"}
   5.208 +	query.Include(pan.GetUnquotedColumn(subscription, "Status")+" IN ("+pan.VariableList(len(statuses))+")", statuses...)
   5.209 +	return query.FlushExpressions(" ")
   5.210 +}
   5.211 +
   5.212 +func (p Postgres) getSubscriptionStatsPlansSQL() *pan.Query {
   5.213 +	var subscription Subscription
   5.214 +	fields := []interface{}{pan.GetUnquotedColumn(subscription, "Plan"), "COUNT(*)"}
   5.215 +	query := pan.New(pan.POSTGRES, "SELECT "+pan.QueryList(fields)+" FROM")
   5.216 +	query.Include(pan.GetTableName(subscription))
   5.217 +	query.Include("GROUP BY " + pan.GetUnquotedColumn(subscription, "Plan"))
   5.218  	return query.FlushExpressions(" ")
   5.219  }
   5.220  
   5.221  func (p Postgres) getSubscriptionStats() (SubscriptionStats, error) {
   5.222 -	query := p.getSubscriptionStatsSQL()
   5.223 +	stats := SubscriptionStats{
   5.224 +		Plans: map[string]int64{},
   5.225 +	}
   5.226 +	query := p.getSubscriptionStatsCountSQL()
   5.227 +	err := p.QueryRow(query.String(), query.Args...).Scan(&stats.Number)
   5.228 +	if err != nil {
   5.229 +		log.Printf("Error querying for total subscriptions: %+v\n", err)
   5.230 +		return stats, err
   5.231 +	}
   5.232 +	query = p.getSubscriptionStatsCancelingSQL()
   5.233 +	err = p.QueryRow(query.String(), query.Args...).Scan(&stats.Canceling)
   5.234 +	if err != nil {
   5.235 +		log.Printf("Error querying for canceling subscriptions: %+v\n", err)
   5.236 +		return stats, err
   5.237 +	}
   5.238 +	query = p.getSubscriptionStatsFailingSQL()
   5.239 +	err = p.QueryRow(query.String(), query.Args...).Scan(&stats.Failing)
   5.240 +	if err != nil {
   5.241 +		log.Printf("Error querying for failing subscriptions: %+v\n", err)
   5.242 +		return stats, err
   5.243 +	}
   5.244 +	query = p.getSubscriptionStatsPlansSQL()
   5.245  	rows, err := p.Query(query.String(), query.Args...)
   5.246  	if err != nil {
   5.247 -		return SubscriptionStats{}, err
   5.248 +		log.Printf("Error querying for plans: %+v\n", err)
   5.249 +		return stats, err
   5.250  	}
   5.251 -	var stats SubscriptionStats
   5.252  	for rows.Next() {
   5.253 -		var number, total sql.NullInt64
   5.254 -		var mean sql.NullFloat64
   5.255 -		if err := rows.Scan(number, total, mean); err != nil {
   5.256 -			return stats, err
   5.257 +		var plan string
   5.258 +		var count int64
   5.259 +		err := rows.Scan(&plan, &count)
   5.260 +		if err != nil {
   5.261 +			log.Printf("Error scanning database row for plans: %+v\n", err)
   5.262 +			continue
   5.263  		}
   5.264 -		if number.Valid {
   5.265 -			stats.Number = number.Int64
   5.266 -		}
   5.267 -		if total.Valid {
   5.268 -			stats.TotalAmount = total.Int64
   5.269 -		}
   5.270 -		if mean.Valid {
   5.271 -			stats.MeanAmount = mean.Float64
   5.272 -		}
   5.273 +		stats.Plans[plan] = count
   5.274  	}
   5.275  	if err := rows.Err(); err != nil {
   5.276 +		log.Printf("Error querying for plans: %+v\n", err)
   5.277  		return stats, err
   5.278  	}
   5.279  	return stats, nil
     6.1 --- a/subscription_store_test.go	Sun Jun 14 02:48:08 2015 -0400
     6.2 +++ b/subscription_store_test.go	Tue Jun 16 23:09:59 2015 -0400
     6.3 @@ -10,13 +10,18 @@
     6.4  )
     6.5  
     6.6  const (
     6.7 -	subscriptionChangeStripeCustomer = 1 << iota
     6.8 -	subscriptionChangeAmount
     6.9 -	subscriptionChangePeriod
    6.10 -	subscriptionChangeBeginCharging
    6.11 -	subscriptionChangeLastCharged
    6.12 +	subscriptionChangeStripeSubscription = 1 << iota
    6.13 +	subscriptionChangePlan
    6.14 +	subscriptionChangeStatus
    6.15 +	subscriptionChangeCanceling
    6.16 +	subscriptionChangeTrialStart
    6.17 +	subscriptionChangeTrialEnd
    6.18 +	subscriptionChangePeriodStart
    6.19 +	subscriptionChangePeriodEnd
    6.20 +	subscriptionChangeCanceledAt
    6.21 +	subscriptionChangeFailedChargeAttempts
    6.22 +	subscriptionChangeLastFailedCharge
    6.23  	subscriptionChangeLastNotified
    6.24 -	subscriptionChangeInLockout
    6.25  )
    6.26  
    6.27  func init() {
    6.28 @@ -37,30 +42,45 @@
    6.29  	if !sub1.UserID.Equal(sub2.UserID) {
    6.30  		return false, "UserID", sub1.UserID, sub2.UserID
    6.31  	}
    6.32 -	if sub1.StripeCustomer != sub2.StripeCustomer {
    6.33 -		return false, "StripeCustomer", sub1.StripeCustomer, sub2.StripeCustomer
    6.34 +	if sub1.StripeSubscription != sub2.StripeSubscription {
    6.35 +		return false, "StripeSubscription", sub1.StripeSubscription, sub2.StripeSubscription
    6.36  	}
    6.37 -	if sub1.Amount != sub2.Amount {
    6.38 -		return false, "Amount", sub1.Amount, sub2.Amount
    6.39 +	if sub1.Plan != sub2.Plan {
    6.40 +		return false, "Plan", sub1.Plan, sub2.Plan
    6.41  	}
    6.42 -	if sub1.Period != sub2.Period {
    6.43 -		return false, "Period", sub1.Period, sub2.Period
    6.44 +	if sub1.Status != sub2.Status {
    6.45 +		return false, "Status", sub1.Status, sub2.Status
    6.46 +	}
    6.47 +	if sub1.Canceling != sub2.Canceling {
    6.48 +		return false, "Canceling", sub1.Canceling, sub2.Canceling
    6.49  	}
    6.50  	if !sub1.Created.Equal(sub2.Created) {
    6.51  		return false, "Created", sub1.Created, sub2.Created
    6.52  	}
    6.53 -	if !sub1.BeginCharging.Equal(sub2.BeginCharging) {
    6.54 -		return false, "BeginCharging", sub1.BeginCharging, sub2.BeginCharging
    6.55 +	if !sub1.TrialStart.Equal(sub2.TrialStart) {
    6.56 +		return false, "TrialStart", sub1.TrialStart, sub2.TrialStart
    6.57  	}
    6.58 -	if !sub1.LastCharged.Equal(sub2.LastCharged) {
    6.59 -		return false, "LastCharged", sub1.LastCharged, sub2.LastCharged
    6.60 +	if !sub1.TrialEnd.Equal(sub2.TrialEnd) {
    6.61 +		return false, "TrialEnd", sub1.TrialEnd, sub2.TrialEnd
    6.62 +	}
    6.63 +	if !sub1.PeriodStart.Equal(sub2.PeriodStart) {
    6.64 +		return false, "PeriodStart", sub1.PeriodStart, sub2.PeriodStart
    6.65 +	}
    6.66 +	if !sub1.PeriodEnd.Equal(sub2.PeriodEnd) {
    6.67 +		return false, "PeriodEnd", sub1.PeriodEnd, sub2.PeriodEnd
    6.68 +	}
    6.69 +	if !sub1.CanceledAt.Equal(sub2.CanceledAt) {
    6.70 +		return false, "CanceledAt", sub1.CanceledAt, sub2.CanceledAt
    6.71 +	}
    6.72 +	if sub1.FailedChargeAttempts != sub2.FailedChargeAttempts {
    6.73 +		return false, "FailedChargeAttempts", sub1.FailedChargeAttempts, sub2.FailedChargeAttempts
    6.74 +	}
    6.75 +	if !sub1.LastFailedCharge.Equal(sub2.LastFailedCharge) {
    6.76 +		return false, "LastFailedCharge", sub1.LastFailedCharge, sub2.LastFailedCharge
    6.77  	}
    6.78  	if !sub1.LastNotified.Equal(sub2.LastNotified) {
    6.79  		return false, "LastNotified", sub1.LastNotified, sub2.LastNotified
    6.80  	}
    6.81 -	if sub1.InLockout != sub2.InLockout {
    6.82 -		return false, "InLockout", sub1.InLockout, sub2.InLockout
    6.83 -	}
    6.84  	return true, "", nil, nil
    6.85  }
    6.86  
    6.87 @@ -77,6 +97,31 @@
    6.88  	return true, missing
    6.89  }
    6.90  
    6.91 +func compareSubscriptionStats(stat1, stat2 SubscriptionStats) (bool, string, interface{}, interface{}) {
    6.92 +	if stat1.Number != stat2.Number {
    6.93 +		return false, "Number", stat1.Number, stat2.Number
    6.94 +	}
    6.95 +	if stat1.Canceling != stat2.Canceling {
    6.96 +		return false, "Canceling", stat1.Canceling, stat2.Canceling
    6.97 +	}
    6.98 +	if stat1.Failing != stat2.Failing {
    6.99 +		return false, "Failing", stat1.Failing, stat2.Failing
   6.100 +	}
   6.101 +	if len(stat1.Plans) != len(stat2.Plans) {
   6.102 +		return false, "Plans", stat1.Plans, stat2.Plans
   6.103 +	}
   6.104 +	for key, count := range stat1.Plans {
   6.105 +		count2, ok := stat2.Plans[key]
   6.106 +		if !ok {
   6.107 +			return false, "Plans", stat1.Plans, stat2.Plans
   6.108 +		}
   6.109 +		if count != count2 {
   6.110 +			return false, "Plans", stat1.Plans, stat2.Plans
   6.111 +		}
   6.112 +	}
   6.113 +	return true, "", nil, nil
   6.114 +}
   6.115 +
   6.116  func TestCreateSubscription(t *testing.T) {
   6.117  	for _, store := range testSubscriptionStores {
   6.118  		err := store.reset()
   6.119 @@ -85,12 +130,11 @@
   6.120  		}
   6.121  		customerID := uuid.NewID()
   6.122  		sub := Subscription{
   6.123 -			UserID:         customerID,
   6.124 -			StripeCustomer: "stripeCustomer1",
   6.125 -			Amount:         200,
   6.126 -			Period:         MonthlyPeriod,
   6.127 -			Created:        time.Now().Round(time.Millisecond),
   6.128 -			BeginCharging:  time.Now().Round(time.Millisecond).Add(time.Hour),
   6.129 +			UserID:             customerID,
   6.130 +			StripeSubscription: "stripeSubscription1",
   6.131 +			Created:            time.Now().Round(time.Millisecond),
   6.132 +			TrialStart:         time.Now().Round(time.Millisecond),
   6.133 +			TrialEnd:           time.Now().Round(time.Millisecond).Add(time.Hour * 24 * 31),
   6.134  		}
   6.135  		err = store.createSubscription(sub)
   6.136  		if err != nil {
   6.137 @@ -113,10 +157,10 @@
   6.138  		}
   6.139  		sub.UserID = uuid.NewID()
   6.140  		err = store.createSubscription(sub)
   6.141 -		if err != ErrStripeCustomerAlreadyExists {
   6.142 -			t.Errorf("Unexpected error creating subscription in %T (wanted %+v): %#+v\n", store, ErrStripeCustomerAlreadyExists, err)
   6.143 +		if err != ErrStripeSubscriptionAlreadyExists {
   6.144 +			t.Errorf("Unexpected error creating subscription in %T (wanted %+v): %#+v\n", store, ErrStripeSubscriptionAlreadyExists, err)
   6.145  		}
   6.146 -		sub.StripeCustomer = "stripeCustomer2"
   6.147 +		sub.StripeSubscription = "stripeSubscription2"
   6.148  		err = store.createSubscription(sub)
   6.149  		if err != nil {
   6.150  			t.Errorf("Error creating subscription in %T: %+v\n", store, err)
   6.151 @@ -125,106 +169,128 @@
   6.152  }
   6.153  
   6.154  func TestUpdateSubscription(t *testing.T) {
   6.155 -	variations := 1 << 7
   6.156 +	variations := 1 << 12
   6.157  	sub := Subscription{
   6.158 -		UserID:         uuid.NewID(),
   6.159 -		StripeCustomer: "default",
   6.160 -		Amount:         -1,
   6.161 -		Period:         MonthlyPeriod,
   6.162 -		Created:        time.Now().Round(time.Millisecond).Add(time.Hour * -24),
   6.163 -		BeginCharging:  time.Now().Round(time.Millisecond).Add(time.Hour * -24),
   6.164 -		LastCharged:    time.Now().Round(time.Millisecond).Add(time.Hour * -24),
   6.165 -		LastNotified:   time.Now().Round(time.Millisecond).Add(time.Hour * -24),
   6.166 -		InLockout:      true,
   6.167 +		UserID:             uuid.NewID(),
   6.168 +		StripeSubscription: "default",
   6.169 +		Created:            time.Now().Round(time.Millisecond).Add(time.Hour * -24 * -32),
   6.170 +		TrialStart:         time.Now().Round(time.Millisecond).Add(time.Hour * -24 * -32),
   6.171 +		TrialEnd:           time.Now().Round(time.Millisecond).Add(time.Hour * -24),
   6.172 +		LastNotified:       time.Now().Round(time.Millisecond).Add(time.Hour * -24),
   6.173  	}
   6.174  	sub2 := Subscription{
   6.175 -		UserID:         uuid.NewID(),
   6.176 -		StripeCustomer: "stripeCustomer2",
   6.177 -		Amount:         -2,
   6.178 -		Period:         MonthlyPeriod,
   6.179 -		Created:        time.Now().Round(time.Millisecond),
   6.180 -		BeginCharging:  time.Now().Round(time.Millisecond),
   6.181 -		LastCharged:    time.Now().Round(time.Millisecond),
   6.182 -		LastNotified:   time.Now().Round(time.Millisecond),
   6.183 -		InLockout:      false,
   6.184 +		UserID:             uuid.NewID(),
   6.185 +		StripeSubscription: "stripeSubscription2",
   6.186 +		Created:            time.Now().Round(time.Millisecond),
   6.187 +		TrialStart:         time.Now().Round(time.Millisecond),
   6.188 +		TrialEnd:           time.Now().Round(time.Millisecond),
   6.189 +		LastNotified:       time.Now().Round(time.Millisecond),
   6.190  	}
   6.191  
   6.192 -	for i := 1; i < variations; i++ {
   6.193 -		var stripeCustomer string
   6.194 -		var amount int
   6.195 -		var inLockout bool
   6.196 -		var per period
   6.197 -		var beginCharging, lastCharged, lastNotified time.Time
   6.198 +	for _, store := range testSubscriptionStores {
   6.199 +		err := store.reset()
   6.200 +		if err != nil {
   6.201 +			t.Fatalf("Error resetting %T: %+v\n", store, err)
   6.202 +		}
   6.203 +		err = store.createSubscription(sub)
   6.204 +		if err != nil {
   6.205 +			t.Fatalf("Error saving subscription in %T: %s\n", store, err)
   6.206 +		}
   6.207 +		for i := 1; i < variations; i++ {
   6.208 +			var stripeSubscription, plan, status string
   6.209 +			var canceling bool
   6.210 +			var failedChargeAttempts int
   6.211 +			var trialStart, trialEnd, periodStart, periodEnd, canceledAt, lastFailedCharge, lastNotified time.Time
   6.212  
   6.213 -		change := SubscriptionChange{}
   6.214 -		empty := change.IsEmpty()
   6.215 -		if !empty {
   6.216 -			t.Errorf("Expected empty to be %t, was %t\n", true, empty)
   6.217 -		}
   6.218 -		expectation := sub
   6.219 -		result := sub
   6.220 -		strI := strconv.Itoa(i)
   6.221 +			change := SubscriptionChange{}
   6.222 +			empty := change.IsEmpty()
   6.223 +			if !empty {
   6.224 +				t.Errorf("Expected empty to be %t, was %t\n", true, empty)
   6.225 +			}
   6.226 +			result := sub
   6.227 +			strI := strconv.Itoa(i)
   6.228  
   6.229 -		if i&subscriptionChangeStripeCustomer != 0 {
   6.230 -			stripeCustomer = "stripeCustomer-" + strI
   6.231 -			change.StripeCustomer = &stripeCustomer
   6.232 -			expectation.StripeCustomer = stripeCustomer
   6.233 -		}
   6.234 +			if i&subscriptionChangeStripeSubscription != 0 {
   6.235 +				stripeSubscription = "stripeSubscription-" + strI
   6.236 +				change.StripeSubscription = &stripeSubscription
   6.237 +				sub.StripeSubscription = stripeSubscription
   6.238 +			}
   6.239  
   6.240 -		if i&subscriptionChangeAmount != 0 {
   6.241 -			amount = i
   6.242 -			change.Amount = &amount
   6.243 -			expectation.Amount = amount
   6.244 -		}
   6.245 +			if i&subscriptionChangePlan != 0 {
   6.246 +				plan = "plan-" + strI
   6.247 +				change.Plan = &plan
   6.248 +				sub.Plan = plan
   6.249 +			}
   6.250  
   6.251 -		if i&subscriptionChangePeriod != 0 {
   6.252 -			per = period("period-" + strI)
   6.253 -			change.Period = &per
   6.254 -			expectation.Period = per
   6.255 -		}
   6.256 +			if i&subscriptionChangeStatus != 0 {
   6.257 +				status = "status-" + strI
   6.258 +				change.Status = &status
   6.259 +				sub.Status = status
   6.260 +			}
   6.261  
   6.262 -		if i&subscriptionChangeBeginCharging != 0 {
   6.263 -			beginCharging = time.Now().Round(time.Millisecond).Add(time.Hour * time.Duration(i))
   6.264 -			change.BeginCharging = &beginCharging
   6.265 -			expectation.BeginCharging = beginCharging
   6.266 -		}
   6.267 +			if i&subscriptionChangeCanceling != 0 {
   6.268 +				canceling = i%2 == 0
   6.269 +				change.Canceling = &canceling
   6.270 +				sub.Canceling = canceling
   6.271 +			}
   6.272  
   6.273 -		if i&subscriptionChangeLastCharged != 0 {
   6.274 -			lastCharged = time.Now().Round(time.Millisecond).Add(time.Hour * time.Duration(i))
   6.275 -			change.LastCharged = &lastCharged
   6.276 -			expectation.LastCharged = lastCharged
   6.277 -		}
   6.278 +			if i&subscriptionChangeTrialStart != 0 {
   6.279 +				trialStart = time.Now().Round(time.Millisecond).Add(time.Hour * time.Duration(i))
   6.280 +				change.TrialStart = &trialStart
   6.281 +				sub.TrialStart = trialStart
   6.282 +			}
   6.283  
   6.284 -		if i&subscriptionChangeLastNotified != 0 {
   6.285 -			lastNotified = time.Now().Round(time.Millisecond).Add(time.Hour * time.Duration(i))
   6.286 -			change.LastNotified = &lastNotified
   6.287 -			expectation.LastNotified = lastNotified
   6.288 -		}
   6.289 +			if i&subscriptionChangeTrialEnd != 0 {
   6.290 +				trialEnd = time.Now().Round(time.Millisecond).Add(time.Hour * time.Duration(i))
   6.291 +				change.TrialEnd = &trialEnd
   6.292 +				sub.TrialEnd = trialEnd
   6.293 +			}
   6.294  
   6.295 -		if i&subscriptionChangeInLockout != 0 {
   6.296 -			inLockout = i%2 == 0
   6.297 -			change.InLockout = &inLockout
   6.298 -			expectation.InLockout = inLockout
   6.299 -		}
   6.300 +			if i&subscriptionChangePeriodStart != 0 {
   6.301 +				periodStart = time.Now().Round(time.Millisecond).Add(time.Hour * time.Duration(i))
   6.302 +				change.PeriodStart = &periodStart
   6.303 +				sub.PeriodStart = periodStart
   6.304 +			}
   6.305  
   6.306 -		empty = change.IsEmpty()
   6.307 -		if empty {
   6.308 -			t.Errorf("Expected empty to be %t, was %t\n", false, empty)
   6.309 -		}
   6.310 +			if i&subscriptionChangePeriodEnd != 0 {
   6.311 +				periodEnd = time.Now().Round(time.Millisecond).Add(time.Hour * time.Duration(i))
   6.312 +				change.PeriodEnd = &periodEnd
   6.313 +				sub.PeriodEnd = periodEnd
   6.314 +			}
   6.315  
   6.316 -		result.ApplyChange(change)
   6.317 -		match, field, expected, got := compareSubscriptions(expectation, result)
   6.318 -		if !match {
   6.319 -			t.Errorf("Expected field `%s` to be `%v`, got `%v`\n", field, expected, got)
   6.320 -		}
   6.321 -		for _, store := range testSubscriptionStores {
   6.322 -			err := store.reset()
   6.323 -			if err != nil {
   6.324 -				t.Fatalf("Error resetting %T: %+v\n", store, err)
   6.325 +			if i&subscriptionChangeCanceledAt != 0 {
   6.326 +				canceledAt = time.Now().Round(time.Millisecond).Add(time.Hour * time.Duration(i))
   6.327 +				change.CanceledAt = &canceledAt
   6.328 +				sub.CanceledAt = canceledAt
   6.329  			}
   6.330 -			err = store.createSubscription(sub)
   6.331 -			if err != nil {
   6.332 -				t.Fatalf("Error saving subscription in %T: %s\n", store, err)
   6.333 +
   6.334 +			if i&subscriptionChangeFailedChargeAttempts != 0 {
   6.335 +				failedChargeAttempts = i
   6.336 +				change.FailedChargeAttempts = &failedChargeAttempts
   6.337 +				sub.FailedChargeAttempts = failedChargeAttempts
   6.338 +			}
   6.339 +
   6.340 +			if i&subscriptionChangeLastFailedCharge != 0 {
   6.341 +				lastFailedCharge = time.Now().Round(time.Millisecond).Add(time.Hour * time.Duration(i))
   6.342 +				change.LastFailedCharge = &lastFailedCharge
   6.343 +				sub.LastFailedCharge = lastFailedCharge
   6.344 +			}
   6.345 +
   6.346 +			if i&subscriptionChangeLastNotified != 0 {
   6.347 +				lastNotified = time.Now().Round(time.Millisecond).Add(time.Hour * time.Duration(i))
   6.348 +				change.LastNotified = &lastNotified
   6.349 +				sub.LastNotified = lastNotified
   6.350 +			}
   6.351 +
   6.352 +			empty = change.IsEmpty()
   6.353 +			if empty {
   6.354 +				t.Errorf("Expected empty to be %t, was %t\n", false, empty)
   6.355 +			}
   6.356 +
   6.357 +			result.ApplyChange(change)
   6.358 +			match, field, expected, got := compareSubscriptions(sub, result)
   6.359 +			if !match {
   6.360 +				t.Errorf("Expected field `%s` to be `%v`, got `%v`\n", field, expected, got)
   6.361  			}
   6.362  			err = store.updateSubscription(sub.UserID, change)
   6.363  			if err != nil {
   6.364 @@ -238,21 +304,13 @@
   6.365  			if !ok {
   6.366  				t.Errorf("Expected to retrieve %s from %T, but missing was %+v\n", sub.UserID.String(), store, missing)
   6.367  			}
   6.368 -			match, field, expected, got = compareSubscriptions(expectation, retrieved[sub.UserID.String()])
   6.369 +			match, field, expected, got = compareSubscriptions(sub, retrieved[sub.UserID.String()])
   6.370  			if !match {
   6.371  				t.Errorf("Expected field `%s` to be `%v`, got `%v` from %T\n", field, expected, got, store)
   6.372  			}
   6.373 +			sub = result
   6.374  		}
   6.375 -	}
   6.376 -	for _, store := range testSubscriptionStores {
   6.377 -		err := store.reset()
   6.378 -		if err != nil {
   6.379 -			t.Fatalf("Error resetting %T: %+v\n", store, err)
   6.380 -		}
   6.381 -		err = store.createSubscription(sub)
   6.382 -		if err != nil {
   6.383 -			t.Fatalf("Error saving subscription in %T: %+v\n", store, err)
   6.384 -		}
   6.385 +
   6.386  		err = store.createSubscription(sub2)
   6.387  		if err != nil {
   6.388  			t.Fatalf("Error saving subscription in %T: %+v\n", store, err)
   6.389 @@ -262,15 +320,15 @@
   6.390  		if err != ErrSubscriptionChangeEmpty {
   6.391  			t.Errorf("Expected err to be %+v, but got %+v from %T\n", ErrSubscriptionChangeEmpty, err, store)
   6.392  		}
   6.393 -		stripeCustomer := sub2.StripeCustomer
   6.394 -		change.StripeCustomer = &stripeCustomer
   6.395 +		stripeSubscription := sub2.StripeSubscription
   6.396 +		change.StripeSubscription = &stripeSubscription
   6.397  		err = store.updateSubscription(uuid.NewID(), change)
   6.398  		if err != ErrSubscriptionNotFound {
   6.399  			t.Errorf("Expected err to be %+v, but got %+v from %T\n", ErrSubscriptionNotFound, err, store)
   6.400  		}
   6.401  		err = store.updateSubscription(sub.UserID, change)
   6.402 -		if err != ErrStripeCustomerAlreadyExists {
   6.403 -			t.Errorf("Expected err to be %+v, but got %+v from %T\n", ErrStripeCustomerAlreadyExists, err, store)
   6.404 +		if err != ErrStripeSubscriptionAlreadyExists {
   6.405 +			t.Errorf("Expected err to be %+v, but got %+v from %T\n", ErrStripeSubscriptionAlreadyExists, err, store)
   6.406  		}
   6.407  	}
   6.408  }
   6.409 @@ -282,12 +340,12 @@
   6.410  			t.Fatalf("Error resetting %T: %+v\n", store, err)
   6.411  		}
   6.412  		sub1 := Subscription{
   6.413 -			UserID:         uuid.NewID(),
   6.414 -			StripeCustomer: "stripeCustomer1",
   6.415 +			UserID:             uuid.NewID(),
   6.416 +			StripeSubscription: "stripeSubscription1",
   6.417  		}
   6.418  		sub2 := Subscription{
   6.419 -			UserID:         uuid.NewID(),
   6.420 -			StripeCustomer: "stripeCustomer2",
   6.421 +			UserID:             uuid.NewID(),
   6.422 +			StripeSubscription: "stripeSubscription2",
   6.423  		}
   6.424  		err = store.createSubscription(sub1)
   6.425  		if err != nil {
   6.426 @@ -295,7 +353,7 @@
   6.427  		}
   6.428  		err = store.createSubscription(sub2)
   6.429  		if err != nil {
   6.430 -			t.Fatalf("Error creating %+v in %T: %+v\n", sub1, store, err)
   6.431 +			t.Fatalf("Error creating %+v in %T: %+v\n", sub2, store, err)
   6.432  		}
   6.433  		err = store.deleteSubscription(sub1.UserID)
   6.434  		if err != nil {
   6.435 @@ -320,108 +378,6 @@
   6.436  	}
   6.437  }
   6.438  
   6.439 -func TestListSubscriptionsLastChargedBefore(t *testing.T) {
   6.440 -	for _, store := range testSubscriptionStores {
   6.441 -		err := store.reset()
   6.442 -		if err != nil {
   6.443 -			t.Fatalf("Error resetting %T: %+v\n", store, err)
   6.444 -		}
   6.445 -		sub1 := Subscription{
   6.446 -			UserID:         uuid.NewID(),
   6.447 -			StripeCustomer: "stripeCustomer1",
   6.448 -			Amount:         200,
   6.449 -			Period:         MonthlyPeriod,
   6.450 -			Created:        time.Now().Round(time.Millisecond).Add(time.Hour * -24 * 32),
   6.451 -			BeginCharging:  time.Now().Round(time.Millisecond).Add(time.Hour * -24),
   6.452 -			LastCharged:    time.Now().Round(time.Millisecond).Add(time.Hour * -24),
   6.453 -		}
   6.454 -		sub2 := Subscription{
   6.455 -			UserID:         uuid.NewID(),
   6.456 -			StripeCustomer: "stripeCustomer2",
   6.457 -			Amount:         300,
   6.458 -			Period:         MonthlyPeriod,
   6.459 -			Created:        time.Now().Round(time.Millisecond).Add(time.Hour * -24 * 61),
   6.460 -			BeginCharging:  time.Now().Round(time.Millisecond).Add(time.Hour * -24 * 31),
   6.461 -			LastCharged:    time.Now().Round(time.Millisecond).Add(time.Hour * -24 * 31),
   6.462 -		}
   6.463 -		sub3 := Subscription{
   6.464 -			UserID:         uuid.NewID(),
   6.465 -			StripeCustomer: "stripeCustomer3",
   6.466 -			Amount:         100,
   6.467 -			Period:         MonthlyPeriod,
   6.468 -			Created:        time.Now().Round(time.Millisecond).Add(time.Hour * -1),
   6.469 -			BeginCharging:  time.Now().Round(time.Millisecond).Add(time.Hour * 31),
   6.470 -			LastCharged:    time.Time{},
   6.471 -		}
   6.472 -		err = store.createSubscription(sub1)
   6.473 -		if err != nil {
   6.474 -			t.Fatalf("Error creating %+v in %T: %+v\n", sub1, store, err)
   6.475 -		}
   6.476 -		err = store.createSubscription(sub2)
   6.477 -		if err != nil {
   6.478 -			t.Fatalf("Error creating %+v in %T: %+v\n", sub1, store, err)
   6.479 -		}
   6.480 -		err = store.createSubscription(sub3)
   6.481 -		if err != nil {
   6.482 -			t.Fatalf("Error creating %+v in %T: %+v\n", sub1, store, err)
   6.483 -		}
   6.484 -		t.Logf("sub1: %+v\n", sub1)
   6.485 -		t.Logf("sub2: %+v\n", sub2)
   6.486 -		t.Logf("sub3: %+v\n", sub3)
   6.487 -		// subscriptions last charged before right now
   6.488 -		// should be sub1, sub2, and sub3
   6.489 -		results, err := store.listSubscriptionsLastChargedBefore(time.Now().Round(time.Millisecond))
   6.490 -		if err != nil {
   6.491 -			t.Errorf("Unexpected error listing subscriptions in %T: %+v\n", store, err)
   6.492 -		}
   6.493 -		if len(results) != 3 {
   6.494 -			t.Errorf("Expected three results from %T, got %+v\n", store, results)
   6.495 -		}
   6.496 -		ok, field, expected, result := compareSubscriptions(sub3, results[0])
   6.497 -		if !ok {
   6.498 -			t.Errorf("Expected %s in pos 0 to be %+v, got %+v from %T", field, expected, result, store)
   6.499 -		}
   6.500 -		ok, field, expected, result = compareSubscriptions(sub2, results[1])
   6.501 -		if !ok {
   6.502 -			t.Errorf("Expected %s in pos 1 to be %+v, got %+v from %T", field, expected, result, store)
   6.503 -		}
   6.504 -		ok, field, expected, result = compareSubscriptions(sub1, results[2])
   6.505 -		if !ok {
   6.506 -			t.Errorf("Expected %s in pos 2 to be %+v, got %+v from %T", field, expected, result, store)
   6.507 -		}
   6.508 -		// subscriptions last charged before a week ago
   6.509 -		// should be sub2, sub3
   6.510 -		results, err = store.listSubscriptionsLastChargedBefore(time.Now().Round(time.Millisecond).Add(time.Hour * -24 * 7))
   6.511 -		if err != nil {
   6.512 -			t.Errorf("Unexpected error listing subscriptions in %T: %+v\n", store, err)
   6.513 -		}
   6.514 -		if len(results) != 2 {
   6.515 -			t.Errorf("Expected two results from %T, got %+v\n", store, results)
   6.516 -		}
   6.517 -		ok, field, expected, result = compareSubscriptions(sub3, results[0])
   6.518 -		if !ok {
   6.519 -			t.Errorf("Expected %s in pos 0 to be %+v, got %+v from %T", field, expected, result, store)
   6.520 -		}
   6.521 -		ok, field, expected, result = compareSubscriptions(sub2, results[1])
   6.522 -		if !ok {
   6.523 -			t.Errorf("Expected %s in pos 1 to be %+v, got %+v from %T", field, expected, result, store)
   6.524 -		}
   6.525 -		// subscriptions last charged before 32 days ago
   6.526 -		// should be sub3
   6.527 -		results, err = store.listSubscriptionsLastChargedBefore(time.Now().Round(time.Millisecond).Add(time.Hour * -24 * 32))
   6.528 -		if err != nil {
   6.529 -			t.Errorf("Unexpected error listing subscriptions in %T: %+v\n", store, err)
   6.530 -		}
   6.531 -		if len(results) != 1 {
   6.532 -			t.Errorf("Expected one result from %T, got %+v\n", store, results)
   6.533 -		}
   6.534 -		ok, field, expected, result = compareSubscriptions(sub3, results[0])
   6.535 -		if !ok {
   6.536 -			t.Errorf("Expected %s in pos 0 to be %+v, got %+v from %T", field, expected, result, store)
   6.537 -		}
   6.538 -	}
   6.539 -}
   6.540 -
   6.541  func TestGetSubscriptions(t *testing.T) {
   6.542  	for _, store := range testSubscriptionStores {
   6.543  		err := store.reset()
   6.544 @@ -429,31 +385,31 @@
   6.545  			t.Fatalf("Error resetting %T: %+v\n", store, err)
   6.546  		}
   6.547  		sub1 := Subscription{
   6.548 -			UserID:         uuid.NewID(),
   6.549 -			StripeCustomer: "stripeCustomer1",
   6.550 -			Amount:         200,
   6.551 -			Period:         MonthlyPeriod,
   6.552 -			Created:        time.Now().Round(time.Millisecond),
   6.553 -			BeginCharging:  time.Now().Round(time.Millisecond).Add(time.Hour),
   6.554 +			UserID:             uuid.NewID(),
   6.555 +			StripeSubscription: "stripeSubscription1",
   6.556 +			Plan:               "plan1",
   6.557 +			Created:            time.Now().Round(time.Millisecond),
   6.558 +			TrialStart:         time.Now().Round(time.Millisecond),
   6.559 +			TrialEnd:           time.Now().Round(time.Millisecond).Add(time.Hour * 24 * 32),
   6.560  		}
   6.561  		sub2 := Subscription{
   6.562 -			UserID:         uuid.NewID(),
   6.563 -			StripeCustomer: "stripeCustomer2",
   6.564 -			Amount:         300,
   6.565 -			Period:         MonthlyPeriod,
   6.566 -			Created:        time.Now().Round(time.Millisecond).Add(time.Hour * -720),
   6.567 -			BeginCharging:  time.Now().Round(time.Millisecond).Add(time.Hour*-720 + time.Hour*2),
   6.568 -			LastCharged:    time.Now().Round(time.Millisecond),
   6.569 +			UserID:             uuid.NewID(),
   6.570 +			StripeSubscription: "stripeSubscription2",
   6.571 +			Plan:               "plan2",
   6.572 +			Created:            time.Now().Round(time.Millisecond).Add(time.Hour * -720),
   6.573 +			TrialStart:         time.Now().Round(time.Millisecond).Add(time.Hour * -720),
   6.574 +			TrialEnd:           time.Now().Round(time.Millisecond),
   6.575  		}
   6.576  		sub3 := Subscription{
   6.577 -			UserID:         uuid.NewID(),
   6.578 -			StripeCustomer: "stripeCustomer3",
   6.579 -			Amount:         100,
   6.580 -			Period:         MonthlyPeriod,
   6.581 -			Created:        time.Now().Round(time.Millisecond).Add(time.Hour * -1440),
   6.582 -			BeginCharging:  time.Now().Round(time.Millisecond).Add(time.Hour * -1440),
   6.583 -			LastNotified:   time.Now().Round(time.Millisecond).Add(time.Hour * -720),
   6.584 -			InLockout:      true,
   6.585 +			UserID:             uuid.NewID(),
   6.586 +			StripeSubscription: "stripeSubscription3",
   6.587 +			Plan:               "plan3",
   6.588 +			Created:            time.Now().Round(time.Millisecond).Add(time.Hour * -1440),
   6.589 +			TrialStart:         time.Now().Round(time.Millisecond).Add(time.Hour * -1440),
   6.590 +			TrialEnd:           time.Now().Round(time.Millisecond).Add(time.Hour * -720),
   6.591 +			PeriodStart:        time.Now().Round(time.Millisecond).Add(time.Hour * -720),
   6.592 +			PeriodEnd:          time.Now().Round(time.Millisecond),
   6.593 +			Status:             "unpaid",
   6.594  		}
   6.595  		err = store.createSubscription(sub1)
   6.596  		if err != nil {
   6.597 @@ -555,3 +511,82 @@
   6.598  		}
   6.599  	}
   6.600  }
   6.601 +
   6.602 +func TestGetSubscriptionStats(t *testing.T) {
   6.603 +	for _, store := range testSubscriptionStores {
   6.604 +		err := store.reset()
   6.605 +		if err != nil {
   6.606 +			t.Fatalf("Error resetting %T: %+v\n", store, err)
   6.607 +		}
   6.608 +		sub1 := Subscription{
   6.609 +			UserID:             uuid.NewID(),
   6.610 +			StripeSubscription: "stripeSubscription1",
   6.611 +			Plan:               "plan1",
   6.612 +			Canceling:          true,
   6.613 +		}
   6.614 +		sub2 := Subscription{
   6.615 +			UserID:             uuid.NewID(),
   6.616 +			StripeSubscription: "stripeSubscription2",
   6.617 +			Plan:               "plan2",
   6.618 +			Status:             "past_due",
   6.619 +		}
   6.620 +		err = store.createSubscription(sub1)
   6.621 +		if err != nil {
   6.622 +			t.Fatalf("Error creating %+v in %T: %+v\n", sub1, store, err)
   6.623 +		}
   6.624 +		stats, err := store.getSubscriptionStats()
   6.625 +		if err != nil {
   6.626 +			t.Errorf("Error getting stats from %T: %+v\n", store, err)
   6.627 +		}
   6.628 +		ok, field, expected, results := compareSubscriptionStats(SubscriptionStats{
   6.629 +			Number:    1,
   6.630 +			Canceling: 1,
   6.631 +			Failing:   0,
   6.632 +			Plans: map[string]int64{
   6.633 +				"plan1": 1,
   6.634 +			},
   6.635 +		}, stats)
   6.636 +		if !ok {
   6.637 +			t.Errorf("Expected %s to be %+v, got %+v from %T\n", field, expected, results, store)
   6.638 +		}
   6.639 +		err = store.createSubscription(sub2)
   6.640 +		if err != nil {
   6.641 +			t.Fatalf("Error creating %+v in %T: %+v\n", sub2, store, err)
   6.642 +		}
   6.643 +		stats, err = store.getSubscriptionStats()
   6.644 +		if err != nil {
   6.645 +			t.Errorf("Error getting status from %T: %+v\n", store, err)
   6.646 +		}
   6.647 +		ok, field, expected, results = compareSubscriptionStats(SubscriptionStats{
   6.648 +			Number:    2,
   6.649 +			Canceling: 1,
   6.650 +			Failing:   1,
   6.651 +			Plans: map[string]int64{
   6.652 +				"plan1": 1,
   6.653 +				"plan2": 1,
   6.654 +			},
   6.655 +		}, stats)
   6.656 +		if !ok {
   6.657 +			t.Errorf("Expected %s to be %+v, got %+v from %T\n", field, expected, results, store)
   6.658 +		}
   6.659 +		err = store.deleteSubscription(sub1.UserID)
   6.660 +		if err != nil {
   6.661 +			t.Errorf("Error deleting subscription from %T: %+v\n", store, err)
   6.662 +		}
   6.663 +		stats, err = store.getSubscriptionStats()
   6.664 +		if err != nil {
   6.665 +			t.Errorf("Error getting status from %T: %+v\n", store, err)
   6.666 +		}
   6.667 +		ok, field, expected, results = compareSubscriptionStats(SubscriptionStats{
   6.668 +			Number:    1,
   6.669 +			Canceling: 0,
   6.670 +			Failing:   1,
   6.671 +			Plans: map[string]int64{
   6.672 +				"plan2": 1,
   6.673 +			},
   6.674 +		}, stats)
   6.675 +		if !ok {
   6.676 +			t.Errorf("Expected %s to be %+v, got %+v from %T\n", field, expected, results, store)
   6.677 +		}
   6.678 +	}
   6.679 +}