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 "encoding/json"
6 "errors"
7 "io"
8 "net/http"
9 "time"
10
11 "gorm.io/gorm/clause"
12
13 "amadan.net/rastrillo/rastrillo/carlos"
14
15 "amadan.net/rastrillo/abono/internal/stripeapi"
16)
17
18// The default addresses of the two handlers that answer machines rather
19// than browsers. An app may mount them elsewhere; it passes whatever paths
20// it chose to csrf.Exempt, because neither carries a browser's Origin: the
21// webhook checks Stripe's signature and the sync checks carlos's bearer.
22const (
23 WebhookPath = "/abono/webhook"
24 SyncPath = "/jobs/abono-sync"
25)
26
27// maxWebhookBody is the largest delivery read. Stripe's events are a few
28// kilobytes; a megabyte is generous.
29const maxWebhookBody = 1 << 20
30
31// event is what the handler reads of a Stripe event: which object it is
32// about. The object's fields are in the endpoint's API version, so only
33// ids and the checkout hint are taken from it, and the subscription is
34// fetched again.
35type event struct {
36 ID string `json:"id"`
37 Type string `json:"type"`
38 Data struct {
39 Object struct {
40 ID string `json:"id"`
41 Subscription json.RawMessage `json:"subscription"`
42 ClientReferenceID string `json:"client_reference_id"`
43 CustomerDetails struct {
44 Email string `json:"email"`
45 } `json:"customer_details"`
46 // An invoice's subscription, from 2025-03-31.basil on.
47 Parent struct {
48 SubscriptionDetails struct {
49 Subscription string `json:"subscription"`
50 } `json:"subscription_details"`
51 } `json:"parent"`
52 } `json:"object"`
53 } `json:"data"`
54}
55
56// isCheckout reports whether the event is about a Checkout session, which
57// carries the buyer's own hint.
58func (e event) isCheckout() bool {
59 return e.Type == "checkout.session.completed" || e.Type == "checkout.session.async_payment_succeeded"
60}
61
62// subscriptionID is the event's subscription: the object itself, or the one
63// a checkout session or invoice is for. Events abono does not read have none.
64func (e event) subscriptionID() string {
65 o := e.Data.Object
66 switch e.Type {
67 case "customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted",
68 "customer.subscription.paused", "customer.subscription.resumed":
69 return o.ID
70 case "checkout.session.completed", "checkout.session.async_payment_succeeded",
71 "invoice.paid", "invoice.payment_failed", "invoice.payment_succeeded":
72 var id string
73 if len(o.Subscription) > 0 && o.Subscription[0] == '"' {
74 _ = json.Unmarshal(o.Subscription, &id)
75 } else if len(o.Subscription) > 0 {
76 var obj struct {
77 ID string `json:"id"`
78 }
79 _ = json.Unmarshal(o.Subscription, &obj)
80 id = obj.ID
81 }
82 if id == "" {
83 id = o.Parent.SubscriptionDetails.Subscription
84 }
85 return id
86 }
87 return ""
88}
89
90// hint is what the event and the re-fetched subscription say of whose it
91// is: a Checkout session's client_reference_id and typed address, or, for
92// the subscription's and its invoices' events, the abono_subject Checkout
93// put in the subscription's metadata and the customer's address.
94func (e event) hint(sub stripeapi.Subscription) Hint {
95 if e.isCheckout() {
96 return Hint{Subject: e.Data.Object.ClientReferenceID, Email: e.Data.Object.CustomerDetails.Email}
97 }
98 return subscriptionHint(sub)
99}
100
101// subscriptionHint is what a subscription says of whose it is: the
102// abono_subject Checkout put in its metadata for a signed-in buyer, and its
103// customer's address. The webhook's subscription and invoice events and
104// the reconcile both use it, so a reconcile that recovers lost events ties
105// the subscription to whoever bought it, not to whoever owns the address
106// it was paid with (ruling R34).
107func subscriptionHint(sub stripeapi.Subscription) Hint {
108 return Hint{Subject: sub.Metadata["abono_subject"], Email: sub.Customer.Email}
109}
110
111type eventRow struct {
112 ID string
113 Type string
114 ReceivedAt time.Time
115}
116
117func (eventRow) TableName() string { return "abono_events" }
118
119// ServeWebhook is the Stripe endpoint, POST WebhookPath. A delivery whose
120// signature does not check, or which is over 1 MiB, is 400. A failure to
121// sync, or to reach Stripe, is 500, which Stripe retries for three days.
122// Anything else is 200, including events abono does not read and a
123// subscription Stripe says no longer exists. Off (no key), it is 404.
124//
125// The event is read for ids only. The subscription is fetched again, so a
126// delivery that arrives late or out of order cannot move a row backwards,
127// and a created event that was lost is made up by the next one.
128func (s *Service) ServeWebhook(w http.ResponseWriter, r *http.Request) {
129 if !s.On() {
130 http.NotFound(w, r)
131 return
132 }
133 body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxWebhookBody))
134 if err != nil {
135 http.Error(w, "unreadable body", http.StatusBadRequest)
136 return
137 }
138 if err := stripeapi.VerifySignature(r.Header.Get("Stripe-Signature"), body, s.cfg.WebhookSecrets, s.now()); err != nil {
139 http.Error(w, "bad signature", http.StatusBadRequest)
140 return
141 }
142 if status, msg := s.receive(r.Context(), body); status != http.StatusOK {
143 http.Error(w, msg, status)
144 return
145 }
146 w.WriteHeader(http.StatusOK)
147}
148
149// receive does what an event says, once: the work of ServeWebhook after
150// the signature check, and all of it for the sandbox, whose fake hands
151// events straight in. It answers the status the webhook should, with a
152// message for anything but 200; a failure is logged here.
153func (s *Service) receive(ctx context.Context, body []byte) (int, string) {
154 var e event
155 if err := json.Unmarshal(body, &e); err != nil || e.ID == "" {
156 return http.StatusBadRequest, "not an event"
157 }
158
159 var seen int64
160 if err := s.cfg.DB.WithContext(ctx).Table("abono_events").Where("id = ?", e.ID).Count(&seen).Error; err != nil {
161 return s.fail("webhook: read events", err, "event", e.ID)
162 }
163 if seen > 0 {
164 return http.StatusOK, ""
165 }
166
167 if id := e.subscriptionID(); id != "" {
168 if status, msg := s.receiveSubscription(ctx, e, id); status != http.StatusOK {
169 return status, msg
170 }
171 }
172
173 // Recorded after the work, so a failure above is retried in full. Two
174 // deliveries of one event at once both sync, which is harmless. A record
175 // that cannot be saved is a failure too: answering 200 would stop Stripe
176 // retrying with the deduplication unwritten. Sync is idempotent, so the
177 // retry redoing the work is safe.
178 if err := s.cfg.DB.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).
179 Create(&eventRow{ID: e.ID, Type: e.Type, ReceivedAt: s.now()}).Error; err != nil {
180 return s.fail("webhook: record event", err, "event", e.ID)
181 }
182 return http.StatusOK, ""
183}
184
185// receiveSubscription fetches subscription id again and syncs it, holding
186// its lock from the fetch to the commit (ruling R35).
187func (s *Service) receiveSubscription(ctx context.Context, e event, id string) (int, string) {
188 unlock, err := s.subs.lock(ctx, id)
189 if err != nil {
190 return s.fail("webhook: wait for subscription", err, "event", e.ID, "subscription", id)
191 }
192 defer unlock()
193 sub, err := s.stripe.GetSubscription(ctx, id)
194 switch {
195 case stripeapi.NotFound(err):
196 // Gone from Stripe (deleted with its account data, or the
197 // delivery is from another mode). Retrying cannot help.
198 s.cfg.Logger.Warn("abono: webhook: subscription no longer exists", "event", e.ID, "type", e.Type, "subscription", id)
199 case err != nil:
200 return s.fail("webhook: fetch subscription", err, "event", e.ID, "subscription", id)
201 default:
202 if err := s.sync(ctx, sub, e.hint(sub)); err != nil {
203 return s.fail("webhook: sync", err, "event", e.ID, "subscription", id)
204 }
205 s.cfg.Logger.Info("abono: synced", "event", e.ID, "type", e.Type, "subscription", id, "status", sub.Status)
206 }
207 return http.StatusOK, ""
208}
209
210// ServeSync is the daily tick, POST SyncPath: Reconcile, inside the request
211// so the instance stays awake for it. Only carlos's bearer token gets in
212// (403 otherwise). It answers 204, or 500 when Reconcile failed. It is
213// idempotent, so a retried occurrence needs no dedupe.
214func (s *Service) ServeSync(w http.ResponseWriter, r *http.Request) {
215 if !carlos.Tick(r) {
216 http.Error(w, "forbidden", http.StatusForbidden)
217 return
218 }
219 rep, err := s.Reconcile(r.Context())
220 if err != nil {
221 status, msg := s.fail("reconcile", err, "subscriptions", rep.Subscriptions, "subjects", rep.Subjects)
222 http.Error(w, msg, status)
223 return
224 }
225 s.cfg.Logger.Info("abono: reconciled", "subscriptions", rep.Subscriptions, "subjects", rep.Subjects)
226 w.WriteHeader(http.StatusNoContent)
227}
228
229// fail logs err and returns the 500 to answer. A Stripe 401 is a revoked
230// or wrong key: the log says so, and the answer is still 500, so Stripe
231// retries once the key is fixed.
232func (s *Service) fail(msg string, err error, kv ...any) (int, string) {
233 s.cfg.Logger.Error("abono: "+msg, append(kv, "err", err)...)
234 s.noteRefusedKey(err)
235 return http.StatusInternalServerError, "abono failed"
236}
237
238// noteRefusedKey logs when err is Stripe refusing the API key.
239func (s *Service) noteRefusedKey(err error) {
240 var se *stripeapi.Error
241 if errors.As(err, &se) && se.Status == http.StatusUnauthorized {
242 s.cfg.Logger.Error("abono: Stripe refused the API key (Config.Key): it is wrong, expired or revoked")
243 }
244}
245