rastrillo / abono Public

abono: the last approved copy, the example's own catalog, and an email field in the sandbox

Summary

Writes the ten approved abono.* strings (review 2 and 3, bar the unused failing_nodate) into locales/en.toml byte for byte and removes the pendingReview mechanism from the catalog gate. The example gets its own catalog (example/locales/en.toml, example.signout) merged after abono.Catalog(), with a gate that holds example.* keys to it. The sandbox checkout asks a signed-out tester for an email address (label abono.sandbox.email) and uses it as the Checkout customer's email; an empty or malformed address re-renders the page with 422 and buys nothing. Signed-in testers (a customer, address or subject on the session) are not asked.
Agent notes
Ruling R29. A subject-only signed-in session now gets a customer with no email instead of buyer_N@example.com; the subject identifies them. The 422 page shows no error text because none is approved: the field is required and type=email, and carries aria-invalid. example/README.md still says the words are keys until the catalog lands; left alone as it is prose that needs copy review.
claude agent, operated by paul@keymail.dev 9316fad2b25b63c0aca3f7a9a9d9101d9ae09eb6 parent 1630599
10 files changed, +415 −49
  • example/app.go +3 −3
  • example/catalog.go +57 −0
  • example/catalog_test.go +108 −0
  • example/locales/en.toml +5 −0
  • internal/fakestripe/fake.go +14 −5
  • internal/fakestripe/fake_test.go +119 −0
  • internal/fakestripe/pages.go +36 −3
  • locales/en.toml +10 −0
  • sandbox_test.go +59 −1
  • templates_test.go +4 −37

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./... -count=1
ok  	amadan.net/rastrillo/abono	2.665s
ok  	amadan.net/rastrillo/abono/abonotest	0.061s
ok  	amadan.net/rastrillo/abono/cmd/abono	0.749s
ok  	amadan.net/rastrillo/abono/internal/fakestripe	0.023s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	0.006s
ok  	example	0.256s
=== race ===
CGO_ENABLED=1 go test ./... -race -count=1
ok  	amadan.net/rastrillo/abono	9.785s
ok  	amadan.net/rastrillo/abono/abonotest	1.405s
ok  	amadan.net/rastrillo/abono/cmd/abono	1.981s
ok  	amadan.net/rastrillo/abono/internal/fakestripe	1.082s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	1.019s
ok  	example	2.614s
=== browser ===
ok  	example	0.755s
diff --git a/example/app.go b/example/app.go
index 1d75c08..f3da4ee 100644
--- a/example/app.go
+++ b/example/app.go
@@ -56,7 +56,7 @@ type Config struct {
// Abono, when set, adjusts abono's configuration last (tests point
// it at abonotest's Stripe with abonotest.Stripe.Config).
Abono func(abono.Config) abono.Config
- // Catalog is the base message catalog; abono.Catalog() when nil.
+ // Catalog is the base message catalog; the example's Catalog() when nil.
Catalog rastrillo.Catalog
Logger *slog.Logger
}
@@ -68,7 +68,7 @@ func Options(cfg Config) (rastrillo.Options, error) {
cfg.Logger = slog.Default()
}
if cfg.Catalog == nil {
- cfg.Catalog = abono.Catalog()
+ cfg.Catalog = Catalog()
}
mux, err := App(cfg)
if err != nil {
@@ -91,7 +91,7 @@ func App(cfg Config) (*http.ServeMux, error) {
ctx := context.Background()
logger := cfg.Logger
if cfg.Catalog == nil {
- cfg.Catalog = abono.Catalog()
+ cfg.Catalog = Catalog()
}
if _, err := migrate.Apply(ctx, cfg.DB, BootSchema); err != nil {
return nil, err
diff --git a/example/catalog.go b/example/catalog.go
new file mode 100644
index 0000000..04d77d5
--- /dev/null
+++ b/example/catalog.go
@@ -0,0 +1,57 @@
+package main
+
+import (
+ "embed"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "amadan.net/rastrillo/rastrillo"
+
+ "amadan.net/rastrillo/abono"
+)
+
+//go:embed locales/en.toml
+var localesFS embed.FS
+
+// Catalog is the base message catalog the example runs on: abono's
+// English, then the example's own (locales/en.toml, the "example.*" keys
+// its layout and pages use). A key belongs to the module that renders it,
+// so abono's catalog has none of these.
+func Catalog() rastrillo.Catalog {
+ cat := abono.Catalog()
+ own, err := ownCatalog()
+ if err != nil {
+ panic(fmt.Sprintf("example: locales/en.toml: %v", err)) // a build bug
+ }
+ for k, v := range own {
+ cat[k] = v
+ }
+ return cat
+}
+
+// ownCatalog is locales/en.toml: one key = "value" per line, with #
+// comments and blank lines, the flat format rastrillo's catalogs use.
+func ownCatalog() (rastrillo.Catalog, error) {
+ data, err := localesFS.ReadFile("locales/en.toml")
+ if err != nil {
+ return nil, err
+ }
+ out := rastrillo.Catalog{}
+ for i, raw := range strings.Split(string(data), "\n") {
+ line := strings.TrimSpace(raw)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ key, val, ok := strings.Cut(line, " = ")
+ text, err := strconv.Unquote(strings.TrimSpace(val))
+ if !ok || key == "" || err != nil {
+ return nil, fmt.Errorf("line %d: want key = \"value\"", i+1)
+ }
+ if _, dup := out[key]; dup {
+ return nil, fmt.Errorf("line %d: duplicate key %q", i+1, key)
+ }
+ out[key] = text
+ }
+ return out, nil
+}
diff --git a/example/catalog_test.go b/example/catalog_test.go
new file mode 100644
index 0000000..df58864
--- /dev/null
+++ b/example/catalog_test.go
@@ -0,0 +1,108 @@
+package main
+
+import (
+ "io/fs"
+ "net/http"
+ "os"
+ "path"
+ "strings"
+ "testing"
+
+ "amadan.net/rastrillo/abono"
+)
+
+// exampleKeys is every "example.*" key the example's own templates and Go
+// name. The generated pages (abono_pages.go, templates/abono/) are abono's
+// and use abono.* keys, which its own gate holds to its catalog; main.go
+// is configuration, where "example.db" is a file name, not a key.
+func exampleKeys(t *testing.T) map[string]bool {
+ t.Helper()
+ used := map[string]bool{}
+ root := os.DirFS(".")
+ err := fs.WalkDir(root, ".", func(p string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ if p == "templates/abono" || p == "migrations" {
+ return fs.SkipDir
+ }
+ return nil
+ }
+ if ext := path.Ext(p); (ext != ".html" && ext != ".go") || strings.HasSuffix(p, "_test.go") || p == "abono_pages.go" || p == "main.go" {
+ return nil
+ }
+ src, err := fs.ReadFile(root, p)
+ if err != nil {
+ return err
+ }
+ for _, m := range keyLiteral.FindAllSubmatch(src, -1) {
+ if k := string(m[1]); strings.HasPrefix(k, "example.") {
+ used[k] = true
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return used
+}
+
+// The example's "example.*" keys and locales/en.toml agree: every key its
+// templates use is defined there, every key there is used, and the file
+// holds nothing but example.* keys (abono's belong to abono's catalog).
+func TestExampleCatalogCoversTheKeysTheExampleUses(t *testing.T) {
+ own, err := ownCatalog()
+ if err != nil {
+ t.Fatal(err)
+ }
+ used := exampleKeys(t)
+ if !used["example.signout"] {
+ t.Error("exampleKeys misses example.signout, which the layout uses")
+ }
+ for k := range used {
+ if _, ok := own[k]; !ok {
+ t.Errorf("%s is used but not in locales/en.toml", k)
+ }
+ }
+ for k := range own {
+ if !used[k] {
+ t.Errorf("%s is in locales/en.toml but nothing uses it", k)
+ }
+ if !strings.HasPrefix(k, "example.") {
+ t.Errorf("%s is in the example's catalog but is not an example.* key", k)
+ }
+ }
+ for k := range abono.Catalog() {
+ if strings.HasPrefix(k, "example.") {
+ t.Errorf("abono's catalog has %s, which is the example's", k)
+ }
+ }
+ merged := Catalog()
+ for k, v := range abono.Catalog() {
+ if merged[k] != v {
+ t.Errorf("Catalog() changed abono's %s", k)
+ }
+ }
+ for k, v := range own {
+ if merged[k] != v {
+ t.Errorf("Catalog() lacks the example's %s", k)
+ }
+ }
+}
+
+// With no catalog given, the example runs on its own: a signed-in page
+// says "Sign out", not the key.
+func TestExampleSaysSignOutInItsOwnCatalog(t *testing.T) {
+ app := newApp(t, func(c *Config) { c.Catalog = nil })
+ reader := newClient(t, app)
+ reader.signin("reader@example.com")
+ status, body := reader.get("/subscribe")
+ if status != http.StatusOK {
+ t.Fatalf("GET /subscribe: %d", status)
+ }
+ if !strings.Contains(body, ">Sign out<") || strings.Contains(body, "example.signout") {
+ t.Errorf("the nav does not say Sign out:\n%s", body)
+ }
+}
diff --git a/example/locales/en.toml b/example/locales/en.toml
new file mode 100644
index 0000000..1731a9d
--- /dev/null
+++ b/example/locales/en.toml
@@ -0,0 +1,5 @@
+# The example's own English: the words its layout and pages use that abono does not.
+# abono's are in abono.Catalog(); a key belongs to the module that renders it.
+
+# The layout.
+example.signout = "Sign out"
diff --git a/internal/fakestripe/fake.go b/internal/fakestripe/fake.go
index fbd0904..745d790 100644
--- a/internal/fakestripe/fake.go
+++ b/internal/fakestripe/fake.go
@@ -596,7 +596,9 @@ func (f *Fake) promotionCodes(w http.ResponseWriter, r *http.Request) {
}
// pay completes session id as a card payment that went through: a
-// customer (the one Checkout was given, else a new one with email), an
+// customer (the one Checkout was given, else a new one with the address
+// Checkout was given or, for someone with no subject either, email, which
+// must be an address: errEmail and nothing bought when it is not), an
// active subscription on the session's price for one period, and the
// session complete and paid. It returns where Checkout sends the buyer.
// Paying a session already paid buys nothing more and returns the same
@@ -619,11 +621,17 @@ func (f *Fake) pay(ctx context.Context, id, email string) (string, error) {
now := f.now()
c := f.customers[s.customer]
if c == nil {
- if email == "" {
+ // Checkout's own address wins, and a signed-in tester (Checkout
+ // was given their subject) is not asked for one: the subject says
+ // who they are. Otherwise it is the address typed on the page, and
+ // not one made up.
+ if s.customerEmail != "" {
email = s.customerEmail
- }
- if email == "" {
- email = f.next("buyer") + "@example.com"
+ } else if s.ClientReferenceID != "" {
+ email = ""
+ } else if !validEmail(email) {
+ f.mu.Unlock()
+ return "", errEmail
}
c = &customer{id: s.customer, email: email}
if c.id == "" {
@@ -698,6 +706,7 @@ func (f *Fake) cancel(ctx context.Context, customer, subID string) error {
var (
errUnknown = errors.New("fakestripe: no such session or subscription")
errGone = errors.New("fakestripe: the session is over")
+ errEmail = errors.New("fakestripe: the sandbox page needs an email address")
)
// usable is why session s cannot be paid or declined, or nil. Hold f.mu.
diff --git a/internal/fakestripe/fake_test.go b/internal/fakestripe/fake_test.go
index c065576..03fbda7 100644
--- a/internal/fakestripe/fake_test.go
+++ b/internal/fakestripe/fake_test.go
@@ -603,3 +603,122 @@ func TestFakeCancelAfterAFailedRenewalEnds(t *testing.T) {
t.Errorf("events = %v, want %v", got, want)
}
}
+
+// anonymousCheckout starts a Checkout for someone who is not signed in:
+// no customer, no email, no subject.
+func (r *rig) anonymousCheckout(t *testing.T) stripeapi.CheckoutSession {
+ t.Helper()
+ cs, err := r.client.CreateCheckout(context.Background(), url.Values{
+ "mode": {"subscription"},
+ "line_items[0][price]": {"price_A"},
+ "line_items[0][quantity]": {"1"},
+ "success_url": {"https://site.example/done?session_id={CHECKOUT_SESSION_ID}"},
+ "cancel_url": {"https://site.example/join"},
+ })
+ if err != nil {
+ t.Fatalf("CreateCheckout: %v", err)
+ }
+ return cs
+}
+
+// postPage posts form and returns the status, the Location and the body.
+func postPage(t *testing.T, u string, form url.Values) (int, string, string) {
+ t.Helper()
+ resp, err := noRedirects.PostForm(u, form)
+ if err != nil {
+ t.Fatalf("POST %s: %v", u, err)
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ return resp.StatusCode, resp.Header.Get("Location"), string(body)
+}
+
+// A tester who is not signed in types the address Checkout would have
+// asked for; the sandbox does not make one up. Someone signed in already
+// has theirs, and is not asked.
+func TestFakeCheckoutAsksAnAnonymousTesterForAnEmail(t *testing.T) {
+ r := start(t)
+ ctx := context.Background()
+
+ known := r.checkout(t)
+ _, page := get(t, known.URL)
+ if strings.Contains(page, `name="email"`) {
+ t.Errorf("a signed-in tester is asked for an email:\n%s", page)
+ }
+ // Their address is Checkout's, whatever the form carries.
+ if code, _, _ := postPage(t, known.URL+"/pay", url.Values{"email": {"someone.else@example.com"}}); code != http.StatusSeeOther {
+ t.Fatalf("Pay as a signed-in tester: status %d", code)
+ }
+ if got := r.sub(t, "sub_1").Customer.Email; got != "ana@example.com" {
+ t.Errorf("a signed-in tester's customer email = %q, want Checkout's own", got)
+ }
+
+ // Nor is someone whose subject Checkout has, with no address: the
+ // subject says who they are.
+ subjectOnly, err := r.client.CreateCheckout(ctx, url.Values{
+ "mode": {"subscription"}, "line_items[0][price]": {"price_A"}, "line_items[0][quantity]": {"1"},
+ "success_url": {"https://site.example/done"}, "client_reference_id": {"bo"},
+ })
+ if err != nil {
+ t.Fatalf("CreateCheckout: %v", err)
+ }
+ if _, page := get(t, subjectOnly.URL); strings.Contains(page, `name="email"`) {
+ t.Errorf("a tester with a subject is asked for an email:\n%s", page)
+ }
+ if code, _, body := postPage(t, subjectOnly.URL+"/pay", nil); code != http.StatusSeeOther {
+ t.Fatalf("Pay with a subject and no address: status %d\n%s", code, body)
+ }
+ if got := r.sub(t, "sub_2").Customer.Email; got != "" {
+ t.Errorf("a subject-only customer's email = %q, want none", got)
+ }
+ r.hook.take()
+
+ cs := r.anonymousCheckout(t)
+ code, page := get(t, cs.URL)
+ if code != http.StatusOK {
+ t.Fatalf("checkout page: status %d", code)
+ }
+ keysOnly(t, page, "abono.sandbox.pay", "abono.sandbox.email")
+ for _, want := range []string{`name="email"`, `type="email"`, "required", `autocomplete="email"`} {
+ if !strings.Contains(page, want) {
+ t.Errorf("the email field lacks %s:\n%s", want, page)
+ }
+ }
+ if !regexp.MustCompile(`<label[^>]* for="([^"]+)"[^>]*>abono\.sandbox\.email</label>`).MatchString(page) {
+ t.Errorf("the field has no label saying abono.sandbox.email:\n%s", page)
+ }
+
+ // Nothing typed, or something that is not an address: the page again,
+ // 422, and nothing bought.
+ for _, bad := range []url.Values{nil, {"email": {""}}, {"email": {" "}}, {"email": {"ana"}}, {"email": {"ana@"}},
+ {"email": {"Ana <ana@example.com>"}}, {"email": {"a b@example.com"}}} {
+ code, loc, body := postPage(t, cs.URL+"/pay", bad)
+ if code != http.StatusUnprocessableEntity || loc != "" {
+ t.Errorf("Pay with %q: %d to %q, want 422 and no redirect", bad.Get("email"), code, loc)
+ }
+ keysOnly(t, body, "abono.sandbox.pay", "abono.sandbox.email")
+ if !strings.Contains(body, `name="email"`) {
+ t.Errorf("Pay with %q: the page is not the checkout again:\n%s", bad.Get("email"), body)
+ }
+ }
+ if _, err := r.client.GetSubscription(ctx, "sub_3"); !stripeapi.NotFound(err) {
+ t.Errorf("a rejected address bought a subscription: %v", err)
+ }
+ if got, _ := r.client.GetCheckout(ctx, cs.ID); got.Status != "open" {
+ t.Errorf("after rejected addresses the session is %q, want open", got.Status)
+ }
+ if len(r.hook.take()) != 0 {
+ t.Error("a rejected address sent events")
+ }
+
+ if code, _, body := postPage(t, cs.URL+"/pay", url.Values{"email": {" Bo@Example.com "}}); code != http.StatusSeeOther {
+ t.Fatalf("Pay with an address: status %d\n%s", code, body)
+ }
+ sub := r.sub(t, "sub_3")
+ if sub.Customer.Email != "Bo@Example.com" {
+ t.Errorf("customer email = %q, want the address typed (trimmed)", sub.Customer.Email)
+ }
+ if done, _ := r.client.GetCheckout(ctx, cs.ID); done.CustomerDetails.Email != "Bo@Example.com" {
+ t.Errorf("the finished session's email = %q", done.CustomerDetails.Email)
+ }
+}
diff --git a/internal/fakestripe/pages.go b/internal/fakestripe/pages.go
index d7d4a2a..518b7e9 100644
--- a/internal/fakestripe/pages.go
+++ b/internal/fakestripe/pages.go
@@ -5,6 +5,7 @@ import (
"fmt"
"html/template"
"net/http"
+ "net/mail"
"net/url"
"strconv"
"strings"
@@ -36,6 +37,9 @@ var page = template.Must(template.New("page").Parse(`<!doctype html>
{{- with .Checkout}}
<p>{{.Item}}</p>
<form rst-form method="post" action="{{.PayAction}}">
+{{- if .AskEmail}}
+<div rst-field><label rst-field-label for="sandbox-email">{{.EmailLabel}}</label><input rst-input id="sandbox-email" name="email" type="email" value="{{.Email}}" autocomplete="email" required{{if .EmailBad}} aria-invalid="true"{{end}}></div>
+{{- end}}
<button rst-btn="primary lg" type="submit">{{.Pay}}</button>
</form>
<form rst-form method="post" action="{{.DeclineAction}}">
@@ -75,6 +79,12 @@ type checkoutData struct {
Item, Pay, Decline, Leave string
PayAction, DeclineAction string
LeaveHref template.URL
+ // AskEmail is set for a tester who is not signed in: Checkout was
+ // given no customer, address or subject, so the page asks for one.
+ // Email is what they typed, and EmailBad that it was refused.
+ AskEmail bool
+ EmailLabel, Email string
+ EmailBad bool
}
type portalItem struct {
@@ -85,7 +95,12 @@ func (f *Fake) pages(mux *http.ServeMux) {
mux.HandleFunc("GET /checkout/{id}", f.checkoutPage)
mux.HandleFunc("POST /checkout/{id}/pay", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
- back, err := f.pay(r.Context(), r.PathValue("id"), strings.TrimSpace(r.PostForm.Get("email")))
+ typed := strings.TrimSpace(r.PostForm.Get("email"))
+ back, err := f.pay(r.Context(), r.PathValue("id"), typed)
+ if errors.Is(err, errEmail) {
+ f.showCheckout(w, r.PathValue("id"), http.StatusUnprocessableEntity, typed)
+ return
+ }
f.redirect(w, r, back, err)
})
mux.HandleFunc("POST /checkout/{id}/decline", func(w http.ResponseWriter, r *http.Request) {
@@ -129,7 +144,13 @@ func (f *Fake) checkoutPage(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
- id := r.PathValue("id")
+ f.showCheckout(w, r.PathValue("id"), http.StatusOK, "")
+}
+
+// showCheckout renders session id's page with status. typed is the
+// address the tester sent that the page refused (status 422), put back in
+// the field; "" otherwise.
+func (f *Fake) showCheckout(w http.ResponseWriter, id string, status int, typed string) {
f.mu.Lock()
s := f.sessions[id]
if s == nil || s.Status != "open" {
@@ -137,6 +158,7 @@ func (f *Fake) checkoutPage(w http.ResponseWriter, r *http.Request) {
f.expired(w, s == nil)
return
}
+ ask := f.customers[s.customer] == nil && s.customerEmail == "" && s.ClientReferenceID == ""
plan, amount := "", ""
if p := f.price(s.price); p != nil {
plan, amount = p.plan, f.priceLine(p)
@@ -150,15 +172,26 @@ func (f *Fake) checkoutPage(w http.ResponseWriter, r *http.Request) {
Decline: f.t("abono.sandbox.decline"),
PayAction: f.action("checkout", id, "pay"), DeclineAction: f.action("checkout", id, "decline"),
}
+ if ask {
+ c.AskEmail, c.EmailLabel = true, f.t("abono.sandbox.email")
+ c.Email, c.EmailBad = typed, status == http.StatusUnprocessableEntity
+ }
if cancelURL != "" {
c.Leave, c.LeaveHref = f.t("abono.sandbox.leave"), safeURL(cancelURL)
}
- f.render(w, http.StatusOK, pageData{
+ f.render(w, status, pageData{
Title: f.t("abono.sandbox.checkout_title"), Banner: f.t("abono.sandbox.banner"),
Intro: f.t("abono.sandbox.checkout_intro"), Checkout: c,
})
}
+// validEmail is whether s is one plain address, "ana@example.com", not
+// "Ana <ana@example.com>" and not a list.
+func validEmail(s string) bool {
+ a, err := mail.ParseAddress(s)
+ return err == nil && a.Address == s
+}
+
// priceLine is the price as abono.price.per_<interval> says it, or the
// bare amount for a one-off. Hold f.mu.
func (f *Fake) priceLine(p *price) string {
diff --git a/locales/en.toml b/locales/en.toml
index aebaa9c..347aa46 100644
--- a/locales/en.toml
+++ b/locales/en.toml
@@ -40,12 +40,15 @@ abono.button.end_free = "End"
abono.button.end_free_confirm = "End free membership"
abono.button.keep_free = "Keep it"
abono.button.adopt = "Add to plan"
+abono.button.make_sold = "Sell this price"
+abono.button.add_price = "Add price"
# The sandbox's stand-in Stripe pages.
abono.sandbox.banner = "Sandbox. No money moves on this page, and no card is asked for. It stands in for Stripe while the site is being built."
abono.sandbox.checkout_title = "Sandbox checkout"
abono.sandbox.checkout_intro = "This is a stand-in for Stripe's payment page, for development only. Nothing is charged. Choose what should happen."
abono.sandbox.checkout_item = "{plan}: {price}"
+abono.sandbox.email = "Email address"
abono.sandbox.pay = "Pretend to pay"
abono.sandbox.decline = "Pretend the payment fails"
abono.sandbox.leave = "Go back without paying"
@@ -66,6 +69,7 @@ abono.pages.plan_back = "All plans"
abono.pages.plan_signed_out = "You'll enter your email on the next page."
abono.pages.done_title = "Thank you"
abono.pages.done_paid = "Your payment went through and you're a member. It can take a moment to show up. If it isn't there yet, refresh in a minute."
+abono.pages.done_processing = "Your payment is still going through. Your membership starts as soon as it does, so there's no need to pay again."
abono.pages.done_signin = "Sign in with {email} to read as a member."
abono.pages.done_unpaid_title = "No payment was made"
abono.pages.done_unpaid_body = "You haven't been charged. You can try again whenever you like."
@@ -96,6 +100,7 @@ abono.owner.free_note = "Note"
abono.owner.free_ends_hint = "Leave empty for no end date."
abono.owner.free_note_hint = "Only you can see this."
abono.owner.free_given = "Free membership given."
+abono.owner.free_no_member = "Nobody has an account with that email address yet."
abono.owner.end_title = "End this free membership?"
abono.owner.end_body = "{member} will lose access to {plan} straight away. You can give it to them again later."
abono.owner.end_done = "Free membership ended."
@@ -104,6 +109,9 @@ abono.owner.offers_intro = "What you sell. The prices come from your Stripe acco
abono.owner.offers_empty = "No plans yet. Add them in the site's settings, then restart."
abono.owner.price_sold = "On sale"
abono.owner.price_legacy = "Old price, still counts"
+abono.owner.price_unsupported = "Not offered to readers. Only prices that bill once a month or once a year can be sold here."
+abono.owner.add_price_title = "Add a price from Stripe"
+abono.owner.add_price_id = "Stripe price ID"
abono.owner.unrecognised_title = "Memberships we don't recognise"
abono.owner.unrecognised_body = "These are paying on a Stripe price that isn't part of any of your plans, so they get no access. Choose a plan for each one."
abono.owner.unrecognised_warn = "This adds the price to the plan, so everyone paying that price joins it."
@@ -131,3 +139,5 @@ abono.error.not_available = "Payments aren't available right now. Please try aga
abono.error.free_plan = "Choose one of your plans."
abono.error.sync_failed = "We couldn't reach Stripe, so nothing was changed. Try again in a moment."
abono.error.adopt_failed = "Stripe doesn't have that subscription. It may have been deleted."
+abono.error.add_price_failed = "That price couldn't be added. Check the ID in Stripe and try again."
+abono.error.sync_partial = "Some memberships couldn't be checked with Stripe. The rest are up to date. Try again in a moment."
diff --git a/sandbox_test.go b/sandbox_test.go
index 518b66c..c0cb263 100644
--- a/sandbox_test.go
+++ b/sandbox_test.go
@@ -123,7 +123,7 @@ func TestSandboxRunsTheSameFlowWithoutStripe(t *testing.T) {
}
// The approved English, not the keys (final review I8, ruling R40).
approved := abono.Catalog()
- for _, key := range []string{"abono.sandbox.banner", "abono.sandbox.checkout_title", "abono.sandbox.pay", "abono.sandbox.decline"} {
+ for _, key := range []string{"abono.sandbox.banner", "abono.sandbox.checkout_title", "abono.sandbox.pay", "abono.sandbox.decline", "abono.sandbox.email"} {
if text := html.EscapeString(approved[key]); approved[key] == "" || !strings.Contains(page, text) {
t.Errorf("the checkout page does not say %s (%q)", key, approved[key])
}
@@ -138,6 +138,25 @@ func TestSandboxRunsTheSameFlowWithoutStripe(t *testing.T) {
t.Errorf("a cross-site Pay: status %d, want 403", code)
}
+ // Signed out, the tester types the address Checkout lacks. Nothing, or
+ // something that is not an address, gets the page back (422) and buys
+ // nothing.
+ if !strings.Contains(page, `type="email"`) || !strings.Contains(page, `name="email"`) {
+ t.Errorf("the signed-out checkout page has no email field:\n%s", page)
+ }
+ for _, bad := range []string{"", "ana", "ana@", "Ana <ana@example.com>"} {
+ code, loc, again := b.do(http.MethodPost, u+"/pay", url.Values{"email": {bad}})
+ if code != http.StatusUnprocessableEntity || loc != "" || !strings.Contains(again, `name="email"`) {
+ t.Errorf("Pay with %q: status %d to %q, want the page again with 422", bad, code, loc)
+ }
+ }
+ if hasPlan(t, s, "ana", "annual") {
+ t.Fatal("a refused address bought a membership")
+ }
+ if asked, changes := rec.take(); len(asked) != 0 || len(changes) != 0 {
+ t.Fatalf("a refused address reached the app: Holder %v, OnChange %v", asked, changes)
+ }
+
code, done, _ := b.do(http.MethodPost, u+"/pay", url.Values{"email": {"ana@example.com"}})
if code != http.StatusSeeOther || !strings.HasPrefix(done, origin+"/done?session_id=") {
t.Fatalf("Pay: %d to %q", code, done)
@@ -196,6 +215,45 @@ func TestSandboxRunsTheSameFlowWithoutStripe(t *testing.T) {
wantStatus(t, deliverWith(s, body, sign("whsec_any", body, clk.Now())), http.StatusBadRequest)
}
+// A signed-in tester gives Checkout their address, so the page does not
+// ask for one, and what a form sends does not replace it.
+func TestSandboxDoesNotAskASignedInTesterForAnEmail(t *testing.T) {
+ ctx := context.Background()
+ d := openDB(t)
+ if _, err := migrate.Apply(ctx, d, migrate.Merge(sessions.Schema, abono.Schema)); err != nil {
+ t.Fatalf("migrate.Apply: %v", err)
+ }
+ const origin = "http://localhost:8080"
+ rec := &recorder{subject: "ana"}
+ s, err := abono.New(abono.Config{DB: d.G, Origin: origin, Sandbox: true, Holder: rec.Holder, OnChange: rec.OnChange,
+ Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil))})
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ if err := s.SeedPlans(ctx, []abono.PlanSpec{{Plan: "annual", Sold: "price_annual"}}); err != nil {
+ t.Fatalf("SeedPlans: %v", err)
+ }
+ u, err := s.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", Email: "ana@example.com", SuccessPath: "/done"})
+ if err != nil {
+ t.Fatalf("Checkout: %v", err)
+ }
+ w := httptest.NewRecorder()
+ s.SandboxHandler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, strings.TrimPrefix(u, origin), nil))
+ if w.Code != http.StatusOK || strings.Contains(w.Body.String(), `name="email"`) {
+ t.Errorf("the page for someone with an address: status %d\n%s", w.Code, w.Body)
+ }
+ r := httptest.NewRequest(http.MethodPost, strings.TrimPrefix(u, origin)+"/pay", strings.NewReader(url.Values{"email": {"other@example.com"}}.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w = httptest.NewRecorder()
+ s.SandboxHandler().ServeHTTP(w, r)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("Pay: status %d", w.Code)
+ }
+ if asked, _ := rec.take(); len(asked) != 1 || asked[0] != "ana@example.com" {
+ t.Errorf("Holder asked %v, want Checkout's own address", asked)
+ }
+}
+
func TestSandboxHandlerIsOffWithoutTheSandbox(t *testing.T) {
now := t0
s, _ := newService(t, &now, abono.Config{})
diff --git a/templates_test.go b/templates_test.go
index 6f38a89..5ba0eda 100644
--- a/templates_test.go
+++ b/templates_test.go
@@ -701,46 +701,16 @@ func TestUsedKeysSeesEveryConsumer(t *testing.T) {
}
}
-// pendingReview is the keys a consumer uses whose English is still in copy
-// review, so en.toml has no text for them yet. The gate tolerates exactly
-// these: each must be used and still absent, so the list is emptied as the
-// reviewed text lands (Task 8b's follow-up), and a key left on it after
-// that fails.
-var pendingReview = []string{
- "abono.owner.free_no_member",
- // The final-review fix wave's keys, for the third mini copy review.
- "abono.pages.done_processing",
- "abono.owner.price_unsupported",
- "abono.owner.add_price_title",
- "abono.owner.add_price_id",
- "abono.button.add_price",
- // Beyond the brief's list: the put-on-sale button and the add-price
- // form's failure needed words of their own.
- "abono.button.make_sold",
- "abono.error.add_price_failed",
- "abono.error.sync_partial",
-}
-
-// Every key a consumer uses is in en.toml (bar the pendingReview ones), and
-// every key in en.toml is used by one of them.
+// Every key a consumer uses is in en.toml, and every key in en.toml is
+// used by one of them.
func TestCatalogKeysAreUsedAndDefined(t *testing.T) {
cat := abono.Catalog()
if len(cat) == 0 {
- t.Skip("Task 8b: approved copy pending")
+ t.Fatal("locales/en.toml has no messages")
}
used := usedKeys(t)
- pending := map[string]bool{}
- for _, k := range pendingReview {
- pending[k] = true
- if !used[k] {
- t.Errorf("%s is in pendingReview but nothing uses it", k)
- }
- if _, ok := cat[k]; ok {
- t.Errorf("%s is in pendingReview but locales/en.toml now has it: remove it from the list", k)
- }
- }
for k := range used {
- if _, ok := cat[k]; !ok && !pending[k] {
+ if _, ok := cat[k]; !ok {
t.Errorf("%s is used but not in locales/en.toml", k)
}
}
@@ -752,9 +722,6 @@ func TestCatalogKeysAreUsedAndDefined(t *testing.T) {
}
func TestNoEmDashesInCopy(t *testing.T) {
- if len(abono.Catalog()) == 0 {
- t.Skip("Task 8b: approved copy pending")
- }
src, err := fs.ReadFile(osDir("locales"), "en.toml")
if err != nil {
t.Fatal(err)