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}}
{{- if .Expired}}
{{.Expired}}
{{- else}}
{{.Title}}
{{.Intro}}
{{- with .Checkout}}
{{.Item}}
{{- if .Leave}}
{{.Leave}}
{{- end}}
{{- end}}
{{- range .Portal}}
{{- 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())
}