ducky/subscriptions
ducky/subscriptions/stripe.go
Log Postgres test failures more verbosely, fix SubscriptionChange.IsEmpty. SubscriptionChange.IsEmpty() would return false even if no actual database operations are going to be performed. This is because we allow information we _don't_ store in the database (Stripe source, Stripe email) to be specified in a SubscriptionChange object, just so we can easily access them. Then we use the Stripe API to store them in Stripe's databases, and turn them into data _we_ store in our database. Think of them as pre-processed values that are never stored raw. The problem is, we were treating these properties the same as the properties we actually stored in the database, and (worse) were running database tests for combinations of these properties, which was causing test failures because we were trying to update no columns in the database. Whoops. I removed these properties from the IsEmpty helper, and removed them from the code that generates the SubscriptionChange permutations for testing. This allows tests to pass, but also stays closer to what the system was designed to do. In tracking down this bug, I discovered that the logging we had for errors when running Postgres tests was inadequate, so I updated the logs when that failure occurs while testing Postgres to help surface future failures faster.
| paddy@2 | 1 package subscriptions |
| paddy@2 | 2 |
| paddy@2 | 3 import ( |
| paddy@6 | 4 "errors" |
| paddy@2 | 5 "time" |
| paddy@2 | 6 |
| paddy@2 | 7 "code.secondbit.org/uuid.hg" |
| paddy@2 | 8 |
| paddy@2 | 9 "github.com/stripe/stripe-go" |
| paddy@2 | 10 "github.com/stripe/stripe-go/customer" |
| paddy@2 | 11 "github.com/stripe/stripe-go/sub" |
| paddy@2 | 12 ) |
| paddy@2 | 13 |
| paddy@6 | 14 const ( |
| paddy@13 | 15 // PendingPlan holds the name for the plan users that haven't chosen a plan are subscribed to. |
| paddy@6 | 16 PendingPlan = "pending" |
| paddy@6 | 17 ) |
| paddy@6 | 18 |
| paddy@6 | 19 var ( |
| paddy@13 | 20 // ErrNilCustomer is returned when a *Customer is expected, but nil is passed. |
| paddy@13 | 21 ErrNilCustomer = errors.New("nil customer passed") |
| paddy@13 | 22 // ErrNilCustomerSubs is returned when *Customer.Subs is expected to be a slice, but is nil. |
| paddy@13 | 23 ErrNilCustomerSubs = errors.New("customer with nil subscriptions list passed") |
| paddy@13 | 24 // ErrWrongNumberOfCustomerSubs is returned when a *Customer.Subs slice has more or fewer elements than expected. |
| paddy@6 | 25 ErrWrongNumberOfCustomerSubs = errors.New("customer with wrong number of subscriptions passed") |
| paddy@13 | 26 // ErrNilSubscription is returned when a *Subscription is expected, but nil is passed. |
| paddy@13 | 27 ErrNilSubscription = errors.New("nil subscription passed") |
| paddy@6 | 28 ) |
| paddy@6 | 29 |
| paddy@13 | 30 // Stripe is a wrapper around the clients for Stripe that we're using. It's responsible for |
| paddy@13 | 31 // all the interaction with the Stripe API that we're doing. It is a level higher than the |
| paddy@13 | 32 // raw Stripe clients, and can be thought of as Stripe-from-the-perspective-of-subscriptions. |
| paddy@2 | 33 type Stripe struct { |
| paddy@2 | 34 apiKey string |
| paddy@2 | 35 customers customer.Client |
| paddy@2 | 36 subscriptions sub.Client |
| paddy@2 | 37 } |
| paddy@2 | 38 |
| paddy@13 | 39 // NewStripe creates a new Stripe instance using the passed parameters. The stripe.Backend |
| paddy@13 | 40 // parameter can be used to create a stub instead of something that actually hits the Stripe |
| paddy@13 | 41 // API. See the github.com/stripe/stripe-go documentation for more information about Backends. |
| paddy@2 | 42 func NewStripe(apiKey string, backend stripe.Backend) Stripe { |
| paddy@2 | 43 return Stripe{ |
| paddy@2 | 44 apiKey: apiKey, |
| paddy@2 | 45 customers: customer.Client{ |
| paddy@2 | 46 B: backend, |
| paddy@2 | 47 Key: apiKey, |
| paddy@2 | 48 }, |
| paddy@2 | 49 subscriptions: sub.Client{ |
| paddy@2 | 50 B: backend, |
| paddy@2 | 51 Key: apiKey, |
| paddy@2 | 52 }, |
| paddy@2 | 53 } |
| paddy@2 | 54 } |
| paddy@2 | 55 |
| paddy@13 | 56 // CreateStripeCustomer sets up a customer using the passed Stripe client, ensuring it follows all |
| paddy@13 | 57 // the conventions that subscriptions expects (the description being set properly, the UserID meta, |
| paddy@13 | 58 // etc.) Creating a customer automatically creates a subscription for that customer. |
| paddy@6 | 59 func CreateStripeCustomer(plan, email string, userID uuid.ID, s Stripe) (*stripe.Customer, error) { |
| paddy@2 | 60 customerParams := &stripe.CustomerParams{ |
| paddy@2 | 61 Desc: "Customer for user " + userID.String(), |
| paddy@2 | 62 Email: email, |
| paddy@6 | 63 Plan: plan, |
| paddy@2 | 64 } |
| paddy@2 | 65 customerParams.AddMeta("UserID", userID.String()) |
| paddy@2 | 66 c, err := s.customers.New(customerParams) |
| paddy@2 | 67 if err != nil { |
| paddy@2 | 68 return nil, err |
| paddy@2 | 69 } |
| paddy@2 | 70 return c, nil |
| paddy@2 | 71 } |
| paddy@2 | 72 |
| paddy@13 | 73 // UpdateStripeSubscription modifies a subscription in Stripe, updating the plan and/or token. |
| paddy@13 | 74 // A nil plan or token indicates no change to the parameter. |
| paddy@6 | 75 func UpdateStripeSubscription(customerID string, plan, token *string, s Stripe) (*stripe.Sub, error) { |
| paddy@6 | 76 params := &stripe.SubParams{} |
| paddy@6 | 77 if plan != nil { |
| paddy@6 | 78 params.Plan = *plan |
| paddy@2 | 79 } |
| paddy@6 | 80 if token != nil { |
| paddy@6 | 81 params.Token = *token |
| paddy@6 | 82 } |
| paddy@6 | 83 subscription, err := s.subscriptions.Update(customerID, params) |
| paddy@2 | 84 if err != nil { |
| paddy@2 | 85 return nil, err |
| paddy@2 | 86 } |
| paddy@6 | 87 return subscription, nil |
| paddy@2 | 88 } |
| paddy@2 | 89 |
| paddy@6 | 90 // New should be called when a user's profile is created. At this point, we know nothing about the subscription |
| paddy@6 | 91 // they actually _want_. We just sign them up for the dedicated "pending" plan. This is to make their free trial begin |
| paddy@6 | 92 // immediately and not have to worry about automatically locking them out until they actually create a subscription. |
| paddy@6 | 93 // Basically, we want everyone to have a subscription at all times, but some users will have placeholders until they |
| paddy@6 | 94 // actually update their subscription with a desired plan and payment method. |
| paddy@6 | 95 func New(req SubscriptionChange, s Stripe, store SubscriptionStore) (Subscription, error) { |
| paddy@6 | 96 subscription := Subscription{} |
| paddy@6 | 97 subscription.ApplyChange(req) |
| paddy@6 | 98 // BUG(paddy): need to validate the change |
| paddy@6 | 99 |
| paddy@6 | 100 // create the customer in Stripe, storing the token for reuse |
| paddy@6 | 101 customer, err := CreateStripeCustomer(PendingPlan, *req.Email, req.UserID, s) |
| paddy@6 | 102 if err != nil { |
| paddy@6 | 103 return subscription, err |
| paddy@6 | 104 } |
| paddy@6 | 105 if customer == nil { |
| paddy@6 | 106 return subscription, ErrNilCustomer |
| paddy@6 | 107 } |
| paddy@6 | 108 if customer.Subs == nil { |
| paddy@6 | 109 return subscription, ErrNilCustomerSubs |
| paddy@6 | 110 } |
| paddy@6 | 111 if len(customer.Subs.Values) != 1 { |
| paddy@6 | 112 return subscription, ErrWrongNumberOfCustomerSubs |
| paddy@6 | 113 } |
| paddy@6 | 114 if customer.Subs.Values[0] == nil { |
| paddy@6 | 115 return subscription, ErrNilSubscription |
| paddy@6 | 116 } |
| paddy@6 | 117 |
| paddy@6 | 118 change := StripeSubscriptionChange(subscription, *customer.Subs.Values[0]) |
| paddy@6 | 119 subscription.ApplyChange(change) |
| paddy@6 | 120 |
| paddy@6 | 121 err = store.CreateSubscription(subscription) |
| paddy@2 | 122 if err != nil { |
| paddy@3 | 123 return subscription, err |
| paddy@2 | 124 } |
| paddy@2 | 125 |
| paddy@3 | 126 return subscription, nil |
| paddy@2 | 127 } |
| paddy@2 | 128 |
| paddy@13 | 129 // StripeSubscriptionChange takes a Subscription and a stripe.Sub, and returns a SubscriptionChange |
| paddy@13 | 130 // that will change only the properties necessary to make the Subscription match the stripe.Sub. |
| paddy@2 | 131 func StripeSubscriptionChange(orig Subscription, subscription stripe.Sub) SubscriptionChange { |
| paddy@2 | 132 var change SubscriptionChange |
| paddy@6 | 133 if subscription.ID != orig.StripeSubscription { |
| paddy@6 | 134 change.StripeSubscription = &subscription.ID |
| paddy@6 | 135 } |
| paddy@2 | 136 if subscription.Plan != nil && orig.Plan != subscription.Plan.ID { |
| paddy@2 | 137 change.Plan = &subscription.Plan.ID |
| paddy@2 | 138 } |
| paddy@2 | 139 if string(subscription.Status) != orig.Status { |
| paddy@2 | 140 status := string(subscription.Status) |
| paddy@2 | 141 change.Status = &status |
| paddy@2 | 142 } |
| paddy@2 | 143 if subscription.EndCancel != orig.Canceling { |
| paddy@2 | 144 change.Canceling = &subscription.EndCancel |
| paddy@2 | 145 } |
| paddy@3 | 146 if !time.Unix(subscription.TrialStart, 0).Equal(orig.TrialStart) && !(subscription.TrialStart == 0 && orig.TrialStart.IsZero()) { |
| paddy@2 | 147 trialStart := time.Unix(subscription.TrialStart, 0) |
| paddy@2 | 148 change.TrialStart = &trialStart |
| paddy@2 | 149 } |
| paddy@3 | 150 if !time.Unix(subscription.TrialEnd, 0).Equal(orig.TrialEnd) && !(subscription.TrialEnd == 0 && orig.TrialEnd.IsZero()) { |
| paddy@2 | 151 trialEnd := time.Unix(subscription.TrialEnd, 0) |
| paddy@2 | 152 change.TrialEnd = &trialEnd |
| paddy@2 | 153 } |
| paddy@3 | 154 if !time.Unix(subscription.PeriodStart, 0).Equal(orig.PeriodStart) && !(subscription.PeriodStart == 0 && orig.PeriodStart.IsZero()) { |
| paddy@2 | 155 periodStart := time.Unix(subscription.PeriodStart, 0) |
| paddy@2 | 156 change.PeriodStart = &periodStart |
| paddy@2 | 157 } |
| paddy@3 | 158 if !time.Unix(subscription.PeriodEnd, 0).Equal(orig.PeriodEnd) && !(subscription.PeriodEnd == 0 && orig.PeriodEnd.IsZero()) { |
| paddy@2 | 159 periodEnd := time.Unix(subscription.PeriodEnd, 0) |
| paddy@2 | 160 change.PeriodEnd = &periodEnd |
| paddy@2 | 161 } |
| paddy@3 | 162 if !time.Unix(subscription.Canceled, 0).Equal(orig.CanceledAt) && !(subscription.Canceled == 0 && orig.CanceledAt.IsZero()) { |
| paddy@2 | 163 canceledAt := time.Unix(subscription.Canceled, 0) |
| paddy@2 | 164 change.CanceledAt = &canceledAt |
| paddy@2 | 165 } |
| paddy@2 | 166 return change |
| paddy@2 | 167 } |