rastrillo / abono Public

Clone
git clone https://amadan.net/rastrillo/abono

Plain git — no account needed to clone.

Download

Download this file

1---
2name: abono
3description: Stripe subscriptions for a rastrillo app. Checkout, webhooks, comps, and which plan a signed-in person holds. Load before wiring payments into an app.
4---
5
6# abono
7
8abono 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.
9
10Module `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.
11
12## Wire it
13
141. Merge `abono.Schema` into `BootSchema`, never into your own `Schema`, or `rastrillo migration check` proposes dropping abono's tables.
152. Build one service per process:
16
17```go
18svc, err := abono.New(abono.Config{
19 DB: d.G, Origin: origin, // exactly scheme://host[:port]
20 Key: key, WebhookSecrets: []string{whsec}, // both or neither
21 Holder: holder, OnChange: onChange, Sandbox: dev,
22})
23if err != nil {
24 return fmt.Errorf("abono: %w", err) // svc is nil here
25}
26specs, err := abono.ParsePlans("annual=price_A|price_old,monthly=price_B")
27if err != nil {
28 return fmt.Errorf("abono plans: %w", err)
29}
30if err := svc.SeedPlans(ctx, specs); err != nil { // idempotent; never deletes
31 return fmt.Errorf("abono plans: %w", err)
32}
33```
34
35Stop 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.
36
373. `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.
384. 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.
395. 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.
40
41## Stripe setup
42
43Restricted 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.
44
45Add 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.
46
47## Your two callbacks
48
49`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.
50
51`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.
52
53Both 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.
54
55## Asking and selling
56
57`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`.
58
59Only 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.
60
61Selling: `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.
62
63Owner 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.
64
65## Pages and words
66
67Run 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.
68
69Partials: 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).
70
71## Testing
72
73`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.
74
75## Not in v0.1
76
77One-off payments, Connect, refunds, plan changes or cancellations from your own pages (Portal does them), tax, trials, and any provider but Stripe.
78