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.
38 files changed,
+3797
−14
.gitignore+2 −0Makefile+15 −11cmd/abono/main.go+164 −0cmd/abono/main_test.go+312 −0cmd/abono/pages/abono_pages.go.tmpl+727 −0cmd/abono/pages/account.html.tmpl+32 −0cmd/abono/pages/done.html.tmpl+29 −0cmd/abono/pages/owner-end.html.tmpl+17 −0cmd/abono/pages/owner-members.html.tmpl+57 −0cmd/abono/pages/owner-offers.html.tmpl+46 −0cmd/abono/pages/owner-stripe.html.tmpl+23 −0cmd/abono/pages/plan.html.tmpl+30 −0cmd/abono/pages/pricing.html.tmpl+20 −0example/README.md+50 −0example/abono_pages.go+727 −0example/app.go+299 −0example/app_test.go+335 −0example/browser_test.go+90 −0example/go.mod+38 −0example/go.sum+99 −0example/main.go+63 −0example/migrations/0001_init.sql+3 −0example/models.go+127 −0example/templates/abono/account.html+32 −0example/templates/abono/done.html+29 −0example/templates/abono/owner-end.html+17 −0example/templates/abono/owner-members.html+57 −0example/templates/abono/owner-offers.html+46 −0example/templates/abono/owner-stripe.html+23 −0example/templates/abono/plan.html+30 −0example/templates/abono/pricing.html+20 −0example/templates/account.html+4 −0example/templates/layout.html+28 −0example/templates/signin.html+13 −0example/templates/signup.html+13 −0owner.go+61 −0owner_test.go+114 −0templates_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
Intent
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) |