package abono_test import ( "context" "errors" "strings" "testing" "time" "gorm.io/gorm" "amadan.net/rastrillo/abono" ) func TestReconcileEndsAnExpiredCompThroughOnChange(t *testing.T) { now := t0 d := newStripeDouble(t) rec := &recorder{} s, _ := d.service(t, &now, rec.config()) seedPlans(t, s) ctx := context.Background() ends := t0.Add(2 * time.Hour) if _, err := s.Comp(ctx, "ana", "annual", &ends, "press"); err != nil { t.Fatalf("Comp: %v", err) } rec.take() if !hasPlan(t, s, "ana", "annual") { t.Fatal("the comp does not grant before it runs out") } now = t0.Add(3 * time.Hour) rep, err := s.Reconcile(ctx) if err != nil { t.Fatalf("Reconcile: %v", err) } if rep.Subjects != 1 { t.Errorf("Report = %+v, want 1 subject", rep) } if _, changes := rec.take(); len(changes) != 1 || changes[0] != "ana" { t.Errorf("OnChange ran %v, want once for ana", changes) } if hasPlan(t, s, "ana", "annual") { t.Error("the expired comp still grants") } } func TestReconcileTellsEverySubjectHoldingAnything(t *testing.T) { now := t0 d := newStripeDouble(t) rec := &recorder{} s, _ := d.service(t, &now, rec.config()) seedPlans(t, s) ctx := context.Background() if _, err := s.Comp(ctx, "cy", "annual", nil, ""); err != nil { t.Fatalf("Comp: %v", err) } // ana and bo hold subscriptions (one on a price nobody sells); a third // is unclaimed and has nobody to tell. d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day), Metadata: map[string]string{"abono_subject": "ana"}}) d.put(t, subSpec{ID: "sub_2", Status: "active", Price: "price_Z", Customer: "cus_2", Email: "bo@example.com", End: t0.Add(30 * day)}) d.put(t, subSpec{ID: "sub_3", Status: "active", Price: "price_M", Customer: "cus_3", Email: "di@example.com", End: t0.Add(30 * day)}) for _, id := range []string{"sub_1", "sub_2"} { if err := s.Sync(ctx, d.sub(t, id), abono.Hint{Subject: map[string]string{"sub_1": "ana", "sub_2": "bo"}[id]}); err != nil { t.Fatalf("Sync %s: %v", id, err) } } rec.take() rep, err := s.Reconcile(ctx) if err != nil { t.Fatalf("Reconcile: %v", err) } if rep.Subscriptions != 3 || rep.Subjects != 3 { t.Errorf("Report = %+v, want 3 subscriptions and 3 subjects", rep) } _, changes := rec.take() got := map[string]int{} for _, c := range changes { got[c]++ } // sub_3's sync makes its row (unclaimed: Holder answers ""), and ana, bo // and cy are each told once by the sweep. for _, who := range []string{"ana", "bo", "cy"} { if got[who] != 1 { t.Errorf("%s was told %d times, want 1 (all: %v)", who, got[who], changes) } } if got[""] != 0 || len(got) != 3 { t.Errorf("OnChange ran for %v", changes) } } func TestReconcileRefreshesPrices(t *testing.T) { now := t0 d := newStripeDouble(t) s, db := d.service(t, &now, abono.Config{}) seedPlans(t, s) const q = `SELECT stripe_price_id, interval, amount, currency FROM abono_prices ORDER BY stripe_price_id` // Stripe's prices change under the account (a new currency, say). d.price("price_A", "year", 7200, "usd") d.price("price_M", "month", 700, "usd") d.price("price_old", "year", 4500, "usd") rep, err := s.Reconcile(context.Background()) if err != nil { t.Fatalf("Reconcile: %v", err) } if rep.Subscriptions != 0 || rep.Subjects != 0 { t.Errorf("Report = %+v, want nothing", rep) } want := "price_A|year|7200|usd\nprice_M|month|700|usd\nprice_old|year|4500|usd\n" if got := db.dump(q); got != want { t.Errorf("prices:\n%s\nwant:\n%s", got, want) } } func TestReconcileRecoversALostSubscription(t *testing.T) { now := t0 d := newStripeDouble(t) rec := &recorder{subject: "ana"} s, db := d.service(t, &now, rec.config()) seedPlans(t, s) d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day)}) d.put(t, subSpec{ID: "sub_0", Status: "canceled", Price: "price_A", Customer: "cus_0", Email: "old@example.com", End: t0.Add(-day)}) rep, err := s.Reconcile(context.Background()) if err != nil { t.Fatalf("Reconcile: %v", err) } if rep.Subscriptions != 2 || rep.Subjects != 1 { t.Errorf("Report = %+v, want 2 subscriptions and 1 subject", rep) } if got := db.dump(subsQuery); got != "sub_1|ana|annual|active|ana@example.com\n" { t.Errorf("subscriptions:\n%s", got) } } func TestReconcileWithStripeOffStillSweepsComps(t *testing.T) { now := t0 rec := &recorder{} s, _ := newService(t, &now, rec.config()) if _, err := s.Comp(context.Background(), "ana", "annual", nil, ""); err != nil { t.Fatalf("Comp: %v", err) } rec.take() rep, err := s.Reconcile(context.Background()) if err != nil { t.Fatalf("Reconcile: %v", err) } if rep.Subjects != 1 { t.Errorf("Report = %+v", rep) } if _, changes := rec.take(); len(changes) != 1 { t.Errorf("OnChange ran %v", changes) } } // One subscription the app cannot take (Holder fails for it) does not stop // the ones listed after it, and is named in the error. func TestReconcileCarriesOnPastOneBadSubscription(t *testing.T) { now := t0 d := newStripeDouble(t) s, db := d.service(t, &now, abono.Config{ Holder: func(_ context.Context, _ *gorm.DB, email, _ string) (string, error) { if email == "bad@example.com" { return "", errors.New("the app refuses " + email) } return "bo", nil }, }) seedPlans(t, s) // Listed in id order: the failing one first. d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "bad@example.com", End: t0.Add(30 * day)}) d.put(t, subSpec{ID: "sub_2", Status: "active", Price: "price_M", Customer: "cus_2", Email: "bo@example.com", End: t0.Add(30 * day)}) d.price("price_M", "month", 700, "usd") rep, err := s.Reconcile(context.Background()) if err == nil || !strings.Contains(err.Error(), "sub_1") { t.Fatalf("Reconcile error = %v, want one naming sub_1", err) } if strings.Contains(err.Error(), "sub_2") { t.Errorf("the error blames the good subscription: %v", err) } if rep.Subscriptions != 2 || rep.Failed != 1 || rep.Subjects != 1 { t.Errorf("Report = %+v, want 2 subscriptions seen, 1 failed and 1 subject told", rep) } if got := db.dump(subsQuery); got != "sub_2|bo|monthly|active|bo@example.com\n" { t.Errorf("subscriptions:\n%s", got) } // The later steps ran too. if got := db.dump(`SELECT amount FROM abono_prices WHERE stripe_price_id = 'price_M'`); got != "700\n" { t.Errorf("price_M amount = %q", got) } } // TestReconcileRestoresTheSignedInBuyer is final review I1 (ruling R34): // subject A, signed in, pays with an address that belongs to B, and both // Checkout's return and the webhooks are lost. The reconcile that recovers // the subscription reads Checkout's abono_subject metadata, so it is A's, // not B's by address, and not left unclaimed. func TestReconcileRestoresTheSignedInBuyer(t *testing.T) { now := t0 d := newStripeDouble(t) rec := &recorder{subject: "B"} // the app knows the address as B's s, db := d.service(t, &now, rec.config()) seedPlans(t, s) d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "b@example.com", End: t0.Add(30 * day), Metadata: map[string]string{"abono_subject": "A", "abono_plan": "annual"}}) if _, err := s.Reconcile(context.Background()); err != nil { t.Fatalf("Reconcile: %v", err) } if got := db.dump(subsQuery); got != "sub_1|A|annual|active|b@example.com\n" { t.Errorf("subscriptions:\n%s\nwant sub_1 held by A, who bought it", got) } if got := db.dump(`SELECT stripe_customer_id, subject FROM abono_customers`); got != "cus_1|A\n" { t.Errorf("customers = %q, want cus_1 mapped to A", got) } if !hasPlan(t, s, "A", "annual") || hasPlan(t, s, "B", "annual") { t.Error("the plan went to the address's owner, not the buyer") } }