ducky/subscriptions
ducky/subscriptions/stripe.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.
| paddy@2 | 1 package subscriptions |
| paddy@2 | 2 |
| paddy@2 | 3 import ( |
| paddy@2 | 4 "time" |
| paddy@2 | 5 |
| paddy@2 | 6 "code.secondbit.org/uuid.hg" |
| paddy@2 | 7 |
| paddy@2 | 8 "github.com/stripe/stripe-go" |
| paddy@2 | 9 "github.com/stripe/stripe-go/customer" |
| paddy@2 | 10 "github.com/stripe/stripe-go/sub" |
| paddy@2 | 11 ) |
| paddy@2 | 12 |
| paddy@2 | 13 type Stripe struct { |
| paddy@2 | 14 apiKey string |
| paddy@2 | 15 customers customer.Client |
| paddy@2 | 16 subscriptions sub.Client |
| paddy@2 | 17 } |
| paddy@2 | 18 |
| paddy@2 | 19 func NewStripe(apiKey string, backend stripe.Backend) Stripe { |
| paddy@2 | 20 return Stripe{ |
| paddy@2 | 21 apiKey: apiKey, |
| paddy@2 | 22 customers: customer.Client{ |
| paddy@2 | 23 B: backend, |
| paddy@2 | 24 Key: apiKey, |
| paddy@2 | 25 }, |
| paddy@2 | 26 subscriptions: sub.Client{ |
| paddy@2 | 27 B: backend, |
| paddy@2 | 28 Key: apiKey, |
| paddy@2 | 29 }, |
| paddy@2 | 30 } |
| paddy@2 | 31 } |
| paddy@2 | 32 |
| paddy@2 | 33 func CreateStripeCustomer(token, email string, userID uuid.ID, s Stripe) (*stripe.Customer, error) { |
| paddy@2 | 34 customerParams := &stripe.CustomerParams{ |
| paddy@2 | 35 Desc: "Customer for user " + userID.String(), |
| paddy@2 | 36 Email: email, |
| paddy@2 | 37 } |
| paddy@2 | 38 customerParams.AddMeta("UserID", userID.String()) |
| paddy@2 | 39 customerParams.SetSource(token) |
| paddy@2 | 40 c, err := s.customers.New(customerParams) |
| paddy@2 | 41 if err != nil { |
| paddy@2 | 42 return nil, err |
| paddy@2 | 43 } |
| paddy@2 | 44 return c, nil |
| paddy@2 | 45 } |
| paddy@2 | 46 |
| paddy@2 | 47 func CreateStripeSubscription(customer, plan string, userID uuid.ID, s Stripe) (*stripe.Sub, error) { |
| paddy@2 | 48 subParams := &stripe.SubParams{ |
| paddy@2 | 49 Plan: plan, |
| paddy@2 | 50 Customer: customer, |
| paddy@2 | 51 } |
| paddy@2 | 52 subParams.AddMeta("UserID", userID.String()) |
| paddy@2 | 53 |
| paddy@2 | 54 resp, err := s.subscriptions.New(subParams) |
| paddy@2 | 55 if err != nil { |
| paddy@2 | 56 return nil, err |
| paddy@2 | 57 } |
| paddy@2 | 58 return resp, nil |
| paddy@2 | 59 } |
| paddy@2 | 60 |
| paddy@2 | 61 func CreateSubscription(token, email string, subscription Subscription, s Stripe, store subscriptionStore) error { |
| paddy@2 | 62 // create the subscription in our datastore |
| paddy@2 | 63 // this will fail if they already have a subscription, which prevents duplicate/orphaned Stripe customers being created |
| paddy@2 | 64 err := store.createSubscription(subscription) |
| paddy@2 | 65 if err != nil { |
| paddy@2 | 66 return err |
| paddy@2 | 67 } |
| paddy@2 | 68 |
| paddy@2 | 69 // create the customer in Stripe, storing the token for reuse |
| paddy@2 | 70 customer, err := CreateStripeCustomer(token, email, subscription.UserID, s) |
| paddy@2 | 71 if err != nil { |
| paddy@2 | 72 // TODO: delete subscription object |
| paddy@2 | 73 return err |
| paddy@2 | 74 } |
| paddy@2 | 75 |
| paddy@2 | 76 // create the subscription in Stripe, storing the ID for tracking and associating purposes |
| paddy@2 | 77 stripeSub, err := CreateStripeSubscription(customer.ID, subscription.Plan, subscription.UserID, s) |
| paddy@2 | 78 if err != nil { |
| paddy@2 | 79 // TODO: delete customer |
| paddy@2 | 80 // TODO: delete subscription object |
| paddy@2 | 81 return err |
| paddy@2 | 82 } |
| paddy@2 | 83 |
| paddy@2 | 84 // update our subscription in the datastore with the latest information from Stripe |
| paddy@2 | 85 change := StripeSubscriptionChange(subscription, *stripeSub) |
| paddy@2 | 86 err = store.updateSubscription(subscription.UserID, change) |
| paddy@2 | 87 if err != nil { |
| paddy@2 | 88 // TODO: log an error, manually retry later? |
| paddy@2 | 89 return err |
| paddy@2 | 90 } |
| paddy@2 | 91 return nil |
| paddy@2 | 92 } |
| paddy@2 | 93 |
| paddy@2 | 94 func StripeSubscriptionChange(orig Subscription, subscription stripe.Sub) SubscriptionChange { |
| paddy@2 | 95 var change SubscriptionChange |
| paddy@2 | 96 if subscription.Plan != nil && orig.Plan != subscription.Plan.ID { |
| paddy@2 | 97 change.Plan = &subscription.Plan.ID |
| paddy@2 | 98 } |
| paddy@2 | 99 if string(subscription.Status) != orig.Status { |
| paddy@2 | 100 status := string(subscription.Status) |
| paddy@2 | 101 change.Status = &status |
| paddy@2 | 102 } |
| paddy@2 | 103 if subscription.EndCancel != orig.Canceling { |
| paddy@2 | 104 change.Canceling = &subscription.EndCancel |
| paddy@2 | 105 } |
| paddy@2 | 106 if !time.Unix(subscription.TrialStart, 0).Equal(orig.TrialStart) { |
| paddy@2 | 107 trialStart := time.Unix(subscription.TrialStart, 0) |
| paddy@2 | 108 change.TrialStart = &trialStart |
| paddy@2 | 109 } |
| paddy@2 | 110 if !time.Unix(subscription.TrialEnd, 0).Equal(orig.TrialEnd) { |
| paddy@2 | 111 trialEnd := time.Unix(subscription.TrialEnd, 0) |
| paddy@2 | 112 change.TrialEnd = &trialEnd |
| paddy@2 | 113 } |
| paddy@2 | 114 if !time.Unix(subscription.PeriodStart, 0).Equal(orig.PeriodStart) { |
| paddy@2 | 115 periodStart := time.Unix(subscription.PeriodStart, 0) |
| paddy@2 | 116 change.PeriodStart = &periodStart |
| paddy@2 | 117 } |
| paddy@2 | 118 if !time.Unix(subscription.PeriodEnd, 0).Equal(orig.PeriodEnd) { |
| paddy@2 | 119 periodEnd := time.Unix(subscription.PeriodEnd, 0) |
| paddy@2 | 120 change.PeriodEnd = &periodEnd |
| paddy@2 | 121 } |
| paddy@2 | 122 if !time.Unix(subscription.Canceled, 0).Equal(orig.CanceledAt) { |
| paddy@2 | 123 canceledAt := time.Unix(subscription.Canceled, 0) |
| paddy@2 | 124 change.CanceledAt = &canceledAt |
| paddy@2 | 125 } |
| paddy@2 | 126 return change |
| paddy@2 | 127 } |