package abono import ( "context" "errors" "log/slog" "net" "net/url" "strings" "time" "gorm.io/gorm" "amadan.net/rastrillo/abono/internal/fakestripe" "amadan.net/rastrillo/abono/internal/stripeapi" ) // ErrNotFound is returned when a row the caller named does not exist. var ErrNotFound = errors.New("abono: not found") // HolderFunc is the app's answer to "whose is this payment?". Given the // address and name a buyer paid with, it returns the app's subject for // them, making one if the app wants paying to be a way in. An app that // wants sign-in before payment returns "", and the payment is recorded // unclaimed, by address. It runs inside abono's transaction: use tx, never // the app's own handle, or it will wait forever on the writer connection. type HolderFunc func(ctx context.Context, tx *gorm.DB, email, name string) (subject string, err error) // ChangeFunc is told that a holding of subject changed. It runs in the same // transaction as the write, so the app's own state never disagrees with // abono's, and its error rolls the write back. Use tx, never the app's own // handle. // // It must be idempotent: the daily Reconcile calls it for every subject that // holds anything, whether or not their answer moved, and a retried webhook // may run it again for one that did. type ChangeFunc func(ctx context.Context, tx *gorm.DB, subject string) error // Config configures New. DB is required. Key and WebhookSecrets go // together: both, or neither. type Config struct { // DB is the app's database, as *gorm.DB (d.G from rastrillo/db), with // abono.Schema already applied. DB *gorm.DB // Key is the Stripe account's restricted key (rk_...). Key string // WebhookSecrets are the endpoint's signing secrets (whsec_...), more // than one while rotating. WebhookSecrets []string // Origin is the site's own address, for the URLs Stripe sends people // back to. Origin string // Holder says whose a payment is. Optional. Holder HolderFunc // OnChange is told when a subject's holdings change, and by Reconcile for // every subject holding anything, so it must be idempotent. Optional. OnChange ChangeFunc // Sandbox runs against a fake of Stripe inside the app instead of the // real one: Checkout and Portal lead to stand-in pages under // SandboxPath (mount SandboxHandler there) that pay, fail or cancel on // a click, and nothing leaves the process. It is only allowed on a // loopback Origin and with no Key, so a development setup cannot be // pointed at a live account. A sandbox service is On. Sandbox bool // Now is the clock; default time.Now. Tests set it. Now func() time.Time // Logger; default slog.Default(). Logger *slog.Logger // StripeBase points the Stripe client at a fake, for tests only. StripeBase string } // Service is abono, wired. Build one per process with New and share it: it // holds no per-request state. type Service struct { cfg Config stripe *stripeapi.Client sandbox *fakestripe.Fake // set with Config.Sandbox subs subLocks // one per subscription, fetch to commit } // New validates cfg and returns a ready *Service. // // Configuration is all or nothing: a Key without WebhookSecrets, or // WebhookSecrets without a Key, is a half a setup and refuses to boot. With // neither, the service is off (On is false) and nothing is for sale. New // does not look at the plans: they are seeded into the database afterwards // (SeedPlans), and the app may manage them there. func New(cfg Config) (*Service, error) { if cfg.DB == nil { return nil, errors.New("abono: Config.DB is required") } // A blank secret is what an unset environment variable splits to. var secrets []string for _, s := range cfg.WebhookSecrets { if s = strings.TrimSpace(s); s != "" { secrets = append(secrets, s) } } cfg.WebhookSecrets = secrets cfg.Key = strings.TrimSpace(cfg.Key) switch { case cfg.Key != "" && len(secrets) == 0: return nil, errors.New("abono: Config.Key is set but Config.WebhookSecrets is not; set both or neither") case cfg.Key == "" && len(secrets) > 0: return nil, errors.New("abono: Config.WebhookSecrets is set but Config.Key is not; set both or neither") } if cfg.Sandbox { if cfg.Key != "" { return nil, errors.New("abono: Config.Sandbox and Config.Key are exclusive; the sandbox never touches a real account") } if !loopback(cfg.Origin) { return nil, errors.New("abono: Config.Sandbox needs a loopback Origin (localhost, *.localhost, 127.0.0.1 or [::1]), got " + quote(cfg.Origin)) } } if cfg.Now == nil { cfg.Now = time.Now } if cfg.Logger == nil { cfg.Logger = slog.Default() } s := &Service{cfg: cfg} switch { case cfg.Key != "": s.stripe = &stripeapi.Client{Key: cfg.Key, Base: cfg.StripeBase} case cfg.Sandbox: s.startSandbox() } return s, nil } // On reports whether Stripe is configured, or the sandbox stands in for // it. When it is not, nothing is for sale and the starter pages say so. Offers, comps and holdings work either // way: a comp needs no Stripe. func (s *Service) On() bool { return s != nil && s.stripe != nil } // now is the one clock the service reads, in UTC. UTC is not cosmetic: // timestamps reach SQLite as time.Time.String(), which appends a monotonic // reading to a time that still carries one, and a stored value with that // suffix compares wrongly as text. time.Now().UTC() strips it. func (s *Service) now() time.Time { return s.cfg.Now().UTC() } // tx runs fn in one transaction. Inside fn use only tx: a statement on the // service's own handle does not join the transaction, queues behind the // single writer connection this one is holding, and hangs rather than // errors. func (s *Service) tx(ctx context.Context, fn func(tx *gorm.DB) error) error { return s.cfg.DB.WithContext(ctx).Transaction(fn) } // changed tells the app that subject's holdings changed, inside tx. A // payment not yet tied to anyone (subject "") has nobody to tell. func (s *Service) changed(ctx context.Context, tx *gorm.DB, subject string) error { if s.cfg.OnChange == nil || subject == "" { return nil } return s.cfg.OnChange(ctx, tx, subject) } // loopback reports whether origin is an http(s) address on this machine: // localhost, anything.localhost, or a loopback IP. func loopback(origin string) bool { u, err := url.Parse(origin) if err != nil || (u.Scheme != "http" && u.Scheme != "https") { return false } host := strings.ToLower(u.Hostname()) if host == "localhost" || strings.HasSuffix(host, ".localhost") { return true } ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() } func quote(s string) string { return `"` + s + `"` }