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.
7 "code.secondbit.org/uuid.hg"
11 // ErrSubscriptionAlreadyExists is returned when a Subscription
12 // with an identical ID already exists in the subscriptionStore.
13 ErrSubscriptionAlreadyExists = errors.New("Subscription already exists")
14 // ErrSubscriptionNotFound is returned when a single Subscription
15 // is acted upon or requested, but cannot be found.
16 ErrSubscriptionNotFound = errors.New("Subscription not found")
17 // ErrStripeSubscriptionAlreadyExists is returned when a Subscription
18 // is created or updates its StripeSubscription property, but that
19 // StripeSubscription is already associated with another Subscription.
20 ErrStripeSubscriptionAlreadyExists = errors.New("Stripe subscription already assigned to another Subscription")
21 // ErrSubscriptionChangeEmpty is returned when a SubscriptionChange
22 // is empty but is passed to subscriptionStore.UpdateSubscription
24 ErrSubscriptionChangeEmpty = errors.New("SubscriptionChange is empty")
25 // ErrNoSubscriptionID is returned when one or more Subscription IDs
26 // are required, but none are provided.
27 ErrNoSubscriptionID = errors.New("no Subscription ID provided")
30 // Subscription represents the state of a user's payments. It holds
31 // metadata about the last time a user was charged, how much a user
32 // should be charged, how to charge a user and how much to charge
34 type Subscription struct {
36 StripeSubscription string
46 FailedChargeAttempts int
47 LastFailedCharge time.Time
48 LastNotified time.Time
51 // SubscriptionChange represents desired changes to a Subscription
52 // object. A nil value means that property should remain unchanged.
53 type SubscriptionChange struct {
54 StripeSubscription *string
60 PeriodStart *time.Time
63 FailedChargeAttempts *int
64 LastFailedCharge *time.Time
65 LastNotified *time.Time
68 // IsEmpty returns true if the SubscriptionChange doesn't request
69 // a change to any property of the Subscription.
70 func (change SubscriptionChange) IsEmpty() bool {
71 if change.StripeSubscription != nil {
74 if change.Plan != nil {
77 if change.Status != nil {
80 if change.Canceling != nil {
83 if change.TrialStart != nil {
86 if change.TrialEnd != nil {
89 if change.PeriodStart != nil {
92 if change.PeriodEnd != nil {
95 if change.CanceledAt != nil {
98 if change.LastNotified != nil {
101 if change.LastFailedCharge != nil {
104 if change.FailedChargeAttempts != nil {
110 // ApplyChange updates a Subscription based on the changes requested
111 // by a SubscriptionChange.
112 func (s *Subscription) ApplyChange(change SubscriptionChange) {
113 if change.StripeSubscription != nil {
114 s.StripeSubscription = *change.StripeSubscription
116 if change.Plan != nil {
117 s.Plan = *change.Plan
119 if change.Status != nil {
120 s.Status = *change.Status
122 if change.Canceling != nil {
123 s.Canceling = *change.Canceling
125 if change.TrialStart != nil {
126 s.TrialStart = *change.TrialStart
128 if change.TrialEnd != nil {
129 s.TrialEnd = *change.TrialEnd
131 if change.PeriodStart != nil {
132 s.PeriodStart = *change.PeriodStart
134 if change.PeriodEnd != nil {
135 s.PeriodEnd = *change.PeriodEnd
137 if change.CanceledAt != nil {
138 s.CanceledAt = *change.CanceledAt
140 if change.LastFailedCharge != nil {
141 s.LastFailedCharge = *change.LastFailedCharge
143 if change.LastNotified != nil {
144 s.LastNotified = *change.LastNotified
146 if change.FailedChargeAttempts != nil {
147 s.FailedChargeAttempts = *change.FailedChargeAttempts
151 // SubscriptionStats represents a set of statistics about our Subscription
152 // data that will be exposed to the Prometheus scraper.
153 type SubscriptionStats struct {
157 Plans map[string]int64
158 // BUG(paddy): Currently, Kubernetes doesn't offer any way to contact _all_ nodes in a service.
159 // Because of this, we can only report stats that will be identical across nodes, e.g. stats
160 // that come from the database. More info here: https://github.com/GoogleCloudPlatform/kubernetes/issues/6666
161 // In the future, we'll need per-node metrics. For now, we'll make do.
163 // Actually, as of https://github.com/GoogleCloudPlatform/kubernetes/pull/9073, we may be all set:
164 // "SRV Records are created for named ports that are part of normal or Headless Services. For each named port,
165 // the SRV record would have the form _my-port-name._my-port-protocol.my-svc.my-namespace.svc.cluster.local.
166 // For a regular service, this resolves to the port number and the CNAME: my-svc.my-namespace.svc.cluster.local.
167 // For a headless service, this resolves to multiple answers, one for each pod that is backing the service, and
168 // contains the port number and a CNAME of the pod with the format auto-generated-name.my-svc.my-namespace.svc.cluster.local
169 // SRV records always contain the 'svc' segment in them and are not supported for old-style CNAMEs where the 'svc' segment
173 type subscriptionStore interface {
175 createSubscription(sub Subscription) error
176 updateSubscription(id uuid.ID, change SubscriptionChange) error
177 deleteSubscription(id uuid.ID) error
178 getSubscriptions(ids []uuid.ID) (map[string]Subscription, error)
179 getSubscriptionStats() (SubscriptionStats, error)