rastrillo / abono Public
--- 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
- Merge
abono.SchemaintoBootSchema, never into your ownSchema, orrastrillo migration checkproposes dropping abono's tables. - Build one service per process:
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,
})
if err != nil {
return fmt.Errorf("abono: %w", err) // svc is nil here
}
specs, err := abono.ParsePlans("annual=price_A|price_old,monthly=price_B")
if err != nil {
return fmt.Errorf("abono plans: %w", err)
}
if err := svc.SeedPlans(ctx, specs); err != nil { // idempotent; never deletes
return fmt.Errorf("abono plans: %w", err)
}Stop startup on any of these. 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, unless Sandbox is set: the sandbox stands in for Stripe and On() is true. A Key without secrets, or the reverse, refuses to boot.
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.- Mount
svc.ServeWebhook(POSTWebhookPath) andsvc.ServeSync(POSTSyncPath). In development also mountsvc.SandboxHandler()atabono.SandboxPath; withoutSandboxit answers 404, so mounting it always is fine. - Schedule the daily sync:
carlos schedule set -name abono-sync -every 24h -path /jobs/abono-sync.ServeSyncis guarded bycarlos.Tickand runs Reconcile inside the request. Reconcile re-reads every subscription (catching webhooks Stripe gave up on; Checkout'sabono_subjectmetadata names the buyer), refreshes prices, and runsOnChangefor 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
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. Each event is read for ids and for who it belongs to: Checkout events carry the client_reference_id and the address typed there, subscription and invoice events the abono_subject metadata and the customer's address. The subscription itself 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. It is skipped for a price no offer recognises and for a customer already mapped to a subject.
OnChange(ctx, tx, subject) runs in the same transaction as the write when a subscription's subject, plan, status, period or cancel flag changes, and for Comp, EndComp, Claim and Adopt. Reconcile also runs it for every subject holding anything, so it must be idempotent: recompute the app's state, do not apply a delta. It is never called with "", and an error rolls the write back.
Both callbacks get tx. Use it for everything, including abono's own reads: svc.HasPlanIn(ctx, tx, subject, plan) and svc.HoldingsIn(ctx, tx, subject). HasPlan and Holdings use the service's own handle, which does not join tx; with one writer connection that waits on the transaction that is calling you, and at best it reads the state from before the write. The In forms see the write and use the service clock.
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. 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. 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
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 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
One-off payments, Connect, refunds, plan changes or cancellations from your own pages (Portal does them), tax, trials, and any provider but Stripe.