// abono_pages.go is abono's starter pages, written into this app by
// `abono pages`. They are yours now: change the routes, the pages and
// the words as you like. Running the command again never overwrites
// them.
//
// Wire them in once, inside the router that runs your session
// middleware and csrf.Protect:
//
// pages := &AbonoPages{
// Service: svc,
// Render: render,
// Subject: func(r *http.Request) (string, bool) { ... },
// IsOwner: func(r *http.Request) bool { ... },
// SubjectForEmail: func(ctx context.Context, email string) (string, bool) { ... },
// }
// pages.Mount(r)
//
// Render executes one of templates/abono/*.html by its name ("abono/pricing")
// with data, which is always a map[string]any: add the request's
// localizer as "L" and whatever your layout reads, as a scaffolded app's
// render does. Each page defines "content"; its data keys are listed at
// the top of its file, and every page's data has "Title", the message
// key of the page's title, for your layout's
. A page that
// answers with something other than 200 has had its status set already:
// Render writes the body as usual.
//
// templates/abono/account.html is not a page but a section,
// "abono-account", for your own account page; Account(r) is its data.
// Parse it with your layout, beside abono.Templates()'s partials, which
// every page here uses.
//
// The words are message keys (abono.pages.*, abono.owner.*,
// abono.error.*, abono.button.*): merge abono.Catalog() into your base
// catalog and override or translate any of them in your own locales.
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"amadan.net/rastrillo/abono"
)
// The pages' addresses. abonoAccountPath is your account page, where the
// "abono-account" section is shown and the billing portal comes back to.
const (
abonoPricingPath = "/subscribe"
abonoDonePath = "/subscribe/done"
abonoAccountPath = "/account"
abonoBillingPath = "/account/billing"
abonoMembersPath = "/admin/members"
abonoOffersPath = "/admin/offers"
abonoStripePath = "/admin/stripe"
)
// AbonoPages is the reader's pages (pricing, a plan, Checkout's return,
// the billing button) and the owner's (members, comps, plans and prices,
// Stripe).
type AbonoPages struct {
Service *abono.Service
// Render executes the page called name with data, a map[string]any.
Render func(w http.ResponseWriter, r *http.Request, name string, data any)
// Subject is the signed-in reader's subject, the one abono keys
// holdings to; ok is false when nobody is signed in.
Subject func(*http.Request) (string, bool)
// IsOwner says whether the request may use the owner's pages. Nil
// lets nobody in.
IsOwner func(*http.Request) bool
// SubjectForEmail finds the subject of the person with this address,
// for the owner's free-membership form. Nil, or not found, refuses
// the form.
SubjectForEmail func(ctx context.Context, email string) (subject string, ok bool)
// Label is what the owner's pages call the person with this
// subject: their address or name. Nil, or "", shows the subject.
Label func(ctx context.Context, subject string) string
}
// Mount adds the pages to r. The reader's pages are open to anyone (the
// billing button needs a signed-in reader); the owner's answer 403 to
// anyone IsOwner refuses.
//
// A plan named "done" cannot have a page: GET /subscribe/done is
// Checkout's return.
func (p *AbonoPages) Mount(r chi.Router) {
r.Get(abonoPricingPath, p.pricing)
r.Get(abonoDonePath, p.done)
r.Get(abonoPricingPath+"/{plan}", p.plan)
r.Post(abonoPricingPath+"/{plan}", p.subscribe)
r.Post(abonoBillingPath, p.billing)
r.Group(func(r chi.Router) {
r.Use(p.ownerOnly)
r.Get(abonoMembersPath, p.members)
r.Post(abonoMembersPath+"/sync", p.sync)
r.Post(abonoMembersPath+"/comps", p.comp)
r.Get(abonoMembersPath+"/comps/{id}/end", p.endCompPage)
r.Post(abonoMembersPath+"/comps/{id}/end", p.endComp)
r.Get(abonoOffersPath, p.offers)
r.Post(abonoOffersPath+"/adopt", p.adopt)
r.Post(abonoOffersPath+"/prices", p.addPrice)
r.Get(abonoStripePath, p.stripe)
})
}
// ---------------------------------------------------------------- reader
// abonoCard is one plan card: an offer at one interval ("" for the
// plan's only price, or when Stripe has not said its interval yet), and
// where its form posts.
type abonoCard struct {
Offer abono.Offer
Interval string
Action string
}
// abonoCards is a card per offer and sold interval, monthly before
// yearly. An offer sold at one price, or whose prices' intervals are not
// known yet, gets one card with no interval; an offer with nothing sold
// gets none. A price billed other than every month or every year
// (Unsupported) is not sold, so it makes no card.
func abonoCards(offers []abono.Offer) []abonoCard {
var cards []abonoCard
for _, o := range offers {
var intervals []string
sold, unknown := 0, false
for _, pr := range o.Prices {
if !pr.Sold || pr.Unsupported() {
continue
}
sold++
if pr.Interval == "" {
unknown = true
} else if !abonoContains(intervals, pr.Interval) {
intervals = append(intervals, pr.Interval)
}
}
if sold == 0 {
continue
}
action := abonoPricingPath + "/" + url.PathEscape(o.Plan)
if sold == 1 || unknown {
cards = append(cards, abonoCard{Offer: o, Action: action})
continue
}
for _, iv := range []string{"day", "week", "month", "year"} {
if abonoContains(intervals, iv) {
cards = append(cards, abonoCard{Offer: o, Interval: iv, Action: action})
}
}
}
return cards
}
func abonoContains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// forSale is the offers with a card: none while Stripe is off.
func (p *AbonoPages) forSale(ctx context.Context) ([]abono.Offer, []abonoCard, error) {
if !p.Service.On() {
return nil, nil, nil
}
offers, err := p.Service.Offers(ctx)
if err != nil {
return nil, nil, err
}
return offers, abonoCards(offers), nil
}
// GET /subscribe: every plan on sale.
func (p *AbonoPages) pricing(w http.ResponseWriter, r *http.Request) {
_, cards, err := p.forSale(r.Context())
if err != nil {
p.fail(w, r, "offers", err)
return
}
p.render(w, r, http.StatusOK, "abono/pricing", map[string]any{
"Title": "abono.pages.pricing_title",
"Open": len(cards) > 0,
"Cards": cards,
})
}
// GET /subscribe/{plan}: one plan, with a promotion code field.
func (p *AbonoPages) plan(w http.ResponseWriter, r *http.Request) {
p.renderPlan(w, r, http.StatusOK, chi.URLParam(r, "plan"), "")
}
// renderPlan shows plan's page, with the message errKey above it when
// there is one. A plan that is not on sale shows only the message, or
// says nothing is for sale.
func (p *AbonoPages) renderPlan(w http.ResponseWriter, r *http.Request, status int, plan, errKey string) {
offers, all, err := p.forSale(r.Context())
if err != nil {
p.fail(w, r, "offers", err)
return
}
var cards []abonoCard
for _, c := range all {
if c.Offer.Plan == plan {
cards = append(cards, c)
}
}
if len(cards) == 0 && len(offers) > 0 && errKey == "" {
status, errKey = http.StatusNotFound, "abono.error.unknown_plan"
}
_, signedIn := p.subject(r)
p.render(w, r, status, "abono/plan", map[string]any{
"Title": "abono.pages.pricing_title",
"Open": len(all) > 0,
"Cards": cards,
"SignedIn": signedIn,
"PlansURL": abonoPricingPath,
"Error": errKey,
})
}
// POST /subscribe/{plan}: start Checkout for the plan at the posted
// interval, as the signed-in reader or as nobody yet.
func (p *AbonoPages) subscribe(w http.ResponseWriter, r *http.Request) {
plan := chi.URLParam(r, "plan")
if !p.Service.On() {
p.renderPlan(w, r, http.StatusServiceUnavailable, plan, "abono.error.not_available")
return
}
subject, _ := p.subject(r)
u, err := p.Service.Checkout(r.Context(), abono.CheckoutRequest{
Plan: plan,
Interval: r.PostFormValue("interval"),
Subject: subject,
PromotionCode: r.PostFormValue("code"),
SuccessPath: abonoDonePath,
CancelPath: abonoPricingPath + "/" + url.PathEscape(plan),
})
switch {
case errors.Is(err, abono.ErrUnknownCode):
p.renderPlan(w, r, http.StatusUnprocessableEntity, plan, "abono.error.unknown_code")
case errors.Is(err, abono.ErrUnknownPlan):
p.renderPlan(w, r, http.StatusNotFound, plan, "abono.error.unknown_plan")
case err != nil:
slog.ErrorContext(r.Context(), "abono pages: checkout", "plan", plan, "err", err)
p.renderPlan(w, r, http.StatusBadGateway, plan, "abono.error.unreachable")
default:
http.Redirect(w, r, u, http.StatusSeeOther)
}
}
// GET /subscribe/done: Checkout's return. Finish records the payment at
// once, so the account page already shows it.
func (p *AbonoPages) done(w http.ResponseWriter, r *http.Request) {
_, signedIn := p.subject(r)
data := map[string]any{
"Title": "abono.pages.done_unpaid_title",
"Paid": false,
"Processing": false,
"Email": "",
"SignedIn": signedIn,
"AccountURL": abonoAccountPath,
"PlansURL": abonoPricingPath,
"Error": "",
}
id := r.URL.Query().Get("session_id")
switch {
case !p.Service.On():
data["Error"] = "abono.error.not_available"
p.render(w, r, http.StatusServiceUnavailable, "abono/done", data)
return
case id == "":
p.render(w, r, http.StatusOK, "abono/done", data)
return
}
f, err := p.Service.Finish(r.Context(), id)
if err != nil {
slog.ErrorContext(r.Context(), "abono pages: finish", "err", err)
data["Error"] = "abono.error.finish_failed"
p.render(w, r, http.StatusBadGateway, "abono/done", data)
return
}
switch {
case f.Paid:
data["Title"], data["Paid"], data["Email"] = "abono.pages.done_title", true, f.Email
case f.Processing:
// Complete, the money still on its way: not "no payment was made",
// and no second purchase.
data["Title"], data["Processing"] = "abono.pages.done_title", true
}
p.render(w, r, http.StatusOK, "abono/done", data)
}
// POST /account/billing: the signed-in reader's billing portal for the
// posted customer, one of theirs, coming back to the account page. A
// failure goes back there too, saying why.
func (p *AbonoPages) billing(w http.ResponseWriter, r *http.Request) {
back := func(problem string) {
http.Redirect(w, r, abonoAccountPath+"?billing="+problem, http.StatusSeeOther)
}
subject, ok := p.subject(r)
switch {
case !ok:
back("signin")
return
case !p.Service.On():
back("off")
return
}
u, err := p.Service.Portal(r.Context(), subject, r.PostFormValue("customer"), abonoAccountPath)
switch {
case errors.Is(err, abono.ErrNoCustomer):
back("none")
case err != nil:
slog.ErrorContext(r.Context(), "abono pages: portal", "err", err)
back("unreachable")
default:
http.Redirect(w, r, u, http.StatusSeeOther)
}
}
// abonoBillingProblems are the ?billing= values billing sends the reader
// back with, and what each says. Anything else says nothing.
var abonoBillingProblems = map[string]string{
"signin": "abono.error.signin_needed",
"off": "abono.error.not_available",
"none": "abono.error.no_billing",
"unreachable": "abono.error.unreachable",
}
// Account is the data for the "abono-account" section of your account
// page: the reader's holdings, a billing button for each Stripe customer
// they pay through (even one whose subscription no plan recognises: it is
// still charged), and the problem the billing button came back with. Add
// the request's localizer before rendering it:
//
// {{template "abono-account" (withL .Abono $L)}}
func (p *AbonoPages) Account(r *http.Request) (map[string]any, error) {
subject, _ := p.subject(r)
holdings, err := p.Service.Holdings(r.Context(), subject)
if err != nil {
return nil, err
}
granted := false
for _, h := range holdings {
granted = granted || h.Grants
}
var customers []string
if p.Service.On() {
if customers, err = p.Service.Customers(r.Context(), subject); err != nil {
return nil, err
}
}
return map[string]any{
"Holdings": holdings,
"Now": p.Service.Now(),
"Manage": len(customers) > 0,
"Customers": customers,
"Granted": granted,
"BillingAction": abonoBillingPath,
"PlansURL": abonoPricingPath,
"Error": abonoBillingProblems[r.URL.Query().Get("billing")],
}, nil
}
// ---------------------------------------------------------------- owner
func (p *AbonoPages) ownerOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if p.IsOwner == nil || !p.IsOwner(r) {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// abonoMember is one row of the members table.
type abonoMember struct {
abono.Member
// Label is what to call the member (AbonoPages.Label).
Label string
// EndURL is the confirm page that ends a comp still granting; ""
// for anything else.
EndURL string
}
// abonoCompForm is the free-membership form as posted, and what was wrong
// with it: message keys, by field.
type abonoCompForm struct {
Member, Plan, Ends, Note string
Errors map[string]string
}
// abonoNotice is what a finished owner action says on the page it
// returns to: Kind is sync, comp, end or adopt; N and Plan fill its
// sentence.
type abonoNotice struct {
Kind string
N int
Plan string
}
// abonoReadNotice reads the ?done= an owner action redirected with. A plan that
// is not one of offers is dropped, so a link cannot put words on the page.
func abonoReadNotice(r *http.Request, offers []abono.Offer) *abonoNotice {
q := r.URL.Query()
n := &abonoNotice{Kind: q.Get("done")}
switch n.Kind {
case "sync":
n.N, _ = strconv.Atoi(q.Get("n"))
case "comp", "adopt":
for _, o := range offers {
if o.Plan == q.Get("plan") {
n.Plan = o.Name
}
}
if n.Plan == "" {
return nil
}
case "end":
default:
return nil
}
return n
}
// GET /admin/members: every holding, sync now, and give a comp.
func (p *AbonoPages) members(w http.ResponseWriter, r *http.Request) {
p.renderMembers(w, r, http.StatusOK, abonoCompForm{}, "")
}
func (p *AbonoPages) renderMembers(w http.ResponseWriter, r *http.Request, status int, form abonoCompForm, errKey string) {
ctx := r.Context()
ms, err := p.Service.Members(ctx)
if err != nil {
p.fail(w, r, "members", err)
return
}
offers, err := p.Service.Offers(ctx)
if err != nil {
p.fail(w, r, "offers", err)
return
}
var unknown []abonoUnrecognised
if p.Service.On() {
if unknown, err = p.unrecognised(ctx); err != nil {
p.fail(w, r, "unrecognised", err)
return
}
}
rows := make([]abonoMember, len(ms))
for i, m := range ms {
rows[i] = abonoMember{Member: m, Label: p.label(ctx, m.Subject)}
if m.Source == abono.SourceComp && m.Grants {
rows[i].EndURL = abonoMembersPath + "/comps/" + strconv.FormatInt(m.CompID, 10) + "/end"
}
}
p.render(w, r, status, "abono/owner-members", map[string]any{
"Title": "abono.owner.members_title",
"On": p.Service.On(),
"Members": rows,
"Unrecognised": unknown,
"Now": p.Service.Now(),
"Offers": offers,
"PlanOptions": abonoPlanOptions(offers, form.Plan),
"AdoptOptions": abonoPlanOptions(offers, ""),
"Form": form,
"SyncAction": abonoMembersPath + "/sync",
"CompAction": abonoMembersPath + "/comps",
"AdoptAction": abonoOffersPath + "/adopt",
"StripeURL": abonoStripePath,
"Done": abonoReadNotice(r, offers),
"Error": errKey,
})
}
// POST /admin/members/sync: Reconcile now. Reconcile keeps every
// subscription it could sync when others fail, so the page says nothing
// changed only when no subscription was synced.
func (p *AbonoPages) sync(w http.ResponseWriter, r *http.Request) {
rep, err := p.Service.Reconcile(r.Context())
if err != nil {
slog.ErrorContext(r.Context(), "abono pages: sync", "err", err)
key := "abono.error.sync_failed"
if rep.Subscriptions > rep.Failed {
key = "abono.error.sync_partial"
}
p.renderMembers(w, r, http.StatusBadGateway, abonoCompForm{}, key)
return
}
http.Redirect(w, r, abonoMembersPath+"?done=sync&n="+strconv.Itoa(rep.Subscriptions), http.StatusSeeOther)
}
// POST /admin/members/comps: give the person with the posted address
// a plan, until the posted day or for good.
func (p *AbonoPages) comp(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
form := abonoCompForm{
Member: strings.TrimSpace(r.PostFormValue("member")),
Plan: r.PostFormValue("plan"),
Ends: strings.TrimSpace(r.PostFormValue("ends")),
Note: strings.TrimSpace(r.PostFormValue("note")),
Errors: map[string]string{},
}
var ends *time.Time
if form.Ends != "" {
t, err := time.Parse("2006-01-02", form.Ends)
if err != nil {
// A date field always posts this format; anything else is not a browser.
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
ends = &t
}
offers, err := p.Service.Offers(ctx)
if err != nil {
p.fail(w, r, "offers", err)
return
}
if !abonoIsPlan(offers, form.Plan) {
form.Errors["plan"] = "abono.error.free_plan"
}
subject, found := "", false
if p.SubjectForEmail != nil && form.Member != "" {
subject, found = p.SubjectForEmail(ctx, form.Member)
}
if !found {
form.Errors["member"] = "abono.owner.free_no_member"
}
if len(form.Errors) > 0 {
p.renderMembers(w, r, http.StatusUnprocessableEntity, form, "")
return
}
if _, err := p.Service.Comp(ctx, subject, form.Plan, ends, form.Note); err != nil {
p.fail(w, r, "comp", err)
return
}
http.Redirect(w, r, abonoMembersPath+"?done=comp&plan="+url.QueryEscape(form.Plan), http.StatusSeeOther)
}
// abonoOption is one choice of a field-select.
type abonoOption struct {
Value, Label string
Selected bool
}
// abonoPlanOptions is every offer as a choice, selected marked.
func abonoPlanOptions(offers []abono.Offer, selected string) []abonoOption {
opts := make([]abonoOption, len(offers))
for i, o := range offers {
opts[i] = abonoOption{Value: o.Plan, Label: o.Name, Selected: o.Plan == selected}
}
return opts
}
func abonoIsPlan(offers []abono.Offer, plan string) bool {
for _, o := range offers {
if o.Plan == plan {
return true
}
}
return false
}
// compFor is the comp {id} names, while it still grants.
func (p *AbonoPages) compFor(r *http.Request) (abono.Member, bool, error) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
return abono.Member{}, false, nil
}
ms, err := p.Service.Members(r.Context())
if err != nil {
return abono.Member{}, false, err
}
for _, m := range ms {
if m.Source == abono.SourceComp && m.CompID == id && m.Grants {
return m, true, nil
}
}
return abono.Member{}, false, nil
}
// GET /admin/members/comps/{id}/end: are you sure?
func (p *AbonoPages) endCompPage(w http.ResponseWriter, r *http.Request) {
m, ok, err := p.compFor(r)
switch {
case err != nil:
p.fail(w, r, "members", err)
return
case !ok:
http.NotFound(w, r)
return
}
offers, err := p.Service.Offers(r.Context())
if err != nil {
p.fail(w, r, "offers", err)
return
}
plan := m.Plan
for _, o := range offers {
if o.Plan == m.Plan {
plan = o.Name
}
}
p.render(w, r, http.StatusOK, "abono/owner-end", map[string]any{
"Title": "abono.owner.end_title",
"Member": m,
"Label": p.label(r.Context(), m.Subject),
"Plan": plan,
"Action": r.URL.Path,
"MembersURL": abonoMembersPath,
})
}
// POST /admin/members/comps/{id}/end: end it now.
func (p *AbonoPages) endComp(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
switch err := p.Service.EndComp(r.Context(), id); {
case errors.Is(err, abono.ErrNotFound):
http.NotFound(w, r)
case err != nil:
p.fail(w, r, "end comp", err)
default:
http.Redirect(w, r, abonoMembersPath+"?done=end", http.StatusSeeOther)
}
}
// abonoPriceForm is the add-a-price form as posted.
type abonoPriceForm struct {
Plan, Price string
Sold bool
}
// GET /admin/offers: plans and their prices, a way to add an existing
// Stripe price to a plan or put a legacy one back on sale, and paying
// subscriptions no plan recognises, each with a way onto a plan.
func (p *AbonoPages) offers(w http.ResponseWriter, r *http.Request) {
p.renderOffers(w, r, http.StatusOK, abonoPriceForm{}, "")
}
func (p *AbonoPages) renderOffers(w http.ResponseWriter, r *http.Request, status int, form abonoPriceForm, errKey string) {
ctx := r.Context()
offers, err := p.Service.Offers(ctx)
if err != nil {
p.fail(w, r, "offers", err)
return
}
var unknown []abonoUnrecognised
if p.Service.On() {
if unknown, err = p.unrecognised(ctx); err != nil {
p.fail(w, r, "unrecognised", err)
return
}
}
p.render(w, r, status, "abono/owner-offers", map[string]any{
"Title": "abono.owner.offers_title",
"On": p.Service.On(),
"Offers": offers,
"Unrecognised": unknown,
"PlanOptions": abonoPlanOptions(offers, ""),
"AdoptAction": abonoOffersPath + "/adopt",
"PriceOptions": abonoPlanOptions(offers, form.Plan),
"PriceForm": form,
"PriceAction": abonoOffersPath + "/prices",
"StripeURL": abonoStripePath,
"Done": abonoReadNotice(r, offers),
"Error": errKey,
})
}
// POST /admin/offers/prices: add an existing Stripe price to the posted
// plan, on sale (sold) or as a legacy price whose subscribers keep the
// plan; posting a price the plan already has changes whether it is on
// sale, which is how a legacy price goes back on sale. A price put on
// sale replaces the plan's sold price that bills the same way.
func (p *AbonoPages) addPrice(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
form := abonoPriceForm{
Plan: r.PostFormValue("plan"),
Price: strings.TrimSpace(r.PostFormValue("price")),
Sold: r.PostFormValue("sold") != "",
}
offers, err := p.Service.Offers(ctx)
if err != nil {
p.fail(w, r, "offers", err)
return
}
switch {
case !abonoIsPlan(offers, form.Plan):
p.renderOffers(w, r, http.StatusUnprocessableEntity, form, "abono.error.unknown_plan")
return
case form.Price == "":
p.renderOffers(w, r, http.StatusUnprocessableEntity, form, "abono.error.add_price_failed")
return
}
if err := p.Service.AddPrice(ctx, form.Plan, form.Price, form.Sold); err != nil {
slog.ErrorContext(ctx, "abono pages: add price", "plan", form.Plan, "price", form.Price, "err", err)
p.renderOffers(w, r, http.StatusUnprocessableEntity, form, "abono.error.add_price_failed")
return
}
http.Redirect(w, r, abonoOffersPath, http.StatusSeeOther)
}
// POST /admin/offers/adopt: put an unrecognised subscription, and every
// other on its price, onto the posted plan. Its holder is found as a sync
// finds one. The form is on the members page and the offers page; a
// posted from=members goes back to the first, anything else to the
// second.
func (p *AbonoPages) adopt(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sub, plan := r.PostFormValue("subscription"), r.PostFormValue("plan")
back, again := abonoOffersPath, func(status int, errKey string) { p.renderOffers(w, r, status, abonoPriceForm{}, errKey) }
if r.PostFormValue("from") == "members" {
back, again = abonoMembersPath, func(status int, errKey string) { p.renderMembers(w, r, status, abonoCompForm{}, errKey) }
}
offers, err := p.Service.Offers(ctx)
if err != nil {
p.fail(w, r, "offers", err)
return
}
if !abonoIsPlan(offers, plan) {
again(http.StatusUnprocessableEntity, "abono.error.unknown_plan")
return
}
if err := p.Service.Adopt(ctx, sub, "", plan); err != nil {
slog.ErrorContext(ctx, "abono pages: adopt", "subscription", sub, "err", err)
again(http.StatusUnprocessableEntity, "abono.error.adopt_failed")
return
}
http.Redirect(w, r, back+"?done=adopt&plan="+url.QueryEscape(plan), http.StatusSeeOther)
}
// GET /admin/stripe: whether Stripe is connected, how, and the webhook's
// address.
func (p *AbonoPages) stripe(w http.ResponseWriter, r *http.Request) {
mode := "off"
switch {
case p.Service.Sandbox():
mode = "sandbox"
case p.Service.On() && p.Service.TestMode():
mode = "test"
case p.Service.On():
mode = "live"
}
p.render(w, r, http.StatusOK, "abono/owner-stripe", map[string]any{
"Title": "abono.owner.stripe_title",
"Mode": mode,
"WebhookURL": strings.TrimRight(p.Service.Origin(), "/") + abono.WebhookPath,
})
}
// ---------------------------------------------------------------- plumbing
// abonoUnrecognised is a paying subscription no plan recognises, with
// what to call its holder when it has one.
type abonoUnrecognised struct {
abono.Unrecognised
Label string
}
func (p *AbonoPages) unrecognised(ctx context.Context) ([]abonoUnrecognised, error) {
us, err := p.Service.Unrecognised(ctx)
if err != nil {
return nil, err
}
out := make([]abonoUnrecognised, len(us))
for i, u := range us {
out[i] = abonoUnrecognised{Unrecognised: u}
if u.Subject != "" {
out[i].Label = p.label(ctx, u.Subject)
}
}
return out, nil
}
// label is what the owner's pages call subject.
func (p *AbonoPages) label(ctx context.Context, subject string) string {
if p.Label != nil {
if l := p.Label(ctx, subject); l != "" {
return l
}
}
return subject
}
func (p *AbonoPages) subject(r *http.Request) (string, bool) {
if p.Subject == nil {
return "", false
}
s, ok := p.Subject(r)
return s, ok && s != ""
}
// render hands the page to Render, with the response's status set when it
// is not 200.
func (p *AbonoPages) render(w http.ResponseWriter, r *http.Request, status int, name string, data map[string]any) {
if status != http.StatusOK {
w = &abonoStatusWriter{ResponseWriter: w, status: status}
}
p.Render(w, r, name, data)
}
// fail is the answer to a failure no page explains: logged, and a 500.
func (p *AbonoPages) fail(w http.ResponseWriter, r *http.Request, what string, err error) {
slog.ErrorContext(r.Context(), "abono pages: "+what, "err", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
// abonoStatusWriter writes status instead of the 200 a page's body would
// imply, and lets any other status (a Render that fails with a 500) pass.
type abonoStatusWriter struct {
http.ResponseWriter
status int
wrote bool
}
func (s *abonoStatusWriter) WriteHeader(code int) {
if s.wrote {
return
}
s.wrote = true
if code == http.StatusOK {
code = s.status
}
s.ResponseWriter.WriteHeader(code)
}
func (s *abonoStatusWriter) Write(b []byte) (int, error) {
if !s.wrote {
s.WriteHeader(http.StatusOK)
}
return s.ResponseWriter.Write(b)
}
func (s *abonoStatusWriter) Unwrap() http.ResponseWriter { return s.ResponseWriter }