rastrillo / abono Public

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

Plain git — no account needed to clone.

Download

Download this file

1package abono
2
3import (
4 "context"
5 "errors"
6 "log/slog"
7 "net"
8 "net/url"
9 "strings"
10 "time"
11
12 "gorm.io/gorm"
13
14 "amadan.net/rastrillo/abono/internal/fakestripe"
15 "amadan.net/rastrillo/abono/internal/stripeapi"
16)
17
18// ErrNotFound is returned when a row the caller named does not exist.
19var ErrNotFound = errors.New("abono: not found")
20
21// HolderFunc is the app's answer to "whose is this payment?". Given the
22// address and name a buyer paid with, it returns the app's subject for
23// them, making one if the app wants paying to be a way in. An app that
24// wants sign-in before payment returns "", and the payment is recorded
25// unclaimed, by address. It runs inside abono's transaction: use tx, never
26// the app's own handle, or it will wait forever on the writer connection.
27type HolderFunc func(ctx context.Context, tx *gorm.DB, email, name string) (subject string, err error)
28
29// ChangeFunc is told that a holding of subject changed. It runs in the same
30// transaction as the write, so the app's own state never disagrees with
31// abono's, and its error rolls the write back. Use tx, never the app's own
32// handle.
33//
34// It must be idempotent: the daily Reconcile calls it for every subject that
35// holds anything, whether or not their answer moved, and a retried webhook
36// may run it again for one that did.
37type ChangeFunc func(ctx context.Context, tx *gorm.DB, subject string) error
38
39// Config configures New. DB is required. Key and WebhookSecrets go
40// together: both, or neither.
41type Config struct {
42 // DB is the app's database, as *gorm.DB (d.G from rastrillo/db), with
43 // abono.Schema already applied.
44 DB *gorm.DB
45
46 // Key is the Stripe account's restricted key (rk_...).
47 Key string
48 // WebhookSecrets are the endpoint's signing secrets (whsec_...), more
49 // than one while rotating.
50 WebhookSecrets []string
51
52 // Origin is the site's own address, for the URLs Stripe sends people
53 // back to.
54 Origin string
55
56 // Holder says whose a payment is. Optional.
57 Holder HolderFunc
58 // OnChange is told when a subject's holdings change, and by Reconcile for
59 // every subject holding anything, so it must be idempotent. Optional.
60 OnChange ChangeFunc
61
62 // Sandbox runs against a fake of Stripe inside the app instead of the
63 // real one: Checkout and Portal lead to stand-in pages under
64 // SandboxPath (mount SandboxHandler there) that pay, fail or cancel on
65 // a click, and nothing leaves the process. It is only allowed on a
66 // loopback Origin and with no Key, so a development setup cannot be
67 // pointed at a live account. A sandbox service is On.
68 Sandbox bool
69
70 // Now is the clock; default time.Now. Tests set it.
71 Now func() time.Time
72 // Logger; default slog.Default().
73 Logger *slog.Logger
74
75 // StripeBase points the Stripe client at a fake, for tests only.
76 StripeBase string
77}
78
79// Service is abono, wired. Build one per process with New and share it: it
80// holds no per-request state.
81type Service struct {
82 cfg Config
83 stripe *stripeapi.Client
84 sandbox *fakestripe.Fake // set with Config.Sandbox
85 subs subLocks // one per subscription, fetch to commit
86}
87
88// New validates cfg and returns a ready *Service.
89//
90// Configuration is all or nothing: a Key without WebhookSecrets, or
91// WebhookSecrets without a Key, is a half a setup and refuses to boot. With
92// neither, the service is off (On is false) and nothing is for sale. New
93// does not look at the plans: they are seeded into the database afterwards
94// (SeedPlans), and the app may manage them there.
95func New(cfg Config) (*Service, error) {
96 if cfg.DB == nil {
97 return nil, errors.New("abono: Config.DB is required")
98 }
99
100 // A blank secret is what an unset environment variable splits to.
101 var secrets []string
102 for _, s := range cfg.WebhookSecrets {
103 if s = strings.TrimSpace(s); s != "" {
104 secrets = append(secrets, s)
105 }
106 }
107 cfg.WebhookSecrets = secrets
108 cfg.Key = strings.TrimSpace(cfg.Key)
109 switch {
110 case cfg.Key != "" && len(secrets) == 0:
111 return nil, errors.New("abono: Config.Key is set but Config.WebhookSecrets is not; set both or neither")
112 case cfg.Key == "" && len(secrets) > 0:
113 return nil, errors.New("abono: Config.WebhookSecrets is set but Config.Key is not; set both or neither")
114 }
115
116 if cfg.Sandbox {
117 if cfg.Key != "" {
118 return nil, errors.New("abono: Config.Sandbox and Config.Key are exclusive; the sandbox never touches a real account")
119 }
120 if !loopback(cfg.Origin) {
121 return nil, errors.New("abono: Config.Sandbox needs a loopback Origin (localhost, *.localhost, 127.0.0.1 or [::1]), got " + quote(cfg.Origin))
122 }
123 }
124
125 if cfg.Now == nil {
126 cfg.Now = time.Now
127 }
128 if cfg.Logger == nil {
129 cfg.Logger = slog.Default()
130 }
131
132 s := &Service{cfg: cfg}
133 switch {
134 case cfg.Key != "":
135 s.stripe = &stripeapi.Client{Key: cfg.Key, Base: cfg.StripeBase}
136 case cfg.Sandbox:
137 s.startSandbox()
138 }
139 return s, nil
140}
141
142// On reports whether Stripe is configured, or the sandbox stands in for
143// it. When it is not, nothing is for sale and the starter pages say so. Offers, comps and holdings work either
144// way: a comp needs no Stripe.
145func (s *Service) On() bool {
146 return s != nil && s.stripe != nil
147}
148
149// now is the one clock the service reads, in UTC. UTC is not cosmetic:
150// timestamps reach SQLite as time.Time.String(), which appends a monotonic
151// reading to a time that still carries one, and a stored value with that
152// suffix compares wrongly as text. time.Now().UTC() strips it.
153func (s *Service) now() time.Time { return s.cfg.Now().UTC() }
154
155// tx runs fn in one transaction. Inside fn use only tx: a statement on the
156// service's own handle does not join the transaction, queues behind the
157// single writer connection this one is holding, and hangs rather than
158// errors.
159func (s *Service) tx(ctx context.Context, fn func(tx *gorm.DB) error) error {
160 return s.cfg.DB.WithContext(ctx).Transaction(fn)
161}
162
163// changed tells the app that subject's holdings changed, inside tx. A
164// payment not yet tied to anyone (subject "") has nobody to tell.
165func (s *Service) changed(ctx context.Context, tx *gorm.DB, subject string) error {
166 if s.cfg.OnChange == nil || subject == "" {
167 return nil
168 }
169 return s.cfg.OnChange(ctx, tx, subject)
170}
171
172// loopback reports whether origin is an http(s) address on this machine:
173// localhost, anything.localhost, or a loopback IP.
174func loopback(origin string) bool {
175 u, err := url.Parse(origin)
176 if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
177 return false
178 }
179 host := strings.ToLower(u.Hostname())
180 if host == "localhost" || strings.HasSuffix(host, ".localhost") {
181 return true
182 }
183 ip := net.ParseIP(host)
184 return ip != nil && ip.IsLoopback()
185}
186
187func quote(s string) string { return `"` + s + `"` }
188