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.
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")
29 planOptions = map[string]bool{
30 "basic_monthly": false,
31 "basic_yearly": false,
32 "supporter_monthly": false,
33 "supporter_yearly": false,
38 // Version tracks the build ID of the binary, set using
43 // Subscription represents the state of a user's payments. It holds
44 // metadata about the last time a user was charged, how much a user
45 // should be charged, how to charge a user and how much to charge
47 type Subscription struct {
48 UserID uuid.ID `json:"user_id"`
49 StripeSubscription string `json:"stripe_subscription"`
50 Plan string `json:"plan"`
51 Status string `json:"status"`
52 Canceling bool `json:"canceling"`
53 Created time.Time `json:"created"`
54 TrialStart time.Time `json:"trial_start,omitempty"`
55 TrialEnd time.Time `json:"trial_end,omitempty"`
56 PeriodStart time.Time `json:"period_start,omitempty"`
57 PeriodEnd time.Time `json:"period_end,omitempty"`
58 CanceledAt time.Time `json:"canceled_at,omitempty"`
59 FailedChargeAttempts int `json:"failed_charge_attempts"`
60 LastFailedCharge time.Time `json:"last_failed_charge,omitempty"`
61 LastNotified time.Time `json:"last_notified,omitempty"`
64 // SubscriptionChange represents desired changes to a Subscription
65 // object. A nil value means that property should remain unchanged.
66 type SubscriptionChange struct {
67 UserID uuid.ID `json:"user_id"`
69 // User-controlled and not stored in DB (helper properties for the API)
70 StripeSource *string `json:"stripe_source,omitempty"`
71 Email *string `json:"email,omitempty"`
73 // User-controlled and stored in DB
74 Plan *string `json:"plan,omitempty"`
75 Canceling *bool `json:"cenceling,omitempty"`
78 StripeSubscription *string `json:"stripe_subscription,omitempty"`
79 Status *string `json:"status,omitempty"`
80 TrialStart *time.Time `json:"trial_start,omitempty"`
81 TrialEnd *time.Time `json:"trial_end,omitempty"`
82 PeriodStart *time.Time `json:"period_start,omitempty"`
83 PeriodEnd *time.Time `json:"period_end,omitempty"`
84 CanceledAt *time.Time `json:"canceled_at,omitempty"`
85 FailedChargeAttempts *int `json:"failed_charge_attempts,omitempty"`
86 LastFailedCharge *time.Time `json:"last_failed_charge,omitempty"`
87 LastNotified *time.Time `json:"last_notified,omitempty"`
90 // IsEmpty returns true if the SubscriptionChange doesn't request
91 // a change to any property of the Subscription.
92 func (change SubscriptionChange) IsEmpty() bool {
93 if change.Plan != nil {
96 if change.Canceling != nil {
99 if change.StripeSubscription != nil {
102 if change.Status != nil {
105 if change.TrialStart != nil {
108 if change.TrialEnd != nil {
111 if change.PeriodStart != nil {
114 if change.PeriodEnd != nil {
117 if change.CanceledAt != nil {
120 if change.LastNotified != nil {
123 if change.LastFailedCharge != nil {
126 if change.FailedChargeAttempts != nil {
132 // ApplyChange updates a Subscription based on the changes requested
133 // by a SubscriptionChange.
134 func (s *Subscription) ApplyChange(change SubscriptionChange) {
135 if change.StripeSubscription != nil {
136 s.StripeSubscription = *change.StripeSubscription
138 if change.Plan != nil {
139 s.Plan = *change.Plan
141 if change.Status != nil {
142 s.Status = *change.Status
144 if change.Canceling != nil {
145 s.Canceling = *change.Canceling
147 if change.TrialStart != nil {
148 s.TrialStart = *change.TrialStart
150 if change.TrialEnd != nil {
151 s.TrialEnd = *change.TrialEnd
153 if change.PeriodStart != nil {
154 s.PeriodStart = *change.PeriodStart
156 if change.PeriodEnd != nil {
157 s.PeriodEnd = *change.PeriodEnd
159 if change.CanceledAt != nil {
160 s.CanceledAt = *change.CanceledAt
162 if change.LastFailedCharge != nil {
163 s.LastFailedCharge = *change.LastFailedCharge
165 if change.LastNotified != nil {
166 s.LastNotified = *change.LastNotified
168 if change.FailedChargeAttempts != nil {
169 s.FailedChargeAttempts = *change.FailedChargeAttempts
173 // IsAcceptablePlan returns true if the user can select the specified
174 // plan, taking into account their admin status. If a plan exists, and
175 // is not designated as an admin-only plan, any user selecting it will
176 // return true. If a plan exists, but is designated as admin-only,
177 // IsAcceptablePlan will only return true if admin is true. If the plan
178 // doesn't exist, IsAcceptablePlan always returns false.
179 func IsAcceptablePlan(plan string, admin bool) bool {
180 for p, adminOnly := range planOptions {
182 return admin || adminOnly == false
188 // SubscriptionStats represents a set of statistics about our Subscription
189 // data that will be exposed to the Prometheus scraper.
190 type SubscriptionStats struct {
194 Plans map[string]int64
195 // BUG(paddy): Currently, Kubernetes doesn't offer any way to contact _all_ nodes in a service.
196 // Because of this, we can only report stats that will be identical across nodes, e.g. stats
197 // that come from the database. More info here: https://github.com/GoogleCloudPlatform/kubernetes/issues/6666
198 // In the future, we'll need per-node metrics. For now, we'll make do.
200 // Actually, as of https://github.com/GoogleCloudPlatform/kubernetes/pull/9073, we may be all set:
201 // "SRV Records are created for named ports that are part of normal or Headless Services. For each named port,
202 // the SRV record would have the form _my-port-name._my-port-protocol.my-svc.my-namespace.svc.cluster.local.
203 // For a regular service, this resolves to the port number and the CNAME: my-svc.my-namespace.svc.cluster.local.
204 // For a headless service, this resolves to multiple answers, one for each pod that is backing the service, and
205 // contains the port number and a CNAME of the pod with the format auto-generated-name.my-svc.my-namespace.svc.cluster.local
206 // SRV records always contain the 'svc' segment in them and are not supported for old-style CNAMEs where the 'svc' segment
210 // SubscriptionStore is an interface describing datastore interactions for
212 type SubscriptionStore interface {
214 CreateSubscription(sub Subscription) error
215 UpdateSubscription(id uuid.ID, change SubscriptionChange) error
216 DeleteSubscription(id uuid.ID) error
217 GetSubscriptions(ids []uuid.ID) (map[string]Subscription, error)
218 GetSubscriptionStats() (SubscriptionStats, error)