abono: sync, checkout, finish, portal and adoption
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
sync records a Stripe subscription under its plan (or as unrecognised, plan ''), finding its holder by hint subject, then customer mapping, then the app's Holder by address, all inside one write transaction that re-reads the row first. Checkout builds the subscription form (sold price by interval, subject, known customer or customer_email, promotion code), Finish syncs the session's subscription at once, Portal opens the Customer Portal, Adopt takes an existing subscription onto a plan (recording its price as legacy), Claim attaches unclaimed payments, and Unrecognised lists granting subscriptions on unknown prices.
Agent notes
OnChange is called only for a side of a change that has a plan, so an unrecognised row tells nobody (R5), but losing a plan by moving to an unknown price still tells the subject. A new row needs a non-zero Hint and a granting status (or Adopt); the webhook (Task 6) must pass a non-zero hint for events that may create. The race test pauses Holder until Finish has fetched the subscription; without that pause the concurrent case never overlapped. Tests use a local httptest Stripe double (stripe_double_test.go), for Task 7 to replace.
8 files changed,
+1543
−0
adopt.go+60 −0adopt_test.go+108 −0checkout.go+234 −0checkout_test.go+170 −0export_test.go+13 −0stripe_double_test.go+220 −0sync.go+330 −0sync_test.go+408 −0
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... -count=1 ok amadan.net/rastrillo/abono 0.445s ok amadan.net/rastrillo/abono/internal/stripeapi 0.005s === race === CGO_ENABLED=1 go test ./... -race -count=1 ok amadan.net/rastrillo/abono 5.172s ok amadan.net/rastrillo/abono/internal/stripeapi 1.039s === browser === browser: no browser test file yet, nothing to run
Intent
diff --git a/adopt.go b/adopt.go| new file mode 100644 |
| index 0000000..6b223d8 |
| --- /dev/null |
| +++ b/adopt.go |
| @@ -0,0 +1,60 @@ |
| +package abono |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + |
| + "gorm.io/gorm" |
| +) |
| + |
| +// Adopt takes an existing Stripe subscription as subject's holding of plan: |
| +// a WordPress site's member being imported, or an unrecognised subscription |
| +// an owner places on a plan. The subscription's price becomes a legacy |
| +// price of plan (sold to nobody new) when no plan has it yet; a price that |
| +// belongs to another plan is refused, since moving it would change what |
| +// every subscription on it means. |
| +// |
| +// With subject "", the holder is found as a sync finds it: the customer's |
| +// recorded subject, then Holder for the customer's address. The row is |
| +// recorded whatever the subscription's status, and OnChange runs in the |
| +// same transaction. |
| +func (s *Service) Adopt(ctx context.Context, subscriptionID, subject, plan string) error { |
| + if !s.On() { |
| + return errOff |
| + } |
| + if subscriptionID == "" || plan == "" { |
| + return errors.New("abono: Adopt needs a subscription and a plan") |
| + } |
| + sub, err := s.stripe.GetSubscription(ctx, subscriptionID) |
| + if err != nil { |
| + return err |
| + } |
| + price, interval := sub.PriceID() |
| + if price == "" { |
| + return fmt.Errorf("abono: subscription %q has no price", subscriptionID) |
| + } |
| + return s.tx(ctx, func(tx *gorm.DB) error { |
| + var n int64 |
| + if err := tx.Table("abono_offers").Where("plan = ?", plan).Count(&n).Error; err != nil { |
| + return err |
| + } |
| + if n == 0 { |
| + return fmt.Errorf("abono: no offer for plan %q: %w", plan, ErrNotFound) |
| + } |
| + on, err := planOf(tx, price) |
| + switch { |
| + case err != nil: |
| + return err |
| + case on == "": |
| + if err := tx.Exec(`INSERT INTO abono_prices (stripe_price_id, plan, sold, interval) VALUES (?, ?, 0, ?)`, |
| + price, plan, interval).Error; err != nil { |
| + return err |
| + } |
| + case on != plan: |
| + return fmt.Errorf("abono: price %q belongs to plan %q, not %q", price, on, plan) |
| + } |
| + _, err = s.syncTx(ctx, tx, sub, Hint{Subject: subject}, true) |
| + return err |
| + }) |
| +} |
diff --git a/adopt_test.go b/adopt_test.go| new file mode 100644 |
| index 0000000..70282f5 |
| --- /dev/null |
| +++ b/adopt_test.go |
| @@ -0,0 +1,108 @@ |
| +package abono_test |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/abono" |
| +) |
| + |
| +func TestAdoptAddsTheLegacyPriceAndSyncs(t *testing.T) { |
| + now := t0 |
| + d := newStripeDouble(t) |
| + rec := &recorder{subject: "42"} |
| + s, db := d.service(t, &now, rec.config()) |
| + seedPlans(t, s) |
| + ctx := context.Background() |
| + |
| + // A WordPress-era subscription on a price no plan knows. |
| + d.put(t, subSpec{ID: "sub_wp", Status: "active", Price: "price_wp", Interval: "year", Customer: "cus_wp", Email: "wp@example.com", End: t0.Add(100 * day)}) |
| + if err := s.Adopt(ctx, "sub_wp", "3", "annual"); err != nil { |
| + t.Fatalf("Adopt: %v", err) |
| + } |
| + if got := db.dump("SELECT plan, sold, interval FROM abono_prices WHERE stripe_price_id = 'price_wp'"); got != "annual|0|year\n" { |
| + t.Errorf("price_wp = %q, want a legacy price of annual", got) |
| + } |
| + if got, want := db.dump(rowsQuery), "sub_wp|cus_wp|3|annual|price_wp|active|0|wp@example.com\n"; got != want { |
| + t.Errorf("rows =\n%s\nwant\n%s", got, want) |
| + } |
| + if !hasPlan(t, s, "3", "annual") { |
| + t.Error("HasPlan(3, annual) is false after Adopt") |
| + } |
| + asked, changes := rec.take() |
| + eq(t, "Holder asked", asked, nil) |
| + eq(t, "OnChange", changes, []string{"3"}) |
| + |
| + // Adopting it again is a no-op. |
| + if err := s.Adopt(ctx, "sub_wp", "3", "annual"); err != nil { |
| + t.Fatalf("Adopt again: %v", err) |
| + } |
| + if _, changes := rec.take(); len(changes) != 0 { |
| + t.Errorf("a second Adopt told %q", changes) |
| + } |
| + |
| + // A price already on another plan is refused, and nothing is written. |
| + d.put(t, subSpec{ID: "sub_m", Status: "active", Price: "price_M", Customer: "cus_m", Email: "m@example.com", End: t0.Add(10 * day)}) |
| + if err := s.Adopt(ctx, "sub_m", "3", "annual"); err == nil { |
| + t.Error("Adopt onto annual of a subscription on monthly's price succeeded") |
| + } |
| + if got := db.dump("SELECT plan, sold FROM abono_prices WHERE stripe_price_id = 'price_M'"); got != "monthly|1\n" { |
| + t.Errorf("price_M = %q, want it left on monthly", got) |
| + } |
| + if got := db.dump("SELECT * FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_m'"); got != "" { |
| + t.Errorf("a refused Adopt wrote a row: %q", got) |
| + } |
| + |
| + if err := s.Adopt(ctx, "sub_wp", "3", "nonesuch"); !errors.Is(err, abono.ErrNotFound) { |
| + t.Errorf("Adopt onto a plan with no offer: err = %v, want ErrNotFound", err) |
| + } |
| + if err := s.Adopt(ctx, "sub_gone", "3", "annual"); err == nil { |
| + t.Error("Adopt of a subscription Stripe does not have succeeded") |
| + } |
| +} |
| + |
| +// TestAdoptWithoutSubjectFindsTheHolder is ruling R7: an unrecognised |
| +// row usually has no subject, so Adopt finds one as sync does. |
| +func TestAdoptWithoutSubjectFindsTheHolder(t *testing.T) { |
| + now := t0 |
| + d := newStripeDouble(t) |
| + rec := &recorder{subject: "42"} |
| + s, db := d.service(t, &now, rec.config()) |
| + seedPlans(t, s) |
| + ctx := context.Background() |
| + |
| + sub := d.put(t, subSpec{ID: "sub_z", Status: "active", Price: "price_Z", Customer: "cus_z", Email: "zed@example.com", End: t0.Add(30 * day)}) |
| + if err := s.Sync(ctx, sub, abono.Hint{Email: "zed@example.com"}); err != nil { |
| + t.Fatalf("Sync: %v", err) |
| + } |
| + if asked, _ := rec.take(); len(asked) != 0 { |
| + t.Fatalf("Holder asked %q for an unrecognised price", asked) |
| + } |
| + |
| + if err := s.Adopt(ctx, "sub_z", "", "monthly"); err != nil { |
| + t.Fatalf("Adopt with no subject: %v", err) |
| + } |
| + if got, want := db.dump(rowsQuery), "sub_z|cus_z|42|monthly|price_Z|active|0|zed@example.com\n"; got != want { |
| + t.Errorf("rows =\n%s\nwant\n%s", got, want) |
| + } |
| + asked, changes := rec.take() |
| + eq(t, "Holder asked", asked, []string{"zed@example.com"}) |
| + eq(t, "OnChange", changes, []string{"42"}) |
| + if un, err := s.Unrecognised(ctx); err != nil || len(un) != 0 { |
| + t.Errorf("Unrecognised after Adopt = %+v, %v; want none", un, err) |
| + } |
| + |
| + // A customer already mapped decides before Holder is asked. |
| + db.exec(`INSERT INTO abono_customers (stripe_customer_id, subject) VALUES ('cus_y', '8')`) |
| + d.put(t, subSpec{ID: "sub_y", Status: "active", Price: "price_Y", Customer: "cus_y", Email: "why@example.com", End: t0.Add(30 * day)}) |
| + if err := s.Adopt(ctx, "sub_y", "", "monthly"); err != nil { |
| + t.Fatalf("Adopt of a mapped customer: %v", err) |
| + } |
| + if got := db.dump("SELECT subject FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_y'"); got != "8\n" { |
| + t.Errorf("sub_y subject = %q, want 8", got) |
| + } |
| + if asked, _ := rec.take(); len(asked) != 0 { |
| + t.Errorf("Holder asked %q for a mapped customer", asked) |
| + } |
| +} |
diff --git a/checkout.go b/checkout.go| new file mode 100644 |
| index 0000000..d03a9bc |
| --- /dev/null |
| +++ b/checkout.go |
| @@ -0,0 +1,234 @@ |
| +package abono |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "net/url" |
| + "strings" |
| + |
| + "gorm.io/gorm" |
| +) |
| + |
| +var ( |
| + // ErrUnknownCode is a promotion code Stripe has no active promotion for. |
| + ErrUnknownCode = errors.New("abono: no such promotion code") |
| + // ErrUnknownPlan is a plan not for sale, or not at the interval asked |
| + // for, or sold at several intervals when none was asked for. |
| + ErrUnknownPlan = errors.New("abono: no such plan for sale") |
| + // ErrNoCustomer is a subject with nothing in Stripe to manage. |
| + ErrNoCustomer = errors.New("abono: no Stripe customer") |
| + |
| + errOff = errors.New("abono: Stripe is not configured") |
| +) |
| + |
| +// CheckoutRequest is what a reader is buying, and who they are if signed |
| +// in. |
| +type CheckoutRequest struct { |
| + // Plan is the plan to sell, and Interval which of its sold prices |
| + // ("month", "year"). With no Interval the plan must have exactly one |
| + // sold price. |
| + Plan, Interval string |
| + // Subject is the signed-in reader, "" when signed out; Email is their |
| + // address, given to Checkout when Stripe has no customer for them yet. |
| + Subject, Email string |
| + // PromotionCode is a code the reader typed. Without one, Checkout shows |
| + // its own field for it. |
| + PromotionCode string |
| + // SuccessPath and CancelPath are where Stripe sends the reader back, |
| + // relative to Config.Origin ("/" when empty). SuccessPath gets |
| + // session_id={CHECKOUT_SESSION_ID} added, for Finish. |
| + SuccessPath, CancelPath string |
| +} |
| + |
| +// Checkout starts a hosted Checkout for a subscription to req.Plan and |
| +// returns its address. |
| +func (s *Service) Checkout(ctx context.Context, req CheckoutRequest) (string, error) { |
| + if !s.On() { |
| + return "", errOff |
| + } |
| + g := s.cfg.DB.WithContext(ctx) |
| + price, err := soldPrice(g, req.Plan, req.Interval) |
| + if err != nil { |
| + return "", err |
| + } |
| + success, err := s.back(req.SuccessPath) |
| + if err != nil { |
| + return "", err |
| + } |
| + sep := "?" |
| + if strings.Contains(success, "?") { |
| + sep = "&" |
| + } |
| + cancel, err := s.back(req.CancelPath) |
| + if err != nil { |
| + return "", err |
| + } |
| + f := url.Values{ |
| + "mode": {"subscription"}, |
| + "line_items[0][price]": {price}, |
| + "line_items[0][quantity]": {"1"}, |
| + "success_url": {success + sep + "session_id={CHECKOUT_SESSION_ID}"}, |
| + "cancel_url": {cancel}, |
| + "subscription_data[metadata][abono_plan]": {req.Plan}, |
| + } |
| + customer := "" |
| + if subject := strings.TrimSpace(req.Subject); subject != "" { |
| + f.Set("client_reference_id", subject) |
| + f.Set("subscription_data[metadata][abono_subject]", subject) |
| + if customer, err = customerOf(g, subject); err != nil { |
| + return "", err |
| + } |
| + } |
| + if customer != "" { |
| + f.Set("customer", customer) |
| + } else if email := strings.TrimSpace(req.Email); email != "" { |
| + f.Set("customer_email", email) |
| + } |
| + if code := strings.TrimSpace(req.PromotionCode); code != "" { |
| + promo, err := s.stripe.PromotionCode(ctx, code) |
| + if err != nil { |
| + return "", err |
| + } |
| + if promo == "" { |
| + return "", fmt.Errorf("%w: %q", ErrUnknownCode, code) |
| + } |
| + f.Set("discounts[0][promotion_code]", promo) |
| + } else { |
| + f.Set("allow_promotion_codes", "true") |
| + } |
| + cs, err := s.stripe.CreateCheckout(ctx, f) |
| + if err != nil { |
| + return "", err |
| + } |
| + return cs.URL, nil |
| +} |
| + |
| +// soldPrice is the price to sell for plan at interval: the sold price with |
| +// that interval, or with no interval the plan's only sold price. A price's |
| +// interval is "" until a reconcile has read it from Stripe, so a plan |
| +// seeded from the environment sells with no interval asked for, and not |
| +// with one. |
| +func soldPrice(g *gorm.DB, plan, interval string) (string, error) { |
| + var prices []priceRow |
| + if err := g.Raw(`SELECT p.stripe_price_id, p.interval FROM abono_prices p |
| + JOIN abono_offers o ON o.plan = p.plan |
| + WHERE p.plan = ? AND p.sold = 1 AND o.archived_at IS NULL |
| + ORDER BY p.stripe_price_id`, plan).Scan(&prices).Error; err != nil { |
| + return "", err |
| + } |
| + var match []string |
| + for _, p := range prices { |
| + if interval == "" || p.Interval == interval { |
| + match = append(match, p.StripePriceID) |
| + } |
| + } |
| + if len(match) != 1 { |
| + if interval == "" { |
| + return "", fmt.Errorf("%w: %q", ErrUnknownPlan, plan) |
| + } |
| + return "", fmt.Errorf("%w: %q by the %s", ErrUnknownPlan, plan, interval) |
| + } |
| + return match[0], nil |
| +} |
| + |
| +// back is Origin plus path, a path on this site ("/" when empty). |
| +func (s *Service) back(path string) (string, error) { |
| + if path == "" { |
| + path = "/" |
| + } |
| + if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") { |
| + return "", fmt.Errorf("abono: %q is not a path on this site", path) |
| + } |
| + return strings.TrimRight(s.cfg.Origin, "/") + path, nil |
| +} |
| + |
| +// customerOf is the Stripe customer subject pays as: their newest |
| +// subscription's, else one mapped to them, else "". |
| +func customerOf(g *gorm.DB, subject string) (string, error) { |
| + if subject == "" { |
| + return "", nil |
| + } |
| + var ids []string |
| + if err := g.Raw(`SELECT stripe_customer_id FROM abono_subscriptions |
| + WHERE subject = ? AND stripe_customer_id <> '' ORDER BY created_at DESC LIMIT 1`, subject).Scan(&ids).Error; err != nil { |
| + return "", err |
| + } |
| + if len(ids) == 0 { |
| + if err := g.Raw(`SELECT stripe_customer_id FROM abono_customers |
| + WHERE subject = ? ORDER BY rowid DESC LIMIT 1`, subject).Scan(&ids).Error; err != nil { |
| + return "", err |
| + } |
| + } |
| + if len(ids) == 0 { |
| + return "", nil |
| + } |
| + return ids[0], nil |
| +} |
| + |
| +// Finished is what Checkout's return found: the address paid with, the |
| +// subject the subscription is now tied to ("" when unclaimed), and whether |
| +// it was paid. |
| +type Finished struct { |
| + Email, Subject string |
| + Paid bool |
| +} |
| + |
| +// Finish is Checkout's return. It syncs the session's subscription at once |
| +// rather than waiting for the webhook, so the next page already reflects |
| +// the payment. It signs nobody in. A session that is not complete is not |
| +// paid, and records nothing. |
| +func (s *Service) Finish(ctx context.Context, sessionID string) (Finished, error) { |
| + if !s.On() { |
| + return Finished{}, errOff |
| + } |
| + cs, err := s.stripe.GetCheckout(ctx, sessionID) |
| + if err != nil { |
| + return Finished{}, err |
| + } |
| + f := Finished{Email: normalEmail(cs.CustomerDetails.Email)} |
| + if cs.Status != "complete" || cs.Subscription == "" { |
| + return f, nil |
| + } |
| + sub, err := s.stripe.GetSubscription(ctx, cs.Subscription) |
| + if err != nil { |
| + return Finished{}, err |
| + } |
| + hint := Hint{Subject: cs.ClientReferenceID, Email: cs.CustomerDetails.Email} |
| + if hint.Subject == "" { |
| + hint.Subject = sub.Metadata["abono_subject"] |
| + } |
| + var subject string |
| + if err := s.tx(ctx, func(tx *gorm.DB) error { |
| + var err error |
| + subject, err = s.syncTx(ctx, tx, sub, hint, false) |
| + return err |
| + }); err != nil { |
| + return Finished{}, err |
| + } |
| + if e := normalEmail(sub.Customer.Email); e != "" { |
| + f.Email = e |
| + } |
| + f.Subject, f.Paid = subject, true |
| + return f, nil |
| +} |
| + |
| +// Portal is a Customer Portal session for subject, where they change their |
| +// card or plan or cancel, coming back to returnPath on this site. |
| +func (s *Service) Portal(ctx context.Context, subject, returnPath string) (string, error) { |
| + if !s.On() { |
| + return "", errOff |
| + } |
| + back, err := s.back(returnPath) |
| + if err != nil { |
| + return "", err |
| + } |
| + customer, err := customerOf(s.cfg.DB.WithContext(ctx), strings.TrimSpace(subject)) |
| + if err != nil { |
| + return "", err |
| + } |
| + if customer == "" { |
| + return "", ErrNoCustomer |
| + } |
| + return s.stripe.PortalURL(ctx, customer, back) |
| +} |
diff --git a/checkout_test.go b/checkout_test.go| new file mode 100644 |
| index 0000000..e91dd30 |
| --- /dev/null |
| +++ b/checkout_test.go |
| @@ -0,0 +1,170 @@ |
| +package abono_test |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net/url" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/abono" |
| +) |
| + |
| +func TestCheckoutForm(t *testing.T) { |
| + now := t0 |
| + d := newStripeDouble(t) |
| + d.promos["INNOVATE"] = "promo_1" |
| + s, db := d.service(t, &now, abono.Config{Origin: "https://site.example"}) |
| + seedPlans(t, s) |
| + ctx := context.Background() |
| + db.exec(`INSERT INTO abono_customers (stripe_customer_id, subject) VALUES ('cus_known', '5')`) |
| + |
| + base := url.Values{ |
| + "mode": {"subscription"}, |
| + "line_items[0][price]": {"price_A"}, |
| + "line_items[0][quantity]": {"1"}, |
| + "success_url": {"https://site.example/join/done?session_id={CHECKOUT_SESSION_ID}"}, |
| + "cancel_url": {"https://site.example/join/annual"}, |
| + "subscription_data[metadata][abono_plan]": {"annual"}, |
| + } |
| + with := func(kv ...string) url.Values { |
| + v := url.Values{} |
| + for k, vs := range base { |
| + v[k] = vs |
| + } |
| + for i := 0; i < len(kv); i += 2 { |
| + v.Set(kv[i], kv[i+1]) |
| + } |
| + return v |
| + } |
| + |
| + for _, c := range []struct { |
| + name string |
| + req abono.CheckoutRequest |
| + want url.Values |
| + }{ |
| + { |
| + name: "signed out, no code", |
| + req: abono.CheckoutRequest{Plan: "annual"}, |
| + want: with("allow_promotion_codes", "true"), |
| + }, |
| + { |
| + name: "signed out, with a code", |
| + req: abono.CheckoutRequest{Plan: "annual", PromotionCode: " INNOVATE "}, |
| + want: with("discounts[0][promotion_code]", "promo_1"), |
| + }, |
| + { |
| + name: "signed in, a known customer", |
| + req: abono.CheckoutRequest{Plan: "annual", Subject: "5", Email: "five@example.com"}, |
| + want: with("allow_promotion_codes", "true", "client_reference_id", "5", |
| + "subscription_data[metadata][abono_subject]", "5", "customer", "cus_known"), |
| + }, |
| + { |
| + name: "signed in, no customer yet, with a code", |
| + req: abono.CheckoutRequest{Plan: "annual", Subject: "6", Email: "six@example.com", PromotionCode: "INNOVATE"}, |
| + want: with("discounts[0][promotion_code]", "promo_1", "client_reference_id", "6", |
| + "subscription_data[metadata][abono_subject]", "6", "customer_email", "six@example.com"), |
| + }, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + c.req.SuccessPath, c.req.CancelPath = "/join/done", "/join/annual" |
| + u, err := s.Checkout(ctx, c.req) |
| + if err != nil { |
| + t.Fatalf("Checkout: %v", err) |
| + } |
| + if u == "" { |
| + t.Error("Checkout returned no URL") |
| + } |
| + if got := d.lastCheckout(t); got.Encode() != c.want.Encode() { |
| + t.Errorf("form =\n%s\nwant\n%s", got.Encode(), c.want.Encode()) |
| + } |
| + }) |
| + } |
| + |
| + if _, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", PromotionCode: "NOPE"}); !errors.Is(err, abono.ErrUnknownCode) { |
| + t.Errorf("an unknown code: err = %v, want ErrUnknownCode", err) |
| + } |
| + if _, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "lifetime"}); !errors.Is(err, abono.ErrUnknownPlan) { |
| + t.Errorf("an unknown plan: err = %v, want ErrUnknownPlan", err) |
| + } |
| +} |
| + |
| +// TestCheckoutChoosesThePriceByInterval is ruling R15: an interval picks |
| +// the sold price with it; no interval needs exactly one sold price. |
| +func TestCheckoutChoosesThePriceByInterval(t *testing.T) { |
| + now := t0 |
| + d := newStripeDouble(t) |
| + s, db := d.service(t, &now, abono.Config{}) |
| + seedPlans(t, s) |
| + ctx := context.Background() |
| + req := func(plan, interval string) abono.CheckoutRequest { |
| + return abono.CheckoutRequest{Plan: plan, Interval: interval, SuccessPath: "/done", CancelPath: "/"} |
| + } |
| + |
| + // Before a refresh, intervals are unknown: one sold price is sold |
| + // with no interval asked for, and not when one is. |
| + if _, err := s.Checkout(ctx, req("annual", "")); err != nil { |
| + t.Fatalf("one sold price, unknown interval: %v", err) |
| + } |
| + if got := d.lastCheckout(t).Get("line_items[0][price]"); got != "price_A" { |
| + t.Errorf("price = %q, want price_A", got) |
| + } |
| + if _, err := s.Checkout(ctx, req("annual", "year")); !errors.Is(err, abono.ErrUnknownPlan) { |
| + t.Errorf("an interval no price is known to have: err = %v, want ErrUnknownPlan", err) |
| + } |
| + |
| + // The plan sold yearly and monthly. |
| + if err := s.AddPrice(ctx, "annual", "price_A_month", true); err != nil { |
| + t.Fatalf("AddPrice: %v", err) |
| + } |
| + db.exec(`UPDATE abono_prices SET interval = 'year' WHERE stripe_price_id = 'price_A'`) |
| + db.exec(`UPDATE abono_prices SET interval = 'month' WHERE stripe_price_id = 'price_A_month'`) |
| + for interval, want := range map[string]string{"year": "price_A", "month": "price_A_month"} { |
| + if _, err := s.Checkout(ctx, req("annual", interval)); err != nil { |
| + t.Fatalf("interval %s: %v", interval, err) |
| + } |
| + if got := d.lastCheckout(t).Get("line_items[0][price]"); got != want { |
| + t.Errorf("interval %s: price = %q, want %q", interval, got, want) |
| + } |
| + } |
| + if _, err := s.Checkout(ctx, req("annual", "")); !errors.Is(err, abono.ErrUnknownPlan) { |
| + t.Errorf("two sold prices and no interval: err = %v, want ErrUnknownPlan", err) |
| + } |
| + if _, err := s.Checkout(ctx, req("annual", "week")); !errors.Is(err, abono.ErrUnknownPlan) { |
| + t.Errorf("an interval the plan is not sold at: err = %v, want ErrUnknownPlan", err) |
| + } |
| + |
| + // A legacy price is never sold. |
| + db.exec(`UPDATE abono_prices SET sold = 0 WHERE plan = 'monthly'`) |
| + if _, err := s.Checkout(ctx, req("monthly", "")); !errors.Is(err, abono.ErrUnknownPlan) { |
| + t.Errorf("a plan with no sold price: err = %v, want ErrUnknownPlan", err) |
| + } |
| +} |
| + |
| +func TestPortalWithoutCustomer(t *testing.T) { |
| + now := t0 |
| + d := newStripeDouble(t) |
| + s, db := d.service(t, &now, abono.Config{Origin: "https://site.example"}) |
| + ctx := context.Background() |
| + |
| + if _, err := s.Portal(ctx, "5", "/account"); !errors.Is(err, abono.ErrNoCustomer) { |
| + t.Errorf("Portal with no customer: err = %v, want ErrNoCustomer", err) |
| + } |
| + if _, err := s.Portal(ctx, "", "/account"); !errors.Is(err, abono.ErrNoCustomer) { |
| + t.Errorf("Portal for nobody: err = %v, want ErrNoCustomer", err) |
| + } |
| + |
| + db.exec(`INSERT INTO abono_customers (stripe_customer_id, subject) VALUES ('cus_1', '5')`) |
| + u, err := s.Portal(ctx, "5", "/account") |
| + if err != nil { |
| + t.Fatalf("Portal: %v", err) |
| + } |
| + if u != "https://billing.stripe.test/cus_1" { |
| + t.Errorf("Portal URL = %q", u) |
| + } |
| + d.mu.Lock() |
| + got := d.portals[len(d.portals)-1] |
| + d.mu.Unlock() |
| + if got.Get("customer") != "cus_1" || got.Get("return_url") != "https://site.example/account" { |
| + t.Errorf("portal form = %v", got) |
| + } |
| +} |
diff --git a/export_test.go b/export_test.go| new file mode 100644 |
| index 0000000..7c36ef3 |
| --- /dev/null |
| +++ b/export_test.go |
| @@ -0,0 +1,13 @@ |
| +package abono |
| + |
| +import ( |
| + "context" |
| + |
| + "amadan.net/rastrillo/abono/internal/stripeapi" |
| +) |
| + |
| +// Sync exposes sync to the tests, which drive it as the webhook and the |
| +// reconcile will. |
| +func (s *Service) Sync(ctx context.Context, sub stripeapi.Subscription, hint Hint) error { |
| + return s.sync(ctx, sub, hint) |
| +} |
diff --git a/stripe_double_test.go b/stripe_double_test.go| new file mode 100644 |
| index 0000000..45f4345 |
| --- /dev/null |
| +++ b/stripe_double_test.go |
| @@ -0,0 +1,220 @@ |
| +package abono_test |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| + |
| + "amadan.net/rastrillo/abono" |
| + "amadan.net/rastrillo/abono/internal/stripeapi" |
| +) |
| + |
| +// stripeDouble is the little of Stripe's API these tests need: the |
| +// subscriptions and Checkout sessions a test put there, promotion codes, |
| +// and a record of every Checkout and Portal session asked for. A later |
| +// task replaces it with the shared fake. |
| +type stripeDouble struct { |
| + srv *httptest.Server |
| + |
| + mu sync.Mutex |
| + subs map[string]any |
| + sessions map[string]any |
| + promos map[string]string // code -> promo_... |
| + checkouts []url.Values |
| + portals []url.Values |
| + |
| + // fetched, when set, is called each time a subscription is read. |
| + fetched func() |
| +} |
| + |
| +func newStripeDouble(t *testing.T) *stripeDouble { |
| + t.Helper() |
| + d := &stripeDouble{subs: map[string]any{}, sessions: map[string]any{}, promos: map[string]string{}} |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /v1/subscriptions/{id}", func(w http.ResponseWriter, r *http.Request) { |
| + d.reply(w, d.subs, r.PathValue("id")) |
| + if d.fetched != nil { |
| + d.fetched() |
| + } |
| + }) |
| + mux.HandleFunc("GET /v1/checkout/sessions/{id}", func(w http.ResponseWriter, r *http.Request) { |
| + d.reply(w, d.sessions, r.PathValue("id")) |
| + }) |
| + mux.HandleFunc("POST /v1/checkout/sessions", func(w http.ResponseWriter, r *http.Request) { |
| + _ = r.ParseForm() |
| + d.mu.Lock() |
| + d.checkouts = append(d.checkouts, r.PostForm) |
| + id := fmt.Sprintf("cs_%d", len(d.checkouts)) |
| + d.mu.Unlock() |
| + writeJSON(w, http.StatusOK, map[string]any{"id": id, "url": "https://checkout.stripe.test/" + id}) |
| + }) |
| + mux.HandleFunc("GET /v1/promotion_codes", func(w http.ResponseWriter, r *http.Request) { |
| + d.mu.Lock() |
| + id, ok := d.promos[r.URL.Query().Get("code")] |
| + d.mu.Unlock() |
| + data := []any{} |
| + if ok { |
| + data = append(data, map[string]any{"id": id}) |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"data": data}) |
| + }) |
| + mux.HandleFunc("POST /v1/billing_portal/sessions", func(w http.ResponseWriter, r *http.Request) { |
| + _ = r.ParseForm() |
| + d.mu.Lock() |
| + d.portals = append(d.portals, r.PostForm) |
| + d.mu.Unlock() |
| + writeJSON(w, http.StatusOK, map[string]any{"url": "https://billing.stripe.test/" + r.PostForm.Get("customer")}) |
| + }) |
| + d.srv = httptest.NewServer(mux) |
| + t.Cleanup(d.srv.Close) |
| + return d |
| +} |
| + |
| +func (d *stripeDouble) reply(w http.ResponseWriter, objects map[string]any, id string) { |
| + d.mu.Lock() |
| + obj, ok := objects[id] |
| + d.mu.Unlock() |
| + if !ok { |
| + writeJSON(w, http.StatusNotFound, map[string]any{"error": map[string]any{ |
| + "type": "invalid_request_error", "code": "resource_missing", "message": "No such object: " + id}}) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, obj) |
| +} |
| + |
| +func writeJSON(w http.ResponseWriter, status int, v any) { |
| + w.Header().Set("Content-Type", "application/json") |
| + w.WriteHeader(status) |
| + _ = json.NewEncoder(w).Encode(v) |
| +} |
| + |
| +// service is a service over a fresh database, talking to this double. |
| +func (d *stripeDouble) service(t *testing.T, now *time.Time, cfg abono.Config) (*abono.Service, *testDB) { |
| + t.Helper() |
| + cfg.Key = "rk_test_double" |
| + cfg.WebhookSecrets = []string{"whsec_double"} |
| + cfg.StripeBase = d.srv.URL |
| + if cfg.Origin == "" { |
| + cfg.Origin = "https://site.example" |
| + } |
| + return newService(t, now, cfg) |
| +} |
| + |
| +// subSpec is a subscription as a test describes it. |
| +type subSpec struct { |
| + ID, Status, Price, Interval string |
| + Customer, Email, Name string |
| + End time.Time |
| + Cancel bool |
| + Metadata map[string]string |
| +} |
| + |
| +// put stores sp in the double, with its customer expanded, and returns |
| +// it as the client would read it. |
| +func (d *stripeDouble) put(t *testing.T, sp subSpec) stripeapi.Subscription { |
| + t.Helper() |
| + if sp.Interval == "" { |
| + sp.Interval = "year" |
| + } |
| + var end int64 |
| + if !sp.End.IsZero() { |
| + end = sp.End.Unix() |
| + } |
| + obj := map[string]any{ |
| + "id": sp.ID, |
| + "status": sp.Status, |
| + "cancel_at_period_end": sp.Cancel, |
| + "customer": map[string]any{"id": sp.Customer, "email": sp.Email, "name": sp.Name}, |
| + "items": map[string]any{"data": []any{map[string]any{ |
| + "current_period_end": end, |
| + "price": map[string]any{"id": sp.Price, "recurring": map[string]any{"interval": sp.Interval}}, |
| + }}}, |
| + "metadata": sp.Metadata, |
| + } |
| + d.mu.Lock() |
| + d.subs[sp.ID] = obj |
| + d.mu.Unlock() |
| + return asSubscription(t, obj) |
| +} |
| + |
| +// session stores a Checkout session. |
| +func (d *stripeDouble) session(id, status, subID, ref, email string) { |
| + d.mu.Lock() |
| + defer d.mu.Unlock() |
| + d.sessions[id] = map[string]any{ |
| + "id": id, "status": status, "subscription": subID, "client_reference_id": ref, |
| + "customer_details": map[string]any{"email": email}, |
| + } |
| +} |
| + |
| +func (d *stripeDouble) lastCheckout(t *testing.T) url.Values { |
| + t.Helper() |
| + d.mu.Lock() |
| + defer d.mu.Unlock() |
| + if len(d.checkouts) == 0 { |
| + t.Fatal("no Checkout session was created") |
| + } |
| + return d.checkouts[len(d.checkouts)-1] |
| +} |
| + |
| +func asSubscription(t *testing.T, obj any) stripeapi.Subscription { |
| + t.Helper() |
| + b, err := json.Marshal(obj) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var s stripeapi.Subscription |
| + if err := json.Unmarshal(b, &s); err != nil { |
| + t.Fatal(err) |
| + } |
| + return s |
| +} |
| + |
| +// recorder is an app's Holder and OnChange, counting what it was asked. |
| +type recorder struct { |
| + mu sync.Mutex |
| + subject string // what Holder answers |
| + asked []string |
| + changes []string |
| + |
| + // pause, when set, runs at the start of every Holder call. |
| + pause func() |
| +} |
| + |
| +func (r *recorder) Holder(_ context.Context, _ *gorm.DB, email, _ string) (string, error) { |
| + if r.pause != nil { |
| + r.pause() |
| + } |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + r.asked = append(r.asked, email) |
| + return r.subject, nil |
| +} |
| + |
| +func (r *recorder) OnChange(_ context.Context, _ *gorm.DB, subject string) error { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + r.changes = append(r.changes, subject) |
| + return nil |
| +} |
| + |
| +func (r *recorder) config() abono.Config { |
| + return abono.Config{Holder: r.Holder, OnChange: r.OnChange} |
| +} |
| + |
| +// take returns what Holder and OnChange were given since the last take. |
| +func (r *recorder) take() (asked, changes []string) { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + asked, changes = r.asked, r.changes |
| + r.asked, r.changes = nil, nil |
| + return asked, changes |
| +} |
diff --git a/sync.go b/sync.go| new file mode 100644 |
| index 0000000..78979f8 |
| --- /dev/null |
| +++ b/sync.go |
| @@ -0,0 +1,330 @@ |
| +package abono |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "strings" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| + |
| + "amadan.net/rastrillo/abono/internal/stripeapi" |
| +) |
| + |
| +// Hint is what a caller knows of whose a subscription is, beyond Stripe's |
| +// customer: Checkout's client_reference_id (or the subscription's |
| +// abono_subject metadata) and the address typed there. |
| +// |
| +// A hint is also the permission to record a subscription abono has no row |
| +// for: a sync with the zero Hint only updates rows that already exist (the |
| +// spec's "no resurrection" rule). The webhook passes a non-zero hint for |
| +// the events that may create a row. |
| +type Hint struct { |
| + Subject, Email string |
| +} |
| + |
| +// subRecord is a whole abono_subscriptions row. |
| +type subRecord struct { |
| + StripeSubscriptionID string |
| + StripeCustomerID string |
| + Subject string |
| + Plan string |
| + StripePriceID string |
| + Status string |
| + CurrentPeriodEnd *time.Time |
| + CancelAtPeriodEnd bool |
| + Email string |
| + CreatedAt time.Time |
| + UpdatedAt time.Time |
| +} |
| + |
| +func (subRecord) TableName() string { return "abono_subscriptions" } |
| + |
| +// sync makes abono's row for sub match it, in one transaction. The webhook |
| +// and the reconcile call it; Finish and Adopt use syncTx inside their own. |
| +func (s *Service) sync(ctx context.Context, sub stripeapi.Subscription, hint Hint) error { |
| + return s.tx(ctx, func(tx *gorm.DB) error { |
| + _, err := s.syncTx(ctx, tx, sub, hint, false) |
| + return err |
| + }) |
| +} |
| + |
| +// syncTx is sync inside tx, and returns the row's subject afterwards ("" for |
| +// none, or for a payment not yet tied to anyone). |
| +// |
| +// The row is read again by subscription id first, inside the transaction. |
| +// SQLite has one writer, so two syncs of the same new subscription (Checkout's |
| +// return and its webhook, say) run one after the other: the second finds the |
| +// first's row and updates it, and Holder is asked at most once. Holder is |
| +// only ever called from here, inside tx. |
| +// |
| +// With no row yet, one is made only when the subscription grants and there |
| +// is a hint, or when adopt is set (an owner said so). The plan is the one |
| +// whose prices include the subscription's first price, or "" when none |
| +// does: such a subscription is recorded as unrecognised and never grants. |
| +// |
| +// OnChange runs for the subject before and after whenever the subject, |
| +// plan, status, period end or cancel flag changed, but only for a side that |
| +// has a plan: an unrecognised row, like an unclaimed one, tells nobody. |
| +func (s *Service) syncTx(ctx context.Context, tx *gorm.DB, sub stripeapi.Subscription, hint Hint, adopt bool) (string, error) { |
| + if sub.ID == "" { |
| + return "", errors.New("abono: sync: a subscription with no id") |
| + } |
| + hint.Subject = strings.TrimSpace(hint.Subject) |
| + hint.Email = normalEmail(hint.Email) |
| + |
| + var have []subRecord |
| + if err := tx.Where("stripe_subscription_id = ?", sub.ID).Limit(1).Find(&have).Error; err != nil { |
| + return "", err |
| + } |
| + price, _ := sub.PriceID() |
| + plan, err := planOf(tx, price) |
| + if err != nil { |
| + return "", err |
| + } |
| + now := s.now() |
| + row := subRecord{ |
| + StripeSubscriptionID: sub.ID, |
| + StripeCustomerID: sub.Customer.ID, |
| + Plan: plan, |
| + StripePriceID: price, |
| + Status: sub.Status, |
| + CancelAtPeriodEnd: sub.CancelAtPeriodEnd, |
| + Email: firstAddress(sub, hint), |
| + CreatedAt: now, |
| + UpdatedAt: now, |
| + } |
| + if end := sub.PeriodEnd(); !end.IsZero() { |
| + row.CurrentPeriodEnd = &end |
| + } |
| + |
| + if len(have) == 0 { |
| + ok, _ := grants(row.Status, row.CurrentPeriodEnd, row.CancelAtPeriodEnd, now) |
| + if !adopt && (hint == (Hint{}) || !ok) { |
| + // An ended subscription first seen now (a reconcile reading the |
| + // account's history), or one nobody vouched for: not ours to |
| + // record. |
| + return "", nil |
| + } |
| + // Holder is never asked about a price no offer recognises. |
| + if row.Subject, err = s.holder(ctx, tx, sub, hint, plan != ""); err != nil { |
| + return "", err |
| + } |
| + if err := tx.Exec(`INSERT INTO abono_subscriptions |
| + (stripe_subscription_id, stripe_customer_id, subject, plan, stripe_price_id, status, |
| + current_period_end, cancel_at_period_end, email, created_at, updated_at) |
| + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, |
| + row.StripeSubscriptionID, row.StripeCustomerID, row.Subject, row.Plan, row.StripePriceID, row.Status, |
| + row.CurrentPeriodEnd, row.CancelAtPeriodEnd, row.Email, row.CreatedAt, row.UpdatedAt).Error; err != nil { |
| + return "", err |
| + } |
| + if row.Plan != "" { |
| + return row.Subject, s.changed(ctx, tx, row.Subject) |
| + } |
| + return row.Subject, nil |
| + } |
| + |
| + old := have[0] |
| + switch { |
| + case hint.Subject != "": |
| + // The signed-in buyer, or an owner adopting: this beats whoever the |
| + // row was first tied to. |
| + row.Subject = hint.Subject |
| + if err := mapCustomer(tx, sub.Customer.ID, hint.Subject); err != nil { |
| + return "", err |
| + } |
| + case old.Subject != "": |
| + row.Subject = old.Subject |
| + default: |
| + // Not tied to anyone yet. Holder is asked only if it never was: the |
| + // row was unrecognised and its price now belongs to a plan. An |
| + // unclaimed row was already refused by Holder and waits for Claim. |
| + if row.Subject, err = s.holder(ctx, tx, sub, hint, old.Plan == "" && plan != ""); err != nil { |
| + return "", err |
| + } |
| + } |
| + if row.Email == "" { |
| + row.Email = old.Email |
| + } |
| + if row.StripeCustomerID == "" { |
| + row.StripeCustomerID = old.StripeCustomerID |
| + } |
| + |
| + moved := row.Subject != old.Subject || row.Plan != old.Plan || row.Status != old.Status || |
| + row.CancelAtPeriodEnd != old.CancelAtPeriodEnd || !sameTime(row.CurrentPeriodEnd, old.CurrentPeriodEnd) |
| + if !moved && row.StripePriceID == old.StripePriceID && row.StripeCustomerID == old.StripeCustomerID && row.Email == old.Email { |
| + return row.Subject, nil |
| + } |
| + if err := tx.Exec(`UPDATE abono_subscriptions SET |
| + stripe_customer_id = ?, subject = ?, plan = ?, stripe_price_id = ?, status = ?, |
| + current_period_end = ?, cancel_at_period_end = ?, email = ?, updated_at = ? |
| + WHERE stripe_subscription_id = ?`, |
| + row.StripeCustomerID, row.Subject, row.Plan, row.StripePriceID, row.Status, |
| + row.CurrentPeriodEnd, row.CancelAtPeriodEnd, row.Email, now, row.StripeSubscriptionID).Error; err != nil { |
| + return "", err |
| + } |
| + if !moved { |
| + return row.Subject, nil |
| + } |
| + if old.Plan != "" { |
| + if err := s.changed(ctx, tx, old.Subject); err != nil { |
| + return "", err |
| + } |
| + } |
| + if row.Plan != "" && (old.Plan == "" || old.Subject != row.Subject) { |
| + if err := s.changed(ctx, tx, row.Subject); err != nil { |
| + return "", err |
| + } |
| + } |
| + return row.Subject, nil |
| +} |
| + |
| +// holder is whose a subscription is: the hint's subject, then whoever its |
| +// Stripe customer is already mapped to, then, if ask, the app's Holder for |
| +// the customer's address and then the hint's. A subject from the hint or |
| +// from Holder is recorded as the customer's. "" means nobody yet: the row is |
| +// recorded unclaimed, by address. |
| +func (s *Service) holder(ctx context.Context, tx *gorm.DB, sub stripeapi.Subscription, hint Hint, ask bool) (string, error) { |
| + if hint.Subject != "" { |
| + return hint.Subject, mapCustomer(tx, sub.Customer.ID, hint.Subject) |
| + } |
| + if sub.Customer.ID != "" { |
| + var mapped []string |
| + if err := tx.Raw(`SELECT subject FROM abono_customers WHERE stripe_customer_id = ? AND subject <> ''`, |
| + sub.Customer.ID).Scan(&mapped).Error; err != nil { |
| + return "", err |
| + } |
| + if len(mapped) > 0 { |
| + return mapped[0], nil |
| + } |
| + } |
| + if !ask || s.cfg.Holder == nil { |
| + return "", nil |
| + } |
| + tried := "" |
| + for _, addr := range []string{normalEmail(sub.Customer.Email), hint.Email} { |
| + if addr == "" || addr == tried { |
| + continue |
| + } |
| + tried = addr |
| + subject, err := s.cfg.Holder(ctx, tx, addr, strings.TrimSpace(sub.Customer.Name)) |
| + if err != nil { |
| + return "", err |
| + } |
| + if subject = strings.TrimSpace(subject); subject != "" { |
| + return subject, mapCustomer(tx, sub.Customer.ID, subject) |
| + } |
| + } |
| + return "", nil |
| +} |
| + |
| +// mapCustomer records that customer pays as subject. |
| +func mapCustomer(tx *gorm.DB, customer, subject string) error { |
| + if customer == "" || subject == "" { |
| + return nil |
| + } |
| + return tx.Exec(`INSERT INTO abono_customers (stripe_customer_id, subject) VALUES (?, ?) |
| + ON CONFLICT(stripe_customer_id) DO UPDATE SET subject = excluded.subject`, customer, subject).Error |
| +} |
| + |
| +// planOf is the plan price belongs to, sold or legacy, or "". |
| +func planOf(tx *gorm.DB, price string) (string, error) { |
| + if price == "" { |
| + return "", nil |
| + } |
| + var plans []string |
| + if err := tx.Raw(`SELECT plan FROM abono_prices WHERE stripe_price_id = ?`, price).Scan(&plans).Error; err != nil { |
| + return "", err |
| + } |
| + if len(plans) == 0 { |
| + return "", nil |
| + } |
| + return plans[0], nil |
| +} |
| + |
| +// firstAddress is the address a subscription was paid with: its customer's, |
| +// else the one the hint carries. |
| +func firstAddress(sub stripeapi.Subscription, hint Hint) string { |
| + if a := normalEmail(sub.Customer.Email); a != "" { |
| + return a |
| + } |
| + return hint.Email |
| +} |
| + |
| +func normalEmail(s string) string { return strings.ToLower(strings.TrimSpace(s)) } |
| + |
| +func sameTime(a, b *time.Time) bool { |
| + if a == nil || b == nil { |
| + return a == nil && b == nil |
| + } |
| + return a.Equal(*b) |
| +} |
| + |
| +// Unrecognised is a granting subscription on a price no offer has: the |
| +// account sells something else too, or a plan's price was never named. The |
| +// owner's members screen lists these, with an action to adopt one onto a |
| +// plan. |
| +type Unrecognised struct { |
| + SubscriptionID, CustomerID, PriceID, Subject, Status string |
| +} |
| + |
| +// Unrecognised lists the subscriptions on a price no offer recognises that |
| +// would grant if they were recognised, oldest first. |
| +func (s *Service) Unrecognised(ctx context.Context) ([]Unrecognised, error) { |
| + var rows []subRecord |
| + if err := s.cfg.DB.WithContext(ctx).Where("plan = ''"). |
| + Order("created_at, stripe_subscription_id").Find(&rows).Error; err != nil { |
| + return nil, err |
| + } |
| + now := s.now() |
| + var out []Unrecognised |
| + for _, r := range rows { |
| + if ok, _ := grants(r.Status, utc(r.CurrentPeriodEnd), r.CancelAtPeriodEnd, now); !ok { |
| + continue |
| + } |
| + out = append(out, Unrecognised{ |
| + SubscriptionID: r.StripeSubscriptionID, CustomerID: r.StripeCustomerID, |
| + PriceID: r.StripePriceID, Subject: r.Subject, Status: r.Status, |
| + }) |
| + } |
| + return out, nil |
| +} |
| + |
| +// Claim attaches the subscriptions paid for with email and not yet tied to |
| +// anyone to subject, records their customers as subject's, tells the app |
| +// (OnChange, in the same transaction), and returns how many it attached. |
| +// The app calls it once it knows subject owns email, at sign-in say: abono |
| +// takes its word for that. |
| +func (s *Service) Claim(ctx context.Context, subject, email string) (int, error) { |
| + subject, email = strings.TrimSpace(subject), normalEmail(email) |
| + if subject == "" || email == "" { |
| + return 0, errors.New("abono: Claim needs a subject and an address") |
| + } |
| + var n int |
| + err := s.tx(ctx, func(tx *gorm.DB) error { |
| + var rows []subRecord |
| + if err := tx.Where("subject = '' AND email = ?", email).Find(&rows).Error; err != nil { |
| + return err |
| + } |
| + tell := false |
| + for _, r := range rows { |
| + if err := tx.Exec(`UPDATE abono_subscriptions SET subject = ?, updated_at = ? WHERE stripe_subscription_id = ?`, |
| + subject, s.now(), r.StripeSubscriptionID).Error; err != nil { |
| + return err |
| + } |
| + if err := mapCustomer(tx, r.StripeCustomerID, subject); err != nil { |
| + return err |
| + } |
| + tell = tell || r.Plan != "" |
| + } |
| + n = len(rows) |
| + if tell { |
| + return s.changed(ctx, tx, subject) |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + return 0, err |
| + } |
| + return n, nil |
| +} |
diff --git a/sync_test.go b/sync_test.go| new file mode 100644 |
| index 0000000..b53713b |
| --- /dev/null |
| +++ b/sync_test.go |
| @@ -0,0 +1,408 @@ |
| +package abono_test |
| + |
| +import ( |
| + "context" |
| + "reflect" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/rastrillo/abono" |
| +) |
| + |
| +const rowsQuery = `SELECT stripe_subscription_id, stripe_customer_id, subject, plan, stripe_price_id, status, cancel_at_period_end, email |
| + FROM abono_subscriptions ORDER BY stripe_subscription_id` |
| + |
| +// seedPlans sells annual (price_A, with legacy price_old) and monthly |
| +// (price_M). |
| +func seedPlans(t *testing.T, s *abono.Service) { |
| + t.Helper() |
| + if err := s.SeedPlans(context.Background(), []abono.PlanSpec{ |
| + {Plan: "annual", Sold: "price_A", Legacy: []string{"price_old"}}, |
| + {Plan: "monthly", Sold: "price_M"}, |
| + }); err != nil { |
| + t.Fatalf("SeedPlans: %v", err) |
| + } |
| +} |
| + |
| +func hasPlan(t *testing.T, s *abono.Service, subject, plan string) bool { |
| + t.Helper() |
| + ok, err := s.HasPlan(context.Background(), subject, plan) |
| + if err != nil { |
| + t.Fatalf("HasPlan(%q, %q): %v", subject, plan, err) |
| + } |
| + return ok |
| +} |
| + |
| +func eq(t *testing.T, what string, got, want []string) { |
| + t.Helper() |
| + if len(got) == 0 && len(want) == 0 { |
| + return |
| + } |
| + if !reflect.DeepEqual(got, want) { |
| + t.Errorf("%s = %q, want %q", what, got, want) |
| + } |
| +} |
| + |
| +func TestSyncCreatesOnlyWithAHint(t *testing.T) { |
| + now := t0 |
| + d := newStripeDouble(t) |
| + rec := &recorder{subject: "42"} |
| + s, db := d.service(t, &now, rec.config()) |
| + seedPlans(t, s) |
| + ctx := context.Background() |
| + |
| + sub := d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "Ana@Example.com", End: t0.Add(300 * day)}) |
| + if err := s.Sync(ctx, sub, abono.Hint{}); err != nil { |
| + t.Fatalf("Sync with no hint: %v", err) |
| + } |
| + if got := db.dump(rowsQuery); got != "" { |
| + t.Fatalf("a sync with no hint and no customer mapping made rows:\n%s", got) |
| + } |
| + asked, changes := rec.take() |
| + eq(t, "Holder asked", asked, nil) |
| + eq(t, "OnChange", changes, nil) |
| + |
| + // A hinted sync of a subscription that does not grant records nothing. |
| + ended := d.put(t, subSpec{ID: "sub_0", Status: "canceled", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(-day)}) |
| + if err := s.Sync(ctx, ended, abono.Hint{Email: "ana@example.com"}); err != nil { |
| + t.Fatalf("Sync of an ended subscription: %v", err) |
| + } |
| + if got := db.dump(rowsQuery); got != "" { |
| + t.Fatalf("an ended subscription first seen now was recorded:\n%s", got) |
| + } |
| + |
| + if err := s.Sync(ctx, sub, abono.Hint{Email: "ana@example.com"}); err != nil { |
| + t.Fatalf("Sync with a hint: %v", err) |
| + } |
| + if got, want := db.dump(rowsQuery), "sub_1|cus_1|42|annual|price_A|active|0|ana@example.com\n"; got != want { |
| + t.Fatalf("rows =\n%s\nwant\n%s", got, want) |
| + } |
| + if got := db.dump("SELECT stripe_customer_id, subject FROM abono_customers"); got != "cus_1|42\n" { |
| + t.Errorf("customers = %q, want the Holder's subject recorded", got) |
| + } |
| + asked, changes = rec.take() |
| + eq(t, "Holder asked", asked, []string{"ana@example.com"}) |
| + eq(t, "OnChange", changes, []string{"42"}) |
| + if !hasPlan(t, s, "42", "annual") { |
| + t.Error("HasPlan(42, annual) is false after the hinted sync") |
| + } |
| + |
| + // The same subscription again changes nothing and tells nobody. |
| + if err := s.Sync(ctx, sub, abono.Hint{}); err != nil { |
| + t.Fatalf("Sync again: %v", err) |
| + } |
| + asked, changes = rec.take() |
| + eq(t, "Holder asked on a repeat", asked, nil) |
| + eq(t, "OnChange on a repeat", changes, nil) |
| + |
| + // An existing row follows Stripe with no hint. |
| + sub = d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(300 * day), Cancel: true}) |
| + if err := s.Sync(ctx, sub, abono.Hint{}); err != nil { |
| + t.Fatalf("Sync of a cancelling subscription: %v", err) |
| + } |
| + if got := db.dump("SELECT cancel_at_period_end FROM abono_subscriptions"); got != "1\n" { |
| + t.Errorf("cancel_at_period_end = %q, want 1", got) |
| + } |
| + _, changes = rec.take() |
| + eq(t, "OnChange after cancelling", changes, []string{"42"}) |
| +} |
| + |
| +func TestSyncUnknownPriceIsUnrecognised(t *testing.T) { |
| + now := t0 |
| + d := newStripeDouble(t) |
| + rec := &recorder{subject: "42"} |
| + s, db := d.service(t, &now, rec.config()) |
| + seedPlans(t, s) |
| + ctx := context.Background() |
| + |
| + sub := d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_Z", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day)}) |
| + if err := s.Sync(ctx, sub, abono.Hint{Email: "ana@example.com"}); err != nil { |
| + t.Fatalf("Sync: %v", err) |
| + } |
| + if got, want := db.dump(rowsQuery), "sub_1|cus_1|||price_Z|active|0|ana@example.com\n"; got != want { |
| + t.Fatalf("rows =\n%s\nwant\n%s", got, want) |
| + } |
| + asked, changes := rec.take() |
| + eq(t, "Holder asked for an unrecognised price", asked, nil) |
| + eq(t, "OnChange for an unrecognised price", changes, nil) |
| + |
| + un, err := s.Unrecognised(ctx) |
| + if err != nil { |
| + t.Fatalf("Unrecognised: %v", err) |
| + } |
| + want := []abono.Unrecognised{{SubscriptionID: "sub_1", CustomerID: "cus_1", PriceID: "price_Z", Status: "active"}} |
| + if !reflect.DeepEqual(un, want) { |
| + t.Errorf("Unrecognised = %+v, want %+v", un, want) |
| + } |
| +} |
| + |
| +// TestPortalPlanSwitchMovesThePlan is Review Focus 2: a reader switching |
| +// plan in the Customer Portal keeps the same subscription id on a new |
| +// price. |
| +func TestPortalPlanSwitchMovesThePlan(t *testing.T) { |
| + now := t0 |
| + d := newStripeDouble(t) |
| + rec := &recorder{} |
| + s, db := d.service(t, &now, rec.config()) |
| + seedPlans(t, s) |
| + ctx := context.Background() |
| + |
| + spec := subSpec{ID: "sub_1", Status: "active", Price: "price_M", Interval: "month", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(20 * day)} |
| + if err := s.Sync(ctx, d.put(t, spec), abono.Hint{Subject: "5"}); err != nil { |
| + t.Fatalf("Sync monthly: %v", err) |
| + } |
| + if !hasPlan(t, s, "5", "monthly") { |
| + t.Fatal("HasPlan(5, monthly) is false") |
| + } |
| + rec.take() |
| + |
| + spec.Price, spec.Interval = "price_A", "year" |
| + if err := s.Sync(ctx, d.put(t, spec), abono.Hint{}); err != nil { |
| + t.Fatalf("Sync annual: %v", err) |
| + } |
| + if got := db.dump("SELECT plan, stripe_price_id FROM abono_subscriptions"); got != "annual|price_A\n" { |
| + t.Errorf("after the switch the row is %q, want annual|price_A", got) |
| + } |
| + if !hasPlan(t, s, "5", "annual") || hasPlan(t, s, "5", "monthly") { |
| + t.Error("after the switch, want annual and not monthly") |
| + } |
| + _, changes := rec.take() |
| + eq(t, "OnChange after the switch", changes, []string{"5"}) |
| + |
| + spec.Price = "price_Z" |
| + if err := s.Sync(ctx, d.put(t, spec), abono.Hint{}); err != nil { |
| + t.Fatalf("Sync onto an unknown price: %v", err) |
| + } |
| + if hasPlan(t, s, "5", "annual") { |
| + t.Error("an unknown price still grants annual") |
| + } |
| + if got := db.dump("SELECT subject, plan, stripe_price_id FROM abono_subscriptions"); got != "5||price_Z\n" { |
| + t.Errorf("row = %q, want 5||price_Z", got) |
| + } |
| + un, err := s.Unrecognised(ctx) |
| + if err != nil || len(un) != 1 || un[0].Subject != "5" { |
| + t.Errorf("Unrecognised = %+v, %v; want sub_1 for subject 5", un, err) |
| + } |
| + // Losing the plan is a change to subject 5's holdings, so the app |
| + // hears of it. |
| + _, changes = rec.take() |
| + eq(t, "OnChange after moving to an unknown price", changes, []string{"5"}) |
| +} |
| + |
| +// TestHintSubjectBeatsAddress is Review Focus 3. |
| +func TestHintSubjectBeatsAddress(t *testing.T) { |
| + now := t0 |
| + d := newStripeDouble(t) |
| + rec := &recorder{subject: "9"} |
| + s, db := d.service(t, &now, rec.config()) |
| + seedPlans(t, s) |
| + ctx := context.Background() |
| + |
| + sub := d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "nine@example.com", End: t0.Add(30 * day)}) |
| + if err := s.Sync(ctx, sub, abono.Hint{Subject: "7", Email: "nine@example.com"}); err != nil { |
| + t.Fatalf("Sync: %v", err) |
| + } |
| + if got := db.dump("SELECT subject FROM abono_subscriptions"); got != "7\n" { |
| + t.Errorf("subject = %q, want 7", got) |
| + } |
| + asked, changes := rec.take() |
| + eq(t, "Holder asked", asked, nil) |
| + eq(t, "OnChange", changes, []string{"7"}) |
| + if !hasPlan(t, s, "7", "annual") || hasPlan(t, s, "9", "annual") { |
| + t.Error("want 7 to hold annual and 9 not") |
| + } |
| + |
| + // A row first made from the address is corrected by a later hint, and |
| + // both subjects hear of it. |
| + other := d.put(t, subSpec{ID: "sub_2", Status: "active", Price: "price_M", Customer: "cus_2", Email: "nine@example.com", End: t0.Add(30 * day)}) |
| + if err := s.Sync(ctx, other, abono.Hint{Email: "nine@example.com"}); err != nil { |
| + t.Fatalf("Sync sub_2 by address: %v", err) |
| + } |
| + if got := db.dump("SELECT subject FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_2'"); got != "9\n" { |
| + t.Fatalf("sub_2 subject = %q, want 9 from Holder", got) |
| + } |
| + rec.take() |
| + if err := s.Sync(ctx, other, abono.Hint{Subject: "7"}); err != nil { |
| + t.Fatalf("Sync sub_2 with a subject: %v", err) |
| + } |
| + if got := db.dump("SELECT subject FROM abono_subscriptions WHERE stripe_subscription_id = 'sub_2'"); got != "7\n" { |
| + t.Errorf("sub_2 subject = %q, want 7 after the hint", got) |
| + } |
| + asked, changes = rec.take() |
| + eq(t, "Holder asked on correction", asked, nil) |
| + eq(t, "OnChange on correction", changes, []string{"9", "7"}) |
| + if hasPlan(t, s, "9", "monthly") || !hasPlan(t, s, "7", "monthly") { |
| + t.Error("after the correction, want 7 to hold monthly and 9 not") |
| + } |
| +} |
| + |
| +// TestFinishAndWebhookRace is Review Focus 1: Checkout's return and the |
| +// webhook sync the same new subscription, in either order or at once, |
| +// and make one row and at most one person. |
| +func TestFinishAndWebhookRace(t *testing.T) { |
| + run := func(t *testing.T, order string) { |
| + now := t0 |
| + d := newStripeDouble(t) |
| + rec := &recorder{subject: "42"} |
| + s, db := d.service(t, &now, rec.config()) |
| + seedPlans(t, s) |
| + ctx := context.Background() |
| + |
| + sub := 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.session("cs_1", "complete", "sub_1", "", "ana@example.com") |
| + hint := abono.Hint{Email: "ana@example.com"} |
| + |
| + finish := func() error { |
| + f, err := s.Finish(ctx, "cs_1") |
| + if err == nil && (!f.Paid || f.Subject != "42") { |
| + t.Errorf("Finish = %+v, want paid by 42", f) |
| + } |
| + return err |
| + } |
| + webhook := func() error { return s.Sync(ctx, sub, hint) } |
| + |
| + var errs [2]error |
| + switch order { |
| + case "finish first": |
| + errs[0], errs[1] = finish(), webhook() |
| + case "webhook first": |
| + errs[1], errs[0] = webhook(), finish() |
| + case "at once": |
| + // The webhook needs no round trip to Stripe and would always |
| + // finish before Finish has read anything. Holding Holder until |
| + // Finish has fetched the subscription puts Finish's read in the |
| + // middle of the webhook's write, which is the race. |
| + fetched := make(chan struct{}) |
| + var once sync.Once |
| + d.fetched = func() { once.Do(func() { close(fetched) }) } |
| + rec.pause = func() { |
| + select { |
| + case <-fetched: |
| + case <-time.After(2 * time.Second): |
| + } |
| + time.Sleep(20 * time.Millisecond) |
| + } |
| + var wg sync.WaitGroup |
| + wg.Add(2) |
| + go func() { defer wg.Done(); errs[0] = finish() }() |
| + go func() { defer wg.Done(); errs[1] = webhook() }() |
| + wg.Wait() |
| + } |
| + for _, err := range errs { |
| + if err != nil { |
| + t.Fatalf("%s: %v", order, err) |
| + } |
| + } |
| + if got, want := db.dump(rowsQuery), "sub_1|cus_1|42|annual|price_A|active|0|ana@example.com\n"; got != want { |
| + t.Errorf("%s: rows =\n%s\nwant\n%s", order, got, want) |
| + } |
| + if asked, _ := rec.take(); len(asked) > 1 { |
| + t.Errorf("%s: Holder called %d times, want at most once", order, len(asked)) |
| + } |
| + } |
| + for _, order := range []string{"finish first", "webhook first"} { |
| + t.Run(order, func(t *testing.T) { run(t, order) }) |
| + } |
| + for range 10 { |
| + t.Run("at once", func(t *testing.T) { run(t, "at once") }) |
| + } |
| +} |
| + |
| +func TestFinishUnpaidIsNotPaid(t *testing.T) { |
| + now := t0 |
| + d := newStripeDouble(t) |
| + rec := &recorder{subject: "42"} |
| + s, db := d.service(t, &now, rec.config()) |
| + seedPlans(t, s) |
| + ctx := context.Background() |
| + |
| + 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.session("cs_open", "open", "sub_1", "", "ana@example.com") |
| + f, err := s.Finish(ctx, "cs_open") |
| + if err != nil { |
| + t.Fatalf("Finish: %v", err) |
| + } |
| + if f.Paid { |
| + t.Errorf("Finish of an open session = %+v, want not paid", f) |
| + } |
| + if got := db.dump(rowsQuery); got != "" { |
| + t.Errorf("an unpaid session made rows:\n%s", got) |
| + } |
| + |
| + // Signed in: the session's client_reference_id is the subject, and |
| + // the address is the customer's. |
| + d.session("cs_paid", "complete", "sub_1", "7", "typed@example.com") |
| + f, err = s.Finish(ctx, "cs_paid") |
| + if err != nil { |
| + t.Fatalf("Finish paid: %v", err) |
| + } |
| + if want := (abono.Finished{Email: "ana@example.com", Subject: "7", Paid: true}); f != want { |
| + t.Errorf("Finish = %+v, want %+v", f, want) |
| + } |
| + if asked, _ := rec.take(); len(asked) != 0 { |
| + t.Errorf("Holder asked %q for a signed-in reader", asked) |
| + } |
| + |
| + if _, err := s.Finish(ctx, "cs_missing"); err == nil { |
| + t.Error("Finish of a session Stripe does not have succeeded") |
| + } |
| +} |
| + |
| +func TestUnclaimedThenClaim(t *testing.T) { |
| + now := t0 |
| + d := newStripeDouble(t) |
| + rec := &recorder{subject: ""} // this app wants sign-in before payment |
| + s, db := d.service(t, &now, rec.config()) |
| + seedPlans(t, s) |
| + ctx := context.Background() |
| + |
| + sub := d.put(t, subSpec{ID: "sub_1", Status: "active", Price: "price_A", Customer: "cus_1", Email: "ana@example.com", End: t0.Add(30 * day)}) |
| + if err := s.Sync(ctx, sub, abono.Hint{Email: "ana@example.com"}); err != nil { |
| + t.Fatalf("Sync: %v", err) |
| + } |
| + if got, want := db.dump(rowsQuery), "sub_1|cus_1||annual|price_A|active|0|ana@example.com\n"; got != want { |
| + t.Fatalf("rows =\n%s\nwant\n%s", got, want) |
| + } |
| + if got := db.dump("SELECT * FROM abono_customers"); got != "" { |
| + t.Errorf("an unclaimed payment mapped its customer: %q", got) |
| + } |
| + asked, changes := rec.take() |
| + eq(t, "Holder asked", asked, []string{"ana@example.com"}) |
| + eq(t, "OnChange for an unclaimed payment", changes, nil) |
| + if hs, err := s.Holdings(ctx, ""); err != nil || len(hs) != 0 { |
| + t.Errorf("Holdings(\"\") = %+v, %v; an unclaimed payment grants nobody", hs, err) |
| + } |
| + |
| + if n, err := s.Claim(ctx, "6", "bob@example.com"); err != nil || n != 0 { |
| + t.Errorf("Claim with another address = %d, %v; want 0", n, err) |
| + } |
| + if n, err := s.Claim(ctx, "5", " Ana@Example.com "); err != nil || n != 1 { |
| + t.Fatalf("Claim = %d, %v; want 1", n, err) |
| + } |
| + if !hasPlan(t, s, "5", "annual") { |
| + t.Error("HasPlan(5, annual) is false after Claim") |
| + } |
| + if hasPlan(t, s, "6", "annual") { |
| + t.Error("the other address's claim took the plan") |
| + } |
| + _, changes = rec.take() |
| + eq(t, "OnChange after Claim", changes, []string{"5"}) |
| + if got := db.dump("SELECT stripe_customer_id, subject FROM abono_customers"); got != "cus_1|5\n" { |
| + t.Errorf("customers after Claim = %q, want cus_1|5", got) |
| + } |
| + |
| + // Claimed is claimed: a second claim of the same address attaches |
| + // nothing. |
| + if n, err := s.Claim(ctx, "6", "ana@example.com"); err != nil || n != 0 { |
| + t.Errorf("a second Claim = %d, %v; want 0", n, err) |
| + } |
| + // And the next sync keeps the subject, through the customer mapping. |
| + if err := s.Sync(ctx, sub, abono.Hint{}); err != nil { |
| + t.Fatalf("Sync after Claim: %v", err) |
| + } |
| + if got := db.dump("SELECT subject FROM abono_subscriptions"); !strings.HasPrefix(got, "5") { |
| + t.Errorf("subject after a later sync = %q, want 5", got) |
| + } |
| +} |