ducky/subscriptions

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

2:61c4ce5850da Go to Latest

ducky/subscriptions/subscription.go

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

History
paddy@0 1 package subscriptions
paddy@0 2
paddy@0 3 import (
paddy@0 4 "errors"
paddy@0 5 "time"
paddy@0 6
paddy@0 7 "code.secondbit.org/uuid.hg"
paddy@0 8 )
paddy@0 9
paddy@0 10 var (
paddy@0 11 // ErrSubscriptionAlreadyExists is returned when a Subscription
paddy@0 12 // with an identical ID already exists in the subscriptionStore.
paddy@0 13 ErrSubscriptionAlreadyExists = errors.New("Subscription already exists")
paddy@0 14 // ErrSubscriptionNotFound is returned when a single Subscription
paddy@0 15 // is acted upon or requested, but cannot be found.
paddy@0 16 ErrSubscriptionNotFound = errors.New("Subscription not found")
paddy@2 17 // ErrStripeSubscriptionAlreadyExists is returned when a Subscription
paddy@2 18 // is created or updates its StripeSubscription property, but that
paddy@2 19 // StripeSubscription is already associated with another Subscription.
paddy@2 20 ErrStripeSubscriptionAlreadyExists = errors.New("Stripe subscription already assigned to another Subscription")
paddy@0 21 // ErrSubscriptionChangeEmpty is returned when a SubscriptionChange
paddy@0 22 // is empty but is passed to subscriptionStore.UpdateSubscription
paddy@0 23 // anyways.
paddy@0 24 ErrSubscriptionChangeEmpty = errors.New("SubscriptionChange is empty")
paddy@0 25 // ErrNoSubscriptionID is returned when one or more Subscription IDs
paddy@0 26 // are required, but none are provided.
paddy@0 27 ErrNoSubscriptionID = errors.New("no Subscription ID provided")
paddy@0 28 )
paddy@0 29
paddy@0 30 // Subscription represents the state of a user's payments. It holds
paddy@0 31 // metadata about the last time a user was charged, how much a user
paddy@0 32 // should be charged, how to charge a user and how much to charge
paddy@0 33 // the user.
paddy@0 34 type Subscription struct {
paddy@2 35 UserID uuid.ID
paddy@2 36 StripeSubscription string
paddy@2 37 Plan string
paddy@2 38 Status string
paddy@2 39 Canceling bool
paddy@2 40 Created time.Time
paddy@2 41 TrialStart time.Time
paddy@2 42 TrialEnd time.Time
paddy@2 43 PeriodStart time.Time
paddy@2 44 PeriodEnd time.Time
paddy@2 45 CanceledAt time.Time
paddy@2 46 FailedChargeAttempts int
paddy@2 47 LastFailedCharge time.Time
paddy@2 48 LastNotified time.Time
paddy@0 49 }
paddy@0 50
paddy@0 51 // SubscriptionChange represents desired changes to a Subscription
paddy@0 52 // object. A nil value means that property should remain unchanged.
paddy@0 53 type SubscriptionChange struct {
paddy@2 54 StripeSubscription *string
paddy@2 55 Plan *string
paddy@2 56 Status *string
paddy@2 57 Canceling *bool
paddy@2 58 TrialStart *time.Time
paddy@2 59 TrialEnd *time.Time
paddy@2 60 PeriodStart *time.Time
paddy@2 61 PeriodEnd *time.Time
paddy@2 62 CanceledAt *time.Time
paddy@2 63 FailedChargeAttempts *int
paddy@2 64 LastFailedCharge *time.Time
paddy@2 65 LastNotified *time.Time
paddy@0 66 }
paddy@0 67
paddy@0 68 // IsEmpty returns true if the SubscriptionChange doesn't request
paddy@0 69 // a change to any property of the Subscription.
paddy@0 70 func (change SubscriptionChange) IsEmpty() bool {
paddy@2 71 if change.StripeSubscription != nil {
paddy@0 72 return false
paddy@0 73 }
paddy@2 74 if change.Plan != nil {
paddy@0 75 return false
paddy@0 76 }
paddy@2 77 if change.Status != nil {
paddy@0 78 return false
paddy@0 79 }
paddy@2 80 if change.Canceling != nil {
paddy@0 81 return false
paddy@0 82 }
paddy@2 83 if change.TrialStart != nil {
paddy@2 84 return false
paddy@2 85 }
paddy@2 86 if change.TrialEnd != nil {
paddy@2 87 return false
paddy@2 88 }
paddy@2 89 if change.PeriodStart != nil {
paddy@2 90 return false
paddy@2 91 }
paddy@2 92 if change.PeriodEnd != nil {
paddy@2 93 return false
paddy@2 94 }
paddy@2 95 if change.CanceledAt != nil {
paddy@0 96 return false
paddy@0 97 }
paddy@0 98 if change.LastNotified != nil {
paddy@0 99 return false
paddy@0 100 }
paddy@2 101 if change.LastFailedCharge != nil {
paddy@2 102 return false
paddy@2 103 }
paddy@2 104 if change.FailedChargeAttempts != nil {
paddy@0 105 return false
paddy@0 106 }
paddy@0 107 return true
paddy@0 108 }
paddy@0 109
paddy@0 110 // ApplyChange updates a Subscription based on the changes requested
paddy@0 111 // by a SubscriptionChange.
paddy@0 112 func (s *Subscription) ApplyChange(change SubscriptionChange) {
paddy@2 113 if change.StripeSubscription != nil {
paddy@2 114 s.StripeSubscription = *change.StripeSubscription
paddy@0 115 }
paddy@2 116 if change.Plan != nil {
paddy@2 117 s.Plan = *change.Plan
paddy@0 118 }
paddy@2 119 if change.Status != nil {
paddy@2 120 s.Status = *change.Status
paddy@0 121 }
paddy@2 122 if change.Canceling != nil {
paddy@2 123 s.Canceling = *change.Canceling
paddy@0 124 }
paddy@2 125 if change.TrialStart != nil {
paddy@2 126 s.TrialStart = *change.TrialStart
paddy@2 127 }
paddy@2 128 if change.TrialEnd != nil {
paddy@2 129 s.TrialEnd = *change.TrialEnd
paddy@2 130 }
paddy@2 131 if change.PeriodStart != nil {
paddy@2 132 s.PeriodStart = *change.PeriodStart
paddy@2 133 }
paddy@2 134 if change.PeriodEnd != nil {
paddy@2 135 s.PeriodEnd = *change.PeriodEnd
paddy@2 136 }
paddy@2 137 if change.CanceledAt != nil {
paddy@2 138 s.CanceledAt = *change.CanceledAt
paddy@2 139 }
paddy@2 140 if change.LastFailedCharge != nil {
paddy@2 141 s.LastFailedCharge = *change.LastFailedCharge
paddy@0 142 }
paddy@0 143 if change.LastNotified != nil {
paddy@0 144 s.LastNotified = *change.LastNotified
paddy@0 145 }
paddy@2 146 if change.FailedChargeAttempts != nil {
paddy@2 147 s.FailedChargeAttempts = *change.FailedChargeAttempts
paddy@0 148 }
paddy@0 149 }
paddy@0 150
paddy@1 151 // SubscriptionStats represents a set of statistics about our Subscription
paddy@1 152 // data that will be exposed to the Prometheus scraper.
paddy@1 153 type SubscriptionStats struct {
paddy@2 154 Number int64
paddy@2 155 Canceling int64
paddy@2 156 Failing int64
paddy@2 157 Plans map[string]int64
paddy@1 158 // BUG(paddy): Currently, Kubernetes doesn't offer any way to contact _all_ nodes in a service.
paddy@1 159 // Because of this, we can only report stats that will be identical across nodes, e.g. stats
paddy@1 160 // that come from the database. More info here: https://github.com/GoogleCloudPlatform/kubernetes/issues/6666
paddy@1 161 // In the future, we'll need per-node metrics. For now, we'll make do.
paddy@1 162 //
paddy@1 163 // Actually, as of https://github.com/GoogleCloudPlatform/kubernetes/pull/9073, we may be all set:
paddy@1 164 // "SRV Records are created for named ports that are part of normal or Headless Services. For each named port,
paddy@1 165 // the SRV record would have the form _my-port-name._my-port-protocol.my-svc.my-namespace.svc.cluster.local.
paddy@1 166 // For a regular service, this resolves to the port number and the CNAME: my-svc.my-namespace.svc.cluster.local.
paddy@1 167 // For a headless service, this resolves to multiple answers, one for each pod that is backing the service, and
paddy@1 168 // contains the port number and a CNAME of the pod with the format auto-generated-name.my-svc.my-namespace.svc.cluster.local
paddy@1 169 // SRV records always contain the 'svc' segment in them and are not supported for old-style CNAMEs where the 'svc' segment
paddy@1 170 // was omitted."
paddy@1 171 }
paddy@1 172
paddy@0 173 type subscriptionStore interface {
paddy@0 174 reset() error
paddy@0 175 createSubscription(sub Subscription) error
paddy@0 176 updateSubscription(id uuid.ID, change SubscriptionChange) error
paddy@0 177 deleteSubscription(id uuid.ID) error
paddy@0 178 getSubscriptions(ids []uuid.ID) (map[string]Subscription, error)
paddy@1 179 getSubscriptionStats() (SubscriptionStats, error)
paddy@0 180 }