rastrillo / abono Public

abono: SKILL.md

Summary

Add SKILL.md, the authoring doc for apps wiring abono, and a test that holds it to a byte budget and requires it to name the public surface.
Agent notes
6.4 KB against the 9000 budget. Covers wiring, the restricted key's scopes, webhook events, the daily sync, callbacks, pages, testing and v0.1 non-goals.
claude agent, operated by paul@keymail.dev 04b9d49946ccd541712f89bb7ef80f7fa00b33fc parent 86bde6b
2 files changed, +106 −0
  • SKILL.md +65 −0
  • skillmd_test.go +41 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./... -count=1
ok  	amadan.net/rastrillo/abono	1.839s
ok  	amadan.net/rastrillo/abono/abonotest	0.137s
ok  	amadan.net/rastrillo/abono/cmd/abono	2.408s
ok  	amadan.net/rastrillo/abono/internal/fakestripe	0.070s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	0.026s
ok  	example	0.194s
=== race ===
CGO_ENABLED=1 go test ./... -race -count=1
ok  	amadan.net/rastrillo/abono	7.409s
ok  	amadan.net/rastrillo/abono/abonotest	1.663s
ok  	amadan.net/rastrillo/abono/cmd/abono	2.520s
ok  	amadan.net/rastrillo/abono/internal/fakestripe	1.125s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	1.058s
ok  	example	1.955s
=== browser ===
ok  	example	0.992s
diff --git a/SKILL.md b/SKILL.md
new file mode 100644
index 0000000..cf39301
--- /dev/null
+++ b/SKILL.md
@@ -0,0 +1,65 @@
+---
+name: abono
+description: Stripe subscriptions for a rastrillo app. Checkout, webhooks, comps, and which plan a signed-in person holds. Load before wiring payments into an app.
+---
+
+# abono
+
+abono takes a person to Stripe's hosted Checkout, records the subscriptions Stripe reports, lets an owner give a plan away, and tells your app who holds what. It never decides what a plan lets someone do. That is yours: ask `HasPlan`, then act.
+
+Module `amadan.net/rastrillo/abono`. `example/` is a complete app and `example/app_test.go` drives it through real HTTP. Subscriptions only; one-off payments are not covered.
+
+## Wire it
+
+1. Merge `abono.Schema` into `BootSchema`, never into your own `Schema`, or `rastrillo migration check` proposes dropping abono's tables.
+2. Build one service per process:
+
+```go
+svc, err := abono.New(abono.Config{
+ DB: d.G, Origin: origin, // exactly scheme://host[:port]
+ Key: key, WebhookSecrets: []string{whsec}, // both or neither
+ Holder: holder, OnChange: onChange, Sandbox: dev,
+})
+specs, _ := abono.ParsePlans("annual=price_A|price_old,monthly=price_B")
+err = svc.SeedPlans(ctx, specs) // idempotent; never deletes
+```
+
+In `ParsePlans`' format the first price after `=` is sold; the rest are legacy prices whose subscriptions are the same plan. With no Key, `svc.On()` is false and nothing is for sale. A Key without secrets, or the reverse, refuses to boot.
+
+3. `csrf.Protect(origin, csrf.Exempt(abono.WebhookPath, abono.SyncPath))`. Neither carries a browser Origin: the webhook checks Stripe's signature, the sync checks carlos's bearer.
+4. Mount `svc.ServeWebhook` (POST `WebhookPath`) and `svc.ServeSync` (POST `SyncPath`). In development also mount `svc.SandboxHandler()` at `abono.SandboxPath`; without `Sandbox` it answers 404, so mounting it always is fine.
+5. Schedule the daily sync: `carlos schedule set -name abono-sync -every 24h -path /jobs/abono-sync`. `ServeSync` is guarded by `carlos.Tick` and runs Reconcile inside the request. Reconcile re-reads every subscription (catching webhooks Stripe gave up on), refreshes prices, and runs `OnChange` for every subject, which is how an expired comp or paid period reaches your app.
+
+## Stripe setup
+
+Restricted key (`rk_...`) scopes: Checkout Sessions and Customer portal write; Subscriptions, Customers, Prices and Promotion codes read. Nothing else. abono sends Stripe-Version `2025-03-31.basil` itself, so the account's default version does not matter.
+
+Add a webhook endpoint at `svc.Origin()` plus the path you mounted, with these events: `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `customer.subscription.created`, `.updated`, `.deleted`, `.paused`, `.resumed`, `invoice.paid`, `invoice.payment_succeeded`, `invoice.payment_failed`. Events are read for ids only and the subscription is fetched again, so ordering and replays are harmless. During secret rotation pass both secrets. `svc.TestMode()` reads the key prefix (`rk_test_`) and is for a warning banner.
+
+## Your two callbacks
+
+`Holder(ctx, tx, email, name) (subject, error)` says whose a payment is. Make a user if paying should be a way in, or return `""` to leave the payment unclaimed until you call `svc.Claim(ctx, subject, email)` at sign-in, once you know they own the address. It runs inside the write transaction and is asked again on every sync of an unclaimed row that has an address, so keep it cheap and idempotent. Use `tx`, never your own handle, or it waits forever on the writer.
+
+`OnChange(ctx, tx, subject)` runs in the same transaction as every write (a webhook, Finish, Comp, EndComp, Claim, Adopt) and again on every reconcile for every subject holding anything. Make it idempotent: recompute the app's state from `HasPlan`, do not apply a delta. It is never called with `""`. Its error rolls the write back.
+
+## Asking and selling
+
+`svc.HasPlan(ctx, subject, plan)` is the gate. `svc.Holdings(ctx, subject)` lists everything, ended included, newest first. `abono.Describe(h, now)` (also `h.Describe(now)`) turns a holding into a `Status`: a message key, a tone and a date. Pass `svc.Now()` as now so the words agree with `Grants`.
+
+Only subscriptions on a price that belongs to an offer grant anything. Others appear under `svc.Unrecognised` for the owner to `Adopt` onto a plan. `past_due` still grants until 30 days after the start of the unpaid period (`Backstop` marks that case); a cancelling membership stops at its period end.
+
+Selling: `svc.Offers(ctx)` for the pricing page. `svc.Checkout(ctx, abono.CheckoutRequest{Plan, Interval, Subject, Email, SuccessPath, CancelPath})` returns a URL to redirect to; the reader types a promotion code on Stripe's page, or you pass one. `svc.Finish(ctx, sessionID)` on the success page syncs at once rather than waiting for the webhook; it signs nobody in. `svc.Portal(ctx, subject, returnPath)` is where they change card or plan or cancel. Owner tools: `svc.Comp(ctx, subject, plan, endsAt, note)` and `EndComp`, `Members`, `Adopt`, `AddPrice`. A comp needs no Stripe.
+
+## Pages and words
+
+Run once: `go run amadan.net/rastrillo/abono/cmd/abono pages -pkg <name> -dir <path>`. It writes `abono_pages.go` and `templates/abono/*.html` into your app. They are yours from then on, and the command never overwrites; if any file exists it writes nothing. `AbonoPages` needs `Service`, `Render`, `Subject`, `IsOwner` and `SubjectForEmail`; `Label` is optional. Call `Mount(r)` inside your session middleware.
+
+Partials: parse `abono.Templates()` with `ParseFS(..., "*.html")` after `ui.Templates()` and `ui.Funcs()`. Merge `abono.Catalog()` into the base catalog you pass to `rastrillo.NewLocales`, and translate or override any `abono.*` key in your own locale files. The status partial takes an optional `DateText` until rastrillo has a date helper (rastrillo discussion 112).
+
+## Testing
+
+`abonotest.New(t)` is a fake Stripe on 127.0.0.1: `AddPrice`, `Pay`, `Decline`, `Cancel`, `Advance`, `FailNextRenewal`, `AddSubscription`, `SendEvent`. `stripe.Config(base)` points a Config at it and shares its clock; `SetWebhookURL(app.URL + abono.WebhookPath)` sends it your events. In development, `Sandbox: true` does the same inside the app with click-through Checkout and Portal pages. It needs a loopback Origin and no Key.
+
+## Not in v0.1
+
+One-off payments, Connect, refunds, plan changes or cancellations from your own pages (Portal does them), tax, trials, and any provider but Stripe.
diff --git a/skillmd_test.go b/skillmd_test.go
new file mode 100644
index 0000000..c34f050
--- /dev/null
+++ b/skillmd_test.go
@@ -0,0 +1,41 @@
+package abono
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+// skillBudget is the byte ceiling for SKILL.md. It is what an agent
+// loads instead of reading the source, so it is reviewed like code and
+// kept short; raise it here, with a reason, rather than trimming a
+// load-bearing fact to fit.
+const skillBudget = 9000
+
+func TestSkillMDIsWithinBudgetAndNamesTheSurface(t *testing.T) {
+ b, err := os.ReadFile("SKILL.md")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(b) > skillBudget {
+ t.Fatalf("SKILL.md is %d bytes, budget %d", len(b), skillBudget)
+ }
+ s := string(b)
+ for _, want := range []string{
+ "abono.New", "abono.Schema", "BootSchema", "csrf.Exempt",
+ "WebhookPath", "SyncPath", "carlos schedule",
+ "Holder", "Claim", "OnChange", "idempotent",
+ "HasPlan", "Holdings", "Describe", "Checkout", "Finish", "Portal",
+ "Adopt", "Comp", "SeedPlans", "Templates()", "Catalog()",
+ "abono pages", "abonotest", "Sandbox", "2025-03-31.basil",
+ "Checkout Sessions", "Customer portal", "Subscriptions", "Customers",
+ "Prices", "Promotion codes", "30 days",
+ } {
+ if !strings.Contains(s, want) {
+ t.Errorf("SKILL.md does not mention %q", want)
+ }
+ }
+ if strings.Contains(s, "—") {
+ t.Error("SKILL.md contains an em dash")
+ }
+}