| 1 | package fakestripe |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "html/template" |
| 7 | "net/http" |
| 8 | "net/mail" |
| 9 | "net/url" |
| 10 | "strconv" |
| 11 | "strings" |
| 12 | ) |
| 13 | |
| 14 | // The pages stand in for Stripe's hosted Checkout and Customer Portal. |
| 15 | // They are plain forms in rastrillo's rst- vocabulary with no stylesheet |
| 16 | // of their own, and every word on them is a message key given to |
| 17 | // Options.Lookup (abono.sandbox.*): the copy is reviewed with the rest of |
| 18 | // abono's. Pay, Decline and Cancel are POSTs; a browser sends them from |
| 19 | // the page's own origin, so an app's csrf.Protect lets them through. |
| 20 | |
| 21 | var page = template.Must(template.New("page").Parse(`<!doctype html> |
| 22 | <html> |
| 23 | <head> |
| 24 | <meta charset="utf-8"> |
| 25 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 26 | <meta name="robots" content="noindex"> |
| 27 | <title>{{.Title}}</title> |
| 28 | </head> |
| 29 | <body> |
| 30 | <main rst-page> |
| 31 | <div rst-callout rst-tone="warning"><div rst-callout-body><p>{{.Banner}}</p></div></div> |
| 32 | {{- if .Expired}} |
| 33 | <p>{{.Expired}}</p> |
| 34 | {{- else}} |
| 35 | <h1>{{.Title}}</h1> |
| 36 | <p>{{.Intro}}</p> |
| 37 | {{- with .Checkout}} |
| 38 | <p>{{.Item}}</p> |
| 39 | <form rst-form method="post" action="{{.PayAction}}"> |
| 40 | {{- if .AskEmail}} |
| 41 | <div rst-field><label rst-field-label for="sandbox-email">{{.EmailLabel}}</label><input rst-input id="sandbox-email" name="email" type="email" value="{{.Email}}" autocomplete="email" required{{if .EmailBad}} aria-invalid="true"{{end}}></div> |
| 42 | {{- end}} |
| 43 | <button rst-btn="primary lg" type="submit">{{.Pay}}</button> |
| 44 | </form> |
| 45 | <form rst-form method="post" action="{{.DeclineAction}}"> |
| 46 | <button rst-btn="lg" type="submit">{{.Decline}}</button> |
| 47 | </form> |
| 48 | {{- if .Leave}} |
| 49 | <p><a href="{{.LeaveHref}}">{{.Leave}}</a></p> |
| 50 | {{- end}} |
| 51 | {{- end}} |
| 52 | {{- range .Portal}} |
| 53 | <form rst-form method="post" action="{{.CancelAction}}"> |
| 54 | <p>{{.Plan}}</p> |
| 55 | {{- if .Cancel}} |
| 56 | <input type="hidden" name="subscription" value="{{.ID}}"> |
| 57 | <button rst-btn="lg" type="submit">{{.Cancel}}</button> |
| 58 | {{- end}} |
| 59 | </form> |
| 60 | {{- end}} |
| 61 | {{- if .Return}} |
| 62 | <p><a href="{{.ReturnHref}}">{{.Return}}</a></p> |
| 63 | {{- end}} |
| 64 | {{- end}} |
| 65 | </main> |
| 66 | </body> |
| 67 | </html> |
| 68 | `)) |
| 69 | |
| 70 | type pageData struct { |
| 71 | Title, Banner, Intro, Expired string |
| 72 | Checkout *checkoutData |
| 73 | Portal []portalItem |
| 74 | Return string |
| 75 | ReturnHref template.URL |
| 76 | } |
| 77 | |
| 78 | type checkoutData struct { |
| 79 | Item, Pay, Decline, Leave string |
| 80 | PayAction, DeclineAction string |
| 81 | LeaveHref template.URL |
| 82 | // AskEmail is set for a tester who is not signed in: Checkout was |
| 83 | // given no customer, address or subject, so the page asks for one. |
| 84 | // Email is what they typed, and EmailBad that it was refused. |
| 85 | AskEmail bool |
| 86 | EmailLabel, Email string |
| 87 | EmailBad bool |
| 88 | } |
| 89 | |
| 90 | type portalItem struct { |
| 91 | ID, Plan, Cancel, CancelAction string |
| 92 | } |
| 93 | |
| 94 | func (f *Fake) pages(mux *http.ServeMux) { |
| 95 | mux.HandleFunc("GET /checkout/{id}", f.checkoutPage) |
| 96 | mux.HandleFunc("POST /checkout/{id}/pay", func(w http.ResponseWriter, r *http.Request) { |
| 97 | _ = r.ParseForm() |
| 98 | typed := strings.TrimSpace(r.PostForm.Get("email")) |
| 99 | back, err := f.pay(r.Context(), r.PathValue("id"), typed) |
| 100 | if errors.Is(err, errEmail) { |
| 101 | f.showCheckout(w, r.PathValue("id"), http.StatusUnprocessableEntity, typed) |
| 102 | return |
| 103 | } |
| 104 | f.redirect(w, r, back, err) |
| 105 | }) |
| 106 | mux.HandleFunc("POST /checkout/{id}/decline", func(w http.ResponseWriter, r *http.Request) { |
| 107 | back, err := f.decline(r.Context(), r.PathValue("id")) |
| 108 | f.redirect(w, r, back, err) |
| 109 | }) |
| 110 | mux.HandleFunc("GET /portal/{customer}", f.portalPage) |
| 111 | mux.HandleFunc("POST /portal/{customer}/cancel", func(w http.ResponseWriter, r *http.Request) { |
| 112 | _ = r.ParseForm() |
| 113 | err := f.cancel(r.Context(), r.PathValue("customer"), r.PostForm.Get("subscription")) |
| 114 | f.redirect(w, r, f.pageURL("portal", r.PathValue("customer")), err) |
| 115 | }) |
| 116 | } |
| 117 | |
| 118 | // t is the text for key. |
| 119 | func (f *Fake) t(key string, args ...any) string { |
| 120 | if f.opts.Lookup == nil { |
| 121 | return key |
| 122 | } |
| 123 | return f.opts.Lookup(key, args...) |
| 124 | } |
| 125 | |
| 126 | // pageURL is the address of a page, under PageBase. |
| 127 | func (f *Fake) pageURL(kind, id string) string { |
| 128 | return f.opts.PageBase + "/" + kind + "/" + url.PathEscape(id) |
| 129 | } |
| 130 | |
| 131 | // action is a page's form action: PageBase's path, so the form posts to |
| 132 | // whichever host the page was loaded from. |
| 133 | func (f *Fake) action(kind, id, verb string) string { |
| 134 | path := f.opts.PageBase |
| 135 | if u, err := url.Parse(path); err == nil { |
| 136 | path = u.Path |
| 137 | } |
| 138 | return path + "/" + kind + "/" + url.PathEscape(id) + "/" + verb |
| 139 | } |
| 140 | |
| 141 | func (f *Fake) checkoutPage(w http.ResponseWriter, r *http.Request) { |
| 142 | // The page is where the reader sees time pass in the sandbox. |
| 143 | if err := f.tick(r.Context()); err != nil { |
| 144 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 145 | return |
| 146 | } |
| 147 | f.showCheckout(w, r.PathValue("id"), http.StatusOK, "") |
| 148 | } |
| 149 | |
| 150 | // showCheckout renders session id's page with status. typed is the |
| 151 | // address the tester sent that the page refused (status 422), put back in |
| 152 | // the field; "" otherwise. |
| 153 | func (f *Fake) showCheckout(w http.ResponseWriter, id string, status int, typed string) { |
| 154 | f.mu.Lock() |
| 155 | s := f.sessions[id] |
| 156 | if s == nil || s.Status != "open" { |
| 157 | f.mu.Unlock() |
| 158 | f.expired(w, s == nil) |
| 159 | return |
| 160 | } |
| 161 | ask := f.customers[s.customer] == nil && s.customerEmail == "" && s.ClientReferenceID == "" |
| 162 | plan, amount := "", "" |
| 163 | if p := f.price(s.price); p != nil { |
| 164 | plan, amount = p.plan, f.priceLine(p) |
| 165 | } |
| 166 | cancelURL := s.cancelURL |
| 167 | f.mu.Unlock() |
| 168 | |
| 169 | c := &checkoutData{ |
| 170 | Item: f.t("abono.sandbox.checkout_item", "plan", plan, "price", amount), |
| 171 | Pay: f.t("abono.sandbox.pay"), |
| 172 | Decline: f.t("abono.sandbox.decline"), |
| 173 | PayAction: f.action("checkout", id, "pay"), DeclineAction: f.action("checkout", id, "decline"), |
| 174 | } |
| 175 | if ask { |
| 176 | c.AskEmail, c.EmailLabel = true, f.t("abono.sandbox.email") |
| 177 | c.Email, c.EmailBad = typed, status == http.StatusUnprocessableEntity |
| 178 | } |
| 179 | if cancelURL != "" { |
| 180 | c.Leave, c.LeaveHref = f.t("abono.sandbox.leave"), safeURL(cancelURL) |
| 181 | } |
| 182 | f.render(w, status, pageData{ |
| 183 | Title: f.t("abono.sandbox.checkout_title"), Banner: f.t("abono.sandbox.banner"), |
| 184 | Intro: f.t("abono.sandbox.checkout_intro"), Checkout: c, |
| 185 | }) |
| 186 | } |
| 187 | |
| 188 | // validEmail is whether s is one plain address, "ana@example.com", not |
| 189 | // "Ana <ana@example.com>" and not a list. |
| 190 | func validEmail(s string) bool { |
| 191 | a, err := mail.ParseAddress(s) |
| 192 | return err == nil && a.Address == s |
| 193 | } |
| 194 | |
| 195 | // priceLine is the price as abono.price.per_<interval> says it, or the |
| 196 | // bare amount for a one-off. Hold f.mu. |
| 197 | func (f *Fake) priceLine(p *price) string { |
| 198 | amount := money(p.amount, p.currency) |
| 199 | if p.interval == "" { |
| 200 | return amount |
| 201 | } |
| 202 | return f.t("abono.price.per_"+p.interval, "amount", amount) |
| 203 | } |
| 204 | |
| 205 | func (f *Fake) portalPage(w http.ResponseWriter, r *http.Request) { |
| 206 | if err := f.tick(r.Context()); err != nil { |
| 207 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 208 | return |
| 209 | } |
| 210 | customer := r.PathValue("customer") |
| 211 | f.mu.Lock() |
| 212 | var items []portalItem |
| 213 | for _, id := range f.order { |
| 214 | s := f.subs[id] |
| 215 | if s.customer != customer || !live(s.status) { |
| 216 | continue |
| 217 | } |
| 218 | plan := s.price |
| 219 | if p := f.prices[s.price]; p != nil && p.plan != "" { |
| 220 | plan = p.plan |
| 221 | } |
| 222 | it := portalItem{ID: s.id, Plan: f.t("abono.sandbox.portal_plan", "plan", plan), CancelAction: f.action("portal", customer, "cancel")} |
| 223 | if !s.cancel { |
| 224 | it.Cancel = f.t("abono.sandbox.portal_cancel") |
| 225 | } |
| 226 | items = append(items, it) |
| 227 | } |
| 228 | back := f.portals[customer] |
| 229 | f.mu.Unlock() |
| 230 | |
| 231 | d := pageData{ |
| 232 | Title: f.t("abono.sandbox.portal_title"), Banner: f.t("abono.sandbox.banner"), |
| 233 | Intro: f.t("abono.sandbox.portal_intro"), Portal: items, |
| 234 | } |
| 235 | if back != "" { |
| 236 | d.Return, d.ReturnHref = f.t("abono.sandbox.portal_return"), safeURL(back) |
| 237 | } |
| 238 | f.render(w, http.StatusOK, d) |
| 239 | } |
| 240 | |
| 241 | // expired is the page for a session that is over (410) or never was |
| 242 | // (404). |
| 243 | func (f *Fake) expired(w http.ResponseWriter, unknown bool) { |
| 244 | status := http.StatusGone |
| 245 | if unknown { |
| 246 | status = http.StatusNotFound |
| 247 | } |
| 248 | f.render(w, status, pageData{ |
| 249 | Title: f.t("abono.sandbox.expired"), Banner: f.t("abono.sandbox.banner"), Expired: f.t("abono.sandbox.expired"), |
| 250 | }) |
| 251 | } |
| 252 | |
| 253 | // redirect sends the browser on to back after an action, or shows the |
| 254 | // expired page when there was nothing to act on. |
| 255 | func (f *Fake) redirect(w http.ResponseWriter, r *http.Request, back string, err error) { |
| 256 | switch { |
| 257 | case errors.Is(err, errUnknown), errors.Is(err, errGone): |
| 258 | f.expired(w, errors.Is(err, errUnknown)) |
| 259 | case err != nil: |
| 260 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 261 | case back == "": |
| 262 | w.WriteHeader(http.StatusNoContent) |
| 263 | default: |
| 264 | http.Redirect(w, r, back, http.StatusSeeOther) |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | func (f *Fake) render(w http.ResponseWriter, status int, d pageData) { |
| 269 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 270 | w.Header().Set("Cache-Control", "no-store") |
| 271 | w.WriteHeader(status) |
| 272 | _ = page.Execute(w, d) |
| 273 | } |
| 274 | |
| 275 | // money is amount in currency's minor units as digits and the code, |
| 276 | // "60.00 EUR": a stand-in, not a locale's way of writing money. |
| 277 | func money(amount int64, code string) string { |
| 278 | code = strings.ToUpper(code) |
| 279 | switch code { |
| 280 | case "BIF", "CLP", "DJF", "GNF", "JPY", "KMF", "KRW", "MGA", "PYG", "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF": |
| 281 | return strconv.FormatInt(amount, 10) + " " + code |
| 282 | } |
| 283 | sign := "" |
| 284 | if amount < 0 { |
| 285 | sign, amount = "-", -amount |
| 286 | } |
| 287 | return fmt.Sprintf("%s%d.%02d %s", sign, amount/100, amount%100, code) |
| 288 | } |
| 289 | |
| 290 | // safeURL lets an http(s) address through as a link and turns anything |
| 291 | // else (javascript:, say) into a harmless "#". |
| 292 | func safeURL(s string) template.URL { |
| 293 | u, err := url.Parse(s) |
| 294 | if err != nil || (u.Scheme != "http" && u.Scheme != "https") { |
| 295 | return "#" |
| 296 | } |
| 297 | return template.URL(u.String()) |
| 298 | } |
| 299 | |