rastrillo / abono Public

SKILL.md: card-only Checkout, the unpaid_since grace, billing per customer, pinned subjects, and resetting the sandbox

Summary

SKILL.md now says what the final-review fixes changed: Checkout takes cards only and bank debits are unsupported; past_due counts from UnpaidSince, kept through later failed renewals; Finished.Processing; Customers(ctx, subject) and Portal(ctx, subject, customerID, returnPath) with a billing button per customer; only month or year prices with interval_count 1 are sold and Offers reads unread prices; a sold AddPrice reads Stripe and demotes; Adopt brings everyone on its price and, with Claim, pins the subject; Reconcile names the buyer from metadata and per-subscription locks mean one process per database; and how to reset the sandbox after a restart (M2, documented rather than built). HoldingsIn and HasPlanIn are unchanged.
Agent notes
Rulings R32 to R43 as they touch the public surface; M2 per R43. 8990 bytes against the 9000 budget. TestSkillMDIsWithinBudgetAndNamesTheSurface now also requires the new names (cards only, UnpaidSince, Processing, the Customers and Portal signatures, interval_count, pinned, one process, reset the sandbox).
claude agent, operated by paul@keymail.dev 1630599ad8a491af1ae3b890837f05cfab647e1f parent 55c5bfc
2 files changed, +10 −4
  • SKILL.md +6 −4
  • skillmd_test.go +4 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./... -count=1
ok  	amadan.net/rastrillo/abono	2.616s
ok  	amadan.net/rastrillo/abono/abonotest	0.056s
ok  	amadan.net/rastrillo/abono/cmd/abono	0.667s
ok  	amadan.net/rastrillo/abono/internal/fakestripe	0.020s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	0.008s
ok  	example	0.215s
=== race ===
CGO_ENABLED=1 go test ./... -race -count=1
ok  	amadan.net/rastrillo/abono	8.835s
ok  	amadan.net/rastrillo/abono/abonotest	1.460s
ok  	amadan.net/rastrillo/abono/cmd/abono	1.990s
ok  	amadan.net/rastrillo/abono/internal/fakestripe	1.069s
ok  	amadan.net/rastrillo/abono/internal/stripeapi	1.017s
ok  	example	2.383s
=== browser ===
ok  	example	0.810s
diff --git a/SKILL.md b/SKILL.md
index e65cb5b..b01eba4 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -36,7 +36,7 @@ Stop startup on any of these. In `ParsePlans`' format the first price after `=`
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.
+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; Checkout's `abono_subject` metadata names the buyer), refreshes prices, and runs `OnChange` for every subject, which is how an expired comp or paid period reaches your app. Stripe reads and writes lock per subscription in-process: run one process per database.
## Stripe setup
@@ -56,9 +56,11 @@ Both callbacks get `tx`. Use it for everything, including abono's own reads: `sv
`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. A subscription grants as follows. `active` and `trialing` grant, but a cancelling one stops at its period end. `past_due` grants until 30 days after the start of the unpaid period (30 days after the period end if the start is unknown; with neither, not at all), or until the period end if that is sooner and it is cancelling. `Backstop` marks a `past_due` holding past its paid period. Every other status (`canceled`, `unpaid`, `incomplete`, `paused`) does not grant. A comp grants until its end, or for good.
+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. A subscription grants as follows. `active` and `trialing` grant, but a cancelling one stops at its period end. `past_due` grants until 30 days after the first unpaid period began (`UnpaidSince`, kept through later failed renewals until paid; else the period end; with neither, not at all), or until the period end if that is sooner and it is cancelling. `Backstop` marks a `past_due` holding past its paid period. Every other status (`canceled`, `unpaid`, `incomplete`, `paused`) does not grant. A comp grants until its end, or for good.
-Selling: `svc.Offers(ctx)` for the pricing page. `url, err := svc.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", Interval: "year", Subject: subject, Email: email, SuccessPath: "/subscribe/done", CancelPath: "/subscribe"})` returns a URL to redirect to; the reader types a promotion code on Stripe's page, or you pass `PromotionCode`. `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.
+Selling: `svc.Offers(ctx)` for the pricing page. `url, err := svc.Checkout(ctx, abono.CheckoutRequest{Plan: "annual", Interval: "year", Subject: subject, Email: email, SuccessPath: "/subscribe/done", CancelPath: "/subscribe"})` returns a URL to redirect to; the reader types a promotion code on Stripe's page, or you pass `PromotionCode`. Checkout takes cards only, so `active` means paid. Bank debits (ACH, SEPA) are unsupported; an adopted one is judged by its status. `svc.Finish(ctx, sessionID)` on the success page syncs at once rather than waiting for the webhook; it signs nobody in. `Finished.Processing`: Checkout completed, payment pending; offer no second purchase. `svc.Customers(ctx, subject)` is every Stripe customer they pay, unrecognised prices included: a billing button each. `svc.Portal(ctx, subject, customerID, returnPath)` opens one, where they change card or plan or cancel, and refuses a customer not theirs. Only monthly or yearly prices with `interval_count` 1 are sold (`Price.Unsupported` marks the rest). `Offers` reads any unread price.
+
+Owner tools: `svc.Comp(ctx, subject, plan, endsAt, note)` and `EndComp`, `Members`, `Adopt`, `AddPrice`. A comp needs no Stripe. A sold `AddPrice` reads Stripe and demotes the plan's sold price at that interval. `Adopt` brings every recorded subscription on its price onto the plan. A subject given to `Adopt`, or by `Claim`, is pinned: no later hint moves it.
## Pages and words
@@ -68,7 +70,7 @@ Partials: parse `abono.Templates()` with `ParseFS(..., "*.html")` after `ui.Temp
## 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 installs the fake's clock when `base.Now` is nil (so `Advance` moves abono too); `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.
+`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 installs the fake's clock when `base.Now` is nil (so `Advance` moves abono too); `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. A restart forgets its fake Stripe, not your rows; to reset the sandbox, delete them or the database.
## Not in v0.1
diff --git a/skillmd_test.go b/skillmd_test.go
index b293c29..277610a 100644
--- a/skillmd_test.go
+++ b/skillmd_test.go
@@ -30,6 +30,10 @@ func TestSkillMDIsWithinBudgetAndNamesTheSurface(t *testing.T) {
"abono pages", "abonotest", "Sandbox", "2025-03-31.basil",
"Checkout Sessions", "Customer portal", "Subscriptions", "Customers",
"Prices", "Promotion codes", "30 days",
+ // The final review's fixes that change the surface or behaviour.
+ "cards only", "UnpaidSince", "Processing", "Customers(ctx, subject)",
+ "Portal(ctx, subject, customerID, returnPath)", "interval_count", "pinned", "one process",
+ "reset the sandbox",
} {
if !strings.Contains(s, want) {
t.Errorf("SKILL.md does not mention %q", want)