rastrillo / abono Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package fakestripe is a stand-in for the part of Stripe abono uses: the
2// API calls internal/stripeapi makes, hosted Checkout and Customer Portal
3// pages that pay, fail or cancel on a click, and the signed events those
4// send. abonotest serves it to tests over HTTP; the sandbox runs it inside
5// the app.
6//
7// It is deterministic: ids come from counters (sub_1, cus_1, cs_1, evt_1)
8// and time from Options.Now plus whatever Advance added. It speaks only
9// the API version abono pins, 2025-03-31.basil, in which a
10// subscription's period end is on its items.
11package fakestripe
12
13import (
14 "bytes"
15 "context"
16 "crypto/hmac"
17 "crypto/sha256"
18 "encoding/hex"
19 "encoding/json"
20 "errors"
21 "fmt"
22 "net/http"
23 "net/url"
24 "strconv"
25 "strings"
26 "sync"
27 "time"
28
29 "amadan.net/rastrillo/abono/internal/stripeapi"
30)
31
32// Lookup is the pages' text: the string for a message key, with {name}
33// placeholders filled from alternating name/value args. The pages carry
34// no words of their own.
35type Lookup func(key string, args ...any) string
36
37// Options configures a Fake.
38type Options struct {
39 // WebhookURL is where events are POSTed, signed with WebhookSecret.
40 // With neither it nor Deliver set, events are made and dropped.
41 WebhookURL string
42 WebhookSecret string
43 // Deliver, when set, is given each event instead of WebhookURL: the
44 // sandbox hands them straight to its service.
45 Deliver func(ctx context.Context, event []byte) error
46 // Now is the clock; default time.Now. Advance adds to it.
47 Now func() time.Time
48 // PageBase is the address the pages are served at, with no trailing
49 // slash: the Checkout and Portal URLs the API returns start with it.
50 PageBase string
51 // Lookup renders the pages' message keys; nil shows the keys.
52 Lookup Lookup
53 // IDTag, when set, goes into every id the fake makes (sub_<tag>_1),
54 // so ids stay unique across runs that share a database: the
55 // sandbox's fake starts empty each time the app does, and abono's
56 // tables remember the last run's sub_1 and evt_1.
57 IDTag string
58 // AnyPrice answers a price id nobody added as a recurring price,
59 // with its interval guessed from the id (see guessInterval). The
60 // sandbox has no Stripe account to read prices from.
61 AnyPrice bool
62}
63
64// Subscription is a subscription as a test describes it. An empty ID or
65// Customer is given the next one from the counter. Email and Name are
66// the customer's.
67type Subscription struct {
68 ID, Customer, Email, Name string
69 Status string
70 Price string
71 // Interval is the price's when empty, and "month" for a price nobody
72 // added.
73 Interval string
74 PeriodStart, PeriodEnd time.Time
75 CancelAtPeriodEnd bool
76 Metadata map[string]string
77}
78
79// Session is what a test sets of a Checkout session.
80type Session struct {
81 ID, Status, PaymentStatus, Subscription, ClientReferenceID, Email string
82}
83
84// Fake is one Stripe account in memory. Its methods are safe for
85// concurrent use.
86type Fake struct {
87 opts Options
88
89 mu sync.Mutex
90 offset time.Duration
91 counters map[string]int
92 prices map[string]*price
93 promos map[string]string // code -> promo_...
94 customers map[string]*customer
95 subs map[string]*subscription
96 order []string // subscription ids, oldest first
97 sessions map[string]*session
98 portals map[string]string // customer -> return_url
99 failNext map[string]bool
100
101 checkoutForms []url.Values
102 portalForms []url.Values
103 priceFetches map[string]int
104 onFetch func()
105
106 // queue is the events not yet delivered, oldest first; failures says
107 // why the first of them was not. sending is held while a queue is
108 // being sent, so events go out one at a time and in order.
109 queue [][]byte
110 failures []error
111 sending sync.Mutex
112}
113
114type price struct {
115 id, plan, currency, interval string
116 amount int64
117 count int // interval_count; 0 reads as 1
118}
119
120type customer struct{ id, email, name string }
121
122type subscription struct {
123 id, customer, status, price, interval string
124 periodStart, periodEnd time.Time
125 cancel bool
126 metadata map[string]string
127 // anchor is the billing cycle anchor, and cycles how many periods
128 // past it the current period ends: the period end is always counted
129 // from the anchor (see periodAfter), so a subscription that began on
130 // the 31st renews on the last day of a shorter month and on the 31st
131 // again after it, as Stripe's does.
132 anchor time.Time
133 cycles int
134}
135
136type session struct {
137 Session
138 customer, customerEmail, price string
139 successURL, cancelURL string
140 metadata map[string]string
141}
142
143// New returns an empty account.
144func New(opts Options) *Fake {
145 if opts.Now == nil {
146 opts.Now = time.Now
147 }
148 opts.PageBase = strings.TrimRight(opts.PageBase, "/")
149 return &Fake{
150 opts: opts, counters: map[string]int{}, prices: map[string]*price{}, promos: map[string]string{},
151 customers: map[string]*customer{}, subs: map[string]*subscription{}, sessions: map[string]*session{},
152 portals: map[string]string{}, failNext: map[string]bool{}, priceFetches: map[string]int{},
153 }
154}
155
156// Now is the fake's clock: Options.Now plus every Advance.
157func (f *Fake) Now() time.Time {
158 f.mu.Lock()
159 defer f.mu.Unlock()
160 return f.now()
161}
162
163func (f *Fake) now() time.Time { return f.opts.Now().Add(f.offset).UTC() }
164
165// SetWebhookURL changes where events go: a test's app is often listening
166// only after the fake is made.
167func (f *Fake) SetWebhookURL(u string) {
168 f.mu.Lock()
169 defer f.mu.Unlock()
170 f.opts.WebhookURL = u
171}
172
173// AddPrice adds a recurring price (interval "" for a one-off), or
174// replaces one. plan is the name the Checkout page shows.
175func (f *Fake) AddPrice(id, plan string, amount int64, currency, interval string) {
176 f.mu.Lock()
177 defer f.mu.Unlock()
178 f.prices[id] = &price{id: id, plan: plan, amount: amount, currency: currency, interval: interval}
179}
180
181// SetIntervalCount makes price id bill every n of its intervals (a price
182// of "month" with n 12 bills once a year). AddPrice's prices bill every
183// one.
184func (f *Fake) SetIntervalCount(id string, n int) {
185 f.mu.Lock()
186 defer f.mu.Unlock()
187 if p := f.prices[id]; p != nil {
188 p.count = n
189 }
190}
191
192// AddPromotionCode makes code a live promotion code with id.
193func (f *Fake) AddPromotionCode(code, id string) {
194 f.mu.Lock()
195 defer f.mu.Unlock()
196 f.promos[code] = id
197}
198
199// PutSubscription stores s as it is, replacing any with its id, and
200// returns its id. It sends no event: SendEvent does, when a test wants
201// one.
202func (f *Fake) PutSubscription(s Subscription) string {
203 f.mu.Lock()
204 defer f.mu.Unlock()
205 if s.ID == "" {
206 s.ID = f.next("sub")
207 }
208 if s.Customer == "" {
209 s.Customer = f.next("cus")
210 }
211 c := f.customers[s.Customer]
212 if c == nil {
213 c = &customer{id: s.Customer}
214 f.customers[s.Customer] = c
215 }
216 c.email, c.name = s.Email, s.Name
217 if s.Interval == "" {
218 s.Interval = "month"
219 if p := f.prices[s.Price]; p != nil && p.interval != "" {
220 s.Interval = p.interval
221 }
222 }
223 if _, ok := f.subs[s.ID]; !ok {
224 f.order = append(f.order, s.ID)
225 }
226 f.subs[s.ID] = &subscription{
227 id: s.ID, customer: s.Customer, status: s.Status, price: s.Price, interval: s.Interval,
228 periodStart: s.PeriodStart, periodEnd: s.PeriodEnd, cancel: s.CancelAtPeriodEnd, metadata: s.Metadata,
229 anchor: s.PeriodEnd,
230 }
231 return s.ID
232}
233
234// SubscriptionJSON is the subscription as the API returns it, its
235// customer expanded.
236func (f *Fake) SubscriptionJSON(id string) ([]byte, bool) {
237 f.mu.Lock()
238 defer f.mu.Unlock()
239 s := f.subs[id]
240 if s == nil {
241 return nil, false
242 }
243 b, err := json.Marshal(f.subObject(s, true))
244 return b, err == nil
245}
246
247// PutSession stores a Checkout session, replacing the test-visible part of
248// any with its id.
249func (f *Fake) PutSession(s Session) {
250 f.mu.Lock()
251 defer f.mu.Unlock()
252 if have := f.sessions[s.ID]; have != nil {
253 have.Session = s
254 return
255 }
256 f.sessions[s.ID] = &session{Session: s}
257}
258
259// Session is the test-visible part of a Checkout session.
260func (f *Fake) Session(id string) (Session, bool) {
261 f.mu.Lock()
262 defer f.mu.Unlock()
263 s := f.sessions[id]
264 if s == nil {
265 return Session{}, false
266 }
267 return s.Session, true
268}
269
270// CheckoutForms is every Checkout session's form, in the order asked for.
271func (f *Fake) CheckoutForms() []url.Values {
272 f.mu.Lock()
273 defer f.mu.Unlock()
274 return append([]url.Values(nil), f.checkoutForms...)
275}
276
277// PortalForms is every Portal session's form, in the order asked for.
278func (f *Fake) PortalForms() []url.Values {
279 f.mu.Lock()
280 defer f.mu.Unlock()
281 return append([]url.Values(nil), f.portalForms...)
282}
283
284// PriceFetches is how many times each price has been read.
285func (f *Fake) PriceFetches() map[string]int {
286 f.mu.Lock()
287 defer f.mu.Unlock()
288 out := make(map[string]int, len(f.priceFetches))
289 for k, v := range f.priceFetches {
290 out[k] = v
291 }
292 return out
293}
294
295// OnSubscriptionFetch sets fn to run each time a subscription is read,
296// before the answer is sent.
297func (f *Fake) OnSubscriptionFetch(fn func()) {
298 f.mu.Lock()
299 defer f.mu.Unlock()
300 f.onFetch = fn
301}
302
303// DeliveryErrors is why events are still waiting to be delivered: each
304// failed attempt since the queue was last empty. A delivery that later
305// succeeds clears them.
306func (f *Fake) DeliveryErrors() []error {
307 f.mu.Lock()
308 defer f.mu.Unlock()
309 return append([]error(nil), f.failures...)
310}
311
312// FailNextRenewal makes subID's next renewal fail: it goes past_due.
313func (f *Fake) FailNextRenewal(subID string) {
314 f.mu.Lock()
315 defer f.mu.Unlock()
316 f.failNext[subID] = true
317}
318
319// Advance moves the clock on by d, then does what Stripe would have done
320// meanwhile (Tick).
321func (f *Fake) Advance(d time.Duration) {
322 f.mu.Lock()
323 f.offset += d
324 f.mu.Unlock()
325 f.Tick()
326}
327
328// Tick sends any events still waiting, then does what is due at the
329// fake's now and sends its events. A subscription set to cancel at its
330// period end ends then, whatever its status. Otherwise an active or
331// trialing one whose period has ended renews, paid, and moves to the next
332// period; with FailNextRenewal it goes past_due instead (the period still
333// moves on, as Stripe's does: the failed invoice is for the new one, and
334// current_period_start is where it began). A past_due subscription goes on
335// being invoiced, as Stripe's does when an account leaves subscriptions
336// past_due: each renewal fails again and the period moves on again. Retries
337// within a period are not modelled. Periods are counted from the billing
338// anchor, clamped to the end of a shorter month.
339// A delivery that fails stays queued for the next Tick or page action;
340// DeliveryErrors says why.
341func (f *Fake) Tick() { _ = f.tick(context.Background()) }
342
343func (f *Fake) tick(ctx context.Context) error {
344 if err := f.flush(ctx); err != nil {
345 return err
346 }
347 f.mu.Lock()
348 now := f.now()
349 var events [][]byte
350 for _, id := range f.order {
351 s := f.subs[id]
352 for live(s.status) && !s.periodEnd.IsZero() && !now.Before(s.periodEnd) {
353 if s.cancel {
354 s.status = "canceled"
355 events = append(events, f.event("customer.subscription.deleted", f.subObject(s, false)))
356 break
357 }
358 s.cycles++
359 switch {
360 case f.failNext[id] || s.status == "past_due":
361 delete(f.failNext, id)
362 s.status = "past_due"
363 s.periodStart, s.periodEnd = s.periodEnd, periodAfter(s.anchor, s.cycles, s.interval)
364 events = append(events, f.event("invoice.payment_failed", f.invoice(s, "open")),
365 f.event("customer.subscription.updated", f.subObject(s, false)))
366 default:
367 s.status = "active" // a trial that renews paid is over
368 s.periodStart, s.periodEnd = s.periodEnd, periodAfter(s.anchor, s.cycles, s.interval)
369 events = append(events, f.event("invoice.paid", f.invoice(s, "paid")),
370 f.event("customer.subscription.updated", f.subObject(s, false)))
371 }
372 }
373 }
374 f.mu.Unlock()
375 return f.deliver(ctx, events)
376}
377
378// SendEvent sends an event of typ about subscription subID as it is now,
379// as Stripe does when something changes it outside the fake's pages.
380func (f *Fake) SendEvent(typ, subID string) {
381 f.mu.Lock()
382 s := f.subs[subID]
383 if s == nil {
384 f.mu.Unlock()
385 return
386 }
387 e := f.event(typ, f.subObject(s, false))
388 f.mu.Unlock()
389 _ = f.deliver(context.Background(), [][]byte{e})
390}
391
392// Handler serves the API (/v1/...) and the pages (/checkout/{id},
393// /portal/{customer}).
394func (f *Fake) Handler() http.Handler {
395 mux := http.NewServeMux()
396 api := func(pattern string, h http.HandlerFunc) { mux.Handle(pattern, f.authorised(h)) }
397 api("GET /v1/prices/{id}", f.getPrice)
398 api("GET /v1/subscriptions", f.listSubscriptions)
399 api("GET /v1/subscriptions/{id}", f.getSubscription)
400 api("POST /v1/checkout/sessions", f.createCheckout)
401 api("GET /v1/checkout/sessions/{id}", f.getCheckout)
402 api("POST /v1/billing_portal/sessions", f.createPortal)
403 api("GET /v1/promotion_codes", f.promotionCodes)
404 mux.HandleFunc("/v1/", func(w http.ResponseWriter, r *http.Request) {
405 f.refuse(w, http.StatusNotFound, "Unrecognized request URL: "+r.Method+" "+r.URL.Path)
406 })
407 f.pages(mux)
408 return mux
409}
410
411// authorised refuses what Stripe would: no key, or another API version.
412func (f *Fake) authorised(h http.HandlerFunc) http.Handler {
413 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
414 if key, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); !ok || strings.TrimSpace(key) == "" {
415 f.refuse(w, http.StatusUnauthorized, "You did not provide an API key.")
416 return
417 }
418 if v := r.Header.Get("Stripe-Version"); v != stripeapi.APIVersion {
419 f.refuse(w, http.StatusBadRequest, "This fake speaks only Stripe-Version "+stripeapi.APIVersion+", not "+strconv.Quote(v))
420 return
421 }
422 h(w, r)
423 })
424}
425
426func (f *Fake) getPrice(w http.ResponseWriter, r *http.Request) {
427 id := r.PathValue("id")
428 f.mu.Lock()
429 f.priceFetches[id]++
430 p := f.price(id)
431 f.mu.Unlock()
432 if p == nil {
433 f.missing(w, "price", id)
434 return
435 }
436 writeJSON(w, http.StatusOK, priceObject(p))
437}
438
439// price is the price with id, made up when AnyPrice allows. Hold f.mu.
440func (f *Fake) price(id string) *price {
441 if p := f.prices[id]; p != nil || !f.opts.AnyPrice || id == "" {
442 return p
443 }
444 p := &price{id: id, amount: 500, currency: "eur", interval: guessInterval(id)}
445 f.prices[id] = p
446 return p
447}
448
449// guessInterval is a made-up price's interval, read from its id:
450// price_annual is yearly, price_weekly weekly, anything else monthly.
451func guessInterval(id string) string {
452 s := strings.ToLower(id)
453 switch {
454 case strings.Contains(s, "year") || strings.Contains(s, "annual"):
455 return "year"
456 case strings.Contains(s, "week"):
457 return "week"
458 case strings.Contains(s, "day") || strings.Contains(s, "daily"):
459 return "day"
460 }
461 return "month"
462}
463
464func (f *Fake) listSubscriptions(w http.ResponseWriter, r *http.Request) {
465 q := r.URL.Query()
466 limit, err := strconv.Atoi(q.Get("limit"))
467 if err != nil || limit < 1 || limit > 100 {
468 limit = 10
469 }
470 expand := hasExpand(q, "data.customer")
471 f.mu.Lock()
472 ids := f.order
473 if after := q.Get("starting_after"); after != "" {
474 for i, id := range ids {
475 if id == after {
476 ids = ids[i+1:]
477 break
478 }
479 }
480 }
481 more := len(ids) > limit
482 if more {
483 ids = ids[:limit]
484 }
485 data := make([]any, 0, len(ids))
486 for _, id := range ids {
487 data = append(data, f.subObject(f.subs[id], expand))
488 }
489 f.mu.Unlock()
490 writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": data, "has_more": more})
491}
492
493func (f *Fake) getSubscription(w http.ResponseWriter, r *http.Request) {
494 id := r.PathValue("id")
495 f.mu.Lock()
496 s, onFetch := f.subs[id], f.onFetch
497 var obj map[string]any
498 if s != nil {
499 obj = f.subObject(s, hasExpand(r.URL.Query(), "customer"))
500 }
501 f.mu.Unlock()
502 if onFetch != nil {
503 onFetch()
504 }
505 if s == nil {
506 f.missing(w, "subscription", id)
507 return
508 }
509 writeJSON(w, http.StatusOK, obj)
510}
511
512func (f *Fake) createCheckout(w http.ResponseWriter, r *http.Request) {
513 if err := r.ParseForm(); err != nil {
514 f.refuse(w, http.StatusBadRequest, err.Error())
515 return
516 }
517 form := r.PostForm
518 f.mu.Lock()
519 defer f.mu.Unlock()
520 f.checkoutForms = append(f.checkoutForms, form)
521 priceID := form.Get("line_items[0][price]")
522 switch {
523 case priceID == "":
524 f.refuse(w, http.StatusBadRequest, "Missing required param: line_items[0][price].")
525 return
526 case f.price(priceID) == nil:
527 f.missing(w, "price", priceID)
528 return
529 case form.Get("success_url") == "":
530 f.refuse(w, http.StatusBadRequest, "Missing required param: success_url.")
531 return
532 }
533 s := &session{
534 Session: Session{ID: f.next("cs"), Status: "open", PaymentStatus: "unpaid", ClientReferenceID: form.Get("client_reference_id")},
535 customer: form.Get("customer"), customerEmail: form.Get("customer_email"), price: priceID,
536 successURL: form.Get("success_url"), cancelURL: form.Get("cancel_url"), metadata: map[string]string{},
537 }
538 for k, vs := range form {
539 if name, ok := strings.CutPrefix(k, "subscription_data[metadata]["); ok && len(vs) > 0 {
540 s.metadata[strings.TrimSuffix(name, "]")] = vs[0]
541 }
542 }
543 f.sessions[s.ID] = s
544 writeJSON(w, http.StatusOK, f.sessionObject(s))
545}
546
547func (f *Fake) getCheckout(w http.ResponseWriter, r *http.Request) {
548 id := r.PathValue("id")
549 f.mu.Lock()
550 s := f.sessions[id]
551 var obj map[string]any
552 if s != nil {
553 obj = f.sessionObject(s)
554 }
555 f.mu.Unlock()
556 if s == nil {
557 f.missing(w, "checkout.session", id)
558 return
559 }
560 writeJSON(w, http.StatusOK, obj)
561}
562
563func (f *Fake) createPortal(w http.ResponseWriter, r *http.Request) {
564 if err := r.ParseForm(); err != nil {
565 f.refuse(w, http.StatusBadRequest, err.Error())
566 return
567 }
568 customer := r.PostForm.Get("customer")
569 if customer == "" {
570 f.refuse(w, http.StatusBadRequest, "Missing required param: customer.")
571 return
572 }
573 f.mu.Lock()
574 f.portalForms = append(f.portalForms, r.PostForm)
575 f.portals[customer] = r.PostForm.Get("return_url")
576 id := f.next("bps")
577 f.mu.Unlock()
578 writeJSON(w, http.StatusOK, map[string]any{"id": id, "object": "billing_portal.session", "customer": customer,
579 "url": f.opts.PageBase + "/portal/" + url.PathEscape(customer)})
580}
581
582func (f *Fake) promotionCodes(w http.ResponseWriter, r *http.Request) {
583 code := r.URL.Query().Get("code")
584 f.mu.Lock()
585 var data []any
586 for c, id := range f.promos {
587 if strings.EqualFold(c, code) {
588 data = append(data, map[string]any{"id": id, "object": "promotion_code", "code": c, "active": true})
589 }
590 }
591 f.mu.Unlock()
592 if data == nil {
593 data = []any{}
594 }
595 writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": data, "has_more": false})
596}
597
598// pay completes session id as a card payment that went through: a
599// customer (the one Checkout was given, else a new one with the address
600// Checkout was given or, for someone with no subject either, email, which
601// must be an address: errEmail and nothing bought when it is not), an
602// active subscription on the session's price for one period, and the
603// session complete and paid. It returns where Checkout sends the buyer.
604// Paying a session already paid buys nothing more and returns the same
605// place: the retry after a delivery that failed.
606func (f *Fake) pay(ctx context.Context, id, email string) (string, error) {
607 if err := f.flush(ctx); err != nil {
608 return "", err
609 }
610 f.mu.Lock()
611 s := f.sessions[id]
612 if s != nil && s.Status == "complete" && s.successURL != "" {
613 back := strings.ReplaceAll(s.successURL, "{CHECKOUT_SESSION_ID}", s.ID)
614 f.mu.Unlock()
615 return back, nil
616 }
617 if err := usable(s); err != nil {
618 f.mu.Unlock()
619 return "", err
620 }
621 now := f.now()
622 c := f.customers[s.customer]
623 if c == nil {
624 // Checkout's own address wins, and a signed-in tester (Checkout
625 // was given their subject) is not asked for one: the subject says
626 // who they are. Otherwise it is the address typed on the page, and
627 // not one made up.
628 if s.customerEmail != "" {
629 email = s.customerEmail
630 } else if s.ClientReferenceID != "" {
631 email = ""
632 } else if !validEmail(email) {
633 f.mu.Unlock()
634 return "", errEmail
635 }
636 c = &customer{id: s.customer, email: email}
637 if c.id == "" {
638 c.id = f.next("cus")
639 }
640 f.customers[c.id] = c
641 }
642 interval := "month"
643 if p := f.price(s.price); p != nil && p.interval != "" {
644 interval = p.interval
645 }
646 sub := &subscription{
647 id: f.next("sub"), customer: c.id, status: "active", price: s.price, interval: interval,
648 periodStart: now, periodEnd: periodAfter(now, 1, interval), metadata: s.metadata,
649 anchor: now, cycles: 1,
650 }
651 f.subs[sub.id] = sub
652 f.order = append(f.order, sub.id)
653 s.customer = c.id
654 s.Status, s.PaymentStatus, s.Subscription, s.Email = "complete", "paid", sub.id, c.email
655 events := [][]byte{
656 f.event("customer.subscription.created", f.subObject(sub, false)),
657 f.event("checkout.session.completed", f.sessionObject(s)),
658 }
659 back := strings.ReplaceAll(s.successURL, "{CHECKOUT_SESSION_ID}", s.ID)
660 f.mu.Unlock()
661 return back, f.deliver(ctx, events)
662}
663
664// decline ends session id with nothing bought, and returns where Checkout
665// sends the buyer: a payment that fails the sandbox's way.
666func (f *Fake) decline(ctx context.Context, id string) (string, error) {
667 if err := f.flush(ctx); err != nil {
668 return "", err
669 }
670 f.mu.Lock()
671 s := f.sessions[id]
672 if err := usable(s); err != nil {
673 f.mu.Unlock()
674 return "", err
675 }
676 s.Status = "expired"
677 e := f.event("checkout.session.expired", f.sessionObject(s))
678 back := s.cancelURL
679 f.mu.Unlock()
680 return back, f.deliver(ctx, [][]byte{e})
681}
682
683// cancel sets customer's subscription subID to end with its period.
684// Cancelling one already cancelling changes nothing, but still sends
685// whatever is waiting: the retry after a delivery that failed.
686func (f *Fake) cancel(ctx context.Context, customer, subID string) error {
687 if err := f.flush(ctx); err != nil {
688 return err
689 }
690 f.mu.Lock()
691 s := f.subs[subID]
692 if s == nil || s.customer != customer {
693 f.mu.Unlock()
694 return errUnknown
695 }
696 if s.cancel || !live(s.status) {
697 f.mu.Unlock()
698 return nil
699 }
700 s.cancel = true
701 e := f.event("customer.subscription.updated", f.subObject(s, false))
702 f.mu.Unlock()
703 return f.deliver(ctx, [][]byte{e})
704}
705
706var (
707 errUnknown = errors.New("fakestripe: no such session or subscription")
708 errGone = errors.New("fakestripe: the session is over")
709 errEmail = errors.New("fakestripe: the sandbox page needs an email address")
710)
711
712// usable is why session s cannot be paid or declined, or nil. Hold f.mu.
713func usable(s *session) error {
714 switch {
715 case s == nil:
716 return errUnknown
717 case s.Status != "open":
718 return errGone
719 }
720 return nil
721}
722
723// live reports whether a subscription in status can still be cancelled.
724func live(status string) bool {
725 return status == "active" || status == "trialing" || status == "past_due"
726}
727
728// next is the next id with prefix that is not already taken. Hold f.mu.
729func (f *Fake) next(prefix string) string {
730 for {
731 f.counters[prefix]++
732 id := prefix + "_" + strconv.Itoa(f.counters[prefix])
733 if f.opts.IDTag != "" {
734 id = prefix + "_" + f.opts.IDTag + "_" + strconv.Itoa(f.counters[prefix])
735 }
736 _, sub := f.subs[id]
737 _, cus := f.customers[id]
738 _, cs := f.sessions[id]
739 if !sub && !cus && !cs {
740 return id
741 }
742 }
743}
744
745// event is a whole event, ready to send. Hold f.mu.
746func (f *Fake) event(typ string, object map[string]any) []byte {
747 b, err := json.Marshal(map[string]any{
748 "id": f.next("evt"), "object": "event", "type": typ, "api_version": stripeapi.APIVersion,
749 "created": f.now().Unix(), "livemode": false, "data": map[string]any{"object": object},
750 })
751 if err != nil {
752 panic(err) // maps of strings and numbers always marshal
753 }
754 return b
755}
756
757// deliver queues events behind any still waiting and sends the queue.
758// Call it without f.mu: the receiver usually calls the API back.
759func (f *Fake) deliver(ctx context.Context, events [][]byte) error {
760 f.mu.Lock()
761 f.queue = append(f.queue, events...)
762 f.mu.Unlock()
763 return f.flush(ctx)
764}
765
766// flush sends the queued events in order, stopping at the first that
767// fails, which stays at the head of the queue with the rest behind it.
768// Each attempt is signed afresh, so a retry is never stale. A failure to
769// hand an event to Options.Deliver is returned, so the sandbox's page
770// action fails rather than pretend; a failed webhook POST is not, since
771// Stripe's own pages carry on when a webhook fails.
772func (f *Fake) flush(ctx context.Context) error {
773 f.sending.Lock()
774 defer f.sending.Unlock()
775 for {
776 f.mu.Lock()
777 if len(f.queue) == 0 {
778 f.failures = nil
779 f.mu.Unlock()
780 return nil
781 }
782 e, o, now := f.queue[0], f.opts, f.now()
783 f.mu.Unlock()
784 var err error
785 switch {
786 case o.Deliver != nil:
787 err = o.Deliver(ctx, e)
788 case o.WebhookURL != "":
789 err = post(ctx, o.WebhookURL, e, sign(o.WebhookSecret, e, now))
790 }
791 f.mu.Lock()
792 if err != nil {
793 err = fmt.Errorf("fakestripe: delivering %s: %w", e, err)
794 f.failures = append(f.failures, err)
795 f.mu.Unlock()
796 if o.Deliver != nil {
797 return err
798 }
799 return nil
800 }
801 f.queue = f.queue[1:]
802 f.mu.Unlock()
803 }
804}
805
806func post(ctx context.Context, u string, body []byte, signature string) error {
807 req, err := http.NewRequestWithContext(context.WithoutCancel(ctx), http.MethodPost, u, bytes.NewReader(body))
808 if err != nil {
809 return err
810 }
811 req.Header.Set("Content-Type", "application/json; charset=utf-8")
812 req.Header.Set("Stripe-Signature", signature)
813 resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
814 if err != nil {
815 return err
816 }
817 resp.Body.Close()
818 if resp.StatusCode/100 != 2 {
819 return fmt.Errorf("%s answered %s", u, resp.Status)
820 }
821 return nil
822}
823
824// sign is the Stripe-Signature header for body at now.
825func sign(secret string, body []byte, now time.Time) string {
826 ts := strconv.FormatInt(now.Unix(), 10)
827 mac := hmac.New(sha256.New, []byte(secret))
828 mac.Write([]byte(ts + "."))
829 mac.Write(body)
830 return "t=" + ts + ",v1=" + hex.EncodeToString(mac.Sum(nil))
831}
832
833// subObject is s as the API renders it in 2025-03-31.basil: the period on
834// the items, the customer an id unless expanded. Hold f.mu.
835func (f *Fake) subObject(s *subscription, expand bool) map[string]any {
836 var cust any = s.customer
837 if expand {
838 c := f.customers[s.customer]
839 if c == nil {
840 c = &customer{id: s.customer}
841 }
842 cust = map[string]any{"id": c.id, "object": "customer", "email": nullable(c.email), "name": nullable(c.name)}
843 }
844 var interval any
845 if s.interval != "" {
846 interval = map[string]any{"interval": s.interval, "interval_count": intervalCount(f.prices[s.price])}
847 }
848 metadata := map[string]string{}
849 for k, v := range s.metadata {
850 metadata[k] = v
851 }
852 return map[string]any{
853 "id": s.id, "object": "subscription", "status": s.status, "cancel_at_period_end": s.cancel,
854 "customer": cust, "metadata": metadata,
855 "items": map[string]any{"object": "list", "data": []any{map[string]any{
856 "id": "si_" + strings.TrimPrefix(s.id, "sub_"), "object": "subscription_item",
857 "current_period_start": unix(s.periodStart), "current_period_end": unix(s.periodEnd),
858 "price": map[string]any{"id": s.price, "object": "price", "recurring": interval},
859 }}},
860 }
861}
862
863// sessionObject is s as the API renders it. Hold f.mu.
864func (f *Fake) sessionObject(s *session) map[string]any {
865 email := s.Email
866 if email == "" && s.Status == "complete" {
867 if c := f.customers[s.customer]; c != nil {
868 email = c.email
869 }
870 }
871 var details any
872 if email != "" {
873 details = map[string]any{"email": email}
874 }
875 return map[string]any{
876 "id": s.ID, "object": "checkout.session", "mode": "subscription",
877 "url": f.opts.PageBase + "/checkout/" + url.PathEscape(s.ID), "status": s.Status, "payment_status": s.PaymentStatus,
878 "client_reference_id": nullable(s.ClientReferenceID), "subscription": nullable(s.Subscription),
879 "customer": nullable(s.customer), "customer_details": details,
880 }
881}
882
883// invoice is the invoice for s's latest period. Hold f.mu.
884func (f *Fake) invoice(s *subscription, status string) map[string]any {
885 return map[string]any{
886 "id": f.next("in"), "object": "invoice", "status": status, "customer": s.customer,
887 "parent": map[string]any{"type": "subscription_details", "subscription_details": map[string]any{"subscription": s.id}},
888 }
889}
890
891func priceObject(p *price) map[string]any {
892 var recurring any
893 if p.interval != "" {
894 recurring = map[string]any{"interval": p.interval, "interval_count": intervalCount(p)}
895 }
896 typ := "recurring"
897 if recurring == nil {
898 typ = "one_time"
899 }
900 return map[string]any{"id": p.id, "object": "price", "unit_amount": p.amount, "currency": p.currency,
901 "recurring": recurring, "type": typ, "active": true}
902}
903
904// intervalCount is p's interval_count, 1 unless SetIntervalCount said
905// otherwise.
906func intervalCount(p *price) int {
907 if p == nil || p.count < 1 {
908 return 1
909 }
910 return p.count
911}
912
913// periodAfter is the end of the n-th period of interval counted from
914// anchor. Months and years keep the anchor's day, clamped to the last day
915// of a shorter month (31 January, 28 February, 31 March), which is how
916// Stripe bills; time.AddDate would roll 31 January on to 3 March.
917func periodAfter(anchor time.Time, n int, interval string) time.Time {
918 switch interval {
919 case "year":
920 return addMonths(anchor, 12*n)
921 case "week":
922 return anchor.AddDate(0, 0, 7*n)
923 case "day":
924 return anchor.AddDate(0, 0, n)
925 }
926 return addMonths(anchor, n)
927}
928
929// addMonths is t plus n months, on t's day or the target month's last day
930// when that comes first.
931func addMonths(t time.Time, n int) time.Time {
932 y, m, d := t.Date()
933 first := time.Date(y, m+time.Month(n), 1, t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), t.Location())
934 last := first.AddDate(0, 1, -1).Day()
935 if d > last {
936 d = last
937 }
938 return first.AddDate(0, 0, d-1)
939}
940
941func unix(t time.Time) int64 {
942 if t.IsZero() {
943 return 0
944 }
945 return t.Unix()
946}
947
948func nullable(s string) any {
949 if s == "" {
950 return nil
951 }
952 return s
953}
954
955func hasExpand(q url.Values, field string) bool {
956 for _, e := range q["expand[]"] {
957 if e == field {
958 return true
959 }
960 }
961 return false
962}
963
964func (f *Fake) missing(w http.ResponseWriter, what, id string) {
965 writeJSON(w, http.StatusNotFound, map[string]any{"error": map[string]any{
966 "type": "invalid_request_error", "code": "resource_missing", "message": "No such " + what + ": '" + id + "'"}})
967}
968
969func (f *Fake) refuse(w http.ResponseWriter, status int, msg string) {
970 writeJSON(w, status, map[string]any{"error": map[string]any{"type": "invalid_request_error", "message": msg}})
971}
972
973func writeJSON(w http.ResponseWriter, status int, v any) {
974 w.Header().Set("Content-Type", "application/json")
975 w.WriteHeader(status)
976 _ = json.NewEncoder(w).Encode(v)
977}
978