package abono import ( "context" "encoding/json" "errors" "io" "net/http" "time" "gorm.io/gorm/clause" "amadan.net/rastrillo/rastrillo/carlos" "amadan.net/rastrillo/abono/internal/stripeapi" ) // The default addresses of the two handlers that answer machines rather // than browsers. An app may mount them elsewhere; it passes whatever paths // it chose to csrf.Exempt, because neither carries a browser's Origin: the // webhook checks Stripe's signature and the sync checks carlos's bearer. const ( WebhookPath = "/abono/webhook" SyncPath = "/jobs/abono-sync" ) // maxWebhookBody is the largest delivery read. Stripe's events are a few // kilobytes; a megabyte is generous. const maxWebhookBody = 1 << 20 // event is what the handler reads of a Stripe event: which object it is // about. The object's fields are in the endpoint's API version, so only // ids and the checkout hint are taken from it, and the subscription is // fetched again. type event struct { ID string `json:"id"` Type string `json:"type"` Data struct { Object struct { ID string `json:"id"` Subscription json.RawMessage `json:"subscription"` ClientReferenceID string `json:"client_reference_id"` CustomerDetails struct { Email string `json:"email"` } `json:"customer_details"` // An invoice's subscription, from 2025-03-31.basil on. Parent struct { SubscriptionDetails struct { Subscription string `json:"subscription"` } `json:"subscription_details"` } `json:"parent"` } `json:"object"` } `json:"data"` } // isCheckout reports whether the event is about a Checkout session, which // carries the buyer's own hint. func (e event) isCheckout() bool { return e.Type == "checkout.session.completed" || e.Type == "checkout.session.async_payment_succeeded" } // subscriptionID is the event's subscription: the object itself, or the one // a checkout session or invoice is for. Events abono does not read have none. func (e event) subscriptionID() string { o := e.Data.Object switch e.Type { case "customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted", "customer.subscription.paused", "customer.subscription.resumed": return o.ID case "checkout.session.completed", "checkout.session.async_payment_succeeded", "invoice.paid", "invoice.payment_failed", "invoice.payment_succeeded": var id string if len(o.Subscription) > 0 && o.Subscription[0] == '"' { _ = json.Unmarshal(o.Subscription, &id) } else if len(o.Subscription) > 0 { var obj struct { ID string `json:"id"` } _ = json.Unmarshal(o.Subscription, &obj) id = obj.ID } if id == "" { id = o.Parent.SubscriptionDetails.Subscription } return id } return "" } // hint is what the event and the re-fetched subscription say of whose it // is: a Checkout session's client_reference_id and typed address, or, for // the subscription's and its invoices' events, the abono_subject Checkout // put in the subscription's metadata and the customer's address. func (e event) hint(sub stripeapi.Subscription) Hint { if e.isCheckout() { return Hint{Subject: e.Data.Object.ClientReferenceID, Email: e.Data.Object.CustomerDetails.Email} } return subscriptionHint(sub) } // subscriptionHint is what a subscription says of whose it is: the // abono_subject Checkout put in its metadata for a signed-in buyer, and its // customer's address. The webhook's subscription and invoice events and // the reconcile both use it, so a reconcile that recovers lost events ties // the subscription to whoever bought it, not to whoever owns the address // it was paid with (ruling R34). func subscriptionHint(sub stripeapi.Subscription) Hint { return Hint{Subject: sub.Metadata["abono_subject"], Email: sub.Customer.Email} } type eventRow struct { ID string Type string ReceivedAt time.Time } func (eventRow) TableName() string { return "abono_events" } // ServeWebhook is the Stripe endpoint, POST WebhookPath. A delivery whose // signature does not check, or which is over 1 MiB, is 400. A failure to // sync, or to reach Stripe, is 500, which Stripe retries for three days. // Anything else is 200, including events abono does not read and a // subscription Stripe says no longer exists. Off (no key), it is 404. // // The event is read for ids only. The subscription is fetched again, so a // delivery that arrives late or out of order cannot move a row backwards, // and a created event that was lost is made up by the next one. func (s *Service) ServeWebhook(w http.ResponseWriter, r *http.Request) { if !s.On() { http.NotFound(w, r) return } body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxWebhookBody)) if err != nil { http.Error(w, "unreadable body", http.StatusBadRequest) return } if err := stripeapi.VerifySignature(r.Header.Get("Stripe-Signature"), body, s.cfg.WebhookSecrets, s.now()); err != nil { http.Error(w, "bad signature", http.StatusBadRequest) return } if status, msg := s.receive(r.Context(), body); status != http.StatusOK { http.Error(w, msg, status) return } w.WriteHeader(http.StatusOK) } // receive does what an event says, once: the work of ServeWebhook after // the signature check, and all of it for the sandbox, whose fake hands // events straight in. It answers the status the webhook should, with a // message for anything but 200; a failure is logged here. func (s *Service) receive(ctx context.Context, body []byte) (int, string) { var e event if err := json.Unmarshal(body, &e); err != nil || e.ID == "" { return http.StatusBadRequest, "not an event" } var seen int64 if err := s.cfg.DB.WithContext(ctx).Table("abono_events").Where("id = ?", e.ID).Count(&seen).Error; err != nil { return s.fail("webhook: read events", err, "event", e.ID) } if seen > 0 { return http.StatusOK, "" } if id := e.subscriptionID(); id != "" { if status, msg := s.receiveSubscription(ctx, e, id); status != http.StatusOK { return status, msg } } // Recorded after the work, so a failure above is retried in full. Two // deliveries of one event at once both sync, which is harmless. A record // that cannot be saved is a failure too: answering 200 would stop Stripe // retrying with the deduplication unwritten. Sync is idempotent, so the // retry redoing the work is safe. if err := s.cfg.DB.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}). Create(&eventRow{ID: e.ID, Type: e.Type, ReceivedAt: s.now()}).Error; err != nil { return s.fail("webhook: record event", err, "event", e.ID) } return http.StatusOK, "" } // receiveSubscription fetches subscription id again and syncs it, holding // its lock from the fetch to the commit (ruling R35). func (s *Service) receiveSubscription(ctx context.Context, e event, id string) (int, string) { unlock, err := s.subs.lock(ctx, id) if err != nil { return s.fail("webhook: wait for subscription", err, "event", e.ID, "subscription", id) } defer unlock() sub, err := s.stripe.GetSubscription(ctx, id) switch { case stripeapi.NotFound(err): // Gone from Stripe (deleted with its account data, or the // delivery is from another mode). Retrying cannot help. s.cfg.Logger.Warn("abono: webhook: subscription no longer exists", "event", e.ID, "type", e.Type, "subscription", id) case err != nil: return s.fail("webhook: fetch subscription", err, "event", e.ID, "subscription", id) default: if err := s.sync(ctx, sub, e.hint(sub)); err != nil { return s.fail("webhook: sync", err, "event", e.ID, "subscription", id) } s.cfg.Logger.Info("abono: synced", "event", e.ID, "type", e.Type, "subscription", id, "status", sub.Status) } return http.StatusOK, "" } // ServeSync is the daily tick, POST SyncPath: Reconcile, inside the request // so the instance stays awake for it. Only carlos's bearer token gets in // (403 otherwise). It answers 204, or 500 when Reconcile failed. It is // idempotent, so a retried occurrence needs no dedupe. func (s *Service) ServeSync(w http.ResponseWriter, r *http.Request) { if !carlos.Tick(r) { http.Error(w, "forbidden", http.StatusForbidden) return } rep, err := s.Reconcile(r.Context()) if err != nil { status, msg := s.fail("reconcile", err, "subscriptions", rep.Subscriptions, "subjects", rep.Subjects) http.Error(w, msg, status) return } s.cfg.Logger.Info("abono: reconciled", "subscriptions", rep.Subscriptions, "subjects", rep.Subjects) w.WriteHeader(http.StatusNoContent) } // fail logs err and returns the 500 to answer. A Stripe 401 is a revoked // or wrong key: the log says so, and the answer is still 500, so Stripe // retries once the key is fixed. func (s *Service) fail(msg string, err error, kv ...any) (int, string) { s.cfg.Logger.Error("abono: "+msg, append(kv, "err", err)...) s.noteRefusedKey(err) return http.StatusInternalServerError, "abono failed" } // noteRefusedKey logs when err is Stripe refusing the API key. func (s *Service) noteRefusedKey(err error) { var se *stripeapi.Error if errors.As(err, &se) && se.Status == http.StatusUnauthorized { s.cfg.Logger.Error("abono: Stripe refused the API key (Config.Key): it is wrong, expired or revoked") } }