Make it possible to create a user without payment details.
We want a new subscription flow, in which the system (not the user) is
responsible for creating a subscription when a new profile is created. This is
to prevent issues where a user has an account, but no subscription. This is bad
because the free trial starts ticking from the day the subscription is created,
so we should try to make the subscription and account creation get created as
close to each other as possible.
Our plan is to instead have the authd service fire off an NSQ event when an
auth.Profile is created, which a subscription listener will be listening for.
When that happens, the listener will use the subscription API to create a
subscription. Then the user will update the subscription with their payment info
and the plan they want to use.
To accomplish this, we changed the way things were handled. The
SubscriptionRequest type, along with its Validate method, were removed. Instead,
we get the SubscriptionChange type which handles both the creation of a
subscription and the updating of a subscription.
We also added an endpoint for patching subscriptions, useful for adding the
StripeSubscription or updating the plan. By default, every subscription is
created with a "Pending" plan which has a 31 day free trial. This is so we can
detect users that haven't actually set up their subscription yet, but their free
trial is still timed correctly.
We changed the way we handle scopes, creating actual auth.Scope instances
instead of just declaring an ID for them. This is useful when we have a client,
for example.
With this change, we lose all the validation we had on creating a Subscription,
and we need to rewrite that validation logic. This is because we no longer have
a specific type for "creating a subscription", so we can't just call a validate
method. We should have a helper method validateCreateRequest(change
SubscriptionChange) that will return the API errors we want, so it's easier to
unit test.
We should really be restricting the CreateSubscriptionHandler to
ScopeSubscriptionAdmin, anyways, since Subscriptions should only ever be created
by the system tools or administrators.
We created a PatchSubscriptionHandler that exposes an interface to updating
properties of a Subscription. It allows users to update their own Subscriptions
or requires the ScopeSubscriptionAdmin scope before allowing you to update
another user's Subscription. It, likewise, needs validation still. We also added
the concept of "system-controlled properties" of the SubscriptionChange type,
which only admins or the system tools can update.
We updated our planOptions to distinguish between plans that do and do not need
administrative credentials to be chosen. Our free and pending plans are
available to administrators only.
We updated our StripeChange object to be better organised (separating out the
system and user-controlled properties), and we added a StripeSource and Email
property, so the Stripe part can be better managed, and all our requests can be
made using just this type. This required updating our SubscriptionChange.IsEmpty
helper, which has been updated (along with its tests) and it passes all tests.
To replace our SubscriptionRequest.Validate helper, we created a
ChangingSystemProperties helper (which returns the system-controlled properties
being changed as a slice of JSON pointers, fit for use in error messages) and an
IsAcceptablePlan helper, which returns true if the plan exists and the user has
the authority to select it.
We also updated our stripe helpers to remove the CreateStripeSubscription (we
create one when we create the customer) and create an UpdateStripeSubscription
instead. It does what you'd think it does. We also added some comments to New,
so it at least has some notes about how it's meant to be used and why. Now it
just creates the customer in stripe, then creates a Subscription based on that
customer. We also updated our StripeSubscriptionChange helper to detect when the
StripeSubscription property changed.
9 "code.secondbit.org/uuid.hg"
13 subscriptionChangeStripeSource = 1 << iota
14 subscriptionChangeEmail
15 subscriptionChangeStripeSubscription
16 subscriptionChangePlan
17 subscriptionChangeStatus
18 subscriptionChangeCanceling
19 subscriptionChangeTrialStart
20 subscriptionChangeTrialEnd
21 subscriptionChangePeriodStart
22 subscriptionChangePeriodEnd
23 subscriptionChangeCanceledAt
24 subscriptionChangeFailedChargeAttempts
25 subscriptionChangeLastFailedCharge
26 subscriptionChangeLastNotified
30 if os.Getenv("PG_TEST_DB") != "" {
31 p, err := NewPostgres(os.Getenv("PG_TEST_DB"))
35 testSubscriptionStores = append(testSubscriptionStores, p)
39 var testSubscriptionStores = []SubscriptionStore{
43 func compareSubscriptions(sub1, sub2 Subscription) (bool, string, interface{}, interface{}) {
44 if !sub1.UserID.Equal(sub2.UserID) {
45 return false, "UserID", sub1.UserID, sub2.UserID
47 if sub1.StripeSubscription != sub2.StripeSubscription {
48 return false, "StripeSubscription", sub1.StripeSubscription, sub2.StripeSubscription
50 if sub1.Plan != sub2.Plan {
51 return false, "Plan", sub1.Plan, sub2.Plan
53 if sub1.Status != sub2.Status {
54 return false, "Status", sub1.Status, sub2.Status
56 if sub1.Canceling != sub2.Canceling {
57 return false, "Canceling", sub1.Canceling, sub2.Canceling
59 if !sub1.Created.Equal(sub2.Created) {
60 return false, "Created", sub1.Created, sub2.Created
62 if !sub1.TrialStart.Equal(sub2.TrialStart) {
63 return false, "TrialStart", sub1.TrialStart, sub2.TrialStart
65 if !sub1.TrialEnd.Equal(sub2.TrialEnd) {
66 return false, "TrialEnd", sub1.TrialEnd, sub2.TrialEnd
68 if !sub1.PeriodStart.Equal(sub2.PeriodStart) {
69 return false, "PeriodStart", sub1.PeriodStart, sub2.PeriodStart
71 if !sub1.PeriodEnd.Equal(sub2.PeriodEnd) {
72 return false, "PeriodEnd", sub1.PeriodEnd, sub2.PeriodEnd
74 if !sub1.CanceledAt.Equal(sub2.CanceledAt) {
75 return false, "CanceledAt", sub1.CanceledAt, sub2.CanceledAt
77 if sub1.FailedChargeAttempts != sub2.FailedChargeAttempts {
78 return false, "FailedChargeAttempts", sub1.FailedChargeAttempts, sub2.FailedChargeAttempts
80 if !sub1.LastFailedCharge.Equal(sub2.LastFailedCharge) {
81 return false, "LastFailedCharge", sub1.LastFailedCharge, sub2.LastFailedCharge
83 if !sub1.LastNotified.Equal(sub2.LastNotified) {
84 return false, "LastNotified", sub1.LastNotified, sub2.LastNotified
86 return true, "", nil, nil
89 func subscriptionMapContains(subscriptionMap map[string]Subscription, subscriptions ...Subscription) (bool, []Subscription) {
90 var missing []Subscription
91 for _, sub := range subscriptions {
92 if _, ok := subscriptionMap[sub.UserID.String()]; !ok {
93 missing = append(missing, sub)
102 func compareSubscriptionStats(stat1, stat2 SubscriptionStats) (bool, string, interface{}, interface{}) {
103 if stat1.Number != stat2.Number {
104 return false, "Number", stat1.Number, stat2.Number
106 if stat1.Canceling != stat2.Canceling {
107 return false, "Canceling", stat1.Canceling, stat2.Canceling
109 if stat1.Failing != stat2.Failing {
110 return false, "Failing", stat1.Failing, stat2.Failing
112 if len(stat1.Plans) != len(stat2.Plans) {
113 return false, "Plans", stat1.Plans, stat2.Plans
115 for key, count := range stat1.Plans {
116 count2, ok := stat2.Plans[key]
118 return false, "Plans", stat1.Plans, stat2.Plans
121 return false, "Plans", stat1.Plans, stat2.Plans
124 return true, "", nil, nil
127 func TestCreateSubscription(t *testing.T) {
128 for _, store := range testSubscriptionStores {
131 t.Fatalf("Error resetting %T: %+v\n", store, err)
133 customerID := uuid.NewID()
136 StripeSubscription: "stripeSubscription1",
137 Created: time.Now().Round(time.Millisecond),
138 TrialStart: time.Now().Round(time.Millisecond),
139 TrialEnd: time.Now().Round(time.Millisecond).Add(time.Hour * 24 * 31),
141 err = store.CreateSubscription(sub)
143 t.Errorf("Error creating subscription in %T: %+v\n", store, err)
145 retrieved, err := store.GetSubscriptions([]uuid.ID{sub.UserID})
147 t.Errorf("Error retrieving subscription from %T: %+v\n", store, err)
149 if _, returned := retrieved[sub.UserID.String()]; !returned {
150 t.Errorf("Error retrieving subscription from %T: %s wasn't in the results.", store, sub.UserID)
152 ok, field, expected, result := compareSubscriptions(sub, retrieved[sub.UserID.String()])
154 t.Errorf("Expected %s to be %v, got %v from %T\n", field, expected, result, store)
156 err = store.CreateSubscription(sub)
157 if err != ErrSubscriptionAlreadyExists {
158 t.Errorf("Unexpected error creating subscription in %T (wanted %+v): %+v\n", store, ErrSubscriptionAlreadyExists, err)
160 sub.UserID = uuid.NewID()
161 err = store.CreateSubscription(sub)
162 if err != ErrStripeSubscriptionAlreadyExists {
163 t.Errorf("Unexpected error creating subscription in %T (wanted %+v): %#+v\n", store, ErrStripeSubscriptionAlreadyExists, err)
165 sub.StripeSubscription = "stripeSubscription2"
166 err = store.CreateSubscription(sub)
168 t.Errorf("Error creating subscription in %T: %+v\n", store, err)
173 func TestUpdateSubscription(t *testing.T) {
174 variations := 1 << 14
176 UserID: uuid.NewID(),
177 StripeSubscription: "default",
178 Created: time.Now().Round(time.Millisecond).Add(time.Hour * -24 * -32),
179 TrialStart: time.Now().Round(time.Millisecond).Add(time.Hour * -24 * -32),
180 TrialEnd: time.Now().Round(time.Millisecond).Add(time.Hour * -24),
181 LastNotified: time.Now().Round(time.Millisecond).Add(time.Hour * -24),
183 sub2 := Subscription{
184 UserID: uuid.NewID(),
185 StripeSubscription: "stripeSubscription2",
186 Created: time.Now().Round(time.Millisecond),
187 TrialStart: time.Now().Round(time.Millisecond),
188 TrialEnd: time.Now().Round(time.Millisecond),
189 LastNotified: time.Now().Round(time.Millisecond),
192 for _, store := range testSubscriptionStores {
195 t.Fatalf("Error resetting %T: %+v\n", store, err)
197 err = store.CreateSubscription(sub)
199 t.Fatalf("Error saving subscription in %T: %s\n", store, err)
201 for i := 1; i < variations; i++ {
202 var stripeSource, email, stripeSubscription, plan, status string
204 var failedChargeAttempts int
205 var trialStart, trialEnd, periodStart, periodEnd, canceledAt, lastFailedCharge, lastNotified time.Time
207 change := SubscriptionChange{}
208 empty := change.IsEmpty()
210 t.Errorf("Expected empty to be %t, was %t\n", true, empty)
213 strI := strconv.Itoa(i)
215 if i&subscriptionChangeStripeSource != 0 {
216 stripeSource = "stripeSource-" + strI
217 change.StripeSource = &stripeSource
220 if i&subscriptionChangeEmail != 0 {
221 email = "email-" + strI
222 change.Email = &email
225 if i&subscriptionChangeStripeSubscription != 0 {
226 stripeSubscription = "stripeSubscription-" + strI
227 change.StripeSubscription = &stripeSubscription
228 sub.StripeSubscription = stripeSubscription
231 if i&subscriptionChangePlan != 0 {
232 plan = "plan-" + strI
237 if i&subscriptionChangeStatus != 0 {
238 status = "status-" + strI
239 change.Status = &status
243 if i&subscriptionChangeCanceling != 0 {
245 change.Canceling = &canceling
246 sub.Canceling = canceling
249 if i&subscriptionChangeTrialStart != 0 {
250 trialStart = time.Now().Round(time.Millisecond).Add(time.Hour * time.Duration(i))
251 change.TrialStart = &trialStart
252 sub.TrialStart = trialStart
255 if i&subscriptionChangeTrialEnd != 0 {
256 trialEnd = time.Now().Round(time.Millisecond).Add(time.Hour * time.Duration(i))
257 change.TrialEnd = &trialEnd
258 sub.TrialEnd = trialEnd
261 if i&subscriptionChangePeriodStart != 0 {
262 periodStart = time.Now().Round(time.Millisecond).Add(time.Hour * time.Duration(i))
263 change.PeriodStart = &periodStart
264 sub.PeriodStart = periodStart
267 if i&subscriptionChangePeriodEnd != 0 {
268 periodEnd = time.Now().Round(time.Millisecond).Add(time.Hour * time.Duration(i))
269 change.PeriodEnd = &periodEnd
270 sub.PeriodEnd = periodEnd
273 if i&subscriptionChangeCanceledAt != 0 {
274 canceledAt = time.Now().Round(time.Millisecond).Add(time.Hour * time.Duration(i))
275 change.CanceledAt = &canceledAt
276 sub.CanceledAt = canceledAt
279 if i&subscriptionChangeFailedChargeAttempts != 0 {
280 failedChargeAttempts = i
281 change.FailedChargeAttempts = &failedChargeAttempts
282 sub.FailedChargeAttempts = failedChargeAttempts
285 if i&subscriptionChangeLastFailedCharge != 0 {
286 lastFailedCharge = time.Now().Round(time.Millisecond).Add(time.Hour * time.Duration(i))
287 change.LastFailedCharge = &lastFailedCharge
288 sub.LastFailedCharge = lastFailedCharge
291 if i&subscriptionChangeLastNotified != 0 {
292 lastNotified = time.Now().Round(time.Millisecond).Add(time.Hour * time.Duration(i))
293 change.LastNotified = &lastNotified
294 sub.LastNotified = lastNotified
297 empty = change.IsEmpty()
299 t.Errorf("Expected empty to be %t, was %t\n", false, empty)
302 result.ApplyChange(change)
303 match, field, expected, got := compareSubscriptions(sub, result)
305 t.Errorf("Expected field `%s` to be `%v`, got `%v`\n", field, expected, got)
307 err = store.UpdateSubscription(sub.UserID, change)
309 t.Errorf("Error updating subscription in %T: %s\n", store, err)
311 retrieved, err := store.GetSubscriptions([]uuid.ID{sub.UserID})
313 t.Errorf("Error getting subscription from %T: %s\n", store, err)
315 ok, missing := subscriptionMapContains(retrieved, sub)
317 t.Errorf("Expected to retrieve %s from %T, but missing was %+v\n", sub.UserID.String(), store, missing)
319 match, field, expected, got = compareSubscriptions(sub, retrieved[sub.UserID.String()])
321 t.Errorf("Expected field `%s` to be `%v`, got `%v` from %T\n", field, expected, got, store)
326 err = store.CreateSubscription(sub2)
328 t.Fatalf("Error saving subscription in %T: %+v\n", store, err)
330 change := SubscriptionChange{}
331 err = store.UpdateSubscription(sub.UserID, change)
332 if err != ErrSubscriptionChangeEmpty {
333 t.Errorf("Expected err to be %+v, but got %+v from %T\n", ErrSubscriptionChangeEmpty, err, store)
335 stripeSubscription := sub2.StripeSubscription
336 change.StripeSubscription = &stripeSubscription
337 err = store.UpdateSubscription(uuid.NewID(), change)
338 if err != ErrSubscriptionNotFound {
339 t.Errorf("Expected err to be %+v, but got %+v from %T\n", ErrSubscriptionNotFound, err, store)
341 err = store.UpdateSubscription(sub.UserID, change)
342 if err != ErrStripeSubscriptionAlreadyExists {
343 t.Errorf("Expected err to be %+v, but got %+v from %T\n", ErrStripeSubscriptionAlreadyExists, err, store)
348 func TestDeleteSubscription(t *testing.T) {
349 for _, store := range testSubscriptionStores {
352 t.Fatalf("Error resetting %T: %+v\n", store, err)
354 sub1 := Subscription{
355 UserID: uuid.NewID(),
356 StripeSubscription: "stripeSubscription1",
358 sub2 := Subscription{
359 UserID: uuid.NewID(),
360 StripeSubscription: "stripeSubscription2",
362 err = store.CreateSubscription(sub1)
364 t.Fatalf("Error creating %+v in %T: %+v\n", sub1, store, err)
366 err = store.CreateSubscription(sub2)
368 t.Fatalf("Error creating %+v in %T: %+v\n", sub2, store, err)
370 err = store.DeleteSubscription(sub1.UserID)
372 t.Fatalf("Error deleting %+v in %T: %+v\n", sub1, store, err)
374 retrieved, err := store.GetSubscriptions([]uuid.ID{sub1.UserID, sub2.UserID})
376 t.Errorf("Error retrieving subscriptions from %T: %+v\n", store, err)
378 ok, missing := subscriptionMapContains(retrieved, sub1)
380 t.Errorf("Expected not to retrieve %s from %T, but missing was %+v\n", sub1.UserID.String(), store, missing)
382 ok, missing = subscriptionMapContains(retrieved, sub2)
384 t.Errorf("Expected to retrieve %s from %T, but missing was %+v\n", sub2.UserID.String(), store, missing)
386 err = store.DeleteSubscription(sub1.UserID)
387 if err != ErrSubscriptionNotFound {
388 t.Errorf("Expected err to be %+v, but got %+v from %T\n", ErrSubscriptionNotFound, err, store)
393 func TestGetSubscriptions(t *testing.T) {
394 for _, store := range testSubscriptionStores {
397 t.Fatalf("Error resetting %T: %+v\n", store, err)
399 sub1 := Subscription{
400 UserID: uuid.NewID(),
401 StripeSubscription: "stripeSubscription1",
403 Created: time.Now().Round(time.Millisecond),
404 TrialStart: time.Now().Round(time.Millisecond),
405 TrialEnd: time.Now().Round(time.Millisecond).Add(time.Hour * 24 * 32),
407 sub2 := Subscription{
408 UserID: uuid.NewID(),
409 StripeSubscription: "stripeSubscription2",
411 Created: time.Now().Round(time.Millisecond).Add(time.Hour * -720),
412 TrialStart: time.Now().Round(time.Millisecond).Add(time.Hour * -720),
413 TrialEnd: time.Now().Round(time.Millisecond),
415 sub3 := Subscription{
416 UserID: uuid.NewID(),
417 StripeSubscription: "stripeSubscription3",
419 Created: time.Now().Round(time.Millisecond).Add(time.Hour * -1440),
420 TrialStart: time.Now().Round(time.Millisecond).Add(time.Hour * -1440),
421 TrialEnd: time.Now().Round(time.Millisecond).Add(time.Hour * -720),
422 PeriodStart: time.Now().Round(time.Millisecond).Add(time.Hour * -720),
423 PeriodEnd: time.Now().Round(time.Millisecond),
426 err = store.CreateSubscription(sub1)
428 t.Fatalf("Error creating %+v in %T: %+v\n", sub1, store, err)
430 err = store.CreateSubscription(sub2)
432 t.Fatalf("Error creating %+v in %T: %+v\n", sub1, store, err)
434 err = store.CreateSubscription(sub3)
436 t.Fatalf("Error creating %+v in %T: %+v\n", sub1, store, err)
438 retrieved, err := store.GetSubscriptions([]uuid.ID{})
439 if err != ErrNoSubscriptionID {
440 t.Errorf("Error retrieving no subscriptions from %T. Expected %+v, got %+v\n", store, ErrNoSubscriptionID, err)
442 retrieved, err = store.GetSubscriptions([]uuid.ID{sub1.UserID})
444 t.Errorf("Error retrieving %s from %T: %+v\n", sub1.UserID, store, err)
446 ok, missing := subscriptionMapContains(retrieved, sub1)
448 t.Logf("Results: %+v\n", retrieved)
449 t.Errorf("Expected %+v to be in the results, was not for %T.\n", missing, store)
451 retrieved, err = store.GetSubscriptions([]uuid.ID{sub1.UserID, sub2.UserID})
453 t.Errorf("Error retrieving %s and %s from %T: %+v\n", sub1.UserID, sub2.UserID, store, err)
455 ok, missing = subscriptionMapContains(retrieved, sub1, sub2)
457 t.Logf("Results: %+v\n", retrieved)
458 t.Errorf("Expected %+v to be in the results, was not for %T.\n", missing, store)
460 retrieved, err = store.GetSubscriptions([]uuid.ID{sub1.UserID, sub3.UserID})
462 t.Errorf("Error retrieving %s and %s from %T: %+v\n", sub1.UserID, sub3.UserID, store, err)
464 ok, missing = subscriptionMapContains(retrieved, sub1, sub3)
466 t.Logf("Results: %+v\n", retrieved)
467 t.Errorf("Expected %+v to be in the results, was not for %T.\n", missing, store)
469 retrieved, err = store.GetSubscriptions([]uuid.ID{sub1.UserID, sub2.UserID, sub3.UserID})
471 t.Errorf("Error retrieving %s, %s, and %s from %T: %+v\n", sub1.UserID, sub2.UserID, sub3.UserID, store, err)
473 ok, missing = subscriptionMapContains(retrieved, sub1, sub2, sub3)
475 t.Logf("Results: %+v\n", retrieved)
476 t.Errorf("Expected %+v to be in the results, was not for %T.\n", missing, store)
478 retrieved, err = store.GetSubscriptions([]uuid.ID{sub2.UserID})
480 t.Errorf("Error retrieving %s from %T: %+v\n", sub2.UserID, store, err)
482 ok, missing = subscriptionMapContains(retrieved, sub2)
484 t.Logf("Results: %+v\n", retrieved)
485 t.Errorf("Expected %+v to be in the results, was not for %T.\n", missing, store)
487 retrieved, err = store.GetSubscriptions([]uuid.ID{sub2.UserID, sub3.UserID})
489 t.Errorf("Error retrieving %s and %s from %T: %+v\n", sub2.UserID, sub3.UserID, store, err)
491 ok, missing = subscriptionMapContains(retrieved, sub2, sub3)
493 t.Logf("Results: %+v\n", retrieved)
494 t.Errorf("Expected %+v to be in the results, was not for %T.\n", missing, store)
496 retrieved, err = store.GetSubscriptions([]uuid.ID{sub3.UserID})
498 t.Errorf("Error retrieving %s from %T: %+v\n", sub3.UserID, store, err)
500 ok, missing = subscriptionMapContains(retrieved, sub3)
502 t.Logf("Results: %+v\n", retrieved)
503 t.Errorf("Expected %+v to be in the results, was not for %T.\n", missing, store)
505 retrieved, err = store.GetSubscriptions([]uuid.ID{uuid.NewID()})
507 t.Errorf("Error retrieving non-existent ID from %T: %+v\n", store, err)
509 if len(retrieved) != 0 {
510 t.Errorf("Expected no results, %T returned %+v\n", store, retrieved)
512 retrieved, err = store.GetSubscriptions([]uuid.ID{sub1.UserID, sub2.UserID, uuid.NewID(), sub3.UserID})
514 t.Errorf("Error retrieving non-existent ID from %T: %+v\n", store, err)
516 if len(retrieved) != 3 {
517 t.Errorf("Expected 3 results, %T returned %+v\n", store, retrieved)
519 ok, missing = subscriptionMapContains(retrieved, sub1, sub2, sub3)
521 t.Logf("Results: %+v\n", retrieved)
522 t.Errorf("Expected %+v to be in the results, was not for %T.\n", missing, store)
527 func TestGetSubscriptionStats(t *testing.T) {
528 for _, store := range testSubscriptionStores {
531 t.Fatalf("Error resetting %T: %+v\n", store, err)
533 sub1 := Subscription{
534 UserID: uuid.NewID(),
535 StripeSubscription: "stripeSubscription1",
539 sub2 := Subscription{
540 UserID: uuid.NewID(),
541 StripeSubscription: "stripeSubscription2",
545 err = store.CreateSubscription(sub1)
547 t.Fatalf("Error creating %+v in %T: %+v\n", sub1, store, err)
549 stats, err := store.GetSubscriptionStats()
551 t.Errorf("Error getting stats from %T: %+v\n", store, err)
553 ok, field, expected, results := compareSubscriptionStats(SubscriptionStats{
557 Plans: map[string]int64{
562 t.Errorf("Expected %s to be %+v, got %+v from %T\n", field, expected, results, store)
564 err = store.CreateSubscription(sub2)
566 t.Fatalf("Error creating %+v in %T: %+v\n", sub2, store, err)
568 stats, err = store.GetSubscriptionStats()
570 t.Errorf("Error getting status from %T: %+v\n", store, err)
572 ok, field, expected, results = compareSubscriptionStats(SubscriptionStats{
576 Plans: map[string]int64{
582 t.Errorf("Expected %s to be %+v, got %+v from %T\n", field, expected, results, store)
584 err = store.DeleteSubscription(sub1.UserID)
586 t.Errorf("Error deleting subscription from %T: %+v\n", store, err)
588 stats, err = store.GetSubscriptionStats()
590 t.Errorf("Error getting status from %T: %+v\n", store, err)
592 ok, field, expected, results = compareSubscriptionStats(SubscriptionStats{
596 Plans: map[string]int64{
601 t.Errorf("Expected %s to be %+v, got %+v from %T\n", field, expected, results, store)