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).
2 files changed,
+10
−4
SKILL.md+6 −4skillmd_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
Intent
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) |