ducky/subscriptions
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 +}