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.
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 −0Makefile+38 −0README.md+3 −1doc.go+9 −0go.mod+23 −0go.sum+75 −0migrations.go+16 −0migrations/0001_init.sql+75 −0schema_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
Intent
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) |
| + } |
| + } |
| +} |