rastrillo / abono Public

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

Plain git — no account needed to clone.

Download

Download this file

1package fakestripe
2
3import (
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
21var 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
70type pageData struct {
71 Title, Banner, Intro, Expired string
72 Checkout *checkoutData
73 Portal []portalItem
74 Return string
75 ReturnHref template.URL
76}
77
78type 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
90type portalItem struct {
91 ID, Plan, Cancel, CancelAction string
92}
93
94func (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.
119func (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.
127func (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.
133func (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
141func (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.
153func (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.
190func 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.
197func (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
205func (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).
243func (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.
255func (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
268func (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.
277func 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 "#".
292func 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