package fakestripe import ( "errors" "fmt" "html/template" "net/http" "net/mail" "net/url" "strconv" "strings" ) // The pages stand in for Stripe's hosted Checkout and Customer Portal. // They are plain forms in rastrillo's rst- vocabulary with no stylesheet // of their own, and every word on them is a message key given to // Options.Lookup (abono.sandbox.*): the copy is reviewed with the rest of // abono's. Pay, Decline and Cancel are POSTs; a browser sends them from // the page's own origin, so an app's csrf.Protect lets them through. var page = template.Must(template.New("page").Parse(` {{.Title}}

{{.Banner}}

{{- if .Expired}}

{{.Expired}}

{{- else}}

{{.Title}}

{{.Intro}}

{{- with .Checkout}}

{{.Item}}

{{- if .AskEmail}}
{{- end}}
{{- if .Leave}}

{{.Leave}}

{{- end}} {{- end}} {{- range .Portal}}

{{.Plan}}

{{- if .Cancel}} {{- end}}
{{- end}} {{- if .Return}}

{{.Return}}

{{- end}} {{- end}}
`)) type pageData struct { Title, Banner, Intro, Expired string Checkout *checkoutData Portal []portalItem Return string ReturnHref template.URL } type checkoutData struct { Item, Pay, Decline, Leave string PayAction, DeclineAction string LeaveHref template.URL // AskEmail is set for a tester who is not signed in: Checkout was // given no customer, address or subject, so the page asks for one. // Email is what they typed, and EmailBad that it was refused. AskEmail bool EmailLabel, Email string EmailBad bool } type portalItem struct { ID, Plan, Cancel, CancelAction string } func (f *Fake) pages(mux *http.ServeMux) { mux.HandleFunc("GET /checkout/{id}", f.checkoutPage) mux.HandleFunc("POST /checkout/{id}/pay", func(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() typed := strings.TrimSpace(r.PostForm.Get("email")) back, err := f.pay(r.Context(), r.PathValue("id"), typed) if errors.Is(err, errEmail) { f.showCheckout(w, r.PathValue("id"), http.StatusUnprocessableEntity, typed) return } f.redirect(w, r, back, err) }) mux.HandleFunc("POST /checkout/{id}/decline", func(w http.ResponseWriter, r *http.Request) { back, err := f.decline(r.Context(), r.PathValue("id")) f.redirect(w, r, back, err) }) mux.HandleFunc("GET /portal/{customer}", f.portalPage) mux.HandleFunc("POST /portal/{customer}/cancel", func(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() err := f.cancel(r.Context(), r.PathValue("customer"), r.PostForm.Get("subscription")) f.redirect(w, r, f.pageURL("portal", r.PathValue("customer")), err) }) } // t is the text for key. func (f *Fake) t(key string, args ...any) string { if f.opts.Lookup == nil { return key } return f.opts.Lookup(key, args...) } // pageURL is the address of a page, under PageBase. func (f *Fake) pageURL(kind, id string) string { return f.opts.PageBase + "/" + kind + "/" + url.PathEscape(id) } // action is a page's form action: PageBase's path, so the form posts to // whichever host the page was loaded from. func (f *Fake) action(kind, id, verb string) string { path := f.opts.PageBase if u, err := url.Parse(path); err == nil { path = u.Path } return path + "/" + kind + "/" + url.PathEscape(id) + "/" + verb } func (f *Fake) checkoutPage(w http.ResponseWriter, r *http.Request) { // The page is where the reader sees time pass in the sandbox. if err := f.tick(r.Context()); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } f.showCheckout(w, r.PathValue("id"), http.StatusOK, "") } // showCheckout renders session id's page with status. typed is the // address the tester sent that the page refused (status 422), put back in // the field; "" otherwise. func (f *Fake) showCheckout(w http.ResponseWriter, id string, status int, typed string) { f.mu.Lock() s := f.sessions[id] if s == nil || s.Status != "open" { f.mu.Unlock() f.expired(w, s == nil) return } ask := f.customers[s.customer] == nil && s.customerEmail == "" && s.ClientReferenceID == "" plan, amount := "", "" if p := f.price(s.price); p != nil { plan, amount = p.plan, f.priceLine(p) } cancelURL := s.cancelURL f.mu.Unlock() c := &checkoutData{ Item: f.t("abono.sandbox.checkout_item", "plan", plan, "price", amount), Pay: f.t("abono.sandbox.pay"), Decline: f.t("abono.sandbox.decline"), PayAction: f.action("checkout", id, "pay"), DeclineAction: f.action("checkout", id, "decline"), } if ask { c.AskEmail, c.EmailLabel = true, f.t("abono.sandbox.email") c.Email, c.EmailBad = typed, status == http.StatusUnprocessableEntity } if cancelURL != "" { c.Leave, c.LeaveHref = f.t("abono.sandbox.leave"), safeURL(cancelURL) } f.render(w, status, pageData{ Title: f.t("abono.sandbox.checkout_title"), Banner: f.t("abono.sandbox.banner"), Intro: f.t("abono.sandbox.checkout_intro"), Checkout: c, }) } // validEmail is whether s is one plain address, "ana@example.com", not // "Ana " and not a list. func validEmail(s string) bool { a, err := mail.ParseAddress(s) return err == nil && a.Address == s } // priceLine is the price as abono.price.per_ says it, or the // bare amount for a one-off. Hold f.mu. func (f *Fake) priceLine(p *price) string { amount := money(p.amount, p.currency) if p.interval == "" { return amount } return f.t("abono.price.per_"+p.interval, "amount", amount) } func (f *Fake) portalPage(w http.ResponseWriter, r *http.Request) { if err := f.tick(r.Context()); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } customer := r.PathValue("customer") f.mu.Lock() var items []portalItem for _, id := range f.order { s := f.subs[id] if s.customer != customer || !live(s.status) { continue } plan := s.price if p := f.prices[s.price]; p != nil && p.plan != "" { plan = p.plan } it := portalItem{ID: s.id, Plan: f.t("abono.sandbox.portal_plan", "plan", plan), CancelAction: f.action("portal", customer, "cancel")} if !s.cancel { it.Cancel = f.t("abono.sandbox.portal_cancel") } items = append(items, it) } back := f.portals[customer] f.mu.Unlock() d := pageData{ Title: f.t("abono.sandbox.portal_title"), Banner: f.t("abono.sandbox.banner"), Intro: f.t("abono.sandbox.portal_intro"), Portal: items, } if back != "" { d.Return, d.ReturnHref = f.t("abono.sandbox.portal_return"), safeURL(back) } f.render(w, http.StatusOK, d) } // expired is the page for a session that is over (410) or never was // (404). func (f *Fake) expired(w http.ResponseWriter, unknown bool) { status := http.StatusGone if unknown { status = http.StatusNotFound } f.render(w, status, pageData{ Title: f.t("abono.sandbox.expired"), Banner: f.t("abono.sandbox.banner"), Expired: f.t("abono.sandbox.expired"), }) } // redirect sends the browser on to back after an action, or shows the // expired page when there was nothing to act on. func (f *Fake) redirect(w http.ResponseWriter, r *http.Request, back string, err error) { switch { case errors.Is(err, errUnknown), errors.Is(err, errGone): f.expired(w, errors.Is(err, errUnknown)) case err != nil: http.Error(w, err.Error(), http.StatusInternalServerError) case back == "": w.WriteHeader(http.StatusNoContent) default: http.Redirect(w, r, back, http.StatusSeeOther) } } func (f *Fake) render(w http.ResponseWriter, status int, d pageData) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) _ = page.Execute(w, d) } // money is amount in currency's minor units as digits and the code, // "60.00 EUR": a stand-in, not a locale's way of writing money. func money(amount int64, code string) string { code = strings.ToUpper(code) switch code { case "BIF", "CLP", "DJF", "GNF", "JPY", "KMF", "KRW", "MGA", "PYG", "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF": return strconv.FormatInt(amount, 10) + " " + code } sign := "" if amount < 0 { sign, amount = "-", -amount } return fmt.Sprintf("%s%d.%02d %s", sign, amount/100, amount%100, code) } // safeURL lets an http(s) address through as a link and turns anything // else (javascript:, say) into a harmless "#". func safeURL(s string) template.URL { u, err := url.Parse(s) if err != nil || (u.Scheme != "http" && u.Scheme != "https") { return "#" } return template.URL(u.String()) }