| 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. |
| 11 | package fakestripe |
| 12 | |
| 13 | import ( |
| 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. |
| 35 | type Lookup func(key string, args ...any) string |
| 36 | |
| 37 | // Options configures a Fake. |
| 38 | type 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. |
| 67 | type 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. |
| 80 | type 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. |
| 86 | type 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 | |
| 114 | type price struct { |
| 115 | id, plan, currency, interval string |
| 116 | amount int64 |
| 117 | count int // interval_count; 0 reads as 1 |
| 118 | } |
| 119 | |
| 120 | type customer struct{ id, email, name string } |
| 121 | |
| 122 | type 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 | |
| 136 | type 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. |
| 144 | func 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. |
| 157 | func (f *Fake) Now() time.Time { |
| 158 | f.mu.Lock() |
| 159 | defer f.mu.Unlock() |
| 160 | return f.now() |
| 161 | } |
| 162 | |
| 163 | func (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. |
| 167 | func (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. |
| 175 | func (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. |
| 184 | func (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. |
| 193 | func (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. |
| 202 | func (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. |
| 236 | func (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. |
| 249 | func (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. |
| 260 | func (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. |
| 271 | func (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. |
| 278 | func (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. |
| 285 | func (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. |
| 297 | func (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. |
| 306 | func (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. |
| 313 | func (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). |
| 321 | func (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. |
| 341 | func (f *Fake) Tick() { _ = f.tick(context.Background()) } |
| 342 | |
| 343 | func (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. |
| 380 | func (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}). |
| 394 | func (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. |
| 412 | func (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 | |
| 426 | func (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. |
| 440 | func (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. |
| 451 | func 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 | |
| 464 | func (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 | |
| 493 | func (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 | |
| 512 | func (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 | |
| 547 | func (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 | |
| 563 | func (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 | |
| 582 | func (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. |
| 606 | func (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. |
| 666 | func (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. |
| 686 | func (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 | |
| 706 | var ( |
| 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. |
| 713 | func 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. |
| 724 | func 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. |
| 729 | func (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. |
| 746 | func (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. |
| 759 | func (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. |
| 772 | func (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 | |
| 806 | func 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. |
| 825 | func 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. |
| 835 | func (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. |
| 864 | func (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. |
| 884 | func (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 | |
| 891 | func 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. |
| 906 | func 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. |
| 917 | func 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. |
| 931 | func 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 | |
| 941 | func unix(t time.Time) int64 { |
| 942 | if t.IsZero() { |
| 943 | return 0 |
| 944 | } |
| 945 | return t.Unix() |
| 946 | } |
| 947 | |
| 948 | func nullable(s string) any { |
| 949 | if s == "" { |
| 950 | return nil |
| 951 | } |
| 952 | return s |
| 953 | } |
| 954 | |
| 955 | func 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 | |
| 964 | func (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 | |
| 969 | func (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 | |
| 973 | func 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 | |