rastrillo / abono Public

abono: repository, schema and frozen checksum

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

Summary

Skeleton, six-table migration and frozen checksum for the abono addon
Agent notes
Skeleton copied from aviso v0.1.0; Makefile example lines are conditional and browser runs only once a browser test file exists. Requires rastrillo at pseudo-version v0.28.1-0.20261021231956-5c2b66395b29 (origin/main). Checksum recorded: 2e452a38...ab96c.
claude agent, operated by paul@keymail.dev b8184fd002fff8459c7c6055a803a3f801ceeffd parent 5524ac6
15 files changed, +423 −1
  • .amadan/ci +6 −0
  • .amadan/ci.d/10-vet +3 −0
  • .amadan/ci.d/20-fmt +3 −0
  • .amadan/ci.d/30-test +3 −0
  • .amadan/ci.d/40-race +3 −0
  • .amadan/ci.d/50-browser +3 −0
  • .gitignore +4 −0
  • Makefile +38 −0
  • README.md +3 −1
  • doc.go +9 −0
  • go.mod +23 −0
  • go.sum +75 −0
  • migrations.go +16 −0
  • migrations/0001_init.sql +75 −0
  • schema_test.go +159 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
go: downloading amadan.net/rastrillo/rastrillo v0.28.1-0.20261021231956-5c2b66395b29
go: downloading modernc.org/sqlite v1.55.0
go: downloading gorm.io/gorm v1.31.2
go: downloading gorm.io/plugin/dbresolver v1.6.2
go: downloading github.com/jinzhu/now v1.1.5
go: downloading golang.org/x/text v0.39.0
go: downloading github.com/jinzhu/inflection v1.0.0
go: downloading modernc.org/libc v1.74.1
go: downloading golang.org/x/sys v0.46.0
go: downloading github.com/dustin/go-humanize v1.0.1
go: downloading modernc.org/memory v1.11.0
go: downloading modernc.org/mathutil v1.7.1
go: downloading github.com/google/uuid v1.6.0
go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec
=== fmt ===
=== test ===
go test ./... -count=1
ok  	amadan.net/rastrillo/abono	0.011s
=== race ===
CGO_ENABLED=1 go test ./... -race -count=1
ok  	amadan.net/rastrillo/abono	1.095s
=== browser ===
browser: no browser test file yet, nothing to run
diff --git a/.amadan/ci b/.amadan/ci
new file mode 100755
index 0000000..8c547ee
--- /dev/null
+++ b/.amadan/ci
@@ -0,0 +1,6 @@
+#!/bin/sh
+# amadan CI entry (single-script fallback for runners without step
+# support). The steps in ci.d/ are the same targets, reported one by
+# one. Must stay executable: a non-executable script resolves "skipped".
+set -e
+exec make ci
diff --git a/.amadan/ci.d/10-vet b/.amadan/ci.d/10-vet
new file mode 100755
index 0000000..5c35ae6
--- /dev/null
+++ b/.amadan/ci.d/10-vet
@@ -0,0 +1,3 @@
+#!/bin/sh
+set -e
+exec make vet
diff --git a/.amadan/ci.d/20-fmt b/.amadan/ci.d/20-fmt
new file mode 100755
index 0000000..f78b24e
--- /dev/null
+++ b/.amadan/ci.d/20-fmt
@@ -0,0 +1,3 @@
+#!/bin/sh
+set -e
+exec make fmt-check
diff --git a/.amadan/ci.d/30-test b/.amadan/ci.d/30-test
new file mode 100755
index 0000000..ea440b4
--- /dev/null
+++ b/.amadan/ci.d/30-test
@@ -0,0 +1,3 @@
+#!/bin/sh
+set -e
+exec make test
diff --git a/.amadan/ci.d/40-race b/.amadan/ci.d/40-race
new file mode 100755
index 0000000..1da1751
--- /dev/null
+++ b/.amadan/ci.d/40-race
@@ -0,0 +1,3 @@
+#!/bin/sh
+set -e
+exec make race
diff --git a/.amadan/ci.d/50-browser b/.amadan/ci.d/50-browser
new file mode 100755
index 0000000..1f3bc2a
--- /dev/null
+++ b/.amadan/ci.d/50-browser
@@ -0,0 +1,3 @@
+#!/bin/sh
+set -e
+exec make browser
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..47a58c5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+# Build output only — nothing in here is source.
+/releases/
+# Node's install tree, if anyone runs the js tests with a local node_modules.
+/node_modules/
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..4c204be
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,38 @@
+.PHONY: vet fmt-check test race browser ci
+
+# ci is the one gate: what a runner executes and what you run before
+# pushing are the same definition (amadan's own rule: CI steps
+# delegate to make targets, never keep their own copies).
+ci: vet fmt-check test race browser
+
+# The example, when there is one, is its own module with a replace
+# back here, so ./... does not reach it; each target visits it
+# explicitly.
+vet:
+ go vet ./...
+ @if [ -d example ]; then cd example && go vet ./...; fi
+
+fmt-check:
+ @out=$$(gofmt -l .); if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi
+
+# -count=1 on purpose: these tests build a real SQLite database; a
+# cached PASS re-reports one run as though it were every run.
+test:
+ go test ./... -count=1
+ @if [ -d example ]; then cd example && go test ./... -count=1; fi
+
+# -race needs cgo; everything else runs with the default toolchain, so
+# this target sets CGO_ENABLED for its own command only.
+race:
+ CGO_ENABLED=1 go test ./... -race -count=1
+
+# 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.
+browser:
+ @if ls *_browser_test.go browser_test.go >/dev/null 2>&1; then \
+ go test -tags browser -run TestBrowser -count=1 .; \
+ else \
+ echo "browser: no browser test file yet, nothing to run"; \
+ fi
diff --git a/README.md b/README.md
index f5facfd..1946ab0 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,5 @@
# abono
-Payments and subscriptions on Stripe for rastrillo apps. Work in progress; see the v0.1 branch.
+Payments and memberships on Stripe for rastrillo apps. It takes a person to Stripe's hosted checkout, records the subscriptions Stripe reports back, lets an operator grant a plan by hand, and tells the app which plan a signed-in person holds. An addon: `amadan.net/rastrillo/abono` depends on rastrillo, never the reverse.
+
+It is a work in progress and not yet tagged. When it is, `SKILL.md` will be the authoring doc: how to wire it into an app, and what the app still has to decide for itself.
diff --git a/doc.go b/doc.go
new file mode 100644
index 0000000..63dc56a
--- /dev/null
+++ b/doc.go
@@ -0,0 +1,9 @@
+// Package abono is rastrillo's payments addon: Stripe subscriptions
+// and one-off payments for an app, and the memberships they grant.
+//
+// Two rulings bind everything here, both from rastrillo's addons
+// doctrine. The arrow points one way: abono depends on rastrillo,
+// rastrillo never learns abono exists. The app owns policy: abono
+// records what Stripe says and what an operator grants, and the app
+// decides what a plan lets a person do.
+package abono
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..72d66de
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,23 @@
+module amadan.net/rastrillo/abono
+
+go 1.25.0
+
+require amadan.net/rastrillo/rastrillo v0.28.1-0.20261021231956-5c2b66395b29
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // 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/sys v0.46.0 // indirect
+ golang.org/x/text v0.39.0 // indirect
+ gorm.io/gorm v1.31.2 // 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
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..1eb92c6
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,75 @@
+amadan.net/rastrillo/rastrillo v0.28.1-0.20261021231956-5c2b66395b29 h1:hkNemSeUWs/i8Pow8UzkPpY/XyxlV8gfQgNsYcbXC6M=
+amadan.net/rastrillo/rastrillo v0.28.1-0.20261021231956-5c2b66395b29/go.mod h1:jSSeydqEvlm/fU3hhWoXFnyEaDgroR8LPRZSo9ZhhIM=
+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-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/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/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/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/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/migrations.go b/migrations.go
new file mode 100644
index 0000000..9e0b317
--- /dev/null
+++ b/migrations.go
@@ -0,0 +1,16 @@
+package abono
+
+import (
+ "embed"
+
+ "amadan.net/rastrillo/rastrillo/migrate"
+)
+
+//go:embed migrations/*.sql
+var migrationFS embed.FS
+
+// Schema is the addon's migration set. Merge it into the app's
+// BootSchema, never its Schema: `rastrillo migration check` diffs
+// Schema against Models and would propose dropping a table Models
+// does not know about.
+var Schema = migrate.MustFromFS(migrationFS, "abono")
diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql
new file mode 100644
index 0000000..afb317a
--- /dev/null
+++ b/migrations/0001_init.sql
@@ -0,0 +1,75 @@
+-- abono's tables. Timestamps are DATETIME, not TEXT: modernc.org/sqlite
+-- decodes a stored timestamp into a time.Time only when the column's
+-- declared type is DATE, DATETIME or TIMESTAMP.
+--
+-- No foreign key reaches an app's identity table: `subject` is the
+-- rastrillo session subject, and apps own their identity schema.
+
+-- The plans an app sells. `plan` is the app's own name for it.
+CREATE TABLE abono_offers (
+ plan TEXT NOT NULL PRIMARY KEY,
+ name TEXT NOT NULL DEFAULT '',
+ created_at DATETIME NOT NULL,
+ archived_at DATETIME
+);
+
+-- The Stripe prices that belong to a plan. `sold` marks a price the
+-- app offers to new buyers; a price no longer sold stays, so that
+-- existing subscriptions on it still resolve to their plan.
+CREATE TABLE abono_prices (
+ stripe_price_id TEXT NOT NULL PRIMARY KEY,
+ plan TEXT NOT NULL REFERENCES abono_offers(plan),
+ sold INTEGER NOT NULL DEFAULT 0,
+ interval TEXT NOT NULL DEFAULT '',
+ amount INTEGER NOT NULL DEFAULT 0,
+ currency TEXT NOT NULL DEFAULT '',
+ refreshed_at DATETIME
+);
+CREATE INDEX abono_prices_plan ON abono_prices(plan);
+
+-- Which Stripe customer is which subject.
+CREATE TABLE abono_customers (
+ stripe_customer_id TEXT NOT NULL PRIMARY KEY,
+ subject TEXT NOT NULL
+);
+CREATE INDEX abono_customers_subject ON abono_customers(subject);
+
+-- Stripe's subscriptions, as last reported. plan = '' means the price
+-- was not recognised. subject = '' with `email` set means unclaimed:
+-- paid for, not yet tied to a signed-in person.
+CREATE TABLE abono_subscriptions (
+ stripe_subscription_id TEXT NOT NULL PRIMARY KEY,
+ stripe_customer_id TEXT NOT NULL,
+ subject TEXT NOT NULL DEFAULT '',
+ plan TEXT NOT NULL DEFAULT '',
+ stripe_price_id TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL,
+ current_period_end DATETIME,
+ cancel_at_period_end INTEGER NOT NULL DEFAULT 0,
+ email TEXT NOT NULL DEFAULT '',
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL
+);
+CREATE INDEX abono_subscriptions_subject ON abono_subscriptions(subject);
+CREATE INDEX abono_subscriptions_customer ON abono_subscriptions(stripe_customer_id);
+
+-- Plans an operator gives a subject without payment. A comp, not a
+-- grant, so it is never confused with "grants access".
+CREATE TABLE abono_comps (
+ id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ subject TEXT NOT NULL,
+ plan TEXT NOT NULL,
+ status TEXT NOT NULL CHECK (status IN ('active', 'canceled')),
+ ends_at DATETIME,
+ note TEXT NOT NULL DEFAULT '',
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL
+);
+CREATE INDEX abono_comps_subject ON abono_comps(subject);
+
+-- Stripe webhook events already handled, so a redelivery is a no-op.
+CREATE TABLE abono_events (
+ id TEXT NOT NULL PRIMARY KEY,
+ type TEXT NOT NULL,
+ received_at DATETIME NOT NULL
+);
diff --git a/schema_test.go b/schema_test.go
new file mode 100644
index 0000000..b21c55f
--- /dev/null
+++ b/schema_test.go
@@ -0,0 +1,159 @@
+package abono_test
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/migrate"
+ "amadan.net/rastrillo/rastrillo/sessions"
+
+ "amadan.net/rastrillo/abono"
+)
+
+// openDB is a fresh, on-disk (t.TempDir) rastrillo db: a real
+// database, not an in-memory stand-in.
+func openDB(t *testing.T) *db.DB {
+ t.Helper()
+ d, err := db.Open(filepath.Join(t.TempDir(), "abono.db"), nil)
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+ return d
+}
+
+// TestSchemaAppliesAndReplays checks that merging abono.Schema in
+// after sessions.Schema, the documented BootSchema order, creates all
+// six tables, and that a second Apply is a no-op.
+func TestSchemaAppliesAndReplays(t *testing.T) {
+ d := openDB(t)
+ full := migrate.Merge(sessions.Schema, abono.Schema)
+
+ first, err := migrate.Apply(context.Background(), d, full)
+ if err != nil {
+ t.Fatalf("first migrate.Apply: %v", err)
+ }
+ if len(first.Applied) == 0 {
+ t.Fatal("first migrate.Apply applied nothing; a no-op second run could not be told from a broken first one")
+ }
+ second, err := migrate.Apply(context.Background(), d, full)
+ if err != nil {
+ t.Fatalf("second migrate.Apply: %v", err)
+ }
+ if len(second.Applied) != 0 {
+ t.Errorf("second migrate.Apply applied %v, want none", second.Applied)
+ }
+
+ for _, name := range []string{
+ "abono_offers", "abono_prices", "abono_customers",
+ "abono_subscriptions", "abono_comps", "abono_events",
+ } {
+ var got string
+ err := d.Writer().QueryRow(
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, name,
+ ).Scan(&got)
+ if err != nil {
+ t.Errorf("table %s missing: %v", name, err)
+ }
+ }
+}
+
+// TestSchemaTimestampsDecode writes a comp with an ends_at and reads
+// it back through gorm into a *time.Time. modernc.org/sqlite hands a
+// timestamp back as a time.Time only when the column's DECLARED type
+// is DATE, DATETIME or TIMESTAMP; against TEXT columns every gorm read
+// fails with "unsupported Scan, storing driver.Value type string into
+// type *time.Time". Reverting a column to TEXT must fail here, by name.
+func TestSchemaTimestampsDecode(t *testing.T) {
+ d := openDB(t)
+ if _, err := migrate.Apply(context.Background(), d,
+ migrate.Merge(sessions.Schema, abono.Schema)); err != nil {
+ t.Fatalf("migrate.Apply: %v", err)
+ }
+
+ now := time.Now().UTC().Truncate(time.Millisecond)
+ ends := now.Add(48 * time.Hour)
+ if _, err := d.Writer().Exec(
+ `INSERT INTO abono_comps (subject, plan, status, ends_at, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ "s1", "supporter", "active", ends, now, now,
+ ); err != nil {
+ t.Fatalf("inserting a comp: %v", err)
+ }
+
+ type comp struct {
+ ID int64
+ Subject string
+ EndsAt *time.Time
+ CreatedAt time.Time
+ }
+ var got comp
+ if err := d.G.Table("abono_comps").Where("subject = ?", "s1").Take(&got).Error; err != nil {
+ t.Fatalf("reading the comp back: %v\n"+
+ "An \"unsupported Scan ... into type *time.Time\" here means a timestamp column "+
+ "is declared TEXT; it must be DATETIME.", err)
+ }
+ if got.EndsAt == nil || !got.EndsAt.Equal(ends) {
+ t.Errorf("EndsAt = %v, want %v", got.EndsAt, ends)
+ }
+ if !got.CreatedAt.Equal(now) {
+ t.Errorf("CreatedAt = %v, want %v", got.CreatedAt, now)
+ }
+
+ // A NULL ends_at (a comp with no end) must scan as a nil pointer.
+ if _, err := d.Writer().Exec(
+ `INSERT INTO abono_comps (subject, plan, status, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?)`,
+ "s2", "supporter", "active", now, now,
+ ); err != nil {
+ t.Fatalf("inserting an open-ended comp: %v", err)
+ }
+ var open comp
+ if err := d.G.Table("abono_comps").Where("subject = ?", "s2").Take(&open).Error; err != nil {
+ t.Fatalf("reading the open-ended comp back: %v", err)
+ }
+ if open.EndsAt != nil {
+ t.Errorf("EndsAt = %v, want nil for a NULL timestamp", open.EndsAt)
+ }
+}
+
+// frozenAbonoChecksum is migrate.Checksum of abono's shipped
+// migrations/0001_init.sql.
+//
+// ==> ONCE v0.1.0 IS TAGGED, THIS CONSTANT MAY NEVER BE UPDATED. <==
+//
+// It is recorded in the task that wrote the migration and may be
+// re-recorded only before v0.1.0 is tagged, while no app's ledger
+// holds this migration. From the first tag onwards the sentence above
+// is literal, and a failure here means an edit to revert, not a
+// constant to refresh.
+//
+// A failure here does not mean the constant is stale; it means an
+// edit to a shipped migration file changed its checksum, and that
+// edit must be reverted. Every app that has applied this migration has
+// this checksum in its ledger; migrate.Apply compares the two on every
+// boot and refuses to start if they no longer match. The only
+// legitimate way to change a shipped migration's effect is a new
+// migration file beside it, with a new ID and a new entry here. See
+// rastrillo's own migrate/frozen_checksums_test.go, which this test is
+// deliberately shaped after.
+const frozenAbonoChecksum = "2e452a3832ec97d35318b289716e3c39f3a114d8c127d775d73e57cbc77ab96c"
+
+func TestSchemaFrozenChecksum(t *testing.T) {
+ for _, m := range abono.Schema.All() {
+ if m.ID != "abono/0001_init" {
+ t.Fatalf("unexpected migration id %q; if abono has grown a second migration, "+
+ "this test must be extended, not just re-pointed at the new one", m.ID)
+ }
+ if got := migrate.Checksum(m.SQL); got != frozenAbonoChecksum {
+ t.Errorf("%s: checksum is now %s, was %s.\n"+
+ "A shipped migration file was edited. Revert the edit — do NOT update the "+
+ "constant. Every deployed app has the old checksum in its ledger and will "+
+ "refuse to boot with \"applied with different SQL\". To change what the schema "+
+ "becomes, add a new migration file instead.", m.ID, got, frozenAbonoChecksum)
+ }
+ }
+}