// Package fakestripe is a stand-in for the part of Stripe abono uses: the // API calls internal/stripeapi makes, hosted Checkout and Customer Portal // pages that pay, fail or cancel on a click, and the signed events those // send. abonotest serves it to tests over HTTP; the sandbox runs it inside // the app. // // It is deterministic: ids come from counters (sub_1, cus_1, cs_1, evt_1) // and time from Options.Now plus whatever Advance added. It speaks only // the API version abono pins, 2025-03-31.basil, in which a // subscription's period end is on its items. package fakestripe import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "net/url" "strconv" "strings" "sync" "time" "amadan.net/rastrillo/abono/internal/stripeapi" ) // Lookup is the pages' text: the string for a message key, with {name} // placeholders filled from alternating name/value args. The pages carry // no words of their own. type Lookup func(key string, args ...any) string // Options configures a Fake. type Options struct { // WebhookURL is where events are POSTed, signed with WebhookSecret. // With neither it nor Deliver set, events are made and dropped. WebhookURL string WebhookSecret string // Deliver, when set, is given each event instead of WebhookURL: the // sandbox hands them straight to its service. Deliver func(ctx context.Context, event []byte) error // Now is the clock; default time.Now. Advance adds to it. Now func() time.Time // PageBase is the address the pages are served at, with no trailing // slash: the Checkout and Portal URLs the API returns start with it. PageBase string // Lookup renders the pages' message keys; nil shows the keys. Lookup Lookup // IDTag, when set, goes into every id the fake makes (sub__1), // so ids stay unique across runs that share a database: the // sandbox's fake starts empty each time the app does, and abono's // tables remember the last run's sub_1 and evt_1. IDTag string // AnyPrice answers a price id nobody added as a recurring price, // with its interval guessed from the id (see guessInterval). The // sandbox has no Stripe account to read prices from. AnyPrice bool } // Subscription is a subscription as a test describes it. An empty ID or // Customer is given the next one from the counter. Email and Name are // the customer's. type Subscription struct { ID, Customer, Email, Name string Status string Price string // Interval is the price's when empty, and "month" for a price nobody // added. Interval string PeriodStart, PeriodEnd time.Time CancelAtPeriodEnd bool Metadata map[string]string } // Session is what a test sets of a Checkout session. type Session struct { ID, Status, PaymentStatus, Subscription, ClientReferenceID, Email string } // Fake is one Stripe account in memory. Its methods are safe for // concurrent use. type Fake struct { opts Options mu sync.Mutex offset time.Duration counters map[string]int prices map[string]*price promos map[string]string // code -> promo_... customers map[string]*customer subs map[string]*subscription order []string // subscription ids, oldest first sessions map[string]*session portals map[string]string // customer -> return_url failNext map[string]bool checkoutForms []url.Values portalForms []url.Values priceFetches map[string]int onFetch func() // queue is the events not yet delivered, oldest first; failures says // why the first of them was not. sending is held while a queue is // being sent, so events go out one at a time and in order. queue [][]byte failures []error sending sync.Mutex } type price struct { id, plan, currency, interval string amount int64 count int // interval_count; 0 reads as 1 } type customer struct{ id, email, name string } type subscription struct { id, customer, status, price, interval string periodStart, periodEnd time.Time cancel bool metadata map[string]string // anchor is the billing cycle anchor, and cycles how many periods // past it the current period ends: the period end is always counted // from the anchor (see periodAfter), so a subscription that began on // the 31st renews on the last day of a shorter month and on the 31st // again after it, as Stripe's does. anchor time.Time cycles int } type session struct { Session customer, customerEmail, price string successURL, cancelURL string metadata map[string]string } // New returns an empty account. func New(opts Options) *Fake { if opts.Now == nil { opts.Now = time.Now } opts.PageBase = strings.TrimRight(opts.PageBase, "/") return &Fake{ opts: opts, counters: map[string]int{}, prices: map[string]*price{}, promos: map[string]string{}, customers: map[string]*customer{}, subs: map[string]*subscription{}, sessions: map[string]*session{}, portals: map[string]string{}, failNext: map[string]bool{}, priceFetches: map[string]int{}, } } // Now is the fake's clock: Options.Now plus every Advance. func (f *Fake) Now() time.Time { f.mu.Lock() defer f.mu.Unlock() return f.now() } func (f *Fake) now() time.Time { return f.opts.Now().Add(f.offset).UTC() } // SetWebhookURL changes where events go: a test's app is often listening // only after the fake is made. func (f *Fake) SetWebhookURL(u string) { f.mu.Lock() defer f.mu.Unlock() f.opts.WebhookURL = u } // AddPrice adds a recurring price (interval "" for a one-off), or // replaces one. plan is the name the Checkout page shows. func (f *Fake) AddPrice(id, plan string, amount int64, currency, interval string) { f.mu.Lock() defer f.mu.Unlock() f.prices[id] = &price{id: id, plan: plan, amount: amount, currency: currency, interval: interval} } // SetIntervalCount makes price id bill every n of its intervals (a price // of "month" with n 12 bills once a year). AddPrice's prices bill every // one. func (f *Fake) SetIntervalCount(id string, n int) { f.mu.Lock() defer f.mu.Unlock() if p := f.prices[id]; p != nil { p.count = n } } // AddPromotionCode makes code a live promotion code with id. func (f *Fake) AddPromotionCode(code, id string) { f.mu.Lock() defer f.mu.Unlock() f.promos[code] = id } // PutSubscription stores s as it is, replacing any with its id, and // returns its id. It sends no event: SendEvent does, when a test wants // one. func (f *Fake) PutSubscription(s Subscription) string { f.mu.Lock() defer f.mu.Unlock() if s.ID == "" { s.ID = f.next("sub") } if s.Customer == "" { s.Customer = f.next("cus") } c := f.customers[s.Customer] if c == nil { c = &customer{id: s.Customer} f.customers[s.Customer] = c } c.email, c.name = s.Email, s.Name if s.Interval == "" { s.Interval = "month" if p := f.prices[s.Price]; p != nil && p.interval != "" { s.Interval = p.interval } } if _, ok := f.subs[s.ID]; !ok { f.order = append(f.order, s.ID) } f.subs[s.ID] = &subscription{ id: s.ID, customer: s.Customer, status: s.Status, price: s.Price, interval: s.Interval, periodStart: s.PeriodStart, periodEnd: s.PeriodEnd, cancel: s.CancelAtPeriodEnd, metadata: s.Metadata, anchor: s.PeriodEnd, } return s.ID } // SubscriptionJSON is the subscription as the API returns it, its // customer expanded. func (f *Fake) SubscriptionJSON(id string) ([]byte, bool) { f.mu.Lock() defer f.mu.Unlock() s := f.subs[id] if s == nil { return nil, false } b, err := json.Marshal(f.subObject(s, true)) return b, err == nil } // PutSession stores a Checkout session, replacing the test-visible part of // any with its id. func (f *Fake) PutSession(s Session) { f.mu.Lock() defer f.mu.Unlock() if have := f.sessions[s.ID]; have != nil { have.Session = s return } f.sessions[s.ID] = &session{Session: s} } // Session is the test-visible part of a Checkout session. func (f *Fake) Session(id string) (Session, bool) { f.mu.Lock() defer f.mu.Unlock() s := f.sessions[id] if s == nil { return Session{}, false } return s.Session, true } // CheckoutForms is every Checkout session's form, in the order asked for. func (f *Fake) CheckoutForms() []url.Values { f.mu.Lock() defer f.mu.Unlock() return append([]url.Values(nil), f.checkoutForms...) } // PortalForms is every Portal session's form, in the order asked for. func (f *Fake) PortalForms() []url.Values { f.mu.Lock() defer f.mu.Unlock() return append([]url.Values(nil), f.portalForms...) } // PriceFetches is how many times each price has been read. func (f *Fake) PriceFetches() map[string]int { f.mu.Lock() defer f.mu.Unlock() out := make(map[string]int, len(f.priceFetches)) for k, v := range f.priceFetches { out[k] = v } return out } // OnSubscriptionFetch sets fn to run each time a subscription is read, // before the answer is sent. func (f *Fake) OnSubscriptionFetch(fn func()) { f.mu.Lock() defer f.mu.Unlock() f.onFetch = fn } // DeliveryErrors is why events are still waiting to be delivered: each // failed attempt since the queue was last empty. A delivery that later // succeeds clears them. func (f *Fake) DeliveryErrors() []error { f.mu.Lock() defer f.mu.Unlock() return append([]error(nil), f.failures...) } // FailNextRenewal makes subID's next renewal fail: it goes past_due. func (f *Fake) FailNextRenewal(subID string) { f.mu.Lock() defer f.mu.Unlock() f.failNext[subID] = true } // Advance moves the clock on by d, then does what Stripe would have done // meanwhile (Tick). func (f *Fake) Advance(d time.Duration) { f.mu.Lock() f.offset += d f.mu.Unlock() f.Tick() } // Tick sends any events still waiting, then does what is due at the // fake's now and sends its events. A subscription set to cancel at its // period end ends then, whatever its status. Otherwise an active or // trialing one whose period has ended renews, paid, and moves to the next // period; with FailNextRenewal it goes past_due instead (the period still // moves on, as Stripe's does: the failed invoice is for the new one, and // current_period_start is where it began). A past_due subscription goes on // being invoiced, as Stripe's does when an account leaves subscriptions // past_due: each renewal fails again and the period moves on again. Retries // within a period are not modelled. Periods are counted from the billing // anchor, clamped to the end of a shorter month. // A delivery that fails stays queued for the next Tick or page action; // DeliveryErrors says why. func (f *Fake) Tick() { _ = f.tick(context.Background()) } func (f *Fake) tick(ctx context.Context) error { if err := f.flush(ctx); err != nil { return err } f.mu.Lock() now := f.now() var events [][]byte for _, id := range f.order { s := f.subs[id] for live(s.status) && !s.periodEnd.IsZero() && !now.Before(s.periodEnd) { if s.cancel { s.status = "canceled" events = append(events, f.event("customer.subscription.deleted", f.subObject(s, false))) break } s.cycles++ switch { case f.failNext[id] || s.status == "past_due": delete(f.failNext, id) s.status = "past_due" s.periodStart, s.periodEnd = s.periodEnd, periodAfter(s.anchor, s.cycles, s.interval) events = append(events, f.event("invoice.payment_failed", f.invoice(s, "open")), f.event("customer.subscription.updated", f.subObject(s, false))) default: s.status = "active" // a trial that renews paid is over s.periodStart, s.periodEnd = s.periodEnd, periodAfter(s.anchor, s.cycles, s.interval) events = append(events, f.event("invoice.paid", f.invoice(s, "paid")), f.event("customer.subscription.updated", f.subObject(s, false))) } } } f.mu.Unlock() return f.deliver(ctx, events) } // SendEvent sends an event of typ about subscription subID as it is now, // as Stripe does when something changes it outside the fake's pages. func (f *Fake) SendEvent(typ, subID string) { f.mu.Lock() s := f.subs[subID] if s == nil { f.mu.Unlock() return } e := f.event(typ, f.subObject(s, false)) f.mu.Unlock() _ = f.deliver(context.Background(), [][]byte{e}) } // Handler serves the API (/v1/...) and the pages (/checkout/{id}, // /portal/{customer}). func (f *Fake) Handler() http.Handler { mux := http.NewServeMux() api := func(pattern string, h http.HandlerFunc) { mux.Handle(pattern, f.authorised(h)) } api("GET /v1/prices/{id}", f.getPrice) api("GET /v1/subscriptions", f.listSubscriptions) api("GET /v1/subscriptions/{id}", f.getSubscription) api("POST /v1/checkout/sessions", f.createCheckout) api("GET /v1/checkout/sessions/{id}", f.getCheckout) api("POST /v1/billing_portal/sessions", f.createPortal) api("GET /v1/promotion_codes", f.promotionCodes) mux.HandleFunc("/v1/", func(w http.ResponseWriter, r *http.Request) { f.refuse(w, http.StatusNotFound, "Unrecognized request URL: "+r.Method+" "+r.URL.Path) }) f.pages(mux) return mux } // authorised refuses what Stripe would: no key, or another API version. func (f *Fake) authorised(h http.HandlerFunc) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if key, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); !ok || strings.TrimSpace(key) == "" { f.refuse(w, http.StatusUnauthorized, "You did not provide an API key.") return } if v := r.Header.Get("Stripe-Version"); v != stripeapi.APIVersion { f.refuse(w, http.StatusBadRequest, "This fake speaks only Stripe-Version "+stripeapi.APIVersion+", not "+strconv.Quote(v)) return } h(w, r) }) } func (f *Fake) getPrice(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") f.mu.Lock() f.priceFetches[id]++ p := f.price(id) f.mu.Unlock() if p == nil { f.missing(w, "price", id) return } writeJSON(w, http.StatusOK, priceObject(p)) } // price is the price with id, made up when AnyPrice allows. Hold f.mu. func (f *Fake) price(id string) *price { if p := f.prices[id]; p != nil || !f.opts.AnyPrice || id == "" { return p } p := &price{id: id, amount: 500, currency: "eur", interval: guessInterval(id)} f.prices[id] = p return p } // guessInterval is a made-up price's interval, read from its id: // price_annual is yearly, price_weekly weekly, anything else monthly. func guessInterval(id string) string { s := strings.ToLower(id) switch { case strings.Contains(s, "year") || strings.Contains(s, "annual"): return "year" case strings.Contains(s, "week"): return "week" case strings.Contains(s, "day") || strings.Contains(s, "daily"): return "day" } return "month" } func (f *Fake) listSubscriptions(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() limit, err := strconv.Atoi(q.Get("limit")) if err != nil || limit < 1 || limit > 100 { limit = 10 } expand := hasExpand(q, "data.customer") f.mu.Lock() ids := f.order if after := q.Get("starting_after"); after != "" { for i, id := range ids { if id == after { ids = ids[i+1:] break } } } more := len(ids) > limit if more { ids = ids[:limit] } data := make([]any, 0, len(ids)) for _, id := range ids { data = append(data, f.subObject(f.subs[id], expand)) } f.mu.Unlock() writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": data, "has_more": more}) } func (f *Fake) getSubscription(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") f.mu.Lock() s, onFetch := f.subs[id], f.onFetch var obj map[string]any if s != nil { obj = f.subObject(s, hasExpand(r.URL.Query(), "customer")) } f.mu.Unlock() if onFetch != nil { onFetch() } if s == nil { f.missing(w, "subscription", id) return } writeJSON(w, http.StatusOK, obj) } func (f *Fake) createCheckout(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { f.refuse(w, http.StatusBadRequest, err.Error()) return } form := r.PostForm f.mu.Lock() defer f.mu.Unlock() f.checkoutForms = append(f.checkoutForms, form) priceID := form.Get("line_items[0][price]") switch { case priceID == "": f.refuse(w, http.StatusBadRequest, "Missing required param: line_items[0][price].") return case f.price(priceID) == nil: f.missing(w, "price", priceID) return case form.Get("success_url") == "": f.refuse(w, http.StatusBadRequest, "Missing required param: success_url.") return } s := &session{ Session: Session{ID: f.next("cs"), Status: "open", PaymentStatus: "unpaid", ClientReferenceID: form.Get("client_reference_id")}, customer: form.Get("customer"), customerEmail: form.Get("customer_email"), price: priceID, successURL: form.Get("success_url"), cancelURL: form.Get("cancel_url"), metadata: map[string]string{}, } for k, vs := range form { if name, ok := strings.CutPrefix(k, "subscription_data[metadata]["); ok && len(vs) > 0 { s.metadata[strings.TrimSuffix(name, "]")] = vs[0] } } f.sessions[s.ID] = s writeJSON(w, http.StatusOK, f.sessionObject(s)) } func (f *Fake) getCheckout(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") f.mu.Lock() s := f.sessions[id] var obj map[string]any if s != nil { obj = f.sessionObject(s) } f.mu.Unlock() if s == nil { f.missing(w, "checkout.session", id) return } writeJSON(w, http.StatusOK, obj) } func (f *Fake) createPortal(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { f.refuse(w, http.StatusBadRequest, err.Error()) return } customer := r.PostForm.Get("customer") if customer == "" { f.refuse(w, http.StatusBadRequest, "Missing required param: customer.") return } f.mu.Lock() f.portalForms = append(f.portalForms, r.PostForm) f.portals[customer] = r.PostForm.Get("return_url") id := f.next("bps") f.mu.Unlock() writeJSON(w, http.StatusOK, map[string]any{"id": id, "object": "billing_portal.session", "customer": customer, "url": f.opts.PageBase + "/portal/" + url.PathEscape(customer)}) } func (f *Fake) promotionCodes(w http.ResponseWriter, r *http.Request) { code := r.URL.Query().Get("code") f.mu.Lock() var data []any for c, id := range f.promos { if strings.EqualFold(c, code) { data = append(data, map[string]any{"id": id, "object": "promotion_code", "code": c, "active": true}) } } f.mu.Unlock() if data == nil { data = []any{} } writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": data, "has_more": false}) } // pay completes session id as a card payment that went through: a // customer (the one Checkout was given, else a new one with the address // Checkout was given or, for someone with no subject either, email, which // must be an address: errEmail and nothing bought when it is not), an // active subscription on the session's price for one period, and the // session complete and paid. It returns where Checkout sends the buyer. // Paying a session already paid buys nothing more and returns the same // place: the retry after a delivery that failed. func (f *Fake) pay(ctx context.Context, id, email string) (string, error) { if err := f.flush(ctx); err != nil { return "", err } f.mu.Lock() s := f.sessions[id] if s != nil && s.Status == "complete" && s.successURL != "" { back := strings.ReplaceAll(s.successURL, "{CHECKOUT_SESSION_ID}", s.ID) f.mu.Unlock() return back, nil } if err := usable(s); err != nil { f.mu.Unlock() return "", err } now := f.now() c := f.customers[s.customer] if c == nil { // Checkout's own address wins, and a signed-in tester (Checkout // was given their subject) is not asked for one: the subject says // who they are. Otherwise it is the address typed on the page, and // not one made up. if s.customerEmail != "" { email = s.customerEmail } else if s.ClientReferenceID != "" { email = "" } else if !validEmail(email) { f.mu.Unlock() return "", errEmail } c = &customer{id: s.customer, email: email} if c.id == "" { c.id = f.next("cus") } f.customers[c.id] = c } interval := "month" if p := f.price(s.price); p != nil && p.interval != "" { interval = p.interval } sub := &subscription{ id: f.next("sub"), customer: c.id, status: "active", price: s.price, interval: interval, periodStart: now, periodEnd: periodAfter(now, 1, interval), metadata: s.metadata, anchor: now, cycles: 1, } f.subs[sub.id] = sub f.order = append(f.order, sub.id) s.customer = c.id s.Status, s.PaymentStatus, s.Subscription, s.Email = "complete", "paid", sub.id, c.email events := [][]byte{ f.event("customer.subscription.created", f.subObject(sub, false)), f.event("checkout.session.completed", f.sessionObject(s)), } back := strings.ReplaceAll(s.successURL, "{CHECKOUT_SESSION_ID}", s.ID) f.mu.Unlock() return back, f.deliver(ctx, events) } // decline ends session id with nothing bought, and returns where Checkout // sends the buyer: a payment that fails the sandbox's way. func (f *Fake) decline(ctx context.Context, id string) (string, error) { if err := f.flush(ctx); err != nil { return "", err } f.mu.Lock() s := f.sessions[id] if err := usable(s); err != nil { f.mu.Unlock() return "", err } s.Status = "expired" e := f.event("checkout.session.expired", f.sessionObject(s)) back := s.cancelURL f.mu.Unlock() return back, f.deliver(ctx, [][]byte{e}) } // cancel sets customer's subscription subID to end with its period. // Cancelling one already cancelling changes nothing, but still sends // whatever is waiting: the retry after a delivery that failed. func (f *Fake) cancel(ctx context.Context, customer, subID string) error { if err := f.flush(ctx); err != nil { return err } f.mu.Lock() s := f.subs[subID] if s == nil || s.customer != customer { f.mu.Unlock() return errUnknown } if s.cancel || !live(s.status) { f.mu.Unlock() return nil } s.cancel = true e := f.event("customer.subscription.updated", f.subObject(s, false)) f.mu.Unlock() return f.deliver(ctx, [][]byte{e}) } var ( errUnknown = errors.New("fakestripe: no such session or subscription") errGone = errors.New("fakestripe: the session is over") errEmail = errors.New("fakestripe: the sandbox page needs an email address") ) // usable is why session s cannot be paid or declined, or nil. Hold f.mu. func usable(s *session) error { switch { case s == nil: return errUnknown case s.Status != "open": return errGone } return nil } // live reports whether a subscription in status can still be cancelled. func live(status string) bool { return status == "active" || status == "trialing" || status == "past_due" } // next is the next id with prefix that is not already taken. Hold f.mu. func (f *Fake) next(prefix string) string { for { f.counters[prefix]++ id := prefix + "_" + strconv.Itoa(f.counters[prefix]) if f.opts.IDTag != "" { id = prefix + "_" + f.opts.IDTag + "_" + strconv.Itoa(f.counters[prefix]) } _, sub := f.subs[id] _, cus := f.customers[id] _, cs := f.sessions[id] if !sub && !cus && !cs { return id } } } // event is a whole event, ready to send. Hold f.mu. func (f *Fake) event(typ string, object map[string]any) []byte { b, err := json.Marshal(map[string]any{ "id": f.next("evt"), "object": "event", "type": typ, "api_version": stripeapi.APIVersion, "created": f.now().Unix(), "livemode": false, "data": map[string]any{"object": object}, }) if err != nil { panic(err) // maps of strings and numbers always marshal } return b } // deliver queues events behind any still waiting and sends the queue. // Call it without f.mu: the receiver usually calls the API back. func (f *Fake) deliver(ctx context.Context, events [][]byte) error { f.mu.Lock() f.queue = append(f.queue, events...) f.mu.Unlock() return f.flush(ctx) } // flush sends the queued events in order, stopping at the first that // fails, which stays at the head of the queue with the rest behind it. // Each attempt is signed afresh, so a retry is never stale. A failure to // hand an event to Options.Deliver is returned, so the sandbox's page // action fails rather than pretend; a failed webhook POST is not, since // Stripe's own pages carry on when a webhook fails. func (f *Fake) flush(ctx context.Context) error { f.sending.Lock() defer f.sending.Unlock() for { f.mu.Lock() if len(f.queue) == 0 { f.failures = nil f.mu.Unlock() return nil } e, o, now := f.queue[0], f.opts, f.now() f.mu.Unlock() var err error switch { case o.Deliver != nil: err = o.Deliver(ctx, e) case o.WebhookURL != "": err = post(ctx, o.WebhookURL, e, sign(o.WebhookSecret, e, now)) } f.mu.Lock() if err != nil { err = fmt.Errorf("fakestripe: delivering %s: %w", e, err) f.failures = append(f.failures, err) f.mu.Unlock() if o.Deliver != nil { return err } return nil } f.queue = f.queue[1:] f.mu.Unlock() } } func post(ctx context.Context, u string, body []byte, signature string) error { req, err := http.NewRequestWithContext(context.WithoutCancel(ctx), http.MethodPost, u, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json; charset=utf-8") req.Header.Set("Stripe-Signature", signature) resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) if err != nil { return err } resp.Body.Close() if resp.StatusCode/100 != 2 { return fmt.Errorf("%s answered %s", u, resp.Status) } return nil } // sign is the Stripe-Signature header for body at now. func sign(secret string, body []byte, now time.Time) string { ts := strconv.FormatInt(now.Unix(), 10) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(ts + ".")) mac.Write(body) return "t=" + ts + ",v1=" + hex.EncodeToString(mac.Sum(nil)) } // subObject is s as the API renders it in 2025-03-31.basil: the period on // the items, the customer an id unless expanded. Hold f.mu. func (f *Fake) subObject(s *subscription, expand bool) map[string]any { var cust any = s.customer if expand { c := f.customers[s.customer] if c == nil { c = &customer{id: s.customer} } cust = map[string]any{"id": c.id, "object": "customer", "email": nullable(c.email), "name": nullable(c.name)} } var interval any if s.interval != "" { interval = map[string]any{"interval": s.interval, "interval_count": intervalCount(f.prices[s.price])} } metadata := map[string]string{} for k, v := range s.metadata { metadata[k] = v } return map[string]any{ "id": s.id, "object": "subscription", "status": s.status, "cancel_at_period_end": s.cancel, "customer": cust, "metadata": metadata, "items": map[string]any{"object": "list", "data": []any{map[string]any{ "id": "si_" + strings.TrimPrefix(s.id, "sub_"), "object": "subscription_item", "current_period_start": unix(s.periodStart), "current_period_end": unix(s.periodEnd), "price": map[string]any{"id": s.price, "object": "price", "recurring": interval}, }}}, } } // sessionObject is s as the API renders it. Hold f.mu. func (f *Fake) sessionObject(s *session) map[string]any { email := s.Email if email == "" && s.Status == "complete" { if c := f.customers[s.customer]; c != nil { email = c.email } } var details any if email != "" { details = map[string]any{"email": email} } return map[string]any{ "id": s.ID, "object": "checkout.session", "mode": "subscription", "url": f.opts.PageBase + "/checkout/" + url.PathEscape(s.ID), "status": s.Status, "payment_status": s.PaymentStatus, "client_reference_id": nullable(s.ClientReferenceID), "subscription": nullable(s.Subscription), "customer": nullable(s.customer), "customer_details": details, } } // invoice is the invoice for s's latest period. Hold f.mu. func (f *Fake) invoice(s *subscription, status string) map[string]any { return map[string]any{ "id": f.next("in"), "object": "invoice", "status": status, "customer": s.customer, "parent": map[string]any{"type": "subscription_details", "subscription_details": map[string]any{"subscription": s.id}}, } } func priceObject(p *price) map[string]any { var recurring any if p.interval != "" { recurring = map[string]any{"interval": p.interval, "interval_count": intervalCount(p)} } typ := "recurring" if recurring == nil { typ = "one_time" } return map[string]any{"id": p.id, "object": "price", "unit_amount": p.amount, "currency": p.currency, "recurring": recurring, "type": typ, "active": true} } // intervalCount is p's interval_count, 1 unless SetIntervalCount said // otherwise. func intervalCount(p *price) int { if p == nil || p.count < 1 { return 1 } return p.count } // periodAfter is the end of the n-th period of interval counted from // anchor. Months and years keep the anchor's day, clamped to the last day // of a shorter month (31 January, 28 February, 31 March), which is how // Stripe bills; time.AddDate would roll 31 January on to 3 March. func periodAfter(anchor time.Time, n int, interval string) time.Time { switch interval { case "year": return addMonths(anchor, 12*n) case "week": return anchor.AddDate(0, 0, 7*n) case "day": return anchor.AddDate(0, 0, n) } return addMonths(anchor, n) } // addMonths is t plus n months, on t's day or the target month's last day // when that comes first. func addMonths(t time.Time, n int) time.Time { y, m, d := t.Date() first := time.Date(y, m+time.Month(n), 1, t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), t.Location()) last := first.AddDate(0, 1, -1).Day() if d > last { d = last } return first.AddDate(0, 0, d-1) } func unix(t time.Time) int64 { if t.IsZero() { return 0 } return t.Unix() } func nullable(s string) any { if s == "" { return nil } return s } func hasExpand(q url.Values, field string) bool { for _, e := range q["expand[]"] { if e == field { return true } } return false } func (f *Fake) missing(w http.ResponseWriter, what, id string) { writeJSON(w, http.StatusNotFound, map[string]any{"error": map[string]any{ "type": "invalid_request_error", "code": "resource_missing", "message": "No such " + what + ": '" + id + "'"}}) } func (f *Fake) refuse(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]any{"error": map[string]any{"type": "invalid_request_error", "message": msg}}) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) }