Recently Written · git

sbm-sync

Sync server for sbm bookmark files: one small Go program, plain files, AGPL

git clone https://github.com/equwal/sbm-sync

Log | Files | Refs


commit 46e904b6669488ca9e309410248b07e4ae967d7c
equwal <truex@equwal.com>
2026-09-22 18:28:17 -0700

Billing API for the add-on, and a page with the terms of sale

The add-on shows the plan of the account and starts payments through
three new API calls with the token of the device:

- GET /api/account gives the state of the account, the subscriptions
  that it can start, and the payment links of the server, as JSON.
- POST /api/checkout gives the address of a Stripe Checkout page. It
  refuses a second subscription, so that no customer pays twice.
- POST /api/portal gives the address of the Stripe customer portal.

After a payment, Stripe sends the customer to the new page /done. The
website does not know the sign-in of the add-on, so the page tells the
customer to go back to the add-on.

The new page /terms gives the terms of sale: the seller, what a
customer can buy, how to cancel, and a full refund within 30 days. The
Chrome Web Store asks for them when an add-on leads to payments. All
pages link to it.

SBM_MANAGE_URL is the page where supporters manage or cancel their
subscription, for example the login link of the Stripe customer
portal. The home page, the account page and the API give it together
with the supporter link.

 README.md      |  29 +++++++++-
 main.go        | 164 +++++++++++++++++++++++++++++++++++++++++++++++++++------
 pages.html     |  36 ++++++++++++-
 server_test.go | 131 ++++++++++++++++++++++++++++++++++++++++++++-
 stripe.go      |  16 +++---
 5 files changed, 348 insertions(+), 28 deletions(-)
diff --git a/README.md b/README.md
index 64ec000..7ec036b 100644
--- a/README.md
+++ b/README.md
@@ -46,10 +46,28 @@ Plain HTTP, for any client with curl.
                            body: the bookmark file
                            200: the merged file; header Sbm-Version: <new V>
     POST /api/logout       Authorization: Bearer <token>
+    GET  /api/account      Authorization: Bearer <token>
+                           200: JSON: email; state, one sentence; plans,
+                           the subscriptions that the account can start,
+                           each with id and label; portal, true when
+                           /api/portal works; terms, the address of the
+                           terms of sale; the payment links support,
+                           manage, teams and teams_large, when the server
+                           has them
+    POST /api/checkout     Authorization: Bearer <token>; form field plan
+                           (an id from plans)
+                           200: the address of a Stripe Checkout page
+    POST /api/portal       Authorization: Bearer <token>
+                           200: the address of the Stripe customer portal
 
 Errors come as text: 401 (sign in again), 402 (the subscription or the
-trial has ended), 403 (confirm the email address first), 413 (the file is
-larger than 4 MB), 429 (too many sign-in attempts).
+trial has ended), 403 (confirm the email address first), 404 (the server
+has no billing), 409 (a subscription exists already, or no billing exists
+yet), 413 (the file is larger than 4 MB), 429 (too many sign-in attempts).
+
+The add-on opens the Stripe pages in a new tab. After them, Stripe shows
+the page `/done` of the server, which tells the user to go back to the
+add-on.
 
     curl -d email=me@example.org --data-urlencode password=... https://sbm.subread.space/api/login
     curl -H "Authorization: Bearer $token" --data-binary @bookmarks \
@@ -104,6 +122,9 @@ All settings come from the environment. With Docker, put them in `.env`.
     SBM_TEAMS_LARGE_URL  the same for teams of 11 people or more
     SBM_SUPPORT_URL page of a supporter subscription (optional): the home
                     page and the account page link to it
+    SBM_MANAGE_URL  page where supporters manage or cancel their
+                    subscription, such as the login link of the Stripe
+                    customer portal (optional, with SBM_SUPPORT_URL)
     SBM_TRIAL_DAYS  days of sync before payment, with billing on (default 30)
     SBM_BILLING_START  date when billing starts, as 2026-10-01: accounts
                     from before it get the full trial from that date
@@ -145,6 +166,10 @@ is free for all accounts. To turn it on:
 
 Keep the keys out of git: put them in the environment file of the service.
 
+The page `/terms` shows the terms of sale, for all payments of the server.
+The Chrome Web Store asks for them. To change them, edit the template
+`terms` in `pages.html`.
+
 ## Data
 
     data/accounts.json            accounts: email, bcrypt hash, token hashes,
diff --git a/main.go b/main.go
index 9f4584d..8084f8a 100644
--- a/main.go
+++ b/main.go
@@ -42,11 +42,12 @@ type server struct {
 	since   time.Time // start of billing: no trial ends before since + trial
 	contact string
 	// Payment pages, or "": the pre-order of team bookmarks for up to 10
-	// people, the same for 11 people or more, and a supporter subscription.
-	teams, teamsLarge, support string
-	prices                     map[string]string // plan ("month", "year"): Stripe price ID
-	labels                     map[string]string // plan: price as text
-	limit                      limiter
+	// people, the same for 11 people or more, a supporter subscription, and
+	// the page where supporters manage or cancel their subscription.
+	teams, teamsLarge, support, manage string
+	prices                             map[string]string // plan ("month", "year"): Stripe price ID
+	labels                             map[string]string // plan: price as text
+	limit                              limiter
 	// mail sends an email. It is nil when the server has no SMTP server:
 	// then accounts need no email check.
 	mail func(to, subject, body string) error
@@ -83,6 +84,7 @@ func main() {
 		teams:      link("SBM_TEAMS_URL"),
 		teamsLarge: link("SBM_TEAMS_LARGE_URL"),
 		support:    link("SBM_SUPPORT_URL"),
+		manage:     link("SBM_MANAGE_URL"),
 		prices:     map[string]string{"month": os.Getenv("STRIPE_PRICE_MONTH"), "year": os.Getenv("STRIPE_PRICE_YEAR")},
 		labels:     map[string]string{"month": "monthly", "year": "yearly"},
 	}
@@ -148,6 +150,7 @@ func (s *server) routes() http.Handler {
 	m := http.NewServeMux()
 	m.HandleFunc("GET /{$}", s.home)
 	m.HandleFunc("GET /privacy", s.privacy)
+	m.HandleFunc("GET /terms", s.terms)
 	m.HandleFunc("GET /signup", s.form("signup"))
 	m.HandleFunc("POST /signup", s.signup)
 	m.HandleFunc("GET /login", s.form("login"))
@@ -159,10 +162,14 @@ func (s *server) routes() http.Handler {
 	m.HandleFunc("POST /account/checkout", s.checkout)
 	m.HandleFunc("POST /account/portal", s.portal)
 	m.HandleFunc("POST /account/delete", s.remove)
+	m.HandleFunc("GET /done", s.done)
 	m.HandleFunc("POST /stripe", s.webhook)
 	m.HandleFunc("POST /api/login", s.apiLogin)
 	m.HandleFunc("POST /api/logout", s.apiLogout)
 	m.HandleFunc("POST /api/sync", s.sync)
+	m.HandleFunc("GET /api/account", s.apiAccount)
+	m.HandleFunc("POST /api/checkout", s.apiCheckout)
+	m.HandleFunc("POST /api/portal", s.apiPortal)
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		h := w.Header()
 		h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; "+
@@ -181,7 +188,7 @@ func (s *server) routes() http.Handler {
 
 type page struct {
 	Title, Error, Email, State, URL, Contact string
-	Teams, TeamsLarge, Support               string // addresses of payment pages
+	Teams, TeamsLarge, Support, Manage       string // addresses of payment pages
 	Month, Year                              string
 	TrialDays                                int
 	Billing, CanSubscribe, Unconfirmed       bool
@@ -190,7 +197,7 @@ type page struct {
 
 func (s *server) page(title string) page {
 	return page{Title: title, URL: s.site, Contact: s.contact, Billing: s.stripe != nil,
-		Teams: s.teams, TeamsLarge: s.teamsLarge, Support: s.support,
+		Teams: s.teams, TeamsLarge: s.teamsLarge, Support: s.support, Manage: s.manage,
 		Month: s.labels["month"], Year: s.labels["year"], TrialDays: int(s.trial.Hours() / 24)}
 }
 
@@ -214,6 +221,12 @@ func (s *server) privacy(w http.ResponseWriter, r *http.Request) {
 	s.render(w, http.StatusOK, "privacy", s.page("Privacy"))
 }
 
+// terms shows the terms of sale. The Chrome Web Store asks for them when an
+// add-on leads to payments.
+func (s *server) terms(w http.ResponseWriter, r *http.Request) {
+	s.render(w, http.StatusOK, "terms", s.page("Terms of sale"))
+}
+
 func (s *server) form(name string) http.HandlerFunc {
 	title := map[string]string{"signup": "Create an account", "login": "Sign in"}[name]
 	return func(w http.ResponseWriter, r *http.Request) {
@@ -420,25 +433,30 @@ func (s *server) showAccount(w http.ResponseWriter, status int, a Account, probl
 	p := s.page("Your account")
 	p.Email, p.Error, p.Customer = a.Email, problem, a.Customer
 	p.Unconfirmed = !s.verified(a)
+	p.State = s.state(a)
+	p.CanSubscribe = s.stripe != nil && !paid(a.Status)
+	s.render(w, status, "account", p)
+}
+
+// state tells in one sentence if sync works for the account, and why.
+func (s *server) state(a Account) string {
 	left := s.trialLeft(a)
 	switch {
 	case s.stripe == nil:
-		p.State = "Sync is on."
+		return "Sync is on."
 	case a.Status == "past_due":
-		p.State = "Your last payment failed. Update your card in Manage billing."
+		return "Your last payment failed. Update your card in Manage billing."
 	case paid(a.Status):
-		p.State = "Your subscription is active."
+		return "Your subscription is active."
 	case left > 0:
 		days := int(left.Hours()/24) + 1
-		p.State = "Free trial: " + strconv.Itoa(days) + " days left."
 		if days == 1 {
-			p.State = "Free trial: 1 day left."
+			return "Free trial: 1 day left."
 		}
+		return "Free trial: " + strconv.Itoa(days) + " days left."
 	default:
-		p.State = "Your free trial has ended. Sync is paused until you subscribe."
+		return "Your free trial has ended. Sync is paused until you subscribe."
 	}
-	p.CanSubscribe = s.stripe != nil && !paid(a.Status)
-	s.render(w, status, "account", p)
 }
 
 func (s *server) checkout(w http.ResponseWriter, r *http.Request) {
@@ -455,7 +473,7 @@ func (s *server) checkout(w http.ResponseWriter, r *http.Request) {
 		http.Error(w, "no such plan", http.StatusBadRequest)
 		return
 	}
-	to, err := s.stripe.checkout(s.store.view(u), price, s.site)
+	to, err := s.stripe.checkout(s.store.view(u), price, s.site+"/account")
 	if err != nil {
 		log.Printf("checkout for %s: %v", u.ID, err)
 		s.showAccount(w, http.StatusBadGateway, s.store.view(u), "Stripe did not answer. Try again later.")
@@ -478,7 +496,7 @@ func (s *server) portal(w http.ResponseWriter, r *http.Request) {
 		http.Redirect(w, r, "/account", http.StatusSeeOther)
 		return
 	}
-	to, err := s.stripe.portal(a.Customer, s.site)
+	to, err := s.stripe.portal(a.Customer, s.site+"/account")
 	if err != nil {
 		log.Printf("portal for %s: %v", u.ID, err)
 		s.showAccount(w, http.StatusBadGateway, a, "Stripe did not answer. Try again later.")
@@ -518,6 +536,13 @@ func (s *server) remove(w http.ResponseWriter, r *http.Request) {
 	http.Redirect(w, r, "/", http.StatusSeeOther)
 }
 
+// done is the page where Stripe sends a customer after a payment that the
+// add-on started. This website does not know the sign-in of the add-on, so
+// the page tells the customer to go back to the add-on.
+func (s *server) done(w http.ResponseWriter, r *http.Request) {
+	s.render(w, http.StatusOK, "done", s.page("Back to sbm"))
+}
+
 // webhook takes the events of Stripe that change a subscription.
 func (s *server) webhook(w http.ResponseWriter, r *http.Request) {
 	if s.stripe == nil {
@@ -664,6 +689,111 @@ func (s *server) sync(w http.ResponseWriter, r *http.Request) {
 	io.WriteString(w, text)
 }
 
+// accountInfo is the answer of GET /api/account: the state of the account
+// and the payments that it can start. The add-on shows it.
+type accountInfo struct {
+	Email      string     `json:"email"`
+	State      string     `json:"state"`
+	Plans      []planInfo `json:"plans"`  // subscriptions that the account can start
+	Portal     bool       `json:"portal"` // POST /api/portal works: the account is a Stripe customer
+	Terms      string     `json:"terms"`  // address of the terms of sale
+	Support    string     `json:"support,omitempty"`
+	Manage     string     `json:"manage,omitempty"`
+	Teams      string     `json:"teams,omitempty"`
+	TeamsLarge string     `json:"teams_large,omitempty"`
+}
+
+type planInfo struct {
+	ID    string `json:"id"`    // value of the form field plan of POST /api/checkout
+	Label string `json:"label"` // price as text, such as "$30 a year"
+}
+
+func (s *server) apiAccount(w http.ResponseWriter, r *http.Request) {
+	u := s.apiUser(r)
+	if u == nil {
+		http.Error(w, "not signed in: sign in again", http.StatusUnauthorized)
+		return
+	}
+	a := s.store.view(u)
+	info := accountInfo{Email: a.Email, State: s.state(a), Plans: []planInfo{},
+		Portal: s.stripe != nil && a.Customer != "", Terms: s.site + "/terms", Support: s.support, Teams: s.teams}
+	if s.stripe != nil && !paid(a.Status) {
+		for _, id := range []string{"year", "month"} {
+			info.Plans = append(info.Plans, planInfo{id, s.labels[id]})
+		}
+	}
+	// As on the web pages: a link that needs another link goes without it.
+	if s.support != "" {
+		info.Manage = s.manage
+	}
+	if s.teams != "" {
+		info.TeamsLarge = s.teamsLarge
+	}
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(info)
+}
+
+// apiCheckout starts a subscription to the plan in the form field plan. It
+// gives the address of the Stripe Checkout page as text.
+func (s *server) apiCheckout(w http.ResponseWriter, r *http.Request) {
+	a, ok := s.apiBilling(w, r)
+	if !ok {
+		return
+	}
+	price := s.prices[r.PostFormValue("plan")]
+	if price == "" {
+		http.Error(w, "no such plan", http.StatusBadRequest)
+		return
+	}
+	if paid(a.Status) {
+		http.Error(w, "you have a subscription already: see Manage billing", http.StatusConflict)
+		return
+	}
+	to, err := s.stripe.checkout(a, price, s.site+"/done")
+	s.sendAddress(w, a, to, err)
+}
+
+// apiPortal gives the address of the Stripe customer portal as text.
+func (s *server) apiPortal(w http.ResponseWriter, r *http.Request) {
+	a, ok := s.apiBilling(w, r)
+	if !ok {
+		return
+	}
+	if a.Customer == "" {
+		http.Error(w, "you have no billing yet: subscribe first", http.StatusConflict)
+		return
+	}
+	to, err := s.stripe.portal(a.Customer, s.site+"/done")
+	s.sendAddress(w, a, to, err)
+}
+
+// apiBilling gives the account of a billing request. It answers the request
+// itself, and gives false, when it is not signed in or the server has no
+// billing.
+func (s *server) apiBilling(w http.ResponseWriter, r *http.Request) (Account, bool) {
+	u := s.apiUser(r)
+	if u == nil {
+		http.Error(w, "not signed in: sign in again", http.StatusUnauthorized)
+		return Account{}, false
+	}
+	if s.stripe == nil {
+		http.Error(w, "this server has no billing", http.StatusNotFound)
+		return Account{}, false
+	}
+	return s.store.view(u), true
+}
+
+// sendAddress answers with the address of a Stripe page, as text.
+func (s *server) sendAddress(w http.ResponseWriter, a Account, to string, err error) {
+	if err != nil {
+		log.Printf("billing for %s: %v", a.ID, err)
+		http.Error(w, "Stripe did not answer: try again later", http.StatusBadGateway)
+		return
+	}
+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+	io.WriteString(w, to+"\n")
+}
+
 func (s *server) fail(w http.ResponseWriter, err error) {
 	log.Print(err)
 	http.Error(w, "server error: try again later", http.StatusInternalServerError)
diff --git a/pages.html b/pages.html
index 4f83bd2..c00082f 100644
--- a/pages.html
+++ b/pages.html
@@ -33,6 +33,7 @@ button { font: inherit; padding: .4em 1em; margin: .2em .5em .2em 0; }
 <footer>
 <a href="https://github.com/equwal/sbm-sync">Source code</a> ·
 <a href="/privacy">Privacy</a> ·
+<a href="/terms">Terms of sale</a> ·
 <a href="https://ko-fi.com/truex">Support sbm on Ko-fi</a>
 </footer>
 </body>
@@ -40,7 +41,8 @@ button { font: inherit; padding: .4em 1em; margin: .2em .5em .2em 0; }
 {{end}}
 
 {{define "support"}}{{with .Support}}<p>sbm Sync is free. To pay for the server and the work on sbm,
-<a href="{{.}}">become a supporter</a>.</p>{{end}}{{end}}
+<a href="{{.}}">become a supporter</a>.{{with $.Manage}} Supporters can
+<a href="{{.}}">manage or cancel</a> their subscription.{{end}}</p>{{end}}{{end}}
 
 {{define "home"}}{{template "head" .}}
 <h1>Your bookmarks, the same everywhere</h1>
@@ -122,6 +124,38 @@ their bookmark files.</p>
 <p><a href="/account">Your account</a></p>
 {{template "foot" .}}{{end}}
 
+{{define "done"}}{{template "head" .}}
+<h1>Back to sbm</h1>
+<p>You can close this tab. The options page of the sbm add-on shows the new
+state of your account.</p>
+<p class="note"><a href="/account">Your account</a> on this website</p>
+{{template "foot" .}}{{end}}
+
+{{define "terms"}}{{template "head" .}}
+<h1>Terms of sale</h1>
+<p>The operator of this sbm Sync server sells the paid plans on it,
+not Google, Mozilla or Stripe.{{with .Contact}} Contact: <a href="mailto:{{.}}">{{.}}</a>.{{end}}
+Stripe takes the payments. This server never sees your card.</p>
+<h2>What you can buy</h2>
+<ul>
+{{if .Billing}}<li>Sync on this server: {{.Year}} or {{.Month}}, after a free trial of {{.TrialDays}} days.</li>{{end}}
+{{with .Support}}<li>A supporter subscription. It pays for the server and the work on sbm. It adds no features.</li>{{end}}
+{{with .Teams}}<li>A pre-order of team bookmarks. You pay now for a plan that comes later.</li>{{end}}
+{{if not (or .Billing .Support .Teams)}}<li>Nothing: sync on this server is free.</li>{{end}}
+</ul>
+<h2>Cancel</h2>
+<p>You can cancel a subscription at any time.{{if .Billing}} For sync, use
+Manage billing on your account page or in the add-on.{{end}}{{with .Manage}}
+For a supporter subscription, use <a href="{{.}}">this page</a>.{{end}}{{with .Contact}}
+You can also write to <a href="mailto:{{.}}">{{.}}</a>.{{end}} A subscription
+that you cancel stays active to the end of the period that you paid for.
+Then the charges stop.</p>
+<h2>Refunds</h2>
+<p>For a full refund, write {{with .Contact}}to <a href="mailto:{{.}}">{{.}}</a>
+{{end}}within 30 days of a charge.{{if .Teams}} A pre-order gets a full
+refund at any time before the team plan starts.{{end}}</p>
+{{template "foot" .}}{{end}}
+
 {{define "privacy"}}{{template "head" .}}
 <h1>Privacy</h1>
 <p>This policy is for this sbm Sync server, the sbm app for Android and the
diff --git a/server_test.go b/server_test.go
index 50cda29..82a5836 100644
--- a/server_test.go
+++ b/server_test.go
@@ -332,7 +332,8 @@ func TestTrialAndSubscription(t *testing.T) {
 		t.Fatalf("checkout: %s to %q", resp.Status, resp.Header.Get("Location"))
 	}
 	call := e.stripe.called("POST /v1/checkout/sessions")
-	for _, want := range []string{"client_reference_id=" + a.ID, "price%5D=price_y", "customer_email=me%40example.org", "mode=subscription"} {
+	for _, want := range []string{"client_reference_id=" + a.ID, "price%5D=price_y", "customer_email=me%40example.org", "mode=subscription",
+		"success_url=" + url.QueryEscape(e.web.URL+"/account")} {
 		if !strings.Contains(call, want) {
 			t.Errorf("checkout call %q lacks %q", call, want)
 		}
@@ -523,6 +524,134 @@ func TestPaymentLinks(t *testing.T) {
 	if !strings.Contains(e.body(c, "/account"), `<a href="https://buy.stripe.com/support">become a supporter</a>`) {
 		t.Error("the account page has no supporter link")
 	}
+	e.s.manage = "https://billing.stripe.com/p/login/x"
+	if !strings.Contains(e.body(c, "/account"), `<a href="https://billing.stripe.com/p/login/x">manage or cancel</a>`) {
+		t.Error("the account page does not tell supporters where to cancel")
+	}
+}
+
+func TestTerms(t *testing.T) {
+	e := newEnv(t, true)
+	e.s.contact, e.s.support, e.s.manage = "me@example.org", "https://buy.stripe.com/support", "https://billing.stripe.com/p/login/x"
+	page := e.body(http.DefaultClient, "/terms")
+	for _, want := range []string{"Terms of sale", "not Google", "$30 a year", "supporter subscription",
+		`<a href="https://billing.stripe.com/p/login/x">`, "within 30 days", `<a href="mailto:me@example.org">`} {
+		if !strings.Contains(page, want) {
+			t.Errorf("the terms lack %q", want)
+		}
+	}
+	if !strings.Contains(e.body(http.DefaultClient, "/"), `<a href="/terms">Terms of sale</a>`) {
+		t.Error("the home page does not link to the terms")
+	}
+}
+
+// api sends a request with the token of a device, as the add-on does, and
+// gives the status and the body of the answer.
+func (e *harness) api(method, token, path string, form url.Values) (int, string) {
+	req, _ := http.NewRequest(method, e.web.URL+path, strings.NewReader(form.Encode()))
+	req.Header.Set("Authorization", "Bearer "+token)
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		e.t.Fatal(err)
+	}
+	defer resp.Body.Close()
+	b, _ := io.ReadAll(resp.Body)
+	return resp.StatusCode, string(b)
+}
+
+func TestAccountAPIWithoutBilling(t *testing.T) {
+	e := newEnv(t, false)
+	e.signup("me@example.org")
+	tok := e.token("me@example.org")
+	if code, _ := e.api("GET", "wrong", "/api/account", nil); code != http.StatusUnauthorized {
+		t.Errorf("bad token: %d", code)
+	}
+	start := `{"email":"me@example.org","state":"Sync is on.","plans":[],"portal":false,"terms":"` + e.web.URL + `/terms"`
+	if code, body := e.api("GET", tok, "/api/account", nil); code != http.StatusOK || body != start+"}\n" {
+		t.Errorf("account: %d %s", code, body)
+	}
+	// The link for large teams needs the link for small teams, and the page
+	// that manages a supporter subscription needs the supporter link, as on
+	// the web pages.
+	e.s.manage, e.s.teamsLarge = "https://billing.stripe.com/p/login/x", "https://buy.stripe.com/large"
+	if _, body := e.api("GET", tok, "/api/account", nil); body != start+"}\n" {
+		t.Errorf("account with links that need other links: %s", body)
+	}
+	e.s.support = "https://buy.stripe.com/support"
+	want := start + `,"support":"https://buy.stripe.com/support","manage":"https://billing.stripe.com/p/login/x"}` + "\n"
+	if _, body := e.api("GET", tok, "/api/account", nil); body != want {
+		t.Errorf("account with a supporter link: %s", body)
+	}
+	e.s.teams = "https://buy.stripe.com/small"
+	_, body := e.api("GET", tok, "/api/account", nil)
+	if !strings.HasSuffix(body, `"teams":"https://buy.stripe.com/small","teams_large":"https://buy.stripe.com/large"}`+"\n") {
+		t.Errorf("account with team links: %s", body)
+	}
+	for _, path := range []string{"/api/checkout", "/api/portal"} {
+		if code, _ := e.api("POST", tok, path, url.Values{"plan": {"year"}}); code != http.StatusNotFound {
+			t.Errorf("%s without billing: %d", path, code)
+		}
+	}
+}
+
+func TestBillingAPI(t *testing.T) {
+	e := newEnv(t, true)
+	e.signup("me@example.org")
+	tok := e.token("me@example.org")
+	a := e.s.store.byTok(tok)
+	terms := `"terms":"` + e.web.URL + `/terms"`
+	want := `{"email":"me@example.org","state":"Free trial: 30 days left.",` +
+		`"plans":[{"id":"year","label":"$30 a year"},{"id":"month","label":"$3 a month"}],"portal":false,` + terms + "}\n"
+	if _, body := e.api("GET", tok, "/api/account", nil); body != want {
+		t.Errorf("account in the trial: %s", body)
+	}
+	if code, _ := e.api("POST", "wrong", "/api/checkout", url.Values{"plan": {"year"}}); code != http.StatusUnauthorized {
+		t.Errorf("checkout with a bad token: %d", code)
+	}
+	if code, _ := e.api("POST", tok, "/api/checkout", url.Values{"plan": {"week"}}); code != http.StatusBadRequest {
+		t.Errorf("checkout for no such plan: %d", code)
+	}
+	if code, _ := e.api("POST", tok, "/api/portal", nil); code != http.StatusConflict {
+		t.Errorf("portal before a subscription: %d", code)
+	}
+
+	code, body := e.api("POST", tok, "/api/checkout", url.Values{"plan": {"month"}})
+	if code != http.StatusOK || body != "https://checkout.stripe.test/s1\n" {
+		t.Fatalf("checkout: %d %q", code, body)
+	}
+	// Stripe sends the customer to a page that says to go back to the add-on:
+	// the website does not know the add-on's sign-in.
+	call := e.stripe.called("POST /v1/checkout/sessions")
+	for _, want := range []string{"client_reference_id=" + a.ID, "price%5D=price_m",
+		"success_url=" + url.QueryEscape(e.web.URL+"/done"), "cancel_url=" + url.QueryEscape(e.web.URL+"/done")} {
+		if !strings.Contains(call, want) {
+			t.Errorf("checkout call %q lacks %q", call, want)
+		}
+	}
+	if !strings.Contains(e.body(http.DefaultClient, "/done"), "You can close this tab.") {
+		t.Error("the page after Stripe does not say to close it")
+	}
+
+	now := time.Now().Unix()
+	e.event(now, "checkout.session.completed", fmt.Sprintf(`{"customer":"cus_9","client_reference_id":%q}`, a.ID))
+	e.event(now+1, "customer.subscription.created", `{"customer":"cus_9","status":"active"}`)
+	want = `{"email":"me@example.org","state":"Your subscription is active.","plans":[],"portal":true,` + terms + "}\n"
+	if _, body := e.api("GET", tok, "/api/account", nil); body != want {
+		t.Errorf("account with a subscription: %s", body)
+	}
+	// A second subscription would charge the customer twice.
+	if code, _ := e.api("POST", tok, "/api/checkout", url.Values{"plan": {"year"}}); code != http.StatusConflict {
+		t.Errorf("second checkout: %d", code)
+	}
+	code, body = e.api("POST", tok, "/api/portal", nil)
+	if code != http.StatusOK || body != "https://billing.stripe.test/p1\n" {
+		t.Fatalf("portal: %d %q", code, body)
+	}
+	call = e.stripe.called("POST /v1/billing_portal/sessions")
+	if !strings.Contains(call, "customer=cus_9") || !strings.Contains(call, "return_url="+url.QueryEscape(e.web.URL+"/done")) {
+		t.Errorf("portal call: %q", call)
+	}
 }
 
 func TestEmailCheck(t *testing.T) {
diff --git a/stripe.go b/stripe.go
index 7e73a4b..02ea652 100644
--- a/stripe.go
+++ b/stripe.go
@@ -50,16 +50,17 @@ func (s *Stripe) call(method, path string, form url.Values) (map[string]any, err
 }
 
 // checkout starts a Stripe Checkout session for a subscription to price and
-// gives the address of its page.
-func (s *Stripe) checkout(a Account, price, site string) (string, error) {
+// gives the address of its page. After the payment, or without it, Stripe
+// sends the customer to back.
+func (s *Stripe) checkout(a Account, price, back string) (string, error) {
 	form := url.Values{
 		"mode":                                 {"subscription"},
 		"line_items[0][price]":                 {price},
 		"line_items[0][quantity]":              {"1"},
 		"client_reference_id":                  {a.ID},
 		"subscription_data[metadata][account]": {a.ID},
-		"success_url":                          {site + "/account"},
-		"cancel_url":                           {site + "/account"},
+		"success_url":                          {back},
+		"cancel_url":                           {back},
 	}
 	if a.Customer != "" {
 		form.Set("customer", a.Customer)
@@ -78,10 +79,11 @@ func (s *Stripe) checkout(a Account, price, site string) (string, error) {
 }
 
 // portal gives the address of a Stripe customer portal session, where the
-// customer changes the card, sees invoices or cancels.
-func (s *Stripe) portal(customer, site string) (string, error) {
+// customer changes the card, sees invoices or cancels. The portal links
+// back to back.
+func (s *Stripe) portal(customer, back string) (string, error) {
 	out, err := s.call("POST", "/v1/billing_portal/sessions",
-		url.Values{"customer": {customer}, "return_url": {site + "/account"}})
+		url.Values{"customer": {customer}, "return_url": {back}})
 	if err != nil {
 		return "", err
 	}