| 1 | package abono |
| 2 | |
| 3 | import ( |
| 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. |
| 19 | var 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. |
| 27 | type 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. |
| 37 | type 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. |
| 41 | type 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. |
| 81 | type 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. |
| 95 | func 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. |
| 145 | func (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. |
| 153 | func (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. |
| 159 | func (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. |
| 165 | func (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. |
| 174 | func 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 | |
| 187 | func quote(s string) string { return `"` + s + `"` } |
| 188 | |