rastrillo / abono Public

abono: generated starter pages and the example app

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Summary

abono pages -pkg <name> -dir <path> writes abono_pages.go (AbonoPages with Service, Render, Subject, IsOwner, SubjectForEmail, and Mount) and templates/abono/{pricing,plan,done,account,owner-members,owner-end,owner-offers,owner-stripe}.html, refusing and listing any target that exists. The pages render message keys only. The Service gains Members, TestMode, Sandbox, Origin and Now for the owner pages. example/ is its own module: password sign-in, a Holder that makes a user row, the sandbox, the generated pages committed byte for byte, HTTP tests and a Chromium drive from the plans to cancelling. The Makefile runs the example's browser drive and race tests.
Agent notes
Deviations: an eighth page, owner-end.html, is the comp's confirm page the routes call for; account.html is a section (abono-account) for the app's own /account, with AbonoPages.Account(r) as its data, since the routes have no GET /account; the browser drive lives in example/ (it imports the example) and make browser now visits example/. An unknown address in the comp form shows abono.error.signin_needed (no draft key fits; a dedicated key is wanted in the copy review); an unknown plan in the comp or adopt form shows abono.error.free_plan. Owner rows show the subject, not an address. Report: .superpowers/sdd/2026-10-22-abono-v0.1/task-9-report.md in the design worktree.
claude agent, operated by paul@keymail.dev 047217ee0ee84e3bb73dd272413c290c6a19ffb5 parent 92c9abe
38 files changed, +3797 −14
  • .gitignore +2 −0
  • Makefile +15 −11
  • cmd/abono/main.go +164 −0
  • cmd/abono/main_test.go +312 −0
  • cmd/abono/pages/abono_pages.go.tmpl +727 −0
  • cmd/abono/pages/account.html.tmpl +32 −0
  • cmd/abono/pages/done.html.tmpl +29 −0
  • cmd/abono/pages/owner-end.html.tmpl +17 −0
  • cmd/abono/pages/owner-members.html.tmpl +57 −0
  • cmd/abono/pages/owner-offers.html.tmpl +46 −0
  • cmd/abono/pages/owner-stripe.html.tmpl +23 −0
  • cmd/abono/pages/plan.html.tmpl +30 −0
  • cmd/abono/pages/pricing.html.tmpl +20 −0
  • example/README.md +50 −0
  • example/abono_pages.go +727 −0
  • example/app.go +299 −0
  • example/app_test.go +335 −0
  • example/browser_test.go +90 −0
  • example/go.mod +38 −0
  • example/go.sum +99 −0
  • example/main.go +63 −0
  • example/migrations/0001_init.sql +3 −0
  • example/models.go +127 −0
  • example/templates/abono/account.html +32 −0
  • example/templates/abono/done.html +29 −0
  • example/templates/abono/owner-end.html +17 −0
  • example/templates/abono/owner-members.html +57 −0
  • example/templates/abono/owner-offers.html +46 −0
  • example/templates/abono/owner-stripe.html +23 −0
  • example/templates/abono/plan.html +30 −0
  • example/templates/abono/pricing.html +20 −0
  • example/templates/account.html +4 −0
  • example/templates/layout.html +28 −0
  • example/templates/signin.html +13 −0
  • example/templates/signup.html +13 −0
  • owner.go +61 −0
  • owner_test.go +114 −0
  • templates_test.go +5 −3

CI passed — run details

CI log
=== vet ===
go vet ./...
go: downloading github.com/go-chi/chi/v5 v5.3.2
go: downloading golang.org/x/crypto v0.53.0
go: downloading github.com/chromedp/chromedp v0.14.2
go: downloading github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
go: downloading github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2
go: downloading github.com/chromedp/sysutil v1.1.0
go: downloading github.com/gobwas/ws v1.4.0
go: downloading github.com/gobwas/pool v0.2.1
go: downloading github.com/gobwas/httphead v0.1.0
=== fmt ===
=== test ===
go test ./... -count=1
ok  	amadan.net/rastrillo/abono	0.923s
ok  	amadan.net/rastrillo/abono/abonotest	0.070s
ok  	amadan.net/rastrillo/abono/cmd/abono	1.117s
ok  	amadan.net/rastrillo/abono/internal/fakestripe	0.022s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	0.008s
ok  	example	0.334s
=== race ===
CGO_ENABLED=1 go test ./... -race -count=1
ok  	amadan.net/rastrillo/abono	6.875s
ok  	amadan.net/rastrillo/abono/abonotest	1.581s
ok  	amadan.net/rastrillo/abono/cmd/abono	2.284s
ok  	amadan.net/rastrillo/abono/internal/fakestripe	1.117s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	1.037s
ok  	example	2.267s
=== browser ===
ok  	example	1.297s
diff --git a/.gitignore b/.gitignore
index 47a58c5..a969266 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,5 @@
/releases/
# Node's install tree, if anyone runs the js tests with a local node_modules.
/node_modules/
+# The example's database, when it is run from its directory.
+/example/example.db*
diff --git a/Makefile b/Makefile
index 8187518..6207a45 100644
--- a/Makefile
+++ b/Makefile
@@ -10,7 +10,7 @@ ci: vet fmt-check test race browser
# explicitly.
vet:
go vet ./...
- @if [ -d example ]; then cd example && go vet ./...; fi
+ @if [ -d example ]; then cd example && go vet ./... && go vet -tags browser ./...; fi
fmt-check:
@out=$$(gofmt -l .); if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi
@@ -25,17 +25,21 @@ test:
# this target sets CGO_ENABLED for its own command only.
race:
CGO_ENABLED=1 go test ./... -race -count=1
+ @if [ -d example ]; then cd example && CGO_ENABLED=1 go test ./... -race -count=1; fi
# The browser drive, via rastrillo's harness. It fails, not skips,
-# without a browser (rastrillo's rule: a skip is not a pass). It runs
-# only once a browser test file exists, so an empty package does not
-# fail the gate.
+# without a browser (rastrillo's rule: a skip is not a pass). It runs in
+# each of the module and the example that has a browser test file, so
+# an empty package does not fail the gate; the example's drives the
+# starter pages.
browser:
- @found=; for f in *_browser_test.go browser_test.go; do \
- if [ -f "$$f" ]; then found=1; fi; \
+ @ran=; for dir in . example; do \
+ found=; for f in $$dir/*_browser_test.go $$dir/browser_test.go; do \
+ if [ -f "$$f" ]; then found=1; fi; \
+ done; \
+ if [ -n "$$found" ]; then \
+ (cd $$dir && go test -tags browser -run TestBrowser -count=1 .) || exit 1; \
+ ran=1; \
+ fi; \
done; \
- if [ -n "$$found" ]; then \
- go test -tags browser -run TestBrowser -count=1 .; \
- else \
- echo "browser: no browser test file yet, nothing to run"; \
- fi
+ if [ -z "$$ran" ]; then echo "browser: no browser test file yet, nothing to run"; fi
diff --git a/cmd/abono/main.go b/cmd/abono/main.go
new file mode 100644
index 0000000..18036a2
--- /dev/null
+++ b/cmd/abono/main.go
@@ -0,0 +1,164 @@
+// Command abono writes abono's starter pages into an app.
+//
+// abono pages -pkg <name> -dir <path>
+//
+// writes <path>/abono_pages.go, in package <name>, and the pages it
+// renders, <path>/templates/abono/*.html. From then on they are the app's:
+// committed, edited, translated. The command never overwrites a file. If
+// any of them exists it writes nothing and lists the ones in the way;
+// move them aside to start again.
+package main
+
+import (
+ "bytes"
+ "embed"
+ "errors"
+ "flag"
+ "fmt"
+ "go/format"
+ "go/token"
+ "io"
+ "io/fs"
+ "os"
+ "path"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+//go:embed pages/*.tmpl
+var sources embed.FS
+
+// packageLine is the line of abono_pages.go.tmpl that -pkg replaces.
+const packageLine = "\npackage PKG\n"
+
+func main() {
+ os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
+}
+
+const usage = `usage: abono pages -pkg <name> -dir <path>
+
+Writes abono's starter pages into an app: <path>/abono_pages.go, in Go
+package <name>, and <path>/templates/abono/*.html. Nothing is ever
+overwritten: if any of those files exists, nothing is written.
+`
+
+// run is the command, for main and the tests: its exit code, 0 on
+// success, 1 when it refused or failed, 2 for a usage mistake.
+func run(args []string, stdout, stderr io.Writer) int {
+ if len(args) == 0 || args[0] != "pages" {
+ fmt.Fprint(stderr, usage)
+ return 2
+ }
+ fset := flag.NewFlagSet("abono pages", flag.ContinueOnError)
+ fset.SetOutput(stderr)
+ fset.Usage = func() { fmt.Fprint(stderr, usage) }
+ pkg := fset.String("pkg", "", "the Go package abono_pages.go is in")
+ dir := fset.String("dir", "", "the directory to write into")
+ if err := fset.Parse(args[1:]); err != nil {
+ return 2
+ }
+ switch {
+ case fset.NArg() > 0:
+ fmt.Fprintf(stderr, "abono pages: unexpected %q\n%s", fset.Arg(0), usage)
+ return 2
+ case *pkg == "" || *dir == "":
+ fmt.Fprintf(stderr, "abono pages: -pkg and -dir are both required\n%s", usage)
+ return 2
+ case !token.IsIdentifier(*pkg) || token.IsKeyword(*pkg):
+ fmt.Fprintf(stderr, "abono pages: -pkg %q is not a Go package name\n", *pkg)
+ return 2
+ }
+
+ files, err := pages(*pkg)
+ if err != nil {
+ fmt.Fprintf(stderr, "abono pages: %v\n", err)
+ return 1
+ }
+ if err := write(*dir, files); err != nil {
+ fmt.Fprintf(stderr, "abono pages: %v\n", err)
+ return 1
+ }
+ names := make([]string, 0, len(files))
+ for name := range files {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ fmt.Fprintf(stdout, "wrote %s\n", filepath.Join(*dir, filepath.FromSlash(name)))
+ }
+ return 0
+}
+
+// pages is every file to write, by its path under -dir: abono_pages.go
+// in package pkg, and each page as it is embedded.
+func pages(pkg string) (map[string][]byte, error) {
+ entries, err := fs.ReadDir(sources, "pages")
+ if err != nil {
+ return nil, err
+ }
+ files := map[string][]byte{}
+ for _, e := range entries {
+ src, err := fs.ReadFile(sources, "pages/"+e.Name())
+ if err != nil {
+ return nil, err
+ }
+ name := strings.TrimSuffix(e.Name(), ".tmpl")
+ switch path.Ext(name) {
+ case ".go":
+ if bytes.Count(src, []byte(packageLine)) != 1 {
+ return nil, fmt.Errorf("%s: no %q line to set the package on", e.Name(), strings.TrimSpace(packageLine))
+ }
+ src = bytes.Replace(src, []byte(packageLine), []byte("\npackage "+pkg+"\n"), 1)
+ if src, err = format.Source(src); err != nil {
+ return nil, fmt.Errorf("%s: %w", e.Name(), err)
+ }
+ case ".html":
+ name = "templates/abono/" + name
+ default:
+ return nil, fmt.Errorf("%s: not a page or Go source", e.Name())
+ }
+ files[name] = src
+ }
+ return files, nil
+}
+
+// write writes files under dir, or nothing at all if any is there
+// already: the error names every one in the way.
+func write(dir string, files map[string][]byte) error {
+ var there []string
+ for name := range files {
+ target := filepath.Join(dir, filepath.FromSlash(name))
+ switch _, err := os.Lstat(target); {
+ case err == nil:
+ there = append(there, target)
+ case !errors.Is(err, fs.ErrNotExist):
+ return err
+ }
+ }
+ if len(there) > 0 {
+ sort.Strings(there)
+ return fmt.Errorf("these files exist, so nothing was written (the pages are yours once written; move these aside to write them again):\n %s",
+ strings.Join(there, "\n "))
+ }
+ for name, body := range files {
+ target := filepath.Join(dir, filepath.FromSlash(name))
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return err
+ }
+ // O_EXCL: a file that appeared since the check is still not
+ // overwritten.
+ f, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
+ if err != nil {
+ return err
+ }
+ _, err = f.Write(body)
+ if cerr := f.Close(); err == nil {
+ err = cerr
+ }
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/cmd/abono/main_test.go b/cmd/abono/main_test.go
new file mode 100644
index 0000000..d9626c2
--- /dev/null
+++ b/cmd/abono/main_test.go
@@ -0,0 +1,312 @@
+package main
+
+import (
+ "bytes"
+ "html/template"
+ "io/fs"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "slices"
+ "strings"
+ "testing"
+
+ "amadan.net/rastrillo/rastrillo/ui"
+
+ "amadan.net/rastrillo/abono"
+)
+
+// wantFiles is every file `abono pages` writes, relative to -dir.
+var wantFiles = []string{
+ "abono_pages.go",
+ "templates/abono/account.html",
+ "templates/abono/done.html",
+ "templates/abono/owner-end.html",
+ "templates/abono/owner-members.html",
+ "templates/abono/owner-offers.html",
+ "templates/abono/owner-stripe.html",
+ "templates/abono/plan.html",
+ "templates/abono/pricing.html",
+}
+
+// cli runs the command in-process and returns its exit code and output.
+func cli(args ...string) (int, string, string) {
+ var stdout, stderr bytes.Buffer
+ code := run(args, &stdout, &stderr)
+ return code, stdout.String(), stderr.String()
+}
+
+// generate writes the pages into a fresh directory as package pkg.
+func generate(t *testing.T, pkg string) string {
+ t.Helper()
+ dir := t.TempDir()
+ if code, _, stderr := cli("pages", "-pkg", pkg, "-dir", dir); code != 0 {
+ t.Fatalf("abono pages: exit %d: %s", code, stderr)
+ }
+ return dir
+}
+
+// written lists the files under dir, slash-separated and sorted.
+func written(t *testing.T, dir string) []string {
+ t.Helper()
+ var got []string
+ err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ rel, _ := filepath.Rel(dir, p)
+ got = append(got, filepath.ToSlash(rel))
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ slices.Sort(got)
+ return got
+}
+
+func TestPagesWritesTheFilesAndRefusesToOverwrite(t *testing.T) {
+ dir := generate(t, "shop")
+ if got := written(t, dir); !slices.Equal(got, wantFiles) {
+ t.Fatalf("wrote %q, want %q", got, wantFiles)
+ }
+ src, err := os.ReadFile(filepath.Join(dir, "abono_pages.go"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Contains(src, []byte("\npackage shop\n")) {
+ t.Errorf("abono_pages.go does not say package shop:\n%s", src[:min(len(src), 400)])
+ }
+
+ // Run again over an edited page: refused, every existing file named,
+ // and nothing touched.
+ edited := filepath.Join(dir, "templates", "abono", "pricing.html")
+ if err := os.WriteFile(edited, []byte("mine"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ code, _, stderr := cli("pages", "-pkg", "shop", "-dir", dir)
+ if code == 0 {
+ t.Fatal("a second run succeeded over existing pages")
+ }
+ for _, f := range wantFiles {
+ if !strings.Contains(stderr, filepath.Join(dir, filepath.FromSlash(f))) {
+ t.Errorf("the refusal does not name %s:\n%s", f, stderr)
+ }
+ }
+ if b, _ := os.ReadFile(edited); string(b) != "mine" {
+ t.Errorf("an existing page was overwritten: %q", b)
+ }
+
+ // One target present is enough to refuse, and nothing else is written.
+ dir2 := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir2, "abono_pages.go"), []byte("package x\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ code, _, stderr = cli("pages", "-pkg", "shop", "-dir", dir2)
+ if code == 0 || !strings.Contains(stderr, filepath.Join(dir2, "abono_pages.go")) {
+ t.Errorf("exit %d over one existing file; stderr:\n%s", code, stderr)
+ }
+ if got := written(t, dir2); !slices.Equal(got, []string{"abono_pages.go"}) {
+ t.Errorf("a refused run wrote %q", got)
+ }
+
+ // There is no -force.
+ if code, _, _ := cli("pages", "-pkg", "shop", "-dir", dir, "-force"); code == 0 {
+ t.Error("-force was accepted")
+ }
+}
+
+func TestPagesRefusesBadArguments(t *testing.T) {
+ dir := t.TempDir()
+ for _, args := range [][]string{
+ {},
+ {"nonsense"},
+ {"pages"},
+ {"pages", "-dir", dir},
+ {"pages", "-pkg", "shop"},
+ {"pages", "-pkg", "9lives", "-dir", dir},
+ {"pages", "-pkg", "func", "-dir", dir},
+ {"pages", "-pkg", "shop", "-dir", dir, "extra"},
+ } {
+ if code, _, stderr := cli(args...); code == 0 || stderr == "" {
+ t.Errorf("abono %q: exit %d, stderr %q; want a refusal", args, code, stderr)
+ }
+ }
+ if got := written(t, dir); len(got) != 0 {
+ t.Errorf("refused runs wrote %q", got)
+ }
+}
+
+// TestGeneratedPagesMatchTheExample: the example's pages are this
+// generator's output, byte for byte, so the example can never drift from
+// what an app gets.
+func TestGeneratedPagesMatchTheExample(t *testing.T) {
+ dir := generate(t, "main")
+ example := filepath.Join("..", "..", "example")
+ for _, f := range wantFiles {
+ want, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(f)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := os.ReadFile(filepath.Join(example, filepath.FromSlash(f)))
+ if err != nil {
+ t.Errorf("example: %v", err)
+ continue
+ }
+ if !bytes.Equal(got, want) {
+ t.Errorf("example/%s differs from the generator's output; regenerate it", f)
+ }
+ }
+ var extra []string
+ for _, f := range written(t, filepath.Join(example, "templates", "abono")) {
+ if !slices.Contains(wantFiles, "templates/abono/"+f) {
+ extra = append(extra, f)
+ }
+ }
+ if len(extra) > 0 {
+ t.Errorf("example/templates/abono has files the generator does not write: %q", extra)
+ }
+}
+
+// TestGeneratedTemplatesParse: every page parses beside ui's partials and
+// abono's, as an app's ui.Pages parses them, and defines what its header
+// says it does.
+func TestGeneratedTemplatesParse(t *testing.T) {
+ dir := generate(t, "shop")
+ base, err := template.New("").Funcs(ui.Funcs()).ParseFS(ui.Templates(), "*.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if base, err = base.ParseFS(abono.Templates(), "*.html"); err != nil {
+ t.Fatal(err)
+ }
+ for _, f := range wantFiles {
+ if !strings.HasSuffix(f, ".html") {
+ continue
+ }
+ c := template.Must(base.Clone())
+ if _, err := c.ParseFiles(filepath.Join(dir, filepath.FromSlash(f))); err != nil {
+ t.Errorf("%s: %v", f, err)
+ continue
+ }
+ want := "content"
+ if strings.HasSuffix(f, "/account.html") {
+ want = "abono-account"
+ }
+ if c.Lookup(want) == nil {
+ t.Errorf("%s does not define %q", f, want)
+ }
+ }
+}
+
+// TestGeneratedCodeBuilds: the generated Go compiles in an app module
+// that imports chi, as rastrillo apps do, with a stub main beside it.
+// The module borrows the example's go.mod and go.sum, so it builds
+// against the versions the example pins.
+func TestGeneratedCodeBuilds(t *testing.T) {
+ if testing.Short() {
+ t.Skip("builds a module")
+ }
+ dir := generate(t, "main")
+ root, err := filepath.Abs(filepath.Join("..", ".."))
+ if err != nil {
+ t.Fatal(err)
+ }
+ mod, err := os.ReadFile(filepath.Join(root, "example", "go.mod"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ sum, err := os.ReadFile(filepath.Join(root, "example", "go.sum"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ gomod := strings.Replace(string(mod), "=> ../", "=> "+filepath.ToSlash(root)+"/", 1)
+ if gomod == string(mod) {
+ t.Fatal("example/go.mod has no replace of abono to ../")
+ }
+ stub := `package main
+
+import "github.com/go-chi/chi/v5"
+
+func main() {
+ p := &AbonoPages{}
+ p.Mount(chi.NewRouter())
+}
+`
+ for name, body := range map[string]string{"go.mod": gomod, "go.sum": string(sum), "stub.go": stub} {
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // -buildvcs=false: the temporary directory may sit inside someone
+ // else's repository. -mod=mod: the stub needs fewer modules than the
+ // example lists, which is not an error worth failing on.
+ cmd := exec.Command("go", "build", "-buildvcs=false", "-mod=mod", "-o", os.DevNull, ".")
+ cmd.Dir = dir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("go build: %v\n%s", err, out)
+ }
+ cmd = exec.Command("go", "vet", "-mod=mod", ".")
+ cmd.Dir = dir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("go vet: %v\n%s", err, out)
+ }
+}
+
+// starterKeys are the message keys the starter pages may use: the draft
+// copy's (ruling R22), every one of which they use, and two price
+// sentences the partials own and the offers page repeats.
+var starterKeys = func() []string {
+ var keys []string
+ for prefix, names := range map[string]string{
+ "abono.button.": "see_plans go_to_account try_again sync give_free end_free end_free_confirm keep_free adopt",
+ "abono.pages.": "pricing_title pricing_intro closed_title closed_body plan_intro plan_back plan_signed_out " +
+ "done_title done_paid done_signin done_unpaid_title done_unpaid_body account_title account_none account_manage_help",
+ "abono.owner.": "members_title members_intro members_empty col_member col_plan col_status col_source " +
+ "source_paid source_free backstop sync_help sync_done sync_done_one free_title free_help free_member " +
+ "free_plan free_ends free_note free_ends_hint free_note_hint free_given end_title end_body end_done " +
+ "offers_title offers_intro offers_empty price_sold price_legacy unrecognised_title unrecognised_body " +
+ "unrecognised_warn unrecognised_none adopt_plan adopted stripe_title stripe_on stripe_test stripe_off " +
+ "stripe_off_help stripe_sandbox stripe_webhook stripe_webhook_help nothing_for_sale",
+ "abono.error.": "unknown_code unknown_plan no_billing signin_needed unreachable finish_failed not_available " +
+ "free_plan sync_failed adopt_failed",
+ } {
+ for _, n := range strings.Fields(names) {
+ keys = append(keys, prefix+n)
+ }
+ }
+ return keys
+}()
+
+// partialKeys are keys the pages may name that the partials already use.
+var partialKeys = []string{"abono.price.per_month", "abono.price.per_year"}
+
+var keyLiteral = regexp.MustCompile(`"(abono\.[a-z][a-z_.]*[a-z])"`)
+
+// TestPagesUseTheDraftKeys: every word on the starter pages is a key of
+// the draft copy, and no draft key is left out. abono.button.subscribe
+// and abono.button.manage are the partials' own, reached through them.
+func TestPagesUseTheDraftKeys(t *testing.T) {
+ dir := generate(t, "shop")
+ used := map[string]bool{}
+ for _, f := range wantFiles {
+ src, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(f)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, m := range keyLiteral.FindAllStringSubmatch(string(src), -1) {
+ used[m[1]] = true
+ }
+ }
+ for k := range used {
+ if !slices.Contains(starterKeys, k) && !slices.Contains(partialKeys, k) {
+ t.Errorf("the pages use %s, which is not a draft key", k)
+ }
+ }
+ for _, k := range starterKeys {
+ if !used[k] {
+ t.Errorf("the pages never use the draft key %s", k)
+ }
+ }
+}
diff --git a/cmd/abono/pages/abono_pages.go.tmpl b/cmd/abono/pages/abono_pages.go.tmpl
new file mode 100644
index 0000000..fee7730
--- /dev/null
+++ b/cmd/abono/pages/abono_pages.go.tmpl
@@ -0,0 +1,727 @@
+// abono_pages.go is abono's starter pages, written into this app by
+// `abono pages`. They are yours now: change the routes, the pages and
+// the words as you like. Running the command again never overwrites
+// them.
+//
+// Wire them in once, inside the router that runs your session
+// middleware and csrf.Protect:
+//
+// pages := &AbonoPages{
+// Service: svc,
+// Render: render,
+// Subject: func(r *http.Request) (string, bool) { ... },
+// IsOwner: func(r *http.Request) bool { ... },
+// SubjectForEmail: func(ctx context.Context, email string) (string, bool) { ... },
+// }
+// pages.Mount(r)
+//
+// Render executes one of templates/abono/*.html by its name ("abono/pricing")
+// with data, which is always a map[string]any: add the request's
+// localizer as "L" and whatever your layout reads, as a scaffolded app's
+// render does. Each page defines "content"; its data keys are listed at
+// the top of its file, and every page's data has "Title", the message
+// key of the page's title, for your layout's <title>. A page that
+// answers with something other than 200 has had its status set already:
+// Render writes the body as usual.
+//
+// templates/abono/account.html is not a page but a section,
+// "abono-account", for your own account page; Account(r) is its data.
+// Parse it with your layout, beside abono.Templates()'s partials, which
+// every page here uses.
+//
+// The words are message keys (abono.pages.*, abono.owner.*,
+// abono.error.*, abono.button.*): merge abono.Catalog() into your base
+// catalog and override or translate any of them in your own locales.
+package PKG
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+
+ "amadan.net/rastrillo/abono"
+)
+
+// The pages' addresses. abonoAccountPath is your account page, where the
+// "abono-account" section is shown and the billing portal comes back to.
+const (
+ abonoPricingPath = "/subscribe"
+ abonoDonePath = "/subscribe/done"
+ abonoAccountPath = "/account"
+ abonoBillingPath = "/account/billing"
+ abonoMembersPath = "/admin/members"
+ abonoOffersPath = "/admin/offers"
+ abonoStripePath = "/admin/stripe"
+)
+
+// AbonoPages is the reader's pages (pricing, a plan, Checkout's return,
+// the billing button) and the owner's (members, comps, plans and prices,
+// Stripe).
+type AbonoPages struct {
+ Service *abono.Service
+ // Render executes the page called name with data, a map[string]any.
+ Render func(w http.ResponseWriter, r *http.Request, name string, data any)
+ // Subject is the signed-in reader's subject, the one abono keys
+ // holdings to; ok is false when nobody is signed in.
+ Subject func(*http.Request) (string, bool)
+ // IsOwner says whether the request may use the owner's pages. Nil
+ // lets nobody in.
+ IsOwner func(*http.Request) bool
+ // SubjectForEmail finds the subject of the person with this address,
+ // for the owner's free-membership form. Nil, or not found, refuses
+ // the form.
+ SubjectForEmail func(ctx context.Context, email string) (subject string, ok bool)
+}
+
+// Mount adds the pages to r. The reader's pages are open to anyone (the
+// billing button needs a signed-in reader); the owner's answer 403 to
+// anyone IsOwner refuses.
+//
+// A plan named "done" cannot have a page: GET /subscribe/done is
+// Checkout's return.
+func (p *AbonoPages) Mount(r chi.Router) {
+ r.Get(abonoPricingPath, p.pricing)
+ r.Get(abonoDonePath, p.done)
+ r.Get(abonoPricingPath+"/{plan}", p.plan)
+ r.Post(abonoPricingPath+"/{plan}", p.subscribe)
+ r.Post(abonoBillingPath, p.billing)
+
+ r.Group(func(r chi.Router) {
+ r.Use(p.ownerOnly)
+ r.Get(abonoMembersPath, p.members)
+ r.Post(abonoMembersPath+"/sync", p.sync)
+ r.Post(abonoMembersPath+"/comps", p.comp)
+ r.Get(abonoMembersPath+"/comps/{id}/end", p.endCompPage)
+ r.Post(abonoMembersPath+"/comps/{id}/end", p.endComp)
+ r.Get(abonoOffersPath, p.offers)
+ r.Post(abonoOffersPath+"/adopt", p.adopt)
+ r.Get(abonoStripePath, p.stripe)
+ })
+}
+
+// ---------------------------------------------------------------- reader
+
+// abonoCard is one plan card: an offer at one interval ("" for the
+// plan's only price, or when Stripe has not said its interval yet), and
+// where its form posts.
+type abonoCard struct {
+ Offer abono.Offer
+ Interval string
+ Action string
+}
+
+// abonoCards is a card per offer and sold interval, monthly before
+// yearly. An offer sold at one price, or whose prices' intervals are not
+// known yet, gets one card with no interval; an offer with nothing sold
+// gets none.
+func abonoCards(offers []abono.Offer) []abonoCard {
+ var cards []abonoCard
+ for _, o := range offers {
+ var intervals []string
+ sold, unknown := 0, false
+ for _, pr := range o.Prices {
+ if !pr.Sold {
+ continue
+ }
+ sold++
+ if pr.Interval == "" {
+ unknown = true
+ } else if !abonoContains(intervals, pr.Interval) {
+ intervals = append(intervals, pr.Interval)
+ }
+ }
+ if sold == 0 {
+ continue
+ }
+ action := abonoPricingPath + "/" + url.PathEscape(o.Plan)
+ if sold == 1 || unknown {
+ cards = append(cards, abonoCard{Offer: o, Action: action})
+ continue
+ }
+ for _, iv := range []string{"day", "week", "month", "year"} {
+ if abonoContains(intervals, iv) {
+ cards = append(cards, abonoCard{Offer: o, Interval: iv, Action: action})
+ }
+ }
+ }
+ return cards
+}
+
+func abonoContains(list []string, s string) bool {
+ for _, v := range list {
+ if v == s {
+ return true
+ }
+ }
+ return false
+}
+
+// forSale is the offers with a card: none while Stripe is off.
+func (p *AbonoPages) forSale(ctx context.Context) ([]abono.Offer, []abonoCard, error) {
+ if !p.Service.On() {
+ return nil, nil, nil
+ }
+ offers, err := p.Service.Offers(ctx)
+ if err != nil {
+ return nil, nil, err
+ }
+ return offers, abonoCards(offers), nil
+}
+
+// GET /subscribe: every plan on sale.
+func (p *AbonoPages) pricing(w http.ResponseWriter, r *http.Request) {
+ _, cards, err := p.forSale(r.Context())
+ if err != nil {
+ p.fail(w, r, "offers", err)
+ return
+ }
+ p.render(w, r, http.StatusOK, "abono/pricing", map[string]any{
+ "Title": "abono.pages.pricing_title",
+ "Open": len(cards) > 0,
+ "Cards": cards,
+ })
+}
+
+// GET /subscribe/{plan}: one plan, with a promotion code field.
+func (p *AbonoPages) plan(w http.ResponseWriter, r *http.Request) {
+ p.renderPlan(w, r, http.StatusOK, chi.URLParam(r, "plan"), "")
+}
+
+// renderPlan shows plan's page, with the message errKey above it when
+// there is one. A plan that is not on sale shows only the message, or
+// says nothing is for sale.
+func (p *AbonoPages) renderPlan(w http.ResponseWriter, r *http.Request, status int, plan, errKey string) {
+ offers, all, err := p.forSale(r.Context())
+ if err != nil {
+ p.fail(w, r, "offers", err)
+ return
+ }
+ var cards []abonoCard
+ for _, c := range all {
+ if c.Offer.Plan == plan {
+ cards = append(cards, c)
+ }
+ }
+ if len(cards) == 0 && len(offers) > 0 && errKey == "" {
+ status, errKey = http.StatusNotFound, "abono.error.unknown_plan"
+ }
+ _, signedIn := p.subject(r)
+ p.render(w, r, status, "abono/plan", map[string]any{
+ "Title": "abono.pages.pricing_title",
+ "Open": len(all) > 0,
+ "Cards": cards,
+ "SignedIn": signedIn,
+ "PlansURL": abonoPricingPath,
+ "Error": errKey,
+ })
+}
+
+// POST /subscribe/{plan}: start Checkout for the plan at the posted
+// interval, as the signed-in reader or as nobody yet.
+func (p *AbonoPages) subscribe(w http.ResponseWriter, r *http.Request) {
+ plan := chi.URLParam(r, "plan")
+ if !p.Service.On() {
+ p.renderPlan(w, r, http.StatusServiceUnavailable, plan, "abono.error.not_available")
+ return
+ }
+ subject, _ := p.subject(r)
+ u, err := p.Service.Checkout(r.Context(), abono.CheckoutRequest{
+ Plan: plan,
+ Interval: r.PostFormValue("interval"),
+ Subject: subject,
+ PromotionCode: r.PostFormValue("code"),
+ SuccessPath: abonoDonePath,
+ CancelPath: abonoPricingPath + "/" + url.PathEscape(plan),
+ })
+ switch {
+ case errors.Is(err, abono.ErrUnknownCode):
+ p.renderPlan(w, r, http.StatusUnprocessableEntity, plan, "abono.error.unknown_code")
+ case errors.Is(err, abono.ErrUnknownPlan):
+ p.renderPlan(w, r, http.StatusNotFound, plan, "abono.error.unknown_plan")
+ case err != nil:
+ slog.ErrorContext(r.Context(), "abono pages: checkout", "plan", plan, "err", err)
+ p.renderPlan(w, r, http.StatusBadGateway, plan, "abono.error.unreachable")
+ default:
+ http.Redirect(w, r, u, http.StatusSeeOther)
+ }
+}
+
+// GET /subscribe/done: Checkout's return. Finish records the payment at
+// once, so the account page already shows it.
+func (p *AbonoPages) done(w http.ResponseWriter, r *http.Request) {
+ _, signedIn := p.subject(r)
+ data := map[string]any{
+ "Title": "abono.pages.done_unpaid_title",
+ "Paid": false,
+ "Email": "",
+ "SignedIn": signedIn,
+ "AccountURL": abonoAccountPath,
+ "PlansURL": abonoPricingPath,
+ "Error": "",
+ }
+ id := r.URL.Query().Get("session_id")
+ switch {
+ case !p.Service.On():
+ data["Error"] = "abono.error.not_available"
+ p.render(w, r, http.StatusServiceUnavailable, "abono/done", data)
+ return
+ case id == "":
+ p.render(w, r, http.StatusOK, "abono/done", data)
+ return
+ }
+ f, err := p.Service.Finish(r.Context(), id)
+ if err != nil {
+ slog.ErrorContext(r.Context(), "abono pages: finish", "err", err)
+ data["Error"] = "abono.error.finish_failed"
+ p.render(w, r, http.StatusBadGateway, "abono/done", data)
+ return
+ }
+ if f.Paid {
+ data["Title"], data["Paid"], data["Email"] = "abono.pages.done_title", true, f.Email
+ }
+ p.render(w, r, http.StatusOK, "abono/done", data)
+}
+
+// POST /account/billing: the signed-in reader's billing portal, coming
+// back to the account page. A failure goes back there too, saying why.
+func (p *AbonoPages) billing(w http.ResponseWriter, r *http.Request) {
+ back := func(problem string) {
+ http.Redirect(w, r, abonoAccountPath+"?billing="+problem, http.StatusSeeOther)
+ }
+ subject, ok := p.subject(r)
+ switch {
+ case !ok:
+ back("signin")
+ return
+ case !p.Service.On():
+ back("off")
+ return
+ }
+ u, err := p.Service.Portal(r.Context(), subject, abonoAccountPath)
+ switch {
+ case errors.Is(err, abono.ErrNoCustomer):
+ back("none")
+ case err != nil:
+ slog.ErrorContext(r.Context(), "abono pages: portal", "err", err)
+ back("unreachable")
+ default:
+ http.Redirect(w, r, u, http.StatusSeeOther)
+ }
+}
+
+// abonoBillingProblems are the ?billing= values billing sends the reader
+// back with, and what each says. Anything else says nothing.
+var abonoBillingProblems = map[string]string{
+ "signin": "abono.error.signin_needed",
+ "off": "abono.error.not_available",
+ "none": "abono.error.no_billing",
+ "unreachable": "abono.error.unreachable",
+}
+
+// Account is the data for the "abono-account" section of your account
+// page: the reader's holdings, the billing button when they pay through
+// Stripe, and the problem the billing button came back with. Add the
+// request's localizer before rendering it:
+//
+// {{template "abono-account" (withL .Abono $L)}}
+func (p *AbonoPages) Account(r *http.Request) (map[string]any, error) {
+ subject, _ := p.subject(r)
+ holdings, err := p.Service.Holdings(r.Context(), subject)
+ if err != nil {
+ return nil, err
+ }
+ manage, granted := false, false
+ for _, h := range holdings {
+ manage = manage || h.Source == abono.SourceSubscription
+ granted = granted || h.Grants
+ }
+ return map[string]any{
+ "Holdings": holdings,
+ "Now": p.Service.Now(),
+ "Manage": manage && p.Service.On(),
+ "Granted": granted,
+ "BillingAction": abonoBillingPath,
+ "PlansURL": abonoPricingPath,
+ "Error": abonoBillingProblems[r.URL.Query().Get("billing")],
+ }, nil
+}
+
+// ---------------------------------------------------------------- owner
+
+func (p *AbonoPages) ownerOnly(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if p.IsOwner == nil || !p.IsOwner(r) {
+ http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// abonoMember is one row of the members table.
+type abonoMember struct {
+ abono.Member
+ // EndURL is the confirm page that ends a comp still granting; ""
+ // for anything else.
+ EndURL string
+}
+
+// abonoCompForm is the free-membership form as posted, and what was wrong
+// with it: message keys, by field.
+type abonoCompForm struct {
+ Member, Plan, Ends, Note string
+ Errors map[string]string
+}
+
+// abonoNotice is what a finished owner action says on the page it
+// returns to: Kind is sync, comp, end or adopt; N and Plan fill its
+// sentence.
+type abonoNotice struct {
+ Kind string
+ N int
+ Plan string
+}
+
+// abonoReadNotice reads the ?done= an owner action redirected with. A plan that
+// is not one of offers is dropped, so a link cannot put words on the page.
+func abonoReadNotice(r *http.Request, offers []abono.Offer) *abonoNotice {
+ q := r.URL.Query()
+ n := &abonoNotice{Kind: q.Get("done")}
+ switch n.Kind {
+ case "sync":
+ n.N, _ = strconv.Atoi(q.Get("n"))
+ case "comp", "adopt":
+ for _, o := range offers {
+ if o.Plan == q.Get("plan") {
+ n.Plan = o.Name
+ }
+ }
+ if n.Plan == "" {
+ return nil
+ }
+ case "end":
+ default:
+ return nil
+ }
+ return n
+}
+
+// GET /admin/members: every holding, sync now, and give a comp.
+func (p *AbonoPages) members(w http.ResponseWriter, r *http.Request) {
+ p.renderMembers(w, r, http.StatusOK, abonoCompForm{}, "")
+}
+
+func (p *AbonoPages) renderMembers(w http.ResponseWriter, r *http.Request, status int, form abonoCompForm, errKey string) {
+ ctx := r.Context()
+ ms, err := p.Service.Members(ctx)
+ if err != nil {
+ p.fail(w, r, "members", err)
+ return
+ }
+ offers, err := p.Service.Offers(ctx)
+ if err != nil {
+ p.fail(w, r, "offers", err)
+ return
+ }
+ rows := make([]abonoMember, len(ms))
+ for i, m := range ms {
+ rows[i] = abonoMember{Member: m}
+ if m.Source == abono.SourceComp && m.Grants {
+ rows[i].EndURL = abonoMembersPath + "/comps/" + strconv.FormatInt(m.CompID, 10) + "/end"
+ }
+ }
+ p.render(w, r, status, "abono/owner-members", map[string]any{
+ "Title": "abono.owner.members_title",
+ "On": p.Service.On(),
+ "Members": rows,
+ "Now": p.Service.Now(),
+ "Offers": offers,
+ "PlanOptions": abonoPlanOptions(offers, form.Plan),
+ "Form": form,
+ "SyncAction": abonoMembersPath + "/sync",
+ "CompAction": abonoMembersPath + "/comps",
+ "StripeURL": abonoStripePath,
+ "Done": abonoReadNotice(r, offers),
+ "Error": errKey,
+ })
+}
+
+// POST /admin/members/sync: Reconcile now.
+func (p *AbonoPages) sync(w http.ResponseWriter, r *http.Request) {
+ rep, err := p.Service.Reconcile(r.Context())
+ if err != nil {
+ slog.ErrorContext(r.Context(), "abono pages: sync", "err", err)
+ p.renderMembers(w, r, http.StatusBadGateway, abonoCompForm{}, "abono.error.sync_failed")
+ return
+ }
+ http.Redirect(w, r, abonoMembersPath+"?done=sync&n="+strconv.Itoa(rep.Subscriptions), http.StatusSeeOther)
+}
+
+// POST /admin/members/comps: give the person with the posted address
+// a plan, until the posted day or for good.
+func (p *AbonoPages) comp(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ form := abonoCompForm{
+ Member: strings.TrimSpace(r.PostFormValue("member")),
+ Plan: r.PostFormValue("plan"),
+ Ends: strings.TrimSpace(r.PostFormValue("ends")),
+ Note: strings.TrimSpace(r.PostFormValue("note")),
+ Errors: map[string]string{},
+ }
+ var ends *time.Time
+ if form.Ends != "" {
+ t, err := time.Parse("2006-01-02", form.Ends)
+ if err != nil {
+ // A date field always posts this format; anything else is not a browser.
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+ ends = &t
+ }
+ offers, err := p.Service.Offers(ctx)
+ if err != nil {
+ p.fail(w, r, "offers", err)
+ return
+ }
+ if !abonoIsPlan(offers, form.Plan) {
+ form.Errors["plan"] = "abono.error.free_plan"
+ }
+ subject, found := "", false
+ if p.SubjectForEmail != nil && form.Member != "" {
+ subject, found = p.SubjectForEmail(ctx, form.Member)
+ }
+ if !found {
+ form.Errors["member"] = "abono.error.signin_needed"
+ }
+ if len(form.Errors) > 0 {
+ p.renderMembers(w, r, http.StatusUnprocessableEntity, form, "")
+ return
+ }
+ if _, err := p.Service.Comp(ctx, subject, form.Plan, ends, form.Note); err != nil {
+ p.fail(w, r, "comp", err)
+ return
+ }
+ http.Redirect(w, r, abonoMembersPath+"?done=comp&plan="+url.QueryEscape(form.Plan), http.StatusSeeOther)
+}
+
+// abonoOption is one choice of a field-select.
+type abonoOption struct {
+ Value, Label string
+ Selected bool
+}
+
+// abonoPlanOptions is every offer as a choice, selected marked.
+func abonoPlanOptions(offers []abono.Offer, selected string) []abonoOption {
+ opts := make([]abonoOption, len(offers))
+ for i, o := range offers {
+ opts[i] = abonoOption{Value: o.Plan, Label: o.Name, Selected: o.Plan == selected}
+ }
+ return opts
+}
+
+func abonoIsPlan(offers []abono.Offer, plan string) bool {
+ for _, o := range offers {
+ if o.Plan == plan {
+ return true
+ }
+ }
+ return false
+}
+
+// compFor is the comp {id} names, while it still grants.
+func (p *AbonoPages) compFor(r *http.Request) (abono.Member, bool, error) {
+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ if err != nil {
+ return abono.Member{}, false, nil
+ }
+ ms, err := p.Service.Members(r.Context())
+ if err != nil {
+ return abono.Member{}, false, err
+ }
+ for _, m := range ms {
+ if m.Source == abono.SourceComp && m.CompID == id && m.Grants {
+ return m, true, nil
+ }
+ }
+ return abono.Member{}, false, nil
+}
+
+// GET /admin/members/comps/{id}/end: are you sure?
+func (p *AbonoPages) endCompPage(w http.ResponseWriter, r *http.Request) {
+ m, ok, err := p.compFor(r)
+ switch {
+ case err != nil:
+ p.fail(w, r, "members", err)
+ return
+ case !ok:
+ http.NotFound(w, r)
+ return
+ }
+ plan := m.Plan
+ if offers, err := p.Service.Offers(r.Context()); err == nil {
+ for _, o := range offers {
+ if o.Plan == m.Plan {
+ plan = o.Name
+ }
+ }
+ }
+ p.render(w, r, http.StatusOK, "abono/owner-end", map[string]any{
+ "Title": "abono.owner.end_title",
+ "Member": m,
+ "Plan": plan,
+ "Action": r.URL.Path,
+ "MembersURL": abonoMembersPath,
+ })
+}
+
+// POST /admin/members/comps/{id}/end: end it now.
+func (p *AbonoPages) endComp(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ switch err := p.Service.EndComp(r.Context(), id); {
+ case errors.Is(err, abono.ErrNotFound):
+ http.NotFound(w, r)
+ case err != nil:
+ p.fail(w, r, "end comp", err)
+ default:
+ http.Redirect(w, r, abonoMembersPath+"?done=end", http.StatusSeeOther)
+ }
+}
+
+// GET /admin/offers: plans and their prices, and paying subscriptions
+// no plan recognises, each with a way onto a plan.
+func (p *AbonoPages) offers(w http.ResponseWriter, r *http.Request) {
+ p.renderOffers(w, r, http.StatusOK, "")
+}
+
+func (p *AbonoPages) renderOffers(w http.ResponseWriter, r *http.Request, status int, errKey string) {
+ ctx := r.Context()
+ offers, err := p.Service.Offers(ctx)
+ if err != nil {
+ p.fail(w, r, "offers", err)
+ return
+ }
+ var unknown []abono.Unrecognised
+ if p.Service.On() {
+ if unknown, err = p.Service.Unrecognised(ctx); err != nil {
+ p.fail(w, r, "unrecognised", err)
+ return
+ }
+ }
+ p.render(w, r, status, "abono/owner-offers", map[string]any{
+ "Title": "abono.owner.offers_title",
+ "On": p.Service.On(),
+ "Offers": offers,
+ "Unrecognised": unknown,
+ "PlanOptions": abonoPlanOptions(offers, ""),
+ "AdoptAction": abonoOffersPath + "/adopt",
+ "StripeURL": abonoStripePath,
+ "Done": abonoReadNotice(r, offers),
+ "Error": errKey,
+ })
+}
+
+// POST /admin/offers/adopt: put an unrecognised subscription, and every
+// other on its price, onto the posted plan. Its holder is found as a sync
+// finds one.
+func (p *AbonoPages) adopt(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ sub, plan := r.PostFormValue("subscription"), r.PostFormValue("plan")
+ offers, err := p.Service.Offers(ctx)
+ if err != nil {
+ p.fail(w, r, "offers", err)
+ return
+ }
+ if !abonoIsPlan(offers, plan) {
+ p.renderOffers(w, r, http.StatusUnprocessableEntity, "abono.error.free_plan")
+ return
+ }
+ if err := p.Service.Adopt(ctx, sub, "", plan); err != nil {
+ slog.ErrorContext(ctx, "abono pages: adopt", "subscription", sub, "err", err)
+ p.renderOffers(w, r, http.StatusUnprocessableEntity, "abono.error.adopt_failed")
+ return
+ }
+ http.Redirect(w, r, abonoOffersPath+"?done=adopt&plan="+url.QueryEscape(plan), http.StatusSeeOther)
+}
+
+// GET /admin/stripe: whether Stripe is connected, how, and the webhook's
+// address.
+func (p *AbonoPages) stripe(w http.ResponseWriter, r *http.Request) {
+ mode := "off"
+ switch {
+ case p.Service.Sandbox():
+ mode = "sandbox"
+ case p.Service.On() && p.Service.TestMode():
+ mode = "test"
+ case p.Service.On():
+ mode = "live"
+ }
+ p.render(w, r, http.StatusOK, "abono/owner-stripe", map[string]any{
+ "Title": "abono.owner.stripe_title",
+ "Mode": mode,
+ "WebhookURL": strings.TrimRight(p.Service.Origin(), "/") + abono.WebhookPath,
+ })
+}
+
+// ---------------------------------------------------------------- plumbing
+
+func (p *AbonoPages) subject(r *http.Request) (string, bool) {
+ if p.Subject == nil {
+ return "", false
+ }
+ s, ok := p.Subject(r)
+ return s, ok && s != ""
+}
+
+// render hands the page to Render, with the response's status set when it
+// is not 200.
+func (p *AbonoPages) render(w http.ResponseWriter, r *http.Request, status int, name string, data map[string]any) {
+ if status != http.StatusOK {
+ w = &abonoStatusWriter{ResponseWriter: w, status: status}
+ }
+ p.Render(w, r, name, data)
+}
+
+// fail is the answer to a failure no page explains: logged, and a 500.
+func (p *AbonoPages) fail(w http.ResponseWriter, r *http.Request, what string, err error) {
+ slog.ErrorContext(r.Context(), "abono pages: "+what, "err", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+}
+
+// abonoStatusWriter writes status instead of the 200 a page's body would
+// imply, and lets any other status (a Render that fails with a 500) pass.
+type abonoStatusWriter struct {
+ http.ResponseWriter
+ status int
+ wrote bool
+}
+
+func (s *abonoStatusWriter) WriteHeader(code int) {
+ if s.wrote {
+ return
+ }
+ s.wrote = true
+ if code == http.StatusOK {
+ code = s.status
+ }
+ s.ResponseWriter.WriteHeader(code)
+}
+
+func (s *abonoStatusWriter) Write(b []byte) (int, error) {
+ if !s.wrote {
+ s.WriteHeader(http.StatusOK)
+ }
+ return s.ResponseWriter.Write(b)
+}
+
+func (s *abonoStatusWriter) Unwrap() http.ResponseWriter { return s.ResponseWriter }
diff --git a/cmd/abono/pages/account.html.tmpl b/cmd/abono/pages/account.html.tmpl
new file mode 100644
index 0000000..556ca9f
--- /dev/null
+++ b/cmd/abono/pages/account.html.tmpl
@@ -0,0 +1,32 @@
+{{/* abono-account: the membership section of your own account page, not
+ a page of its own. Each holding as a plan and a status line, the
+ billing button for a reader who pays through Stripe, a link to the
+ plans for one who holds nothing that grants, and the problem the
+ billing button came back with.
+
+ Call it from your account page with AbonoPages.Account(r) as data:
+ {{template "abono-account" (withL .Abono $L)}}
+
+ Data:
+ Holdings []abono.Holding, newest first
+ Now time.Time, the moment they are described at
+ Manage bool, there is billing to manage
+ Granted bool, something grants access now
+ BillingAction string, where the billing button posts
+ PlansURL string, the pricing page
+ Error string, a message key, or ""
+ L the request's localizer */}}
+{{define "abono-account"}}{{$L := localizer $}}<section rst-box aria-labelledby="abono-account-title">
+<h2 id="abono-account-title">{{T $L "abono.pages.account_title"}}</h2>
+{{with .Error}}{{template "callout" dict "L" $L "Tone" "negative" "Focus" true "Body" (T $L .)}}{{end}}
+{{if .Holdings}}<ul>
+{{range .Holdings}}<li><strong>{{.Plan}}</strong> {{template "abono-status" dict "L" $L "Holding" . "Now" $.Now}}</li>
+{{end}}</ul>
+{{else}}<p>{{T $L "abono.pages.account_none"}}</p>
+{{end}}
+{{if .Manage}}<p>{{T $L "abono.pages.account_manage_help"}}</p>
+{{template "abono-billing-button" dict "L" $L "Action" .BillingAction}}
+{{end}}
+{{if not .Granted}}<p><a rst-btn="primary" href="{{.PlansURL}}">{{T $L "abono.button.see_plans"}}</a></p>
+{{end}}
+</section>{{end}}
diff --git a/cmd/abono/pages/done.html.tmpl b/cmd/abono/pages/done.html.tmpl
new file mode 100644
index 0000000..850198d
--- /dev/null
+++ b/cmd/abono/pages/done.html.tmpl
@@ -0,0 +1,29 @@
+{{/* abono/done: GET /subscribe/done, where Checkout sends the reader
+ back. Paid, it thanks them; a reader who paid signed out is asked to
+ sign in with the address they paid with. Not paid, it says nothing
+ was charged and offers the plans again.
+
+ Data:
+ Title string, the page title's message key
+ Paid bool
+ Email string, the address paid with
+ SignedIn bool
+ AccountURL string, the account page
+ PlansURL string, the pricing page
+ Error string, a message key, or ""
+ L the request's localizer */}}
+{{define "content"}}{{$L := localizer $}}
+{{template "page-header" dict "L" $L "Title" (T $L .Title)}}
+{{if .Paid}}
+<p>{{T $L "abono.pages.done_paid"}}</p>
+{{if .SignedIn}}<p><a rst-btn="primary" href="{{.AccountURL}}">{{T $L "abono.button.go_to_account"}}</a></p>
+{{else}}<p>{{Tf $L "abono.pages.done_signin" "email" .Email}}</p>
+{{end}}
+{{else if .Error}}
+{{template "callout" dict "L" $L "Tone" "negative" "Focus" true "Body" (T $L .Error)}}
+{{if .SignedIn}}<p><a rst-btn="primary" href="{{.AccountURL}}">{{T $L "abono.button.go_to_account"}}</a></p>{{end}}
+{{else}}
+<p>{{T $L "abono.pages.done_unpaid_body"}}</p>
+<p><a rst-btn="primary" href="{{.PlansURL}}">{{T $L "abono.button.try_again"}}</a></p>
+{{end}}
+{{end}}
diff --git a/cmd/abono/pages/owner-end.html.tmpl b/cmd/abono/pages/owner-end.html.tmpl
new file mode 100644
index 0000000..9ab4722
--- /dev/null
+++ b/cmd/abono/pages/owner-end.html.tmpl
@@ -0,0 +1,17 @@
+{{/* abono/owner-end: GET /admin/members/comps/{id}/end, the confirm page
+ for ending a free membership. Its form posts to the same address;
+ the way back keeps it.
+
+ Data:
+ Title string, the page title's message key
+ Member abono.Member, the comp
+ Plan string, the plan's name
+ Action string, where the form posts
+ MembersURL string, the members page
+ L the request's localizer */}}
+{{define "content"}}{{$L := localizer $}}
+{{template "back-nav" dict "Href" .MembersURL "Label" (T $L "abono.owner.members_title")}}
+{{template "page-header" dict "L" $L "Title" (T $L .Title)}}
+<p>{{Tf $L "abono.owner.end_body" "plan" .Plan "member" .Member.Subject}}</p>
+{{template "confirm-form" dict "L" $L "Action" .Action "Label" (T $L "abono.button.end_free_confirm") "Danger" true "CancelHref" .MembersURL "CancelLabel" (T $L "abono.button.keep_free")}}
+{{end}}
diff --git a/cmd/abono/pages/owner-members.html.tmpl b/cmd/abono/pages/owner-members.html.tmpl
new file mode 100644
index 0000000..f797db7
--- /dev/null
+++ b/cmd/abono/pages/owner-members.html.tmpl
@@ -0,0 +1,57 @@
+{{/* abono/owner-members: GET /admin/members, every holding with its
+ status and where it came from, "sync with Stripe now", and the form
+ that gives a free membership (a comp). A comp still granting has a
+ link to its own confirm page, which ends it. A subscription held only
+ by abono's 30-day backstop is flagged with the day access stops.
+
+ Data:
+ Title string, the page title's message key
+ On bool, Stripe (or the sandbox) is configured
+ Members []abonoMember, {Subject, Holding, EndURL}
+ Now time.Time, the moment holdings are described at
+ Offers []abono.Offer, the plans a comp can give
+ PlanOptions []abonoOption, the same as field-select options
+ Form abonoCompForm, {Member, Plan, Ends, Note, Errors}: the
+ comp form as posted, Errors a message key by field
+ SyncAction string, where "sync now" posts
+ CompAction string, where the comp form posts
+ StripeURL string, the Stripe page
+ Done *abonoNotice, {Kind, N, Plan}: what the last action did
+ Error string, a message key, or ""
+ L the request's localizer */}}
+{{define "content"}}{{$L := localizer $}}
+{{template "page-header" dict "L" $L "Title" (T $L .Title) "Sub" (T $L "abono.owner.members_intro")}}
+{{with .Done}}{{if eq .Kind "sync"}}{{if eq .N 1}}{{template "notice" (T $L "abono.owner.sync_done_one")}}{{else}}{{template "notice" (Tf $L "abono.owner.sync_done" "n" .N)}}{{end}}{{else if eq .Kind "comp"}}{{template "notice" (Tf $L "abono.owner.free_given" "plan" .Plan)}}{{else if eq .Kind "end"}}{{template "notice" (T $L "abono.owner.end_done")}}{{end}}{{end}}
+{{with .Error}}{{template "callout" dict "L" $L "Tone" "negative" "Focus" true "Body" (T $L .)}}{{end}}
+{{if .On}}<form rst-form-actions method="post" action="{{.SyncAction}}"><button type="submit" rst-btn>{{T $L "abono.button.sync"}}</button></form>
+<p>{{T $L "abono.owner.sync_help"}}</p>
+{{else}}<p>{{T $L "abono.owner.nothing_for_sale"}} <a href="{{.StripeURL}}">{{T $L "abono.owner.stripe_title"}}</a></p>
+{{end}}
+{{if .Members}}<div rst-box>
+<table>
+<thead><tr><th scope="col">{{T $L "abono.owner.col_member"}}</th><th scope="col">{{T $L "abono.owner.col_plan"}}</th><th scope="col">{{T $L "abono.owner.col_status"}}</th><th scope="col">{{T $L "abono.owner.col_source"}}</th></tr></thead>
+<tbody>
+{{range .Members}}<tr>
+<td>{{.Subject}}</td>
+<td>{{.Plan}}</td>
+<td>{{template "abono-status" dict "L" $L "Holding" .Holding "Now" $.Now}}{{if .Holding.Backstop}}{{$s := .Holding.Describe $.Now}} <small>{{$s.Sentence (T $L "abono.owner.backstop") ""}}</small>{{end}}</td>
+<td>{{if eq .Source "comp"}}{{T $L "abono.owner.source_free"}}{{with .EndURL}} <a href="{{.}}">{{T $L "abono.button.end_free"}}</a>{{end}}{{else}}{{T $L "abono.owner.source_paid"}}{{end}}</td>
+</tr>
+{{end}}</tbody>
+</table>
+</div>
+{{else}}{{template "empty-state" dict "L" $L "Body" (T $L "abono.owner.members_empty")}}
+{{end}}
+{{if .Offers}}{{$memberErr := ""}}{{$planErr := ""}}{{with .Form.Errors}}{{with index . "member"}}{{$memberErr = T $L .}}{{end}}{{with index . "plan"}}{{$planErr = T $L .}}{{end}}{{end}}<section rst-box aria-labelledby="abono-free-title">
+<h2 id="abono-free-title">{{T $L "abono.owner.free_title"}}</h2>
+<p>{{T $L "abono.owner.free_help"}}</p>
+<form rst-form method="post" action="{{.CompAction}}">
+{{template "field-text" dict "L" $L "Name" "member" "Label" (T $L "abono.owner.free_member") "Type" "email" "Value" .Form.Member "Required" true "Autocomplete" "off" "Error" $memberErr}}
+{{template "field-select" dict "L" $L "ID" "plan" "Name" "plan" "Label" (T $L "abono.owner.free_plan") "Options" .PlanOptions "Required" true "Error" $planErr}}
+{{template "field-date" dict "L" $L "Name" "ends" "Label" (T $L "abono.owner.free_ends") "Value" .Form.Ends "Hint" (T $L "abono.owner.free_ends_hint") "Plain" true}}
+{{template "field-text" dict "L" $L "Name" "note" "Label" (T $L "abono.owner.free_note") "Value" .Form.Note "Hint" (T $L "abono.owner.free_note_hint")}}
+{{template "form-foot" dict "L" $L "Submit" (T $L "abono.button.give_free")}}
+</form>
+</section>
+{{end}}
+{{end}}
diff --git a/cmd/abono/pages/owner-offers.html.tmpl b/cmd/abono/pages/owner-offers.html.tmpl
new file mode 100644
index 0000000..457d622
--- /dev/null
+++ b/cmd/abono/pages/owner-offers.html.tmpl
@@ -0,0 +1,46 @@
+{{/* abono/owner-offers: GET /admin/offers, each plan with its prices
+ (the one on sale, and old ones whose members keep the plan), and the
+ paying subscriptions on a price no plan has, each with a form that
+ puts it, and everyone else on its price, onto a plan.
+
+ Data:
+ Title string, the page title's message key
+ On bool, Stripe (or the sandbox) is configured
+ Offers []abono.Offer
+ Unrecognised []abono.Unrecognised, empty while Stripe is off
+ PlanOptions []abonoOption, the plans, as field-select options
+ AdoptAction string, where an adopt form posts
+ StripeURL string, the Stripe page
+ Done *abonoNotice, {Kind, N, Plan}: what the last action did
+ Error string, a message key, or ""
+ L the request's localizer */}}
+{{define "content"}}{{$L := localizer $}}
+{{template "page-header" dict "L" $L "Title" (T $L .Title) "Sub" (T $L "abono.owner.offers_intro")}}
+{{with .Done}}{{if eq .Kind "adopt"}}{{template "notice" (Tf $L "abono.owner.adopted" "plan" .Plan)}}{{end}}{{end}}
+{{with .Error}}{{template "callout" dict "L" $L "Tone" "negative" "Focus" true "Body" (T $L .)}}{{end}}
+{{if not .On}}<p>{{T $L "abono.owner.nothing_for_sale"}} <a href="{{.StripeURL}}">{{T $L "abono.owner.stripe_title"}}</a></p>
+{{end}}
+{{range .Offers}}<section rst-box>
+<h2>{{.Name}}</h2>
+<ul>
+{{range .Prices}}<li><code>{{.ID}}</code>{{if .Currency}}{{$m := money $L .Amount .Currency}} {{if eq .Interval "month"}}{{Tf $L "abono.price.per_month" "amount" $m}}{{else if eq .Interval "year"}}{{Tf $L "abono.price.per_year" "amount" $m}}{{else}}{{$m}}{{end}}{{end}} {{if .Sold}}{{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_sold") "Tone" "positive"}}{{else}}{{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_legacy")}}{{end}}</li>
+{{end}}</ul>
+</section>
+{{else}}{{template "empty-state" dict "L" $L "Body" (T $L "abono.owner.offers_empty")}}
+{{end}}
+{{if .On}}<section rst-box aria-labelledby="abono-unrecognised-title">
+<h2 id="abono-unrecognised-title">{{T $L "abono.owner.unrecognised_title"}}</h2>
+{{if .Unrecognised}}<p>{{T $L "abono.owner.unrecognised_body"}}</p>
+<p>{{T $L "abono.owner.unrecognised_warn"}}</p>
+{{range .Unrecognised}}<form rst-form method="post" action="{{$.AdoptAction}}">
+<p><code>{{.SubscriptionID}}</code> <code>{{.PriceID}}</code> {{.Subject}}</p>
+<input type="hidden" name="subscription" value="{{.SubscriptionID}}">
+{{template "field-select" dict "L" $L "ID" (printf "abono-adopt-%s" .SubscriptionID) "Name" "plan" "Label" (T $L "abono.owner.adopt_plan") "Options" $.PlanOptions "Required" true}}
+<button type="submit" rst-btn>{{T $L "abono.button.adopt"}}</button>
+</form>
+{{end}}
+{{else}}<p>{{T $L "abono.owner.unrecognised_none"}}</p>
+{{end}}
+</section>
+{{end}}
+{{end}}
diff --git a/cmd/abono/pages/owner-stripe.html.tmpl b/cmd/abono/pages/owner-stripe.html.tmpl
new file mode 100644
index 0000000..826aa49
--- /dev/null
+++ b/cmd/abono/pages/owner-stripe.html.tmpl
@@ -0,0 +1,23 @@
+{{/* abono/owner-stripe: GET /admin/stripe, how payments are taken: the
+ sandbox, Stripe in test mode, Stripe live, or not at all (and what to
+ do about it), and the address Stripe's webhook must call.
+
+ Data:
+ Title string, the page title's message key
+ Mode string, sandbox, test, live or off
+ WebhookURL string, the site's origin plus abono.WebhookPath
+ L the request's localizer */}}
+{{define "content"}}{{$L := localizer $}}
+{{template "page-header" dict "L" $L "Title" (T $L .Title)}}
+{{if eq .Mode "sandbox"}}{{template "callout" dict "L" $L "Tone" "warning" "Body" (T $L "abono.owner.stripe_sandbox")}}
+{{else if eq .Mode "test"}}{{template "callout" dict "L" $L "Tone" "info" "Body" (T $L "abono.owner.stripe_test")}}
+{{else if eq .Mode "live"}}{{template "callout" dict "L" $L "Tone" "positive" "Body" (T $L "abono.owner.stripe_on")}}
+{{else}}{{template "callout" dict "L" $L "Tone" "warning" "Body" (T $L "abono.owner.stripe_off")}}
+<p>{{T $L "abono.owner.stripe_off_help"}}</p>
+{{end}}
+{{if or (eq .Mode "test") (eq .Mode "live")}}<section rst-box>
+<p>{{Tf $L "abono.owner.stripe_webhook" "url" .WebhookURL}}</p>
+<p>{{T $L "abono.owner.stripe_webhook_help"}}</p>
+</section>
+{{end}}
+{{end}}
diff --git a/cmd/abono/pages/plan.html.tmpl b/cmd/abono/pages/plan.html.tmpl
new file mode 100644
index 0000000..0fc8530
--- /dev/null
+++ b/cmd/abono/pages/plan.html.tmpl
@@ -0,0 +1,30 @@
+{{/* abono/plan: GET /subscribe/{plan}, one plan with a card per interval
+ it is sold at and a promotion code field, and the page a failed
+ POST /subscribe/{plan} comes back to with its message.
+
+ Data:
+ Title string, the page title's message key
+ Open bool, something is for sale
+ Cards []abonoCard, this plan's cards; none when the plan is
+ not on sale
+ SignedIn bool
+ PlansURL string, the pricing page
+ Error string, a message key, or ""
+ L the request's localizer */}}
+{{define "content"}}{{$L := localizer $}}
+{{template "back-nav" dict "Href" .PlansURL "Label" (T $L "abono.pages.plan_back")}}
+{{if .Cards}}{{$offer := (index .Cards 0).Offer}}
+{{if gt (len .Cards) 1}}{{template "page-header" dict "L" $L "Title" $offer.Name "Sub" (T $L "abono.pages.plan_intro")}}{{else}}{{template "page-header" dict "L" $L "Title" $offer.Name}}{{end}}
+{{with .Error}}{{template "callout" dict "L" $L "Tone" "negative" "Focus" true "Body" (T $L .)}}{{end}}
+{{range .Cards}}{{template "abono-plan-card" dict "L" $L "Offer" .Offer "Interval" .Interval "Action" .Action "Code" true}}
+{{end}}
+{{if not .SignedIn}}<p>{{T $L "abono.pages.plan_signed_out"}}</p>{{end}}
+{{else if .Open}}
+{{template "page-header" dict "L" $L "Title" (T $L "abono.pages.pricing_title")}}
+{{with .Error}}{{template "callout" dict "L" $L "Tone" "negative" "Focus" true "Body" (T $L .)}}{{end}}
+{{else}}
+{{template "page-header" dict "L" $L "Title" (T $L "abono.pages.pricing_title")}}
+{{with .Error}}{{template "callout" dict "L" $L "Tone" "negative" "Focus" true "Body" (T $L .)}}{{end}}
+{{template "empty-state" dict "L" $L "Title" (T $L "abono.pages.closed_title") "Body" (T $L "abono.pages.closed_body")}}
+{{end}}
+{{end}}
diff --git a/cmd/abono/pages/pricing.html.tmpl b/cmd/abono/pages/pricing.html.tmpl
new file mode 100644
index 0000000..c68c0df
--- /dev/null
+++ b/cmd/abono/pages/pricing.html.tmpl
@@ -0,0 +1,20 @@
+{{/* abono/pricing: GET /subscribe, every plan on sale, each a card whose
+ button starts Checkout. When nothing is for sale (Stripe is off, or
+ no plan has a price on sale) it says so instead.
+
+ Data:
+ Title string, the page title's message key
+ Open bool, something is for sale
+ Cards []abonoCard, {Offer, Interval, Action}: one per plan and
+ interval, for the abono-plan-card partial
+ L the request's localizer */}}
+{{define "content"}}{{$L := localizer $}}
+{{if .Open}}
+{{template "page-header" dict "L" $L "Title" (T $L "abono.pages.pricing_title") "Sub" (T $L "abono.pages.pricing_intro")}}
+{{range .Cards}}{{template "abono-plan-card" dict "L" $L "Offer" .Offer "Interval" .Interval "Action" .Action}}
+{{end}}
+{{else}}
+{{template "page-header" dict "L" $L "Title" (T $L "abono.pages.pricing_title")}}
+{{template "empty-state" dict "L" $L "Title" (T $L "abono.pages.closed_title") "Body" (T $L "abono.pages.closed_body")}}
+{{end}}
+{{end}}
diff --git a/example/README.md b/example/README.md
new file mode 100644
index 0000000..221f2f8
--- /dev/null
+++ b/example/README.md
@@ -0,0 +1,50 @@
+# abono example
+
+A small rastrillo app that sells memberships with abono. It has password
+sign-in, the starter pages, and the sandbox in place of Stripe, so you can
+click through the whole flow without a Stripe account.
+
+## Run it
+
+```sh
+cd example
+go run .
+```
+
+Open http://localhost:8080. It listens on 127.0.0.1 only.
+
+1. Sign up with any address and buy a plan. The checkout and billing pages
+ are the sandbox's stand-ins for Stripe's: nothing is charged.
+2. Sign up as `owner@example.com` to see the owner's pages under `/admin`:
+ members, free memberships, plans and prices, and the Stripe status.
+
+The words on the pages are message keys (`abono.pages.pricing_title`) until
+abono's English catalog lands; the layout and sign-in pages use a few
+`example.*` keys of their own.
+
+Settings are environment variables; `main.go` lists them. Set `STRIPE_KEY`
+and `STRIPE_WEBHOOK_SECRET` to use a Stripe test account instead of the
+sandbox.
+
+## Where things are
+
+- `abono_pages.go` and `templates/abono/` were written by
+ `abono pages -pkg main -dir .` and are not edited here, so they stay
+ exactly what an app gets. abono's `cmd/abono` tests fail if they drift.
+- `app.go` wires it together: sessions, password sign-in, `abono.New` with
+ the sandbox, the pages, and the webhook and daily-sync endpoints, which
+ `csrf.Exempt` lets through.
+- `models.go` has the user table, and the `Holder` that makes a user for
+ someone who pays before signing up. Signing up later with the same
+ address takes over that user and its membership.
+
+## Test it
+
+```sh
+go test ./...
+RASTRILLO_CHROME=/usr/bin/chromium go test -tags browser -run TestBrowser .
+```
+
+The second drives the pages in Chromium from the plans to cancelling. It
+fails, rather than skips, when there is no browser. `make ci` at the
+repository root runs both.
diff --git a/example/abono_pages.go b/example/abono_pages.go
new file mode 100644
index 0000000..043a253
--- /dev/null
+++ b/example/abono_pages.go
@@ -0,0 +1,727 @@
+// abono_pages.go is abono's starter pages, written into this app by
+// `abono pages`. They are yours now: change the routes, the pages and
+// the words as you like. Running the command again never overwrites
+// them.
+//
+// Wire them in once, inside the router that runs your session
+// middleware and csrf.Protect:
+//
+// pages := &AbonoPages{
+// Service: svc,
+// Render: render,
+// Subject: func(r *http.Request) (string, bool) { ... },
+// IsOwner: func(r *http.Request) bool { ... },
+// SubjectForEmail: func(ctx context.Context, email string) (string, bool) { ... },
+// }
+// pages.Mount(r)
+//
+// Render executes one of templates/abono/*.html by its name ("abono/pricing")
+// with data, which is always a map[string]any: add the request's
+// localizer as "L" and whatever your layout reads, as a scaffolded app's
+// render does. Each page defines "content"; its data keys are listed at
+// the top of its file, and every page's data has "Title", the message
+// key of the page's title, for your layout's <title>. A page that
+// answers with something other than 200 has had its status set already:
+// Render writes the body as usual.
+//
+// templates/abono/account.html is not a page but a section,
+// "abono-account", for your own account page; Account(r) is its data.
+// Parse it with your layout, beside abono.Templates()'s partials, which
+// every page here uses.
+//
+// The words are message keys (abono.pages.*, abono.owner.*,
+// abono.error.*, abono.button.*): merge abono.Catalog() into your base
+// catalog and override or translate any of them in your own locales.
+package main
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+
+ "amadan.net/rastrillo/abono"
+)
+
+// The pages' addresses. abonoAccountPath is your account page, where the
+// "abono-account" section is shown and the billing portal comes back to.
+const (
+ abonoPricingPath = "/subscribe"
+ abonoDonePath = "/subscribe/done"
+ abonoAccountPath = "/account"
+ abonoBillingPath = "/account/billing"
+ abonoMembersPath = "/admin/members"
+ abonoOffersPath = "/admin/offers"
+ abonoStripePath = "/admin/stripe"
+)
+
+// AbonoPages is the reader's pages (pricing, a plan, Checkout's return,
+// the billing button) and the owner's (members, comps, plans and prices,
+// Stripe).
+type AbonoPages struct {
+ Service *abono.Service
+ // Render executes the page called name with data, a map[string]any.
+ Render func(w http.ResponseWriter, r *http.Request, name string, data any)
+ // Subject is the signed-in reader's subject, the one abono keys
+ // holdings to; ok is false when nobody is signed in.
+ Subject func(*http.Request) (string, bool)
+ // IsOwner says whether the request may use the owner's pages. Nil
+ // lets nobody in.
+ IsOwner func(*http.Request) bool
+ // SubjectForEmail finds the subject of the person with this address,
+ // for the owner's free-membership form. Nil, or not found, refuses
+ // the form.
+ SubjectForEmail func(ctx context.Context, email string) (subject string, ok bool)
+}
+
+// Mount adds the pages to r. The reader's pages are open to anyone (the
+// billing button needs a signed-in reader); the owner's answer 403 to
+// anyone IsOwner refuses.
+//
+// A plan named "done" cannot have a page: GET /subscribe/done is
+// Checkout's return.
+func (p *AbonoPages) Mount(r chi.Router) {
+ r.Get(abonoPricingPath, p.pricing)
+ r.Get(abonoDonePath, p.done)
+ r.Get(abonoPricingPath+"/{plan}", p.plan)
+ r.Post(abonoPricingPath+"/{plan}", p.subscribe)
+ r.Post(abonoBillingPath, p.billing)
+
+ r.Group(func(r chi.Router) {
+ r.Use(p.ownerOnly)
+ r.Get(abonoMembersPath, p.members)
+ r.Post(abonoMembersPath+"/sync", p.sync)
+ r.Post(abonoMembersPath+"/comps", p.comp)
+ r.Get(abonoMembersPath+"/comps/{id}/end", p.endCompPage)
+ r.Post(abonoMembersPath+"/comps/{id}/end", p.endComp)
+ r.Get(abonoOffersPath, p.offers)
+ r.Post(abonoOffersPath+"/adopt", p.adopt)
+ r.Get(abonoStripePath, p.stripe)
+ })
+}
+
+// ---------------------------------------------------------------- reader
+
+// abonoCard is one plan card: an offer at one interval ("" for the
+// plan's only price, or when Stripe has not said its interval yet), and
+// where its form posts.
+type abonoCard struct {
+ Offer abono.Offer
+ Interval string
+ Action string
+}
+
+// abonoCards is a card per offer and sold interval, monthly before
+// yearly. An offer sold at one price, or whose prices' intervals are not
+// known yet, gets one card with no interval; an offer with nothing sold
+// gets none.
+func abonoCards(offers []abono.Offer) []abonoCard {
+ var cards []abonoCard
+ for _, o := range offers {
+ var intervals []string
+ sold, unknown := 0, false
+ for _, pr := range o.Prices {
+ if !pr.Sold {
+ continue
+ }
+ sold++
+ if pr.Interval == "" {
+ unknown = true
+ } else if !abonoContains(intervals, pr.Interval) {
+ intervals = append(intervals, pr.Interval)
+ }
+ }
+ if sold == 0 {
+ continue
+ }
+ action := abonoPricingPath + "/" + url.PathEscape(o.Plan)
+ if sold == 1 || unknown {
+ cards = append(cards, abonoCard{Offer: o, Action: action})
+ continue
+ }
+ for _, iv := range []string{"day", "week", "month", "year"} {
+ if abonoContains(intervals, iv) {
+ cards = append(cards, abonoCard{Offer: o, Interval: iv, Action: action})
+ }
+ }
+ }
+ return cards
+}
+
+func abonoContains(list []string, s string) bool {
+ for _, v := range list {
+ if v == s {
+ return true
+ }
+ }
+ return false
+}
+
+// forSale is the offers with a card: none while Stripe is off.
+func (p *AbonoPages) forSale(ctx context.Context) ([]abono.Offer, []abonoCard, error) {
+ if !p.Service.On() {
+ return nil, nil, nil
+ }
+ offers, err := p.Service.Offers(ctx)
+ if err != nil {
+ return nil, nil, err
+ }
+ return offers, abonoCards(offers), nil
+}
+
+// GET /subscribe: every plan on sale.
+func (p *AbonoPages) pricing(w http.ResponseWriter, r *http.Request) {
+ _, cards, err := p.forSale(r.Context())
+ if err != nil {
+ p.fail(w, r, "offers", err)
+ return
+ }
+ p.render(w, r, http.StatusOK, "abono/pricing", map[string]any{
+ "Title": "abono.pages.pricing_title",
+ "Open": len(cards) > 0,
+ "Cards": cards,
+ })
+}
+
+// GET /subscribe/{plan}: one plan, with a promotion code field.
+func (p *AbonoPages) plan(w http.ResponseWriter, r *http.Request) {
+ p.renderPlan(w, r, http.StatusOK, chi.URLParam(r, "plan"), "")
+}
+
+// renderPlan shows plan's page, with the message errKey above it when
+// there is one. A plan that is not on sale shows only the message, or
+// says nothing is for sale.
+func (p *AbonoPages) renderPlan(w http.ResponseWriter, r *http.Request, status int, plan, errKey string) {
+ offers, all, err := p.forSale(r.Context())
+ if err != nil {
+ p.fail(w, r, "offers", err)
+ return
+ }
+ var cards []abonoCard
+ for _, c := range all {
+ if c.Offer.Plan == plan {
+ cards = append(cards, c)
+ }
+ }
+ if len(cards) == 0 && len(offers) > 0 && errKey == "" {
+ status, errKey = http.StatusNotFound, "abono.error.unknown_plan"
+ }
+ _, signedIn := p.subject(r)
+ p.render(w, r, status, "abono/plan", map[string]any{
+ "Title": "abono.pages.pricing_title",
+ "Open": len(all) > 0,
+ "Cards": cards,
+ "SignedIn": signedIn,
+ "PlansURL": abonoPricingPath,
+ "Error": errKey,
+ })
+}
+
+// POST /subscribe/{plan}: start Checkout for the plan at the posted
+// interval, as the signed-in reader or as nobody yet.
+func (p *AbonoPages) subscribe(w http.ResponseWriter, r *http.Request) {
+ plan := chi.URLParam(r, "plan")
+ if !p.Service.On() {
+ p.renderPlan(w, r, http.StatusServiceUnavailable, plan, "abono.error.not_available")
+ return
+ }
+ subject, _ := p.subject(r)
+ u, err := p.Service.Checkout(r.Context(), abono.CheckoutRequest{
+ Plan: plan,
+ Interval: r.PostFormValue("interval"),
+ Subject: subject,
+ PromotionCode: r.PostFormValue("code"),
+ SuccessPath: abonoDonePath,
+ CancelPath: abonoPricingPath + "/" + url.PathEscape(plan),
+ })
+ switch {
+ case errors.Is(err, abono.ErrUnknownCode):
+ p.renderPlan(w, r, http.StatusUnprocessableEntity, plan, "abono.error.unknown_code")
+ case errors.Is(err, abono.ErrUnknownPlan):
+ p.renderPlan(w, r, http.StatusNotFound, plan, "abono.error.unknown_plan")
+ case err != nil:
+ slog.ErrorContext(r.Context(), "abono pages: checkout", "plan", plan, "err", err)
+ p.renderPlan(w, r, http.StatusBadGateway, plan, "abono.error.unreachable")
+ default:
+ http.Redirect(w, r, u, http.StatusSeeOther)
+ }
+}
+
+// GET /subscribe/done: Checkout's return. Finish records the payment at
+// once, so the account page already shows it.
+func (p *AbonoPages) done(w http.ResponseWriter, r *http.Request) {
+ _, signedIn := p.subject(r)
+ data := map[string]any{
+ "Title": "abono.pages.done_unpaid_title",
+ "Paid": false,
+ "Email": "",
+ "SignedIn": signedIn,
+ "AccountURL": abonoAccountPath,
+ "PlansURL": abonoPricingPath,
+ "Error": "",
+ }
+ id := r.URL.Query().Get("session_id")
+ switch {
+ case !p.Service.On():
+ data["Error"] = "abono.error.not_available"
+ p.render(w, r, http.StatusServiceUnavailable, "abono/done", data)
+ return
+ case id == "":
+ p.render(w, r, http.StatusOK, "abono/done", data)
+ return
+ }
+ f, err := p.Service.Finish(r.Context(), id)
+ if err != nil {
+ slog.ErrorContext(r.Context(), "abono pages: finish", "err", err)
+ data["Error"] = "abono.error.finish_failed"
+ p.render(w, r, http.StatusBadGateway, "abono/done", data)
+ return
+ }
+ if f.Paid {
+ data["Title"], data["Paid"], data["Email"] = "abono.pages.done_title", true, f.Email
+ }
+ p.render(w, r, http.StatusOK, "abono/done", data)
+}
+
+// POST /account/billing: the signed-in reader's billing portal, coming
+// back to the account page. A failure goes back there too, saying why.
+func (p *AbonoPages) billing(w http.ResponseWriter, r *http.Request) {
+ back := func(problem string) {
+ http.Redirect(w, r, abonoAccountPath+"?billing="+problem, http.StatusSeeOther)
+ }
+ subject, ok := p.subject(r)
+ switch {
+ case !ok:
+ back("signin")
+ return
+ case !p.Service.On():
+ back("off")
+ return
+ }
+ u, err := p.Service.Portal(r.Context(), subject, abonoAccountPath)
+ switch {
+ case errors.Is(err, abono.ErrNoCustomer):
+ back("none")
+ case err != nil:
+ slog.ErrorContext(r.Context(), "abono pages: portal", "err", err)
+ back("unreachable")
+ default:
+ http.Redirect(w, r, u, http.StatusSeeOther)
+ }
+}
+
+// abonoBillingProblems are the ?billing= values billing sends the reader
+// back with, and what each says. Anything else says nothing.
+var abonoBillingProblems = map[string]string{
+ "signin": "abono.error.signin_needed",
+ "off": "abono.error.not_available",
+ "none": "abono.error.no_billing",
+ "unreachable": "abono.error.unreachable",
+}
+
+// Account is the data for the "abono-account" section of your account
+// page: the reader's holdings, the billing button when they pay through
+// Stripe, and the problem the billing button came back with. Add the
+// request's localizer before rendering it:
+//
+// {{template "abono-account" (withL .Abono $L)}}
+func (p *AbonoPages) Account(r *http.Request) (map[string]any, error) {
+ subject, _ := p.subject(r)
+ holdings, err := p.Service.Holdings(r.Context(), subject)
+ if err != nil {
+ return nil, err
+ }
+ manage, granted := false, false
+ for _, h := range holdings {
+ manage = manage || h.Source == abono.SourceSubscription
+ granted = granted || h.Grants
+ }
+ return map[string]any{
+ "Holdings": holdings,
+ "Now": p.Service.Now(),
+ "Manage": manage && p.Service.On(),
+ "Granted": granted,
+ "BillingAction": abonoBillingPath,
+ "PlansURL": abonoPricingPath,
+ "Error": abonoBillingProblems[r.URL.Query().Get("billing")],
+ }, nil
+}
+
+// ---------------------------------------------------------------- owner
+
+func (p *AbonoPages) ownerOnly(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if p.IsOwner == nil || !p.IsOwner(r) {
+ http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// abonoMember is one row of the members table.
+type abonoMember struct {
+ abono.Member
+ // EndURL is the confirm page that ends a comp still granting; ""
+ // for anything else.
+ EndURL string
+}
+
+// abonoCompForm is the free-membership form as posted, and what was wrong
+// with it: message keys, by field.
+type abonoCompForm struct {
+ Member, Plan, Ends, Note string
+ Errors map[string]string
+}
+
+// abonoNotice is what a finished owner action says on the page it
+// returns to: Kind is sync, comp, end or adopt; N and Plan fill its
+// sentence.
+type abonoNotice struct {
+ Kind string
+ N int
+ Plan string
+}
+
+// abonoReadNotice reads the ?done= an owner action redirected with. A plan that
+// is not one of offers is dropped, so a link cannot put words on the page.
+func abonoReadNotice(r *http.Request, offers []abono.Offer) *abonoNotice {
+ q := r.URL.Query()
+ n := &abonoNotice{Kind: q.Get("done")}
+ switch n.Kind {
+ case "sync":
+ n.N, _ = strconv.Atoi(q.Get("n"))
+ case "comp", "adopt":
+ for _, o := range offers {
+ if o.Plan == q.Get("plan") {
+ n.Plan = o.Name
+ }
+ }
+ if n.Plan == "" {
+ return nil
+ }
+ case "end":
+ default:
+ return nil
+ }
+ return n
+}
+
+// GET /admin/members: every holding, sync now, and give a comp.
+func (p *AbonoPages) members(w http.ResponseWriter, r *http.Request) {
+ p.renderMembers(w, r, http.StatusOK, abonoCompForm{}, "")
+}
+
+func (p *AbonoPages) renderMembers(w http.ResponseWriter, r *http.Request, status int, form abonoCompForm, errKey string) {
+ ctx := r.Context()
+ ms, err := p.Service.Members(ctx)
+ if err != nil {
+ p.fail(w, r, "members", err)
+ return
+ }
+ offers, err := p.Service.Offers(ctx)
+ if err != nil {
+ p.fail(w, r, "offers", err)
+ return
+ }
+ rows := make([]abonoMember, len(ms))
+ for i, m := range ms {
+ rows[i] = abonoMember{Member: m}
+ if m.Source == abono.SourceComp && m.Grants {
+ rows[i].EndURL = abonoMembersPath + "/comps/" + strconv.FormatInt(m.CompID, 10) + "/end"
+ }
+ }
+ p.render(w, r, status, "abono/owner-members", map[string]any{
+ "Title": "abono.owner.members_title",
+ "On": p.Service.On(),
+ "Members": rows,
+ "Now": p.Service.Now(),
+ "Offers": offers,
+ "PlanOptions": abonoPlanOptions(offers, form.Plan),
+ "Form": form,
+ "SyncAction": abonoMembersPath + "/sync",
+ "CompAction": abonoMembersPath + "/comps",
+ "StripeURL": abonoStripePath,
+ "Done": abonoReadNotice(r, offers),
+ "Error": errKey,
+ })
+}
+
+// POST /admin/members/sync: Reconcile now.
+func (p *AbonoPages) sync(w http.ResponseWriter, r *http.Request) {
+ rep, err := p.Service.Reconcile(r.Context())
+ if err != nil {
+ slog.ErrorContext(r.Context(), "abono pages: sync", "err", err)
+ p.renderMembers(w, r, http.StatusBadGateway, abonoCompForm{}, "abono.error.sync_failed")
+ return
+ }
+ http.Redirect(w, r, abonoMembersPath+"?done=sync&n="+strconv.Itoa(rep.Subscriptions), http.StatusSeeOther)
+}
+
+// POST /admin/members/comps: give the person with the posted address
+// a plan, until the posted day or for good.
+func (p *AbonoPages) comp(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ form := abonoCompForm{
+ Member: strings.TrimSpace(r.PostFormValue("member")),
+ Plan: r.PostFormValue("plan"),
+ Ends: strings.TrimSpace(r.PostFormValue("ends")),
+ Note: strings.TrimSpace(r.PostFormValue("note")),
+ Errors: map[string]string{},
+ }
+ var ends *time.Time
+ if form.Ends != "" {
+ t, err := time.Parse("2006-01-02", form.Ends)
+ if err != nil {
+ // A date field always posts this format; anything else is not a browser.
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+ ends = &t
+ }
+ offers, err := p.Service.Offers(ctx)
+ if err != nil {
+ p.fail(w, r, "offers", err)
+ return
+ }
+ if !abonoIsPlan(offers, form.Plan) {
+ form.Errors["plan"] = "abono.error.free_plan"
+ }
+ subject, found := "", false
+ if p.SubjectForEmail != nil && form.Member != "" {
+ subject, found = p.SubjectForEmail(ctx, form.Member)
+ }
+ if !found {
+ form.Errors["member"] = "abono.error.signin_needed"
+ }
+ if len(form.Errors) > 0 {
+ p.renderMembers(w, r, http.StatusUnprocessableEntity, form, "")
+ return
+ }
+ if _, err := p.Service.Comp(ctx, subject, form.Plan, ends, form.Note); err != nil {
+ p.fail(w, r, "comp", err)
+ return
+ }
+ http.Redirect(w, r, abonoMembersPath+"?done=comp&plan="+url.QueryEscape(form.Plan), http.StatusSeeOther)
+}
+
+// abonoOption is one choice of a field-select.
+type abonoOption struct {
+ Value, Label string
+ Selected bool
+}
+
+// abonoPlanOptions is every offer as a choice, selected marked.
+func abonoPlanOptions(offers []abono.Offer, selected string) []abonoOption {
+ opts := make([]abonoOption, len(offers))
+ for i, o := range offers {
+ opts[i] = abonoOption{Value: o.Plan, Label: o.Name, Selected: o.Plan == selected}
+ }
+ return opts
+}
+
+func abonoIsPlan(offers []abono.Offer, plan string) bool {
+ for _, o := range offers {
+ if o.Plan == plan {
+ return true
+ }
+ }
+ return false
+}
+
+// compFor is the comp {id} names, while it still grants.
+func (p *AbonoPages) compFor(r *http.Request) (abono.Member, bool, error) {
+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ if err != nil {
+ return abono.Member{}, false, nil
+ }
+ ms, err := p.Service.Members(r.Context())
+ if err != nil {
+ return abono.Member{}, false, err
+ }
+ for _, m := range ms {
+ if m.Source == abono.SourceComp && m.CompID == id && m.Grants {
+ return m, true, nil
+ }
+ }
+ return abono.Member{}, false, nil
+}
+
+// GET /admin/members/comps/{id}/end: are you sure?
+func (p *AbonoPages) endCompPage(w http.ResponseWriter, r *http.Request) {
+ m, ok, err := p.compFor(r)
+ switch {
+ case err != nil:
+ p.fail(w, r, "members", err)
+ return
+ case !ok:
+ http.NotFound(w, r)
+ return
+ }
+ plan := m.Plan
+ if offers, err := p.Service.Offers(r.Context()); err == nil {
+ for _, o := range offers {
+ if o.Plan == m.Plan {
+ plan = o.Name
+ }
+ }
+ }
+ p.render(w, r, http.StatusOK, "abono/owner-end", map[string]any{
+ "Title": "abono.owner.end_title",
+ "Member": m,
+ "Plan": plan,
+ "Action": r.URL.Path,
+ "MembersURL": abonoMembersPath,
+ })
+}
+
+// POST /admin/members/comps/{id}/end: end it now.
+func (p *AbonoPages) endComp(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ switch err := p.Service.EndComp(r.Context(), id); {
+ case errors.Is(err, abono.ErrNotFound):
+ http.NotFound(w, r)
+ case err != nil:
+ p.fail(w, r, "end comp", err)
+ default:
+ http.Redirect(w, r, abonoMembersPath+"?done=end", http.StatusSeeOther)
+ }
+}
+
+// GET /admin/offers: plans and their prices, and paying subscriptions
+// no plan recognises, each with a way onto a plan.
+func (p *AbonoPages) offers(w http.ResponseWriter, r *http.Request) {
+ p.renderOffers(w, r, http.StatusOK, "")
+}
+
+func (p *AbonoPages) renderOffers(w http.ResponseWriter, r *http.Request, status int, errKey string) {
+ ctx := r.Context()
+ offers, err := p.Service.Offers(ctx)
+ if err != nil {
+ p.fail(w, r, "offers", err)
+ return
+ }
+ var unknown []abono.Unrecognised
+ if p.Service.On() {
+ if unknown, err = p.Service.Unrecognised(ctx); err != nil {
+ p.fail(w, r, "unrecognised", err)
+ return
+ }
+ }
+ p.render(w, r, status, "abono/owner-offers", map[string]any{
+ "Title": "abono.owner.offers_title",
+ "On": p.Service.On(),
+ "Offers": offers,
+ "Unrecognised": unknown,
+ "PlanOptions": abonoPlanOptions(offers, ""),
+ "AdoptAction": abonoOffersPath + "/adopt",
+ "StripeURL": abonoStripePath,
+ "Done": abonoReadNotice(r, offers),
+ "Error": errKey,
+ })
+}
+
+// POST /admin/offers/adopt: put an unrecognised subscription, and every
+// other on its price, onto the posted plan. Its holder is found as a sync
+// finds one.
+func (p *AbonoPages) adopt(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ sub, plan := r.PostFormValue("subscription"), r.PostFormValue("plan")
+ offers, err := p.Service.Offers(ctx)
+ if err != nil {
+ p.fail(w, r, "offers", err)
+ return
+ }
+ if !abonoIsPlan(offers, plan) {
+ p.renderOffers(w, r, http.StatusUnprocessableEntity, "abono.error.free_plan")
+ return
+ }
+ if err := p.Service.Adopt(ctx, sub, "", plan); err != nil {
+ slog.ErrorContext(ctx, "abono pages: adopt", "subscription", sub, "err", err)
+ p.renderOffers(w, r, http.StatusUnprocessableEntity, "abono.error.adopt_failed")
+ return
+ }
+ http.Redirect(w, r, abonoOffersPath+"?done=adopt&plan="+url.QueryEscape(plan), http.StatusSeeOther)
+}
+
+// GET /admin/stripe: whether Stripe is connected, how, and the webhook's
+// address.
+func (p *AbonoPages) stripe(w http.ResponseWriter, r *http.Request) {
+ mode := "off"
+ switch {
+ case p.Service.Sandbox():
+ mode = "sandbox"
+ case p.Service.On() && p.Service.TestMode():
+ mode = "test"
+ case p.Service.On():
+ mode = "live"
+ }
+ p.render(w, r, http.StatusOK, "abono/owner-stripe", map[string]any{
+ "Title": "abono.owner.stripe_title",
+ "Mode": mode,
+ "WebhookURL": strings.TrimRight(p.Service.Origin(), "/") + abono.WebhookPath,
+ })
+}
+
+// ---------------------------------------------------------------- plumbing
+
+func (p *AbonoPages) subject(r *http.Request) (string, bool) {
+ if p.Subject == nil {
+ return "", false
+ }
+ s, ok := p.Subject(r)
+ return s, ok && s != ""
+}
+
+// render hands the page to Render, with the response's status set when it
+// is not 200.
+func (p *AbonoPages) render(w http.ResponseWriter, r *http.Request, status int, name string, data map[string]any) {
+ if status != http.StatusOK {
+ w = &abonoStatusWriter{ResponseWriter: w, status: status}
+ }
+ p.Render(w, r, name, data)
+}
+
+// fail is the answer to a failure no page explains: logged, and a 500.
+func (p *AbonoPages) fail(w http.ResponseWriter, r *http.Request, what string, err error) {
+ slog.ErrorContext(r.Context(), "abono pages: "+what, "err", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+}
+
+// abonoStatusWriter writes status instead of the 200 a page's body would
+// imply, and lets any other status (a Render that fails with a 500) pass.
+type abonoStatusWriter struct {
+ http.ResponseWriter
+ status int
+ wrote bool
+}
+
+func (s *abonoStatusWriter) WriteHeader(code int) {
+ if s.wrote {
+ return
+ }
+ s.wrote = true
+ if code == http.StatusOK {
+ code = s.status
+ }
+ s.ResponseWriter.WriteHeader(code)
+}
+
+func (s *abonoStatusWriter) Write(b []byte) (int, error) {
+ if !s.wrote {
+ s.WriteHeader(http.StatusOK)
+ }
+ return s.ResponseWriter.Write(b)
+}
+
+func (s *abonoStatusWriter) Unwrap() http.ResponseWriter { return s.ResponseWriter }
diff --git a/example/app.go b/example/app.go
new file mode 100644
index 0000000..8069c38
--- /dev/null
+++ b/example/app.go
@@ -0,0 +1,299 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "embed"
+ "io/fs"
+ "log/slog"
+ "maps"
+ "net/http"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+
+ "amadan.net/rastrillo/rastrillo"
+ "amadan.net/rastrillo/rastrillo/csrf"
+ "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/locales"
+ _ "amadan.net/rastrillo/rastrillo/locales/en"
+ "amadan.net/rastrillo/rastrillo/migrate"
+ "amadan.net/rastrillo/rastrillo/password"
+ "amadan.net/rastrillo/rastrillo/sessions"
+ "amadan.net/rastrillo/rastrillo/ui"
+
+ "gorm.io/gorm"
+
+ "amadan.net/rastrillo/abono"
+)
+
+// DefaultPlans are the plans the example sells when Config.Plans is
+// empty. The sandbox makes up any price it is asked about: a price whose
+// id says annual is yearly, anything else monthly.
+const DefaultPlans = "annual=price_annual,monthly=price_monthly"
+
+// Config is what the example needs.
+type Config struct {
+ DB *db.DB
+ // Origin is the site's address. With no Key it must be on this
+ // machine (localhost, 127.0.0.1): the sandbox refuses anything else.
+ Origin string
+ // Owner is the address of the account that may use the owner's pages.
+ Owner string
+ // Plans is what is for sale, in abono.ParsePlans' format.
+ Plans string
+ // Key and WebhookSecrets are a Stripe account's; without them the
+ // sandbox stands in for Stripe.
+ Key string
+ WebhookSecrets []string
+ Logger *slog.Logger
+}
+
+// Options is the example as rastrillo serves it: main adds an address,
+// the tests hand it to rastrillo.Handler.
+func Options(cfg Config) (rastrillo.Options, error) {
+ if cfg.Logger == nil {
+ cfg.Logger = slog.Default()
+ }
+ mux, err := App(cfg)
+ if err != nil {
+ return rastrillo.Options{}, err
+ }
+ return rastrillo.Options{
+ Mux: mux,
+ Locales: []string{"en"},
+ DefaultLocale: "en",
+ BaseCatalog: abono.Catalog(),
+ Logger: cfg.Logger,
+ }, nil
+}
+
+// App wires the example: its schema and abono's, sessions and password
+// sign-in, abono with the sandbox, the generated pages, and the two
+// machine endpoints (Stripe's webhook, carlos's daily tick), which
+// csrf.Protect lets through because each checks its own credential.
+func App(cfg Config) (*http.ServeMux, error) {
+ ctx := context.Background()
+ logger := cfg.Logger
+ if _, err := migrate.Apply(ctx, cfg.DB, BootSchema); err != nil {
+ return nil, err
+ }
+ writer, err := cfg.DB.G.DB()
+ if err != nil {
+ return nil, err
+ }
+ sess, err := sessions.New(sessions.Config{DB: writer, Origin: cfg.Origin, Logger: logger})
+ if err != nil {
+ return nil, err
+ }
+
+ svc, err := abono.New(abono.Config{
+ DB: cfg.DB.G,
+ Key: cfg.Key,
+ WebhookSecrets: cfg.WebhookSecrets,
+ Sandbox: cfg.Key == "",
+ Origin: cfg.Origin,
+ Holder: holder,
+ // A real app moves roles here (Patrix settles a reader's role).
+ // The example has nothing to move: access is asked of abono.
+ OnChange: func(ctx context.Context, _ *gorm.DB, subject string) error {
+ logger.DebugContext(ctx, "example: holdings changed", "subject", subject)
+ return nil
+ },
+ Logger: logger,
+ })
+ if err != nil {
+ return nil, err
+ }
+ plans := cfg.Plans
+ if plans == "" {
+ plans = DefaultPlans
+ }
+ specs, err := abono.ParsePlans(plans)
+ if err != nil {
+ return nil, err
+ }
+ if err := svc.SeedPlans(ctx, specs); err != nil {
+ return nil, err
+ }
+ // One reconcile reads the prices, so the plans page can show them
+ // before anyone has bought. The daily tick keeps them fresh.
+ if _, err := svc.Reconcile(ctx); err != nil {
+ logger.Warn("example: first reconcile", "err", err)
+ }
+
+ locCfg, err := locales.CheckConfig("en", []string{"en"})
+ if err != nil {
+ return nil, err
+ }
+ pages, err := newPages(locCfg)
+ if err != nil {
+ return nil, err
+ }
+ a := &app{db: cfg.DB.G, pages: pages, owner: strings.ToLower(strings.TrimSpace(cfg.Owner)), logger: logger}
+ a.abono = &AbonoPages{
+ Service: svc,
+ Render: a.render,
+ Subject: subject,
+ IsOwner: a.isOwner,
+ SubjectForEmail: userByEmail(cfg.DB.G),
+ }
+
+ ph, err := password.New(password.Config{
+ Sessions: sess,
+ Lookup: lookupUser(cfg.DB.G),
+ Create: createUser(cfg.DB.G),
+ RenderSignin: a.renderSignin,
+ RenderSignup: a.renderSignup,
+ // The example skips pow's front door to stay small; a real app
+ // keeps it (see rastrillo's examples/notes).
+ ProofOff: true,
+ Logger: logger,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ r := chi.NewRouter()
+ r.Use(csrf.Protect(cfg.Origin, csrf.Exempt(abono.WebhookPath, abono.SyncPath)))
+ r.Use(sess.Middleware)
+ r.Get("/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, abonoPricingPath, http.StatusFound) })
+ r.Get("/signin", ph.SigninPage)
+ r.Post("/signin", ph.Signin)
+ r.Get("/signup", ph.SignupPage)
+ r.Post("/signup", ph.Signup)
+ r.Post("/signout", ph.Signout)
+ r.With(sess.Require).Get(abonoAccountPath, a.account)
+ a.abono.Mount(r)
+
+ r.Post(abono.WebhookPath, svc.ServeWebhook)
+ r.Post(abono.SyncPath, svc.ServeSync)
+ r.Handle(abono.SandboxPath+"*", svc.SandboxHandler())
+
+ r.Get("/static/app.css", func(w http.ResponseWriter, _ *http.Request) {
+ theme, _ := ui.ThemeCSS("day")
+ w.Header().Set("Content-Type", "text/css; charset=utf-8")
+ w.Write(ui.TokensCSS())
+ w.Write([]byte("\n"))
+ w.Write(theme)
+ w.Write([]byte(exampleCSS))
+ })
+
+ mux := http.NewServeMux()
+ mux.Handle("/", pages.Middleware(r))
+ return mux, nil
+}
+
+// exampleCSS is what would otherwise be a style attribute, which the
+// default CSP blocks.
+const exampleCSS = `
+nav form { display: inline; }
+`
+
+//go:embed templates
+var templatesFS embed.FS
+
+// partialsDir is where abono's partials appear in withPartials.
+const partialsDir = "abono-partials/"
+
+// withPartials is the app's templates with abono's partials beside them
+// under partialsDir, so ui.Pages parses both into every page's base.
+type withPartials struct{ app, partials fs.FS }
+
+func (f withPartials) Open(name string) (fs.File, error) {
+ if rest, ok := strings.CutPrefix(name, partialsDir); ok {
+ return f.partials.Open(rest)
+ }
+ return f.app.Open(name)
+}
+
+// newPages is every page: the example's own, and the generated ones,
+// over one layout, with ui's partials, abono's, and the generated
+// account section in the base.
+func newPages(cfg locales.Config) (*ui.Pages, error) {
+ partials, err := fs.Glob(abono.Templates(), "*.html")
+ if err != nil {
+ return nil, err
+ }
+ shared := []string{"templates/abono/account.html"}
+ for _, p := range partials {
+ shared = append(shared, partialsDir+p)
+ }
+ files := map[string]ui.PageFile{}
+ for _, name := range []string{"signin", "signup", "account"} {
+ files[name] = ui.PageFile{Path: "templates/" + name + ".html"}
+ }
+ for _, name := range []string{"pricing", "plan", "done", "owner-members", "owner-end", "owner-offers", "owner-stripe"} {
+ files["abono/"+name] = ui.PageFile{Path: "templates/abono/" + name + ".html"}
+ }
+ return ui.NewPages(ui.PagesConfig{
+ App: withPartials{templatesFS, abono.Templates()},
+ Layout: "templates/layout.html",
+ Shared: shared,
+ Pages: files,
+ Locale: cfg,
+ Catalog: abono.Catalog(),
+ })
+}
+
+type app struct {
+ db *gorm.DB
+ pages *ui.Pages
+ abono *AbonoPages
+ owner string
+ logger *slog.Logger
+}
+
+// subject is the signed-in person's subject, the user's id.
+func subject(r *http.Request) (string, bool) {
+ s, ok := sessions.Current(r)
+ return s.Subject, ok
+}
+
+// isOwner says whether the signed-in person is the owner, by address.
+func (a *app) isOwner(r *http.Request) bool {
+ s, ok := sessions.Current(r)
+ return ok && a.owner != "" && strings.EqualFold(emailOf(a.db, r.Context(), s.Subject), a.owner)
+}
+
+// render executes name with data (a map) plus what the layout reads: the
+// localizer, whether someone is signed in, and whether they are the
+// owner. It renders into a buffer first, so a template error is a clean
+// 500.
+func (a *app) render(w http.ResponseWriter, r *http.Request, name string, data any) {
+ page := map[string]any{}
+ if m, ok := data.(map[string]any); ok {
+ maps.Copy(page, m)
+ }
+ _, signedIn := sessions.Current(r)
+ page["L"] = ui.ForRequest(r).L
+ page["SignedIn"] = signedIn
+ page["Owner"] = a.isOwner(r)
+ var buf bytes.Buffer
+ if err := a.pages.Execute(&buf, name, page); err != nil {
+ a.logger.ErrorContext(r.Context(), "example: render "+name, "err", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ buf.WriteTo(w)
+}
+
+func (a *app) renderSignin(w http.ResponseWriter, r *http.Request, d password.PageData) {
+ a.render(w, r, "signin", map[string]any{"Title": "rastrillo.ui.signin_confirm_submit", "Form": d})
+}
+
+func (a *app) renderSignup(w http.ResponseWriter, r *http.Request, d password.PageData) {
+ a.render(w, r, "signup", map[string]any{"Title": "example.signup", "Form": d})
+}
+
+// account is the example's account page: just abono's section.
+func (a *app) account(w http.ResponseWriter, r *http.Request) {
+ data, err := a.abono.Account(r)
+ if err != nil {
+ a.logger.ErrorContext(r.Context(), "example: account", "err", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ a.render(w, r, "account", map[string]any{"Title": "rastrillo.ui.shell_account", "Abono": data})
+}
diff --git a/example/app_test.go b/example/app_test.go
new file mode 100644
index 0000000..e4801d3
--- /dev/null
+++ b/example/app_test.go
@@ -0,0 +1,335 @@
+package main
+
+import (
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+
+ "amadan.net/rastrillo/rastrillo"
+ "amadan.net/rastrillo/rastrillo/db"
+)
+
+const owner = "owner@example.com"
+
+// newApp serves the whole example, as main does, over a fresh database,
+// with the sandbox standing in for Stripe. The server's own address is
+// the origin, so a POST carrying it as Origin passes csrf.Protect.
+func newApp(t *testing.T) *httptest.Server {
+ t.Helper()
+ quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
+ d, err := db.Open(filepath.Join(t.TempDir(), "example.db"), quiet)
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+ ts := httptest.NewUnstartedServer(nil)
+ opts, err := Options(Config{DB: d, Origin: "http://" + ts.Listener.Addr().String(), Owner: owner, Logger: quiet})
+ if err != nil {
+ t.Fatalf("Options: %v", err)
+ }
+ h, closeApp, err := rastrillo.Handler(opts)
+ if err != nil {
+ t.Fatalf("rastrillo.Handler: %v", err)
+ }
+ t.Cleanup(func() { closeApp() })
+ ts.Config.Handler = h
+ ts.Start()
+ t.Cleanup(ts.Close)
+ return ts
+}
+
+// client is one visitor: a cookie jar, and the Origin a browser's form
+// POST carries. It never follows a redirect on its own.
+type client struct {
+ t *testing.T
+ ts *httptest.Server
+ c *http.Client
+}
+
+func newClient(t *testing.T, ts *httptest.Server) *client {
+ t.Helper()
+ jar, _ := cookiejar.New(nil)
+ return &client{t: t, ts: ts, c: &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ }}}
+}
+
+// do sends a request to path (or an absolute address on the app) and
+// returns the status, the Location and the body. origin is the Origin
+// header, "" for none.
+func (cl *client) do(method, path string, form url.Values, origin string) (int, string, string) {
+ cl.t.Helper()
+ u := path
+ if strings.HasPrefix(path, "/") {
+ u = cl.ts.URL + path
+ }
+ var body io.Reader
+ if form != nil {
+ body = strings.NewReader(form.Encode())
+ }
+ req, err := http.NewRequest(method, u, body)
+ if err != nil {
+ cl.t.Fatal(err)
+ }
+ if form != nil {
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ }
+ if origin != "" {
+ req.Header.Set("Origin", origin)
+ }
+ resp, err := cl.c.Do(req)
+ if err != nil {
+ cl.t.Fatalf("%s %s: %v", method, path, err)
+ }
+ defer resp.Body.Close()
+ b, _ := io.ReadAll(resp.Body)
+ return resp.StatusCode, resp.Header.Get("Location"), string(b)
+}
+
+func (cl *client) get(path string) (int, string) {
+ cl.t.Helper()
+ status, _, body := cl.do(http.MethodGet, path, nil, "")
+ return status, body
+}
+
+// post is a same-origin form POST, as a browser sends one; it returns
+// the status and where it redirects.
+func (cl *client) post(path string, form url.Values) (int, string, string) {
+ cl.t.Helper()
+ if form == nil {
+ form = url.Values{}
+ }
+ return cl.do(http.MethodPost, path, form, cl.ts.URL)
+}
+
+// signup makes an account and leaves the client signed in to it.
+func (cl *client) signup(email string) {
+ cl.t.Helper()
+ status, _, body := cl.post("/signup", url.Values{"email": {email}, "password": {"a long enough password"}})
+ if status != http.StatusSeeOther {
+ cl.t.Fatalf("signup %s: %d\n%s", email, status, body)
+ }
+}
+
+func want(t *testing.T, what string, got, wantStatus int, body string, parts ...string) {
+ t.Helper()
+ if got != wantStatus {
+ t.Fatalf("%s: status %d, want %d\n%s", what, got, wantStatus, body)
+ }
+ for _, p := range parts {
+ if !strings.Contains(body, p) {
+ t.Errorf("%s: no %q in\n%s", what, p, body)
+ }
+ }
+}
+
+var payAction = regexp.MustCompile(`action="(/abono/sandbox/checkout/[^"/]+/pay)"`)
+
+// checkout starts Checkout for plan and pays on the sandbox's page, as
+// email when it is not "", and returns where the sandbox sends the reader.
+func (cl *client) checkout(plan, email string) string {
+ cl.t.Helper()
+ status, loc, body := cl.post("/subscribe/"+plan, nil)
+ if status != http.StatusSeeOther || !strings.HasPrefix(loc, cl.ts.URL+"/abono/sandbox/checkout/") {
+ cl.t.Fatalf("POST /subscribe/%s: %d to %q\n%s", plan, status, loc, body)
+ }
+ status, page := cl.get(loc)
+ want(cl.t, "the sandbox checkout", status, http.StatusOK, page, "abono.sandbox.pay")
+ m := payAction.FindStringSubmatch(page)
+ if m == nil {
+ cl.t.Fatalf("no pay form on the sandbox page:\n%s", page)
+ }
+ form := url.Values{}
+ if email != "" {
+ form.Set("email", email)
+ }
+ status, back, body := cl.post(m[1], form)
+ if status != http.StatusSeeOther || !strings.HasPrefix(back, cl.ts.URL+"/subscribe/done?session_id=") {
+ cl.t.Fatalf("pay: %d to %q\n%s", status, back, body)
+ }
+ return back
+}
+
+// TestSandboxSubscribe is the reader's whole flow over HTTP: the plans,
+// Checkout on the sandbox's page, Checkout's return, and the account
+// page showing the membership.
+func TestSandboxSubscribe(t *testing.T) {
+ ts := newApp(t)
+ reader := newClient(t, ts)
+ reader.signup("reader@example.com")
+
+ status, body := reader.get("/subscribe")
+ want(t, "the plans", status, http.StatusOK, body,
+ "abono.pages.pricing_title", `action="/subscribe/annual"`, `action="/subscribe/monthly"`)
+
+ status, body = reader.get("/subscribe/annual")
+ want(t, "the annual plan", status, http.StatusOK, body, `action="/subscribe/annual"`, `name="code"`, "abono.pages.plan_back")
+
+ status, body = reader.get("/account")
+ want(t, "the account before paying", status, http.StatusOK, body, "abono.pages.account_none", "abono.button.see_plans")
+
+ done := reader.checkout("annual", "")
+ status, body = reader.get(done)
+ want(t, "Checkout's return", status, http.StatusOK, body, "abono.pages.done_paid", "abono.button.go_to_account")
+
+ status, body = reader.get("/account")
+ want(t, "the account after paying", status, http.StatusOK, body,
+ "<strong>annual</strong>", `rst-tone="positive"`, "abono.status.renews", `action="/account/billing"`)
+
+ // The billing button opens the sandbox's portal.
+ st, loc, _ := reader.post("/account/billing", nil)
+ if st != http.StatusSeeOther || !strings.HasPrefix(loc, ts.URL+"/abono/sandbox/portal/") {
+ t.Errorf("billing: %d to %q", st, loc)
+ }
+
+ // A promotion code Stripe does not know is said so, on the plan's page.
+ st, _, body = reader.post("/subscribe/annual", url.Values{"code": {"NOPE"}})
+ want(t, "an unknown code", st, http.StatusUnprocessableEntity, body, "abono.error.unknown_code")
+ st, _, body = reader.post("/subscribe/nonsense", nil)
+ want(t, "an unknown plan", st, http.StatusNotFound, body, "abono.error.unknown_plan")
+}
+
+// TestSignedOutBuyerSignsUpLater: paying signed out makes a user row for
+// the address (the example's Holder), and signing up with that address
+// later takes the row, membership and all.
+func TestSignedOutBuyerSignsUpLater(t *testing.T) {
+ ts := newApp(t)
+ buyer := newClient(t, ts)
+ done := buyer.checkout("monthly", "walkin@example.com")
+ status, body := buyer.get(done)
+ want(t, "Checkout's return, signed out", status, http.StatusOK, body, "abono.pages.done_signin")
+
+ buyer.signup("walkin@example.com")
+ status, body = buyer.get("/account")
+ want(t, "the account after signing up", status, http.StatusOK, body, "<strong>monthly</strong>", "abono.status.renews")
+}
+
+// TestOwnerGivesAndEndsAComp: the owner gives a reader a free
+// membership by address, the reader has it, and the owner ends it on its
+// own confirm page. Nobody else reaches the owner's pages.
+func TestOwnerGivesAndEndsAComp(t *testing.T) {
+ ts := newApp(t)
+ reader := newClient(t, ts)
+ reader.signup("reader@example.com")
+ boss := newClient(t, ts)
+ boss.signup(owner)
+
+ for _, path := range []string{"/admin/members", "/admin/offers", "/admin/stripe", "/admin/members/comps/1/end"} {
+ if status, _ := reader.get(path); status != http.StatusForbidden {
+ t.Errorf("a reader GET %s: %d, want 403", path, status)
+ }
+ }
+ if status, _, _ := reader.post("/admin/members/comps", url.Values{"member": {"reader@example.com"}, "plan": {"monthly"}}); status != http.StatusForbidden {
+ t.Errorf("a reader POST comps: %d, want 403", status)
+ }
+ if status, _ := newClient(t, ts).get("/admin/members"); status != http.StatusForbidden {
+ t.Errorf("signed out GET /admin/members: %d, want 403", status)
+ }
+
+ status, body := boss.get("/admin/members")
+ want(t, "the members page", status, http.StatusOK, body, "abono.owner.members_empty", "abono.owner.free_title", "abono.button.sync")
+
+ st, _, body := boss.post("/admin/members/comps", url.Values{"member": {"nobody@example.com"}, "plan": {"monthly"}})
+ want(t, "a comp for an unknown address", st, http.StatusUnprocessableEntity, body, "abono.error.signin_needed", `value="nobody@example.com"`)
+ st, _, body = boss.post("/admin/members/comps", url.Values{"member": {"reader@example.com"}, "plan": {"gold"}})
+ want(t, "a comp of an unknown plan", st, http.StatusUnprocessableEntity, body, "abono.error.free_plan")
+
+ st, loc, _ := boss.post("/admin/members/comps", url.Values{"member": {"reader@example.com"}, "plan": {"monthly"}, "note": {"a friend"}})
+ if st != http.StatusSeeOther || loc != "/admin/members?done=comp&plan=monthly" {
+ t.Fatalf("give a comp: %d to %q", st, loc)
+ }
+ status, body = boss.get(loc)
+ want(t, "the members page after a comp", status, http.StatusOK, body,
+ "abono.owner.free_given", "abono.owner.source_free", "abono.status.comp", `href="/admin/members/comps/1/end"`)
+
+ status, body = reader.get("/account")
+ want(t, "the reader's account with a comp", status, http.StatusOK, body, "abono.status.comp")
+
+ status, body = boss.get("/admin/members/comps/1/end")
+ want(t, "the confirm page", status, http.StatusOK, body,
+ "abono.owner.end_title", "abono.owner.end_body", `action="/admin/members/comps/1/end"`, "abono.button.end_free_confirm", "abono.button.keep_free")
+ st, loc, _ = boss.post("/admin/members/comps/1/end", nil)
+ if st != http.StatusSeeOther || loc != "/admin/members?done=end" {
+ t.Fatalf("end the comp: %d to %q", st, loc)
+ }
+ status, body = boss.get(loc)
+ want(t, "the members page after ending", status, http.StatusOK, body, "abono.owner.end_done", "abono.status.ended")
+ if strings.Contains(body, `href="/admin/members/comps/1/end"`) {
+ t.Error("an ended comp still offers to be ended")
+ }
+ if status, _ := boss.get("/admin/members/comps/1/end"); status != http.StatusNotFound {
+ t.Errorf("the confirm page of an ended comp: %d, want 404", status)
+ }
+
+ status, body = reader.get("/account")
+ want(t, "the reader's account after", status, http.StatusOK, body, "abono.status.ended")
+}
+
+// TestOwnerSeesSyncsAndStripe: the owner's other pages, in the sandbox.
+func TestOwnerSeesSyncsAndStripe(t *testing.T) {
+ ts := newApp(t)
+ reader := newClient(t, ts)
+ reader.signup("reader@example.com")
+ reader.get(reader.checkout("annual", ""))
+ boss := newClient(t, ts)
+ boss.signup(owner)
+
+ status, body := boss.get("/admin/members")
+ want(t, "the members page", status, http.StatusOK, body, "abono.owner.source_paid", "abono.status.renews")
+ st, loc, _ := boss.post("/admin/members/sync", nil)
+ if st != http.StatusSeeOther || loc != "/admin/members?done=sync&n=1" {
+ t.Fatalf("sync: %d to %q", st, loc)
+ }
+ status, body = boss.get(loc)
+ want(t, "after a sync", status, http.StatusOK, body, "abono.owner.sync_done_one")
+
+ status, body = boss.get("/admin/offers")
+ want(t, "the offers page", status, http.StatusOK, body,
+ "price_annual", "price_monthly", "abono.owner.price_sold", "abono.owner.unrecognised_none")
+
+ status, body = boss.get("/admin/stripe")
+ want(t, "the Stripe page", status, http.StatusOK, body, "abono.owner.stripe_sandbox")
+}
+
+// TestMachinePathsSkipCSRF: Stripe's webhook and carlos's tick carry no
+// browser's Origin and are exempt, each checking its own credential;
+// every other POST without browser evidence is refused.
+func TestMachinePathsSkipCSRF(t *testing.T) {
+ t.Setenv("CARLOS_ADMIN_TOKEN", "tick-token")
+ ts := newApp(t)
+ machine := newClient(t, ts)
+
+ // The webhook checks Stripe's signature: an unsigned delivery is 400,
+ // not csrf's 403.
+ status, _, body := machine.do(http.MethodPost, "/abono/webhook", url.Values{}, "")
+ if status != http.StatusBadRequest {
+ t.Errorf("unsigned webhook: %d, want 400\n%s", status, body)
+ }
+
+ req, _ := http.NewRequest(http.MethodPost, ts.URL+"/jobs/abono-sync", nil)
+ req.Header.Set("Authorization", "Bearer tick-token")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusNoContent {
+ t.Errorf("the daily tick: %d, want 204", resp.StatusCode)
+ }
+
+ for _, path := range []string{"/subscribe/annual", "/account/billing", "/admin/members/sync", "/signup"} {
+ if status, _, _ := machine.do(http.MethodPost, path, url.Values{}, ""); status != http.StatusForbidden {
+ t.Errorf("POST %s with no Origin: %d, want 403", path, status)
+ }
+ if status, _, _ := machine.do(http.MethodPost, path, url.Values{}, "https://evil.example"); status != http.StatusForbidden {
+ t.Errorf("POST %s from another origin: %d, want 403", path, status)
+ }
+ }
+}
diff --git a/example/browser_test.go b/example/browser_test.go
new file mode 100644
index 0000000..366da6b
--- /dev/null
+++ b/example/browser_test.go
@@ -0,0 +1,90 @@
+//go:build browser
+
+package main
+
+import (
+ "io"
+ "log/slog"
+ "net/http"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/chromedp/chromedp"
+
+ "amadan.net/rastrillo/rastrillo"
+ "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/harness"
+)
+
+// TestBrowserSubscribeAndCancel drives the example in a real Chromium,
+// through rastrillo's harness: sign up, the plans, Checkout on the
+// sandbox's page, Checkout's return, the account page, the sandbox's
+// billing page, cancelling, and the account page saying so. Any console
+// error, failed request or 4xx/5xx fails it at the screen it happened
+// on. Without a browser it fails (harness.ChromePath); it never skips.
+func TestBrowserSubscribeAndCancel(t *testing.T) {
+ quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
+ d, err := db.Open(filepath.Join(t.TempDir(), "example.db"), quiet)
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+
+ rig := harness.New(t, func(origin string) http.Handler {
+ opts, err := Options(Config{DB: d, Origin: origin, Owner: owner, Logger: quiet})
+ if err != nil {
+ t.Fatalf("Options: %v", err)
+ }
+ h, closeApp, err := rastrillo.Handler(opts)
+ if err != nil {
+ t.Fatalf("rastrillo.Handler: %v", err)
+ }
+ t.Cleanup(func() { closeApp() })
+ return h
+ })
+
+ // Sign up, and land on the plans.
+ rig.Run(chromedp.Navigate(rig.Origin + "/signup"))
+ rig.Screen(`form[action="/signup"]`, "sign up")
+ rig.Run(
+ chromedp.SendKeys(`#email`, "reader@example.com", chromedp.ByQuery),
+ chromedp.SendKeys(`#password`, "a long enough password", chromedp.ByQuery),
+ chromedp.Click(`form[action="/signup"] button[type=submit]`, chromedp.ByQuery),
+ )
+ rig.Screen(`form[action="/subscribe/annual"]`, "the plans")
+
+ // Buy the annual plan on the sandbox's Checkout page.
+ rig.Run(chromedp.Click(`form[action="/subscribe/annual"] button[type=submit]`, chromedp.ByQuery))
+ rig.Screen(`form[action$="/pay"]`, "the sandbox's checkout")
+ rig.Run(chromedp.Click(`form[action$="/pay"] button[type=submit]`, chromedp.ByQuery))
+ rig.Screen(`main a[href="/account"]`, "Checkout's return")
+ mustSay(t, rig, "main", "abono.pages.done_paid")
+
+ // The account page shows it renewing.
+ rig.Run(chromedp.Click(`main a[href="/account"]`, chromedp.ByQuery))
+ rig.Screen(`form[action="/account/billing"]`, "the account, paid")
+ mustSay(t, rig, "main", "abono.status.renews")
+
+ // Cancel on the sandbox's billing page, and come back.
+ rig.Run(chromedp.Click(`form[action="/account/billing"] button[type=submit]`, chromedp.ByQuery))
+ rig.Screen(`form[action$="/cancel"] button[type=submit]`, "the sandbox's billing page")
+ rig.Run(
+ chromedp.Click(`form[action$="/cancel"] button[type=submit]`, chromedp.ByQuery),
+ chromedp.WaitNotPresent(`form[action$="/cancel"] button[type=submit]`, chromedp.ByQuery),
+ )
+ rig.Screen(`a[href$="/account"]`, "the sandbox's billing page, cancelled")
+ rig.Run(chromedp.Click(`a[href$="/account"]`, chromedp.ByQuery))
+ rig.Screen(`form[action="/account/billing"]`, "the account, cancelling")
+ mustSay(t, rig, "main", "abono.status.cancelling")
+}
+
+// mustSay fails unless sel's text on the current screen contains text.
+func mustSay(t *testing.T, rig *harness.Rig, sel, text string) {
+ t.Helper()
+ var got string
+ rig.Run(chromedp.Text(sel, &got, chromedp.ByQuery))
+ if !strings.Contains(got, text) {
+ t.Fatalf("%s does not say %q:\n%s", sel, text, got)
+ }
+}
diff --git a/example/go.mod b/example/go.mod
new file mode 100644
index 0000000..b8db037
--- /dev/null
+++ b/example/go.mod
@@ -0,0 +1,38 @@
+module example
+
+go 1.25.0
+
+require (
+ amadan.net/rastrillo/abono v0.0.0
+ amadan.net/rastrillo/rastrillo v0.28.1-0.20261022095550-8fbde25ba6f1
+ github.com/chromedp/chromedp v0.14.2
+ github.com/go-chi/chi/v5 v5.3.2
+ gorm.io/gorm v1.31.2
+)
+
+require (
+ amadan.net/rastrillo/rastrillo/money v0.1.0 // indirect
+ github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 // indirect
+ github.com/chromedp/sysutil v1.1.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
+ github.com/gobwas/httphead v0.1.0 // indirect
+ github.com/gobwas/pool v0.2.1 // indirect
+ github.com/gobwas/ws v1.4.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/crypto v0.53.0 // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ golang.org/x/text v0.39.0 // indirect
+ gorm.io/plugin/dbresolver v1.6.2 // indirect
+ modernc.org/libc v1.74.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+ modernc.org/sqlite v1.55.0 // indirect
+)
+
+replace amadan.net/rastrillo/abono => ../
diff --git a/example/go.sum b/example/go.sum
new file mode 100644
index 0000000..e999b35
--- /dev/null
+++ b/example/go.sum
@@ -0,0 +1,99 @@
+amadan.net/rastrillo/rastrillo v0.28.1-0.20261022095550-8fbde25ba6f1 h1:uGwLHZR1zF5yibK7rPI6o9Lod1aMUw1yfKMdK0Fz+sM=
+amadan.net/rastrillo/rastrillo v0.28.1-0.20261022095550-8fbde25ba6f1/go.mod h1:jSSeydqEvlm/fU3hhWoXFnyEaDgroR8LPRZSo9ZhhIM=
+amadan.net/rastrillo/rastrillo/money v0.1.0 h1:MxU4oqNrW8abxRTkgZBv24OYNpFnvonajNqeLIzmmaM=
+amadan.net/rastrillo/rastrillo/money v0.1.0/go.mod h1:cmgkjBEIQh1pFSYvQk0UU6oPYS8kSDypdT9+t4Flx0U=
+github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
+github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
+github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
+github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
+github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
+github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
+github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
+github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
+github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
+github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
+github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/keymaildev/signin v0.1.1 h1:gO+1IAM99sqUkBtCezkgGxab+/DOrqwDxd5H32N1s9Q=
+github.com/keymaildev/signin v0.1.1/go.mod h1:Eb/sCmEel1jlcdkgPOrNeMn5jvxzoFvJrdjDUxOBHls=
+github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
+github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
+github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
+github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
+golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
+gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
+gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
+gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
+gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
+gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
+modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
+modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
+modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
+modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
+modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
+modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/example/main.go b/example/main.go
new file mode 100644
index 0000000..46e8f59
--- /dev/null
+++ b/example/main.go
@@ -0,0 +1,63 @@
+// Command example is abono's example app: password sign-in, the starter
+// pages `abono pages` wrote (abono_pages.go, templates/abono/), and the
+// sandbox standing in for Stripe, so the whole flow is clickable with no
+// Stripe account. Run it with `go run .` and open http://localhost:8080.
+//
+// Its settings are environment variables, all optional:
+//
+// ABONO_EXAMPLE_ADDR where to listen; 127.0.0.1:8080
+// ABONO_EXAMPLE_ORIGIN the address people use; http://localhost:8080
+// ABONO_EXAMPLE_OWNER the account that sees the owner's pages; owner@example.com
+// ABONO_EXAMPLE_PLANS what is for sale; DefaultPlans
+// ABONO_EXAMPLE_DB the database file; example.db
+// STRIPE_KEY, STRIPE_WEBHOOK_SECRET a Stripe test account instead of the sandbox
+package main
+
+import (
+ "log/slog"
+ "os"
+
+ "amadan.net/rastrillo/rastrillo"
+ "amadan.net/rastrillo/rastrillo/db"
+)
+
+func main() {
+ logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
+ d, err := db.Open(env("ABONO_EXAMPLE_DB", "example.db"), logger)
+ if err != nil {
+ logger.Error("open database", "err", err)
+ os.Exit(1)
+ }
+ defer d.Close()
+
+ var secrets []string
+ if s := os.Getenv("STRIPE_WEBHOOK_SECRET"); s != "" {
+ secrets = []string{s}
+ }
+ opts, err := Options(Config{
+ DB: d,
+ Origin: env("ABONO_EXAMPLE_ORIGIN", "http://localhost:8080"),
+ Owner: env("ABONO_EXAMPLE_OWNER", "owner@example.com"),
+ Plans: os.Getenv("ABONO_EXAMPLE_PLANS"),
+ Key: os.Getenv("STRIPE_KEY"),
+ WebhookSecrets: secrets,
+ Logger: logger,
+ })
+ if err != nil {
+ logger.Error("build app", "err", err)
+ os.Exit(1)
+ }
+ // Loopback by default: rastrillo's own default is every interface.
+ opts.Addr = env("ABONO_EXAMPLE_ADDR", "127.0.0.1:8080")
+ if err := rastrillo.Serve(opts); err != nil {
+ logger.Error("serve", "err", err)
+ os.Exit(1)
+ }
+}
+
+func env(name, def string) string {
+ if v := os.Getenv(name); v != "" {
+ return v
+ }
+ return def
+}
diff --git a/example/migrations/0001_init.sql b/example/migrations/0001_init.sql
new file mode 100644
index 0000000..818de7e
--- /dev/null
+++ b/example/migrations/0001_init.sql
@@ -0,0 +1,3 @@
+CREATE TABLE `users` (`id` integer PRIMARY KEY AUTOINCREMENT,`email` text,`password_hash` text,`created_at` datetime);
+
+CREATE UNIQUE INDEX `idx_users_email` ON `users`(`email`);
diff --git a/example/models.go b/example/models.go
new file mode 100644
index 0000000..50e0941
--- /dev/null
+++ b/example/models.go
@@ -0,0 +1,127 @@
+package main
+
+import (
+ "context"
+ "database/sql"
+ "embed"
+ "errors"
+ "strconv"
+ "strings"
+ "time"
+
+ "gorm.io/gorm"
+
+ "amadan.net/rastrillo/rastrillo/migrate"
+ "amadan.net/rastrillo/rastrillo/sessions"
+
+ "amadan.net/rastrillo/abono"
+)
+
+// User is a person who can sign in. Someone who pays before having an
+// account gets a row with no password (holder, below), and signing up
+// with that address later sets one (createUser).
+type User struct {
+ ID int64
+ Email string `gorm:"uniqueIndex"`
+ PasswordHash string
+ CreatedAt time.Time
+}
+
+// Models is every model the schema generator manages.
+var Models = []any{&User{}}
+
+//go:embed migrations/*.sql
+var migrationFS embed.FS
+
+// Schema is the example's own migrations. BootSchema is everything
+// applied at boot: sessions, abono's tables, then the example's.
+var (
+ Schema = migrate.MustFromFS(migrationFS, "example")
+ BootSchema = migrate.Merge(sessions.Schema, abono.Schema, Schema)
+)
+
+func subjectOf(id int64) string { return strconv.FormatInt(id, 10) }
+
+// lookupUser is password.Config.Lookup. A row with no password (a buyer
+// who has not signed up yet) cannot sign in.
+func lookupUser(g *gorm.DB) func(context.Context, string) (int64, string, error) {
+ return func(ctx context.Context, email string) (int64, string, error) {
+ var u User
+ err := g.WithContext(ctx).Where("email = ? AND password_hash <> ''", email).Take(&u).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return 0, "", sql.ErrNoRows
+ }
+ return u.ID, u.PasswordHash, err
+ }
+}
+
+// createUser is password.Config.Create. An address that paid before
+// signing up already has a row with no password: signing up claims it,
+// and with it the membership. Any other existing address is refused as a
+// duplicate.
+func createUser(g *gorm.DB) func(context.Context, string, string) (int64, error) {
+ return func(ctx context.Context, email, hash string) (int64, error) {
+ var id int64
+ err := g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ res := tx.Model(&User{}).Where("email = ? AND password_hash = ''", email).Update("password_hash", hash)
+ if res.Error != nil {
+ return res.Error
+ }
+ if res.RowsAffected == 1 {
+ return tx.Model(&User{}).Where("email = ?", email).Select("id").Scan(&id).Error
+ }
+ u := User{Email: email, PasswordHash: hash, CreatedAt: time.Now().UTC()}
+ if err := tx.Create(&u).Error; err != nil {
+ return err
+ }
+ id = u.ID
+ return nil
+ })
+ return id, err
+ }
+}
+
+// holder is abono's Holder: paying is a way in, so an address abono has
+// no subject for gets a user row, with no password until its owner signs
+// up. It runs inside abono's transaction, so it uses tx.
+func holder(ctx context.Context, tx *gorm.DB, email, _ string) (string, error) {
+ email = strings.ToLower(strings.TrimSpace(email))
+ if email == "" {
+ return "", nil
+ }
+ var u User
+ err := tx.WithContext(ctx).Where("email = ?", email).Take(&u).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ u = User{Email: email, CreatedAt: time.Now().UTC()}
+ err = tx.WithContext(ctx).Create(&u).Error
+ }
+ if err != nil {
+ return "", err
+ }
+ return subjectOf(u.ID), nil
+}
+
+// userByEmail is the subject of the person with this address, for the
+// owner's free-membership form.
+func userByEmail(g *gorm.DB) func(context.Context, string) (string, bool) {
+ return func(ctx context.Context, email string) (string, bool) {
+ var u User
+ if err := g.WithContext(ctx).Where("email = ?", strings.ToLower(strings.TrimSpace(email))).Take(&u).Error; err != nil {
+ return "", false
+ }
+ return subjectOf(u.ID), true
+ }
+}
+
+// emailOf is the address of subject, "" when there is none.
+func emailOf(g *gorm.DB, ctx context.Context, subject string) string {
+ id, err := strconv.ParseInt(subject, 10, 64)
+ if err != nil {
+ return ""
+ }
+ var u User
+ if err := g.WithContext(ctx).Where("id = ?", id).Take(&u).Error; err != nil {
+ return ""
+ }
+ return u.Email
+}
diff --git a/example/templates/abono/account.html b/example/templates/abono/account.html
new file mode 100644
index 0000000..556ca9f
--- /dev/null
+++ b/example/templates/abono/account.html
@@ -0,0 +1,32 @@
+{{/* abono-account: the membership section of your own account page, not
+ a page of its own. Each holding as a plan and a status line, the
+ billing button for a reader who pays through Stripe, a link to the
+ plans for one who holds nothing that grants, and the problem the
+ billing button came back with.
+
+ Call it from your account page with AbonoPages.Account(r) as data:
+ {{template "abono-account" (withL .Abono $L)}}
+
+ Data:
+ Holdings []abono.Holding, newest first
+ Now time.Time, the moment they are described at
+ Manage bool, there is billing to manage
+ Granted bool, something grants access now
+ BillingAction string, where the billing button posts
+ PlansURL string, the pricing page
+ Error string, a message key, or ""
+ L the request's localizer */}}
+{{define "abono-account"}}{{$L := localizer $}}<section rst-box aria-labelledby="abono-account-title">
+<h2 id="abono-account-title">{{T $L "abono.pages.account_title"}}</h2>
+{{with .Error}}{{template "callout" dict "L" $L "Tone" "negative" "Focus" true "Body" (T $L .)}}{{end}}
+{{if .Holdings}}<ul>
+{{range .Holdings}}<li><strong>{{.Plan}}</strong> {{template "abono-status" dict "L" $L "Holding" . "Now" $.Now}}</li>
+{{end}}</ul>
+{{else}}<p>{{T $L "abono.pages.account_none"}}</p>
+{{end}}
+{{if .Manage}}<p>{{T $L "abono.pages.account_manage_help"}}</p>
+{{template "abono-billing-button" dict "L" $L "Action" .BillingAction}}
+{{end}}
+{{if not .Granted}}<p><a rst-btn="primary" href="{{.PlansURL}}">{{T $L "abono.button.see_plans"}}</a></p>
+{{end}}
+</section>{{end}}
diff --git a/example/templates/abono/done.html b/example/templates/abono/done.html
new file mode 100644
index 0000000..850198d
--- /dev/null
+++ b/example/templates/abono/done.html
@@ -0,0 +1,29 @@
+{{/* abono/done: GET /subscribe/done, where Checkout sends the reader
+ back. Paid, it thanks them; a reader who paid signed out is asked to
+ sign in with the address they paid with. Not paid, it says nothing
+ was charged and offers the plans again.
+
+ Data:
+ Title string, the page title's message key
+ Paid bool
+ Email string, the address paid with
+ SignedIn bool
+ AccountURL string, the account page
+ PlansURL string, the pricing page
+ Error string, a message key, or ""
+ L the request's localizer */}}
+{{define "content"}}{{$L := localizer $}}
+{{template "page-header" dict "L" $L "Title" (T $L .Title)}}
+{{if .Paid}}
+<p>{{T $L "abono.pages.done_paid"}}</p>
+{{if .SignedIn}}<p><a rst-btn="primary" href="{{.AccountURL}}">{{T $L "abono.button.go_to_account"}}</a></p>
+{{else}}<p>{{Tf $L "abono.pages.done_signin" "email" .Email}}</p>
+{{end}}
+{{else if .Error}}
+{{template "callout" dict "L" $L "Tone" "negative" "Focus" true "Body" (T $L .Error)}}
+{{if .SignedIn}}<p><a rst-btn="primary" href="{{.AccountURL}}">{{T $L "abono.button.go_to_account"}}</a></p>{{end}}
+{{else}}
+<p>{{T $L "abono.pages.done_unpaid_body"}}</p>
+<p><a rst-btn="primary" href="{{.PlansURL}}">{{T $L "abono.button.try_again"}}</a></p>
+{{end}}
+{{end}}
diff --git a/example/templates/abono/owner-end.html b/example/templates/abono/owner-end.html
new file mode 100644
index 0000000..9ab4722
--- /dev/null
+++ b/example/templates/abono/owner-end.html
@@ -0,0 +1,17 @@
+{{/* abono/owner-end: GET /admin/members/comps/{id}/end, the confirm page
+ for ending a free membership. Its form posts to the same address;
+ the way back keeps it.
+
+ Data:
+ Title string, the page title's message key
+ Member abono.Member, the comp
+ Plan string, the plan's name
+ Action string, where the form posts
+ MembersURL string, the members page
+ L the request's localizer */}}
+{{define "content"}}{{$L := localizer $}}
+{{template "back-nav" dict "Href" .MembersURL "Label" (T $L "abono.owner.members_title")}}
+{{template "page-header" dict "L" $L "Title" (T $L .Title)}}
+<p>{{Tf $L "abono.owner.end_body" "plan" .Plan "member" .Member.Subject}}</p>
+{{template "confirm-form" dict "L" $L "Action" .Action "Label" (T $L "abono.button.end_free_confirm") "Danger" true "CancelHref" .MembersURL "CancelLabel" (T $L "abono.button.keep_free")}}
+{{end}}
diff --git a/example/templates/abono/owner-members.html b/example/templates/abono/owner-members.html
new file mode 100644
index 0000000..f797db7
--- /dev/null
+++ b/example/templates/abono/owner-members.html
@@ -0,0 +1,57 @@
+{{/* abono/owner-members: GET /admin/members, every holding with its
+ status and where it came from, "sync with Stripe now", and the form
+ that gives a free membership (a comp). A comp still granting has a
+ link to its own confirm page, which ends it. A subscription held only
+ by abono's 30-day backstop is flagged with the day access stops.
+
+ Data:
+ Title string, the page title's message key
+ On bool, Stripe (or the sandbox) is configured
+ Members []abonoMember, {Subject, Holding, EndURL}
+ Now time.Time, the moment holdings are described at
+ Offers []abono.Offer, the plans a comp can give
+ PlanOptions []abonoOption, the same as field-select options
+ Form abonoCompForm, {Member, Plan, Ends, Note, Errors}: the
+ comp form as posted, Errors a message key by field
+ SyncAction string, where "sync now" posts
+ CompAction string, where the comp form posts
+ StripeURL string, the Stripe page
+ Done *abonoNotice, {Kind, N, Plan}: what the last action did
+ Error string, a message key, or ""
+ L the request's localizer */}}
+{{define "content"}}{{$L := localizer $}}
+{{template "page-header" dict "L" $L "Title" (T $L .Title) "Sub" (T $L "abono.owner.members_intro")}}
+{{with .Done}}{{if eq .Kind "sync"}}{{if eq .N 1}}{{template "notice" (T $L "abono.owner.sync_done_one")}}{{else}}{{template "notice" (Tf $L "abono.owner.sync_done" "n" .N)}}{{end}}{{else if eq .Kind "comp"}}{{template "notice" (Tf $L "abono.owner.free_given" "plan" .Plan)}}{{else if eq .Kind "end"}}{{template "notice" (T $L "abono.owner.end_done")}}{{end}}{{end}}
+{{with .Error}}{{template "callout" dict "L" $L "Tone" "negative" "Focus" true "Body" (T $L .)}}{{end}}
+{{if .On}}<form rst-form-actions method="post" action="{{.SyncAction}}"><button type="submit" rst-btn>{{T $L "abono.button.sync"}}</button></form>
+<p>{{T $L "abono.owner.sync_help"}}</p>
+{{else}}<p>{{T $L "abono.owner.nothing_for_sale"}} <a href="{{.StripeURL}}">{{T $L "abono.owner.stripe_title"}}</a></p>
+{{end}}
+{{if .Members}}<div rst-box>
+<table>
+<thead><tr><th scope="col">{{T $L "abono.owner.col_member"}}</th><th scope="col">{{T $L "abono.owner.col_plan"}}</th><th scope="col">{{T $L "abono.owner.col_status"}}</th><th scope="col">{{T $L "abono.owner.col_source"}}</th></tr></thead>
+<tbody>
+{{range .Members}}<tr>
+<td>{{.Subject}}</td>
+<td>{{.Plan}}</td>
+<td>{{template "abono-status" dict "L" $L "Holding" .Holding "Now" $.Now}}{{if .Holding.Backstop}}{{$s := .Holding.Describe $.Now}} <small>{{$s.Sentence (T $L "abono.owner.backstop") ""}}</small>{{end}}</td>
+<td>{{if eq .Source "comp"}}{{T $L "abono.owner.source_free"}}{{with .EndURL}} <a href="{{.}}">{{T $L "abono.button.end_free"}}</a>{{end}}{{else}}{{T $L "abono.owner.source_paid"}}{{end}}</td>
+</tr>
+{{end}}</tbody>
+</table>
+</div>
+{{else}}{{template "empty-state" dict "L" $L "Body" (T $L "abono.owner.members_empty")}}
+{{end}}
+{{if .Offers}}{{$memberErr := ""}}{{$planErr := ""}}{{with .Form.Errors}}{{with index . "member"}}{{$memberErr = T $L .}}{{end}}{{with index . "plan"}}{{$planErr = T $L .}}{{end}}{{end}}<section rst-box aria-labelledby="abono-free-title">
+<h2 id="abono-free-title">{{T $L "abono.owner.free_title"}}</h2>
+<p>{{T $L "abono.owner.free_help"}}</p>
+<form rst-form method="post" action="{{.CompAction}}">
+{{template "field-text" dict "L" $L "Name" "member" "Label" (T $L "abono.owner.free_member") "Type" "email" "Value" .Form.Member "Required" true "Autocomplete" "off" "Error" $memberErr}}
+{{template "field-select" dict "L" $L "ID" "plan" "Name" "plan" "Label" (T $L "abono.owner.free_plan") "Options" .PlanOptions "Required" true "Error" $planErr}}
+{{template "field-date" dict "L" $L "Name" "ends" "Label" (T $L "abono.owner.free_ends") "Value" .Form.Ends "Hint" (T $L "abono.owner.free_ends_hint") "Plain" true}}
+{{template "field-text" dict "L" $L "Name" "note" "Label" (T $L "abono.owner.free_note") "Value" .Form.Note "Hint" (T $L "abono.owner.free_note_hint")}}
+{{template "form-foot" dict "L" $L "Submit" (T $L "abono.button.give_free")}}
+</form>
+</section>
+{{end}}
+{{end}}
diff --git a/example/templates/abono/owner-offers.html b/example/templates/abono/owner-offers.html
new file mode 100644
index 0000000..457d622
--- /dev/null
+++ b/example/templates/abono/owner-offers.html
@@ -0,0 +1,46 @@
+{{/* abono/owner-offers: GET /admin/offers, each plan with its prices
+ (the one on sale, and old ones whose members keep the plan), and the
+ paying subscriptions on a price no plan has, each with a form that
+ puts it, and everyone else on its price, onto a plan.
+
+ Data:
+ Title string, the page title's message key
+ On bool, Stripe (or the sandbox) is configured
+ Offers []abono.Offer
+ Unrecognised []abono.Unrecognised, empty while Stripe is off
+ PlanOptions []abonoOption, the plans, as field-select options
+ AdoptAction string, where an adopt form posts
+ StripeURL string, the Stripe page
+ Done *abonoNotice, {Kind, N, Plan}: what the last action did
+ Error string, a message key, or ""
+ L the request's localizer */}}
+{{define "content"}}{{$L := localizer $}}
+{{template "page-header" dict "L" $L "Title" (T $L .Title) "Sub" (T $L "abono.owner.offers_intro")}}
+{{with .Done}}{{if eq .Kind "adopt"}}{{template "notice" (Tf $L "abono.owner.adopted" "plan" .Plan)}}{{end}}{{end}}
+{{with .Error}}{{template "callout" dict "L" $L "Tone" "negative" "Focus" true "Body" (T $L .)}}{{end}}
+{{if not .On}}<p>{{T $L "abono.owner.nothing_for_sale"}} <a href="{{.StripeURL}}">{{T $L "abono.owner.stripe_title"}}</a></p>
+{{end}}
+{{range .Offers}}<section rst-box>
+<h2>{{.Name}}</h2>
+<ul>
+{{range .Prices}}<li><code>{{.ID}}</code>{{if .Currency}}{{$m := money $L .Amount .Currency}} {{if eq .Interval "month"}}{{Tf $L "abono.price.per_month" "amount" $m}}{{else if eq .Interval "year"}}{{Tf $L "abono.price.per_year" "amount" $m}}{{else}}{{$m}}{{end}}{{end}} {{if .Sold}}{{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_sold") "Tone" "positive"}}{{else}}{{template "badge" dict "L" $L "Label" (T $L "abono.owner.price_legacy")}}{{end}}</li>
+{{end}}</ul>
+</section>
+{{else}}{{template "empty-state" dict "L" $L "Body" (T $L "abono.owner.offers_empty")}}
+{{end}}
+{{if .On}}<section rst-box aria-labelledby="abono-unrecognised-title">
+<h2 id="abono-unrecognised-title">{{T $L "abono.owner.unrecognised_title"}}</h2>
+{{if .Unrecognised}}<p>{{T $L "abono.owner.unrecognised_body"}}</p>
+<p>{{T $L "abono.owner.unrecognised_warn"}}</p>
+{{range .Unrecognised}}<form rst-form method="post" action="{{$.AdoptAction}}">
+<p><code>{{.SubscriptionID}}</code> <code>{{.PriceID}}</code> {{.Subject}}</p>
+<input type="hidden" name="subscription" value="{{.SubscriptionID}}">
+{{template "field-select" dict "L" $L "ID" (printf "abono-adopt-%s" .SubscriptionID) "Name" "plan" "Label" (T $L "abono.owner.adopt_plan") "Options" $.PlanOptions "Required" true}}
+<button type="submit" rst-btn>{{T $L "abono.button.adopt"}}</button>
+</form>
+{{end}}
+{{else}}<p>{{T $L "abono.owner.unrecognised_none"}}</p>
+{{end}}
+</section>
+{{end}}
+{{end}}
diff --git a/example/templates/abono/owner-stripe.html b/example/templates/abono/owner-stripe.html
new file mode 100644
index 0000000..826aa49
--- /dev/null
+++ b/example/templates/abono/owner-stripe.html
@@ -0,0 +1,23 @@
+{{/* abono/owner-stripe: GET /admin/stripe, how payments are taken: the
+ sandbox, Stripe in test mode, Stripe live, or not at all (and what to
+ do about it), and the address Stripe's webhook must call.
+
+ Data:
+ Title string, the page title's message key
+ Mode string, sandbox, test, live or off
+ WebhookURL string, the site's origin plus abono.WebhookPath
+ L the request's localizer */}}
+{{define "content"}}{{$L := localizer $}}
+{{template "page-header" dict "L" $L "Title" (T $L .Title)}}
+{{if eq .Mode "sandbox"}}{{template "callout" dict "L" $L "Tone" "warning" "Body" (T $L "abono.owner.stripe_sandbox")}}
+{{else if eq .Mode "test"}}{{template "callout" dict "L" $L "Tone" "info" "Body" (T $L "abono.owner.stripe_test")}}
+{{else if eq .Mode "live"}}{{template "callout" dict "L" $L "Tone" "positive" "Body" (T $L "abono.owner.stripe_on")}}
+{{else}}{{template "callout" dict "L" $L "Tone" "warning" "Body" (T $L "abono.owner.stripe_off")}}
+<p>{{T $L "abono.owner.stripe_off_help"}}</p>
+{{end}}
+{{if or (eq .Mode "test") (eq .Mode "live")}}<section rst-box>
+<p>{{Tf $L "abono.owner.stripe_webhook" "url" .WebhookURL}}</p>
+<p>{{T $L "abono.owner.stripe_webhook_help"}}</p>
+</section>
+{{end}}
+{{end}}
diff --git a/example/templates/abono/plan.html b/example/templates/abono/plan.html
new file mode 100644
index 0000000..0fc8530
--- /dev/null
+++ b/example/templates/abono/plan.html
@@ -0,0 +1,30 @@
+{{/* abono/plan: GET /subscribe/{plan}, one plan with a card per interval
+ it is sold at and a promotion code field, and the page a failed
+ POST /subscribe/{plan} comes back to with its message.
+
+ Data:
+ Title string, the page title's message key
+ Open bool, something is for sale
+ Cards []abonoCard, this plan's cards; none when the plan is
+ not on sale
+ SignedIn bool
+ PlansURL string, the pricing page
+ Error string, a message key, or ""
+ L the request's localizer */}}
+{{define "content"}}{{$L := localizer $}}
+{{template "back-nav" dict "Href" .PlansURL "Label" (T $L "abono.pages.plan_back")}}
+{{if .Cards}}{{$offer := (index .Cards 0).Offer}}
+{{if gt (len .Cards) 1}}{{template "page-header" dict "L" $L "Title" $offer.Name "Sub" (T $L "abono.pages.plan_intro")}}{{else}}{{template "page-header" dict "L" $L "Title" $offer.Name}}{{end}}
+{{with .Error}}{{template "callout" dict "L" $L "Tone" "negative" "Focus" true "Body" (T $L .)}}{{end}}
+{{range .Cards}}{{template "abono-plan-card" dict "L" $L "Offer" .Offer "Interval" .Interval "Action" .Action "Code" true}}
+{{end}}
+{{if not .SignedIn}}<p>{{T $L "abono.pages.plan_signed_out"}}</p>{{end}}
+{{else if .Open}}
+{{template "page-header" dict "L" $L "Title" (T $L "abono.pages.pricing_title")}}
+{{with .Error}}{{template "callout" dict "L" $L "Tone" "negative" "Focus" true "Body" (T $L .)}}{{end}}
+{{else}}
+{{template "page-header" dict "L" $L "Title" (T $L "abono.pages.pricing_title")}}
+{{with .Error}}{{template "callout" dict "L" $L "Tone" "negative" "Focus" true "Body" (T $L .)}}{{end}}
+{{template "empty-state" dict "L" $L "Title" (T $L "abono.pages.closed_title") "Body" (T $L "abono.pages.closed_body")}}
+{{end}}
+{{end}}
diff --git a/example/templates/abono/pricing.html b/example/templates/abono/pricing.html
new file mode 100644
index 0000000..c68c0df
--- /dev/null
+++ b/example/templates/abono/pricing.html
@@ -0,0 +1,20 @@
+{{/* abono/pricing: GET /subscribe, every plan on sale, each a card whose
+ button starts Checkout. When nothing is for sale (Stripe is off, or
+ no plan has a price on sale) it says so instead.
+
+ Data:
+ Title string, the page title's message key
+ Open bool, something is for sale
+ Cards []abonoCard, {Offer, Interval, Action}: one per plan and
+ interval, for the abono-plan-card partial
+ L the request's localizer */}}
+{{define "content"}}{{$L := localizer $}}
+{{if .Open}}
+{{template "page-header" dict "L" $L "Title" (T $L "abono.pages.pricing_title") "Sub" (T $L "abono.pages.pricing_intro")}}
+{{range .Cards}}{{template "abono-plan-card" dict "L" $L "Offer" .Offer "Interval" .Interval "Action" .Action}}
+{{end}}
+{{else}}
+{{template "page-header" dict "L" $L "Title" (T $L "abono.pages.pricing_title")}}
+{{template "empty-state" dict "L" $L "Title" (T $L "abono.pages.closed_title") "Body" (T $L "abono.pages.closed_body")}}
+{{end}}
+{{end}}
diff --git a/example/templates/account.html b/example/templates/account.html
new file mode 100644
index 0000000..3536cf7
--- /dev/null
+++ b/example/templates/account.html
@@ -0,0 +1,4 @@
+{{define "content"}}{{$L := localizer $}}
+{{template "page-header" dict "L" $L "Title" (T $L "rastrillo.ui.shell_account")}}
+{{template "abono-account" (withL .Abono $L)}}
+{{end}}
diff --git a/example/templates/layout.html b/example/templates/layout.html
new file mode 100644
index 0000000..3e18cc8
--- /dev/null
+++ b/example/templates/layout.html
@@ -0,0 +1,28 @@
+{{define "layout"}}{{$L := localizer $}}<!doctype html>
+<html lang="{{$L.Identity}}" dir="{{$L.Dir}}">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width">
+<link rel="stylesheet" href="/static/app.css">
+<title>{{with .Title}}{{T $L .}}{{end}}</title>
+</head>
+<body>
+<header>
+<nav>
+<a href="/subscribe">{{T $L "abono.pages.pricing_title"}}</a>
+{{if .SignedIn}}<a href="/account">{{T $L "rastrillo.ui.shell_account"}}</a>
+{{if .Owner}}<a href="/admin/members">{{T $L "abono.owner.members_title"}}</a>
+<a href="/admin/offers">{{T $L "abono.owner.offers_title"}}</a>
+<a href="/admin/stripe">{{T $L "abono.owner.stripe_title"}}</a>
+{{end}}<form method="post" action="/signout"><button type="submit" rst-btn="ghost">{{T $L "example.signout"}}</button></form>
+{{else}}<a href="/signin">{{T $L "rastrillo.ui.signin_confirm_submit"}}</a>
+<a href="/signup">{{T $L "example.signup"}}</a>
+{{end}}
+</nav>
+</header>
+<main rst-page>
+{{template "content" .}}
+</main>
+</body>
+</html>
+{{end}}
diff --git a/example/templates/signin.html b/example/templates/signin.html
new file mode 100644
index 0000000..1c6ae8f
--- /dev/null
+++ b/example/templates/signin.html
@@ -0,0 +1,13 @@
+{{define "content"}}{{$L := localizer $}}
+{{template "page-header" dict "L" $L "Title" (T $L "rastrillo.ui.signin_confirm_submit")}}
+{{with .Form}}
+{{template "form-error" .Error}}
+<form rst-form method="post" action="/signin">
+<input type="hidden" name="return_to" value="{{.ReturnTo}}">
+{{template "field-text" dict "L" $L "Name" "email" "Label" (T $L "rastrillo.ui.signin_email") "Type" "email" "Value" .Email "Required" true "Autofocus" true "Autocomplete" "email"}}
+{{template "field-text" dict "L" $L "Name" "password" "Label" (T $L "example.password") "Type" "password" "Required" true "Autocomplete" "current-password"}}
+{{template "form-foot" dict "L" $L "Submit" (T $L "rastrillo.ui.signin_confirm_submit")}}
+</form>
+{{end}}
+<p><a href="/signup">{{T $L "example.signup"}}</a></p>
+{{end}}
diff --git a/example/templates/signup.html b/example/templates/signup.html
new file mode 100644
index 0000000..f79eba0
--- /dev/null
+++ b/example/templates/signup.html
@@ -0,0 +1,13 @@
+{{define "content"}}{{$L := localizer $}}
+{{template "page-header" dict "L" $L "Title" (T $L "example.signup")}}
+{{with .Form}}
+{{template "form-error" .Error}}
+<form rst-form method="post" action="/signup">
+<input type="hidden" name="return_to" value="{{.ReturnTo}}">
+{{template "field-text" dict "L" $L "Name" "email" "Label" (T $L "rastrillo.ui.signin_email") "Type" "email" "Value" .Email "Required" true "Autofocus" true "Autocomplete" "email"}}
+{{template "field-text" dict "L" $L "Name" "password" "Label" (T $L "example.password") "Type" "password" "Required" true "Autocomplete" "new-password" "Minlength" "8"}}
+{{template "form-foot" dict "L" $L "Submit" (T $L "example.signup")}}
+</form>
+{{end}}
+<p><a href="/signin">{{T $L "rastrillo.ui.signin_confirm_submit"}}</a></p>
+{{end}}
diff --git a/owner.go b/owner.go
new file mode 100644
index 0000000..525731f
--- /dev/null
+++ b/owner.go
@@ -0,0 +1,61 @@
+package abono
+
+import (
+ "context"
+ "strings"
+ "time"
+)
+
+// Member is one holding and whose it is, for an owner's screen.
+type Member struct {
+ Subject string
+ Holding
+}
+
+// Members is every holding of every subject: subscriptions and comps,
+// the ones that no longer grant included, ordered by subject and then
+// newest first, as Holdings orders one subject's. Rows not tied to a
+// subject (unclaimed payments) and subscriptions on a price no offer
+// recognises are left out, as Holdings leaves them out; Unrecognised
+// lists the second kind.
+func (s *Service) Members(ctx context.Context) ([]Member, error) {
+ var subjects []string
+ if err := s.cfg.DB.WithContext(ctx).Raw(`SELECT subject FROM abono_subscriptions WHERE subject <> '' AND plan <> ''
+ UNION SELECT subject FROM abono_comps WHERE subject <> '' ORDER BY subject`).Scan(&subjects).Error; err != nil {
+ return nil, err
+ }
+ var out []Member
+ for _, subject := range subjects {
+ hs, err := s.Holdings(ctx, subject)
+ if err != nil {
+ return nil, err
+ }
+ for _, h := range hs {
+ out = append(out, Member{Subject: subject, Holding: h})
+ }
+ }
+ return out, nil
+}
+
+// TestMode reports whether the service runs on a Stripe test key
+// (sk_test_ or rk_test_), read from the key's prefix (ruling R8): a
+// restricted key may not be allowed to ask Stripe. The sandbox is not
+// test mode; Sandbox says so.
+func (s *Service) TestMode() bool {
+ if s == nil {
+ return false
+ }
+ return strings.HasPrefix(s.cfg.Key, "sk_test_") || strings.HasPrefix(s.cfg.Key, "rk_test_")
+}
+
+// Sandbox reports whether the sandbox stands in for Stripe.
+func (s *Service) Sandbox() bool { return s != nil && s.sandbox != nil }
+
+// Origin is Config.Origin, the site's own address: the webhook's address
+// is Origin plus wherever the app mounted ServeWebhook.
+func (s *Service) Origin() string { return s.cfg.Origin }
+
+// Now is the moment holdings are judged at, the clock Config.Now gave,
+// in UTC. A page that describes a Holding passes this as the status
+// partial's Now, so its words agree with Holding.Grants.
+func (s *Service) Now() time.Time { return s.now() }
diff --git a/owner_test.go b/owner_test.go
new file mode 100644
index 0000000..b175af5
--- /dev/null
+++ b/owner_test.go
@@ -0,0 +1,114 @@
+package abono_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/abono"
+)
+
+// TestMembersListsEveryHolding: the owner's members screen sees every
+// subject's holdings, subscriptions and comps, ended ones included, by
+// subject and then newest first, and never an unclaimed or unrecognised
+// row (neither has anyone to show, and neither grants).
+func TestMembersListsEveryHolding(t *testing.T) {
+ now := t0
+ d := newStripeDouble(t)
+ s, _ := d.service(t, &now, abono.Config{})
+ seedPlans(t, s)
+ ctx := context.Background()
+
+ end := t0.AddDate(1, 0, 0)
+ for _, sp := range []subSpec{
+ {ID: "sub_b", Status: "active", Price: "price_A", Customer: "cus_b", Email: "b@example.com", Start: t0, End: end, Metadata: map[string]string{"abono_subject": "b"}},
+ {ID: "sub_x", Status: "active", Price: "price_unknown", Customer: "cus_x", Email: "x@example.com", Start: t0, End: end, Metadata: map[string]string{"abono_subject": "x"}},
+ {ID: "sub_u", Status: "active", Price: "price_A", Customer: "cus_u", Email: "u@example.com", Start: t0, End: end},
+ } {
+ if err := s.Sync(ctx, d.put(t, sp), abono.Hint{Subject: sp.Metadata["abono_subject"], Email: sp.Email}); err != nil {
+ t.Fatalf("Sync %s: %v", sp.ID, err)
+ }
+ }
+ // The unclaimed one: no Holder, so sub_u has an address and nobody.
+ id, err := s.Comp(ctx, "a", "monthly", nil, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ now = t0.Add(time.Minute)
+ ended, err := s.Comp(ctx, "b", "monthly", nil, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.EndComp(ctx, ended); err != nil {
+ t.Fatal(err)
+ }
+
+ ms, err := s.Members(ctx)
+ if err != nil {
+ t.Fatalf("Members: %v", err)
+ }
+ type row struct {
+ subject, plan string
+ source abono.Source
+ grants bool
+ comp int64
+ }
+ var got []row
+ for _, m := range ms {
+ got = append(got, row{m.Subject, m.Plan, m.Source, m.Grants, m.CompID})
+ }
+ want := []row{
+ {"a", "monthly", abono.SourceComp, true, id},
+ {"b", "monthly", abono.SourceComp, false, ended},
+ {"b", "annual", abono.SourceSubscription, true, 0},
+ }
+ if len(got) != len(want) {
+ t.Fatalf("Members = %+v, want %+v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("Members[%d] = %+v, want %+v", i, got[i], want[i])
+ }
+ }
+}
+
+func TestMembersIsEmptyOnAFreshSite(t *testing.T) {
+ now := t0
+ s, _ := newService(t, &now, abono.Config{})
+ ms, err := s.Members(context.Background())
+ if err != nil || len(ms) != 0 {
+ t.Errorf("Members = %v, %v; want none", ms, err)
+ }
+}
+
+// TestModeAccessors are what the owner's Stripe page reads (ruling R8):
+// test or live from the key's prefix, the sandbox, the origin the
+// webhook address is built on, and the clock holdings are judged by.
+func TestModeAccessors(t *testing.T) {
+ now := t0
+ for _, c := range []struct {
+ name string
+ cfg abono.Config
+ on, test, sandbox bool
+ }{
+ {"off", abono.Config{Origin: "https://site.example"}, false, false, false},
+ {"restricted test key", abono.Config{Origin: "https://site.example", Key: "rk_test_x", WebhookSecrets: []string{"whsec_x"}}, true, true, false},
+ {"secret test key", abono.Config{Origin: "https://site.example", Key: "sk_test_x", WebhookSecrets: []string{"whsec_x"}}, true, true, false},
+ {"live key", abono.Config{Origin: "https://site.example", Key: "rk_live_x", WebhookSecrets: []string{"whsec_x"}}, true, false, false},
+ {"sandbox", abono.Config{Origin: "http://localhost:8080", Sandbox: true}, true, false, true},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ s, _ := newService(t, &now, c.cfg)
+ if s.On() != c.on || s.TestMode() != c.test || s.Sandbox() != c.sandbox {
+ t.Errorf("On, TestMode, Sandbox = %v, %v, %v; want %v, %v, %v",
+ s.On(), s.TestMode(), s.Sandbox(), c.on, c.test, c.sandbox)
+ }
+ if s.Origin() != c.cfg.Origin {
+ t.Errorf("Origin = %q, want %q", s.Origin(), c.cfg.Origin)
+ }
+ if !s.Now().Equal(t0) {
+ t.Errorf("Now = %v, want the service's clock, %v", s.Now(), t0)
+ }
+ })
+ }
+}
diff --git a/templates_test.go b/templates_test.go
index 02acff9..7a993e7 100644
--- a/templates_test.go
+++ b/templates_test.go
@@ -596,9 +596,10 @@ var consumers = []struct {
{"templates", false, false, []string{".html"}},
{".", false, false, []string{".go"}},
{"internal/fakestripe", true, false, []string{".go"}},
- // The starter pages land with Task 9; until they do the directory is
- // absent, and only that is allowed to be.
- {"cmd/abono", true, true, []string{".html", ".go"}},
+ // The starter pages: the generator, and the pages and Go it writes,
+ // embedded as .tmpl. The example's copies are byte-for-byte these
+ // (cmd/abono's TestGeneratedPagesMatchTheExample).
+ {"cmd/abono", true, false, []string{".html", ".go", ".tmpl"}},
}
// usedKeys is every abono.* key a consumer names. A directory that cannot
@@ -649,6 +650,7 @@ func TestUsedKeysSeesEveryConsumer(t *testing.T) {
for _, k := range []string{
abono.StatusFailingNoDate, "abono.paywall.title", // Go constant, partial
"abono.sandbox.pay", "abono.sandbox.portal_return", // the sandbox pages
+ "abono.pages.closed_body", "abono.error.free_plan", // a starter page, the starter pages' Go
} {
if !used[k] {
t.Errorf("usedKeys misses %s", k)