ducky/subscriptions
ducky/subscriptions/subscription_postgres.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@1 | 1 package subscriptions |
| paddy@1 | 2 |
| paddy@1 | 3 import ( |
| paddy@2 | 4 "log" |
| paddy@1 | 5 |
| paddy@1 | 6 "code.secondbit.org/uuid.hg" |
| paddy@2 | 7 |
| paddy@1 | 8 "github.com/lib/pq" |
| paddy@1 | 9 "github.com/secondbit/pan" |
| paddy@1 | 10 ) |
| paddy@1 | 11 |
| paddy@1 | 12 // GetSQLTableName fulfills the pan.SQLTableNamer interface, allowing |
| paddy@1 | 13 // us to manipulate Subscriptions with pan. |
| paddy@1 | 14 func (s Subscription) GetSQLTableName() string { |
| paddy@1 | 15 return "subscriptions" |
| paddy@1 | 16 } |
| paddy@1 | 17 |
| paddy@1 | 18 func (p Postgres) resetSQL() *pan.Query { |
| paddy@2 | 19 var subscription Subscription |
| paddy@2 | 20 query := pan.New(pan.POSTGRES, "TRUNCATE "+pan.GetTableName(subscription)) |
| paddy@1 | 21 return query.FlushExpressions(" ") |
| paddy@1 | 22 } |
| paddy@1 | 23 |
| paddy@14 | 24 // Reset returns the database to its initialised state. It is meant to |
| paddy@14 | 25 // be used during testing to reset the databases to a known state between |
| paddy@14 | 26 // tests. It should never be used in production. |
| paddy@3 | 27 func (p Postgres) Reset() error { |
| paddy@1 | 28 query := p.resetSQL() |
| paddy@1 | 29 _, err := p.Exec(query.String(), query.Args...) |
| paddy@1 | 30 if err != nil { |
| paddy@1 | 31 return err |
| paddy@1 | 32 } |
| paddy@1 | 33 return nil |
| paddy@1 | 34 } |
| paddy@1 | 35 |
| paddy@2 | 36 func (p Postgres) createSubscriptionSQL(subscription Subscription) *pan.Query { |
| paddy@2 | 37 fields, values := pan.GetFields(subscription) |
| paddy@2 | 38 query := pan.New(pan.POSTGRES, "INSERT INTO "+pan.GetTableName(subscription)) |
| paddy@1 | 39 query.Include("(" + pan.QueryList(fields) + ")") |
| paddy@1 | 40 query.Include("VALUES") |
| paddy@1 | 41 query.Include("("+pan.VariableList(len(values))+")", values...) |
| paddy@1 | 42 return query.FlushExpressions(" ") |
| paddy@1 | 43 } |
| paddy@1 | 44 |
| paddy@14 | 45 // CreateSubscription stores the passed Subscription in the database. If |
| paddy@14 | 46 // a Subscription sharing the same UserID already exists, an |
| paddy@14 | 47 // ErrSubscriptionAlreadyExists error will be returned. If a Subscription |
| paddy@14 | 48 // sharing the same StripeSubscription already exists, an |
| paddy@14 | 49 // ErrStripeSubscriptionAlreadyExists error will be returned. |
| paddy@3 | 50 func (p Postgres) CreateSubscription(sub Subscription) error { |
| paddy@1 | 51 query := p.createSubscriptionSQL(sub) |
| paddy@1 | 52 _, err := p.Exec(query.String(), query.Args...) |
| paddy@1 | 53 if e, ok := err.(*pq.Error); ok && e.Constraint == "subscriptions_pkey" { |
| paddy@1 | 54 err = ErrSubscriptionAlreadyExists |
| paddy@2 | 55 } else if e, ok := err.(*pq.Error); ok && e.Constraint == "subscriptions_stripe_subscription_key" { |
| paddy@2 | 56 err = ErrStripeSubscriptionAlreadyExists |
| paddy@1 | 57 } |
| paddy@1 | 58 return err |
| paddy@1 | 59 } |
| paddy@1 | 60 |
| paddy@1 | 61 func (p Postgres) updateSubscriptionSQL(id uuid.ID, change SubscriptionChange) *pan.Query { |
| paddy@2 | 62 var subscription Subscription |
| paddy@2 | 63 query := pan.New(pan.POSTGRES, "UPDATE "+pan.GetTableName(subscription)+" SET") |
| paddy@2 | 64 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "StripeSubscription")+" = ?", change.StripeSubscription) |
| paddy@2 | 65 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "Plan")+" = ?", change.Plan) |
| paddy@2 | 66 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "Status")+" = ?", change.Status) |
| paddy@2 | 67 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "Canceling")+" = ?", change.Canceling) |
| paddy@2 | 68 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "TrialStart")+" = ?", change.TrialStart) |
| paddy@2 | 69 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "TrialEnd")+" = ?", change.TrialEnd) |
| paddy@2 | 70 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "PeriodStart")+" = ?", change.PeriodStart) |
| paddy@2 | 71 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "PeriodEnd")+" = ?", change.PeriodEnd) |
| paddy@2 | 72 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "CanceledAt")+" = ?", change.CanceledAt) |
| paddy@2 | 73 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "FailedChargeAttempts")+" = ?", change.FailedChargeAttempts) |
| paddy@2 | 74 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "LastFailedCharge")+" = ?", change.LastFailedCharge) |
| paddy@2 | 75 query.IncludeIfNotNil(pan.GetUnquotedColumn(subscription, "LastNotified")+" = ?", change.LastNotified) |
| paddy@1 | 76 query.FlushExpressions(", ") |
| paddy@1 | 77 query.IncludeWhere() |
| paddy@2 | 78 query.Include(pan.GetUnquotedColumn(subscription, "UserID")+" = ?", id) |
| paddy@1 | 79 return query.FlushExpressions(" ") |
| paddy@1 | 80 } |
| paddy@1 | 81 |
| paddy@14 | 82 // UpdateSubscription applies the SubscriptionChange passed to the Subscription |
| paddy@14 | 83 // stored in the database associated with the passed ID. If change is empty, |
| paddy@14 | 84 // an ErrSubscriptionChangeEmpty error is returned. If no Subscription is found |
| paddy@14 | 85 // in the database with the passed ID, an ErrSubscriptionNotFound error is returned. |
| paddy@14 | 86 // If change is updating the StripeSubscription and a Subscription in the database |
| paddy@14 | 87 // already has that value set for StripeSubscription, an |
| paddy@14 | 88 // ErrStripeSubscriptionAlreadyExists error is returned. |
| paddy@3 | 89 func (p Postgres) UpdateSubscription(id uuid.ID, change SubscriptionChange) error { |
| paddy@1 | 90 if change.IsEmpty() { |
| paddy@1 | 91 return ErrSubscriptionChangeEmpty |
| paddy@1 | 92 } |
| paddy@1 | 93 |
| paddy@1 | 94 query := p.updateSubscriptionSQL(id, change) |
| paddy@1 | 95 res, err := p.Exec(query.String(), query.Args...) |
| paddy@2 | 96 if e, ok := err.(*pq.Error); ok && e.Constraint == "subscriptions_stripe_subscription_key" { |
| paddy@2 | 97 return ErrStripeSubscriptionAlreadyExists |
| paddy@1 | 98 } else if err != nil { |
| paddy@1 | 99 return err |
| paddy@1 | 100 } |
| paddy@1 | 101 rows, err := res.RowsAffected() |
| paddy@1 | 102 if err != nil { |
| paddy@1 | 103 return err |
| paddy@1 | 104 } |
| paddy@1 | 105 if rows < 1 { |
| paddy@1 | 106 return ErrSubscriptionNotFound |
| paddy@1 | 107 } |
| paddy@1 | 108 return nil |
| paddy@1 | 109 } |
| paddy@1 | 110 |
| paddy@1 | 111 func (p Postgres) deleteSubscriptionSQL(id uuid.ID) *pan.Query { |
| paddy@2 | 112 var subscription Subscription |
| paddy@2 | 113 query := pan.New(pan.POSTGRES, "DELETE FROM "+pan.GetTableName(subscription)) |
| paddy@1 | 114 query.IncludeWhere() |
| paddy@2 | 115 query.Include(pan.GetUnquotedColumn(subscription, "UserID")+" = ?", id) |
| paddy@1 | 116 return query.FlushExpressions(" ") |
| paddy@1 | 117 } |
| paddy@1 | 118 |
| paddy@14 | 119 // DeleteSubscription removes the Subscription stored in the database associated |
| paddy@14 | 120 // with the passed ID from the database. If no Subscription is found |
| paddy@14 | 121 // in the database with the passed ID, an ErrSubscriptionNotFound error is returned. |
| paddy@3 | 122 func (p Postgres) DeleteSubscription(id uuid.ID) error { |
| paddy@1 | 123 query := p.deleteSubscriptionSQL(id) |
| paddy@1 | 124 res, err := p.Exec(query.String(), query.Args...) |
| paddy@1 | 125 if err != nil { |
| paddy@1 | 126 return err |
| paddy@1 | 127 } |
| paddy@1 | 128 rows, err := res.RowsAffected() |
| paddy@1 | 129 if err != nil { |
| paddy@1 | 130 return err |
| paddy@1 | 131 } |
| paddy@1 | 132 if rows < 1 { |
| paddy@1 | 133 return ErrSubscriptionNotFound |
| paddy@1 | 134 } |
| paddy@1 | 135 return nil |
| paddy@1 | 136 } |
| paddy@1 | 137 |
| paddy@1 | 138 func (p Postgres) getSubscriptionsSQL(ids []uuid.ID) *pan.Query { |
| paddy@2 | 139 var subscription Subscription |
| paddy@2 | 140 fields, _ := pan.GetFields(subscription) |
| paddy@1 | 141 intIDs := make([]interface{}, len(ids)) |
| paddy@1 | 142 for pos, id := range ids { |
| paddy@1 | 143 intIDs[pos] = id |
| paddy@1 | 144 } |
| paddy@2 | 145 query := pan.New(pan.POSTGRES, "SELECT "+pan.QueryList(fields)+" FROM "+pan.GetTableName(subscription)) |
| paddy@1 | 146 query.IncludeWhere() |
| paddy@2 | 147 query.Include(pan.GetUnquotedColumn(subscription, "UserID") + " IN") |
| paddy@1 | 148 query.Include("("+pan.VariableList(len(intIDs))+")", intIDs...) |
| paddy@1 | 149 return query.FlushExpressions(" ") |
| paddy@1 | 150 } |
| paddy@1 | 151 |
| paddy@14 | 152 // GetSubscriptions retrieves the Subscriptions stored in the database associated |
| paddy@14 | 153 // with the passed IDs. If no IDs are passed, an ErrNoSubscriptionID error is |
| paddy@14 | 154 // returned. No matter how many of the IDs are found (including none), a map is |
| paddy@14 | 155 // returned, with the key being a String()ed version of the ID for the Subscription in |
| paddy@14 | 156 // the value. If no error is returned, the map will represent all of the Subscriptions |
| paddy@14 | 157 // matching the passed IDs that exist in the database, even if it's empty. |
| paddy@3 | 158 func (p Postgres) GetSubscriptions(ids []uuid.ID) (map[string]Subscription, error) { |
| paddy@1 | 159 results := map[string]Subscription{} |
| paddy@1 | 160 if len(ids) < 1 { |
| paddy@1 | 161 return results, ErrNoSubscriptionID |
| paddy@1 | 162 } |
| paddy@1 | 163 query := p.getSubscriptionsSQL(ids) |
| paddy@1 | 164 rows, err := p.Query(query.String(), query.Args...) |
| paddy@1 | 165 if err != nil { |
| paddy@1 | 166 return results, err |
| paddy@1 | 167 } |
| paddy@1 | 168 for rows.Next() { |
| paddy@2 | 169 var subscription Subscription |
| paddy@2 | 170 err := pan.Unmarshal(rows, &subscription) |
| paddy@1 | 171 if err != nil { |
| paddy@1 | 172 return results, err |
| paddy@1 | 173 } |
| paddy@2 | 174 results[subscription.UserID.String()] = subscription |
| paddy@1 | 175 } |
| paddy@1 | 176 if err := rows.Err(); err != nil { |
| paddy@1 | 177 return results, err |
| paddy@1 | 178 } |
| paddy@1 | 179 return results, nil |
| paddy@1 | 180 } |
| paddy@1 | 181 |
| paddy@2 | 182 func (p Postgres) getSubscriptionStatsCountSQL() *pan.Query { |
| paddy@2 | 183 var subscription Subscription |
| paddy@2 | 184 query := pan.New(pan.POSTGRES, "SELECT COUNT(*) FROM") |
| paddy@2 | 185 query.Include(pan.GetTableName(subscription)) |
| paddy@2 | 186 return query.FlushExpressions(" ") |
| paddy@2 | 187 } |
| paddy@2 | 188 |
| paddy@2 | 189 func (p Postgres) getSubscriptionStatsCancelingSQL() *pan.Query { |
| paddy@2 | 190 var subscription Subscription |
| paddy@2 | 191 query := pan.New(pan.POSTGRES, "SELECT COUNT(*) FROM") |
| paddy@2 | 192 query.Include(pan.GetTableName(subscription)) |
| paddy@2 | 193 query.IncludeWhere() |
| paddy@2 | 194 query.Include(pan.GetUnquotedColumn(subscription, "Canceling")+" = ?", true) |
| paddy@2 | 195 return query.FlushExpressions(" ") |
| paddy@2 | 196 } |
| paddy@2 | 197 |
| paddy@2 | 198 func (p Postgres) getSubscriptionStatsFailingSQL() *pan.Query { |
| paddy@2 | 199 var subscription Subscription |
| paddy@2 | 200 query := pan.New(pan.POSTGRES, "SELECT COUNT(*) FROM") |
| paddy@2 | 201 query.Include(pan.GetTableName(subscription)) |
| paddy@2 | 202 query.IncludeWhere() |
| paddy@2 | 203 statuses := []interface{}{"past_due", "unpaid"} |
| paddy@2 | 204 query.Include(pan.GetUnquotedColumn(subscription, "Status")+" IN ("+pan.VariableList(len(statuses))+")", statuses...) |
| paddy@2 | 205 return query.FlushExpressions(" ") |
| paddy@2 | 206 } |
| paddy@2 | 207 |
| paddy@2 | 208 func (p Postgres) getSubscriptionStatsPlansSQL() *pan.Query { |
| paddy@2 | 209 var subscription Subscription |
| paddy@2 | 210 fields := []interface{}{pan.GetUnquotedColumn(subscription, "Plan"), "COUNT(*)"} |
| paddy@2 | 211 query := pan.New(pan.POSTGRES, "SELECT "+pan.QueryList(fields)+" FROM") |
| paddy@2 | 212 query.Include(pan.GetTableName(subscription)) |
| paddy@2 | 213 query.Include("GROUP BY " + pan.GetUnquotedColumn(subscription, "Plan")) |
| paddy@1 | 214 return query.FlushExpressions(" ") |
| paddy@1 | 215 } |
| paddy@1 | 216 |
| paddy@14 | 217 // GetSubscriptionStats returns statistics about the subscription data stored in the |
| paddy@14 | 218 // database as a SubscriptionStats variable. The number of Subscriptions, the |
| paddy@14 | 219 // breakdown of how many Subscriptions belong to each plan, the number of |
| paddy@14 | 220 // Subscriptions that are canceling, and the number of Subscriptions whose payment |
| paddy@14 | 221 // information is failing are all tracked. |
| paddy@3 | 222 func (p Postgres) GetSubscriptionStats() (SubscriptionStats, error) { |
| paddy@2 | 223 stats := SubscriptionStats{ |
| paddy@2 | 224 Plans: map[string]int64{}, |
| paddy@2 | 225 } |
| paddy@2 | 226 query := p.getSubscriptionStatsCountSQL() |
| paddy@2 | 227 err := p.QueryRow(query.String(), query.Args...).Scan(&stats.Number) |
| paddy@2 | 228 if err != nil { |
| paddy@2 | 229 log.Printf("Error querying for total subscriptions: %+v\n", err) |
| paddy@2 | 230 return stats, err |
| paddy@2 | 231 } |
| paddy@2 | 232 query = p.getSubscriptionStatsCancelingSQL() |
| paddy@2 | 233 err = p.QueryRow(query.String(), query.Args...).Scan(&stats.Canceling) |
| paddy@2 | 234 if err != nil { |
| paddy@2 | 235 log.Printf("Error querying for canceling subscriptions: %+v\n", err) |
| paddy@2 | 236 return stats, err |
| paddy@2 | 237 } |
| paddy@2 | 238 query = p.getSubscriptionStatsFailingSQL() |
| paddy@2 | 239 err = p.QueryRow(query.String(), query.Args...).Scan(&stats.Failing) |
| paddy@2 | 240 if err != nil { |
| paddy@2 | 241 log.Printf("Error querying for failing subscriptions: %+v\n", err) |
| paddy@2 | 242 return stats, err |
| paddy@2 | 243 } |
| paddy@2 | 244 query = p.getSubscriptionStatsPlansSQL() |
| paddy@1 | 245 rows, err := p.Query(query.String(), query.Args...) |
| paddy@1 | 246 if err != nil { |
| paddy@2 | 247 log.Printf("Error querying for plans: %+v\n", err) |
| paddy@2 | 248 return stats, err |
| paddy@1 | 249 } |
| paddy@1 | 250 for rows.Next() { |
| paddy@2 | 251 var plan string |
| paddy@2 | 252 var count int64 |
| paddy@2 | 253 err := rows.Scan(&plan, &count) |
| paddy@2 | 254 if err != nil { |
| paddy@2 | 255 log.Printf("Error scanning database row for plans: %+v\n", err) |
| paddy@2 | 256 continue |
| paddy@1 | 257 } |
| paddy@2 | 258 stats.Plans[plan] = count |
| paddy@1 | 259 } |
| paddy@1 | 260 if err := rows.Err(); err != nil { |
| paddy@2 | 261 log.Printf("Error querying for plans: %+v\n", err) |
| paddy@1 | 262 return stats, err |
| paddy@1 | 263 } |
| paddy@1 | 264 return stats, nil |
| paddy@1 | 265 } |