rastrillo / abono Public

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

Plain git — no account needed to clone.

Download

Download this file

1// abono_pages.go is abono's starter pages, written into this app by
2// `abono pages`. They are yours now: change the routes, the pages and
3// the words as you like. Running the command again never overwrites
4// them.
5//
6// Wire them in once, inside the router that runs your session
7// middleware and csrf.Protect:
8//
9// pages := &AbonoPages{
10// Service: svc,
11// Render: render,
12// Subject: func(r *http.Request) (string, bool) { ... },
13// IsOwner: func(r *http.Request) bool { ... },
14// SubjectForEmail: func(ctx context.Context, email string) (string, bool) { ... },
15// }
16// pages.Mount(r)
17//
18// Render executes one of templates/abono/*.html by its name ("abono/pricing")
19// with data, which is always a map[string]any: add the request's
20// localizer as "L" and whatever your layout reads, as a scaffolded app's
21// render does. Each page defines "content"; its data keys are listed at
22// the top of its file, and every page's data has "Title", the message
23// key of the page's title, for your layout's <title>. A page that
24// answers with something other than 200 has had its status set already:
25// Render writes the body as usual.
26//
27// templates/abono/account.html is not a page but a section,
28// "abono-account", for your own account page; Account(r) is its data.
29// Parse it with your layout, beside abono.Templates()'s partials, which
30// every page here uses.
31//
32// The words are message keys (abono.pages.*, abono.owner.*,
33// abono.error.*, abono.button.*): merge abono.Catalog() into your base
34// catalog and override or translate any of them in your own locales.
35package PKG
36
37import (
38 "context"
39 "errors"
40 "log/slog"
41 "net/http"
42 "net/url"
43 "strconv"
44 "strings"
45 "time"
46
47 "github.com/go-chi/chi/v5"
48
49 "amadan.net/rastrillo/abono"
50)
51
52// The pages' addresses. abonoAccountPath is your account page, where the
53// "abono-account" section is shown and the billing portal comes back to.
54const (
55 abonoPricingPath = "/subscribe"
56 abonoDonePath = "/subscribe/done"
57 abonoAccountPath = "/account"
58 abonoBillingPath = "/account/billing"
59 abonoMembersPath = "/admin/members"
60 abonoOffersPath = "/admin/offers"
61 abonoStripePath = "/admin/stripe"
62)
63
64// AbonoPages is the reader's pages (pricing, a plan, Checkout's return,
65// the billing button) and the owner's (members, comps, plans and prices,
66// Stripe).
67type AbonoPages struct {
68 Service *abono.Service
69 // Render executes the page called name with data, a map[string]any.
70 Render func(w http.ResponseWriter, r *http.Request, name string, data any)
71 // Subject is the signed-in reader's subject, the one abono keys
72 // holdings to; ok is false when nobody is signed in.
73 Subject func(*http.Request) (string, bool)
74 // IsOwner says whether the request may use the owner's pages. Nil
75 // lets nobody in.
76 IsOwner func(*http.Request) bool
77 // SubjectForEmail finds the subject of the person with this address,
78 // for the owner's free-membership form. Nil, or not found, refuses
79 // the form.
80 SubjectForEmail func(ctx context.Context, email string) (subject string, ok bool)
81 // Label is what the owner's pages call the person with this
82 // subject: their address or name. Nil, or "", shows the subject.
83 Label func(ctx context.Context, subject string) string
84}
85
86// Mount adds the pages to r. The reader's pages are open to anyone (the
87// billing button needs a signed-in reader); the owner's answer 403 to
88// anyone IsOwner refuses.
89//
90// A plan named "done" cannot have a page: GET /subscribe/done is
91// Checkout's return.
92func (p *AbonoPages) Mount(r chi.Router) {
93 r.Get(abonoPricingPath, p.pricing)
94 r.Get(abonoDonePath, p.done)
95 r.Get(abonoPricingPath+"/{plan}", p.plan)
96 r.Post(abonoPricingPath+"/{plan}", p.subscribe)
97 r.Post(abonoBillingPath, p.billing)
98
99 r.Group(func(r chi.Router) {
100 r.Use(p.ownerOnly)
101 r.Get(abonoMembersPath, p.members)
102 r.Post(abonoMembersPath+"/sync", p.sync)
103 r.Post(abonoMembersPath+"/comps", p.comp)
104 r.Get(abonoMembersPath+"/comps/{id}/end", p.endCompPage)
105 r.Post(abonoMembersPath+"/comps/{id}/end", p.endComp)
106 r.Get(abonoOffersPath, p.offers)
107 r.Post(abonoOffersPath+"/adopt", p.adopt)
108 r.Post(abonoOffersPath+"/prices", p.addPrice)
109 r.Get(abonoStripePath, p.stripe)
110 })
111}
112
113// ---------------------------------------------------------------- reader
114
115// abonoCard is one plan card: an offer at one interval ("" for the
116// plan's only price, or when Stripe has not said its interval yet), and
117// where its form posts.
118type abonoCard struct {
119 Offer abono.Offer
120 Interval string
121 Action string
122}
123
124// abonoCards is a card per offer and sold interval, monthly before
125// yearly. An offer sold at one price, or whose prices' intervals are not
126// known yet, gets one card with no interval; an offer with nothing sold
127// gets none. A price billed other than every month or every year
128// (Unsupported) is not sold, so it makes no card.
129func abonoCards(offers []abono.Offer) []abonoCard {
130 var cards []abonoCard
131 for _, o := range offers {
132 var intervals []string
133 sold, unknown := 0, false
134 for _, pr := range o.Prices {
135 if !pr.Sold || pr.Unsupported() {
136 continue
137 }
138 sold++
139 if pr.Interval == "" {
140 unknown = true
141 } else if !abonoContains(intervals, pr.Interval) {
142 intervals = append(intervals, pr.Interval)
143 }
144 }
145 if sold == 0 {
146 continue
147 }
148 action := abonoPricingPath + "/" + url.PathEscape(o.Plan)
149 if sold == 1 || unknown {
150 cards = append(cards, abonoCard{Offer: o, Action: action})
151 continue
152 }
153 for _, iv := range []string{"day", "week", "month", "year"} {
154 if abonoContains(intervals, iv) {
155 cards = append(cards, abonoCard{Offer: o, Interval: iv, Action: action})
156 }
157 }
158 }
159 return cards
160}
161
162func abonoContains(list []string, s string) bool {
163 for _, v := range list {
164 if v == s {
165 return true
166 }
167 }
168 return false
169}
170
171// forSale is the offers with a card: none while Stripe is off.
172func (p *AbonoPages) forSale(ctx context.Context) ([]abono.Offer, []abonoCard, error) {
173 if !p.Service.On() {
174 return nil, nil, nil
175 }
176 offers, err := p.Service.Offers(ctx)
177 if err != nil {
178 return nil, nil, err
179 }
180 return offers, abonoCards(offers), nil
181}
182
183// GET /subscribe: every plan on sale.
184func (p *AbonoPages) pricing(w http.ResponseWriter, r *http.Request) {
185 _, cards, err := p.forSale(r.Context())
186 if err != nil {
187 p.fail(w, r, "offers", err)
188 return
189 }
190 p.render(w, r, http.StatusOK, "abono/pricing", map[string]any{
191 "Title": "abono.pages.pricing_title",
192 "Open": len(cards) > 0,
193 "Cards": cards,
194 })
195}
196
197// GET /subscribe/{plan}: one plan, with a promotion code field.
198func (p *AbonoPages) plan(w http.ResponseWriter, r *http.Request) {
199 p.renderPlan(w, r, http.StatusOK, chi.URLParam(r, "plan"), "")
200}
201
202// renderPlan shows plan's page, with the message errKey above it when
203// there is one. A plan that is not on sale shows only the message, or
204// says nothing is for sale.
205func (p *AbonoPages) renderPlan(w http.ResponseWriter, r *http.Request, status int, plan, errKey string) {
206 offers, all, err := p.forSale(r.Context())
207 if err != nil {
208 p.fail(w, r, "offers", err)
209 return
210 }
211 var cards []abonoCard
212 for _, c := range all {
213 if c.Offer.Plan == plan {
214 cards = append(cards, c)
215 }
216 }
217 if len(cards) == 0 && len(offers) > 0 && errKey == "" {
218 status, errKey = http.StatusNotFound, "abono.error.unknown_plan"
219 }
220 _, signedIn := p.subject(r)
221 p.render(w, r, status, "abono/plan", map[string]any{
222 "Title": "abono.pages.pricing_title",
223 "Open": len(all) > 0,
224 "Cards": cards,
225 "SignedIn": signedIn,
226 "PlansURL": abonoPricingPath,
227 "Error": errKey,
228 })
229}
230
231// POST /subscribe/{plan}: start Checkout for the plan at the posted
232// interval, as the signed-in reader or as nobody yet.
233func (p *AbonoPages) subscribe(w http.ResponseWriter, r *http.Request) {
234 plan := chi.URLParam(r, "plan")
235 if !p.Service.On() {
236 p.renderPlan(w, r, http.StatusServiceUnavailable, plan, "abono.error.not_available")
237 return
238 }
239 subject, _ := p.subject(r)
240 u, err := p.Service.Checkout(r.Context(), abono.CheckoutRequest{
241 Plan: plan,
242 Interval: r.PostFormValue("interval"),
243 Subject: subject,
244 PromotionCode: r.PostFormValue("code"),
245 SuccessPath: abonoDonePath,
246 CancelPath: abonoPricingPath + "/" + url.PathEscape(plan),
247 })
248 switch {
249 case errors.Is(err, abono.ErrUnknownCode):
250 p.renderPlan(w, r, http.StatusUnprocessableEntity, plan, "abono.error.unknown_code")
251 case errors.Is(err, abono.ErrUnknownPlan):
252 p.renderPlan(w, r, http.StatusNotFound, plan, "abono.error.unknown_plan")
253 case err != nil:
254 slog.ErrorContext(r.Context(), "abono pages: checkout", "plan", plan, "err", err)
255 p.renderPlan(w, r, http.StatusBadGateway, plan, "abono.error.unreachable")
256 default:
257 http.Redirect(w, r, u, http.StatusSeeOther)
258 }
259}
260
261// GET /subscribe/done: Checkout's return. Finish records the payment at
262// once, so the account page already shows it.
263func (p *AbonoPages) done(w http.ResponseWriter, r *http.Request) {
264 _, signedIn := p.subject(r)
265 data := map[string]any{
266 "Title": "abono.pages.done_unpaid_title",
267 "Paid": false,
268 "Processing": false,
269 "Email": "",
270 "SignedIn": signedIn,
271 "AccountURL": abonoAccountPath,
272 "PlansURL": abonoPricingPath,
273 "Error": "",
274 }
275 id := r.URL.Query().Get("session_id")
276 switch {
277 case !p.Service.On():
278 data["Error"] = "abono.error.not_available"
279 p.render(w, r, http.StatusServiceUnavailable, "abono/done", data)
280 return
281 case id == "":
282 p.render(w, r, http.StatusOK, "abono/done", data)
283 return
284 }
285 f, err := p.Service.Finish(r.Context(), id)
286 if err != nil {
287 slog.ErrorContext(r.Context(), "abono pages: finish", "err", err)
288 data["Error"] = "abono.error.finish_failed"
289 p.render(w, r, http.StatusBadGateway, "abono/done", data)
290 return
291 }
292 switch {
293 case f.Paid:
294 data["Title"], data["Paid"], data["Email"] = "abono.pages.done_title", true, f.Email
295 case f.Processing:
296 // Complete, the money still on its way: not "no payment was made",
297 // and no second purchase.
298 data["Title"], data["Processing"] = "abono.pages.done_title", true
299 }
300 p.render(w, r, http.StatusOK, "abono/done", data)
301}
302
303// POST /account/billing: the signed-in reader's billing portal for the
304// posted customer, one of theirs, coming back to the account page. A
305// failure goes back there too, saying why.
306func (p *AbonoPages) billing(w http.ResponseWriter, r *http.Request) {
307 back := func(problem string) {
308 http.Redirect(w, r, abonoAccountPath+"?billing="+problem, http.StatusSeeOther)
309 }
310 subject, ok := p.subject(r)
311 switch {
312 case !ok:
313 back("signin")
314 return
315 case !p.Service.On():
316 back("off")
317 return
318 }
319 u, err := p.Service.Portal(r.Context(), subject, r.PostFormValue("customer"), abonoAccountPath)
320 switch {
321 case errors.Is(err, abono.ErrNoCustomer):
322 back("none")
323 case err != nil:
324 slog.ErrorContext(r.Context(), "abono pages: portal", "err", err)
325 back("unreachable")
326 default:
327 http.Redirect(w, r, u, http.StatusSeeOther)
328 }
329}
330
331// abonoBillingProblems are the ?billing= values billing sends the reader
332// back with, and what each says. Anything else says nothing.
333var abonoBillingProblems = map[string]string{
334 "signin": "abono.error.signin_needed",
335 "off": "abono.error.not_available",
336 "none": "abono.error.no_billing",
337 "unreachable": "abono.error.unreachable",
338}
339
340// Account is the data for the "abono-account" section of your account
341// page: the reader's holdings, a billing button for each Stripe customer
342// they pay through (even one whose subscription no plan recognises: it is
343// still charged), and the problem the billing button came back with. Add
344// the request's localizer before rendering it:
345//
346// {{template "abono-account" (withL .Abono $L)}}
347func (p *AbonoPages) Account(r *http.Request) (map[string]any, error) {
348 subject, _ := p.subject(r)
349 holdings, err := p.Service.Holdings(r.Context(), subject)
350 if err != nil {
351 return nil, err
352 }
353 granted := false
354 for _, h := range holdings {
355 granted = granted || h.Grants
356 }
357 var customers []string
358 if p.Service.On() {
359 if customers, err = p.Service.Customers(r.Context(), subject); err != nil {
360 return nil, err
361 }
362 }
363 return map[string]any{
364 "Holdings": holdings,
365 "Now": p.Service.Now(),
366 "Manage": len(customers) > 0,
367 "Customers": customers,
368 "Granted": granted,
369 "BillingAction": abonoBillingPath,
370 "PlansURL": abonoPricingPath,
371 "Error": abonoBillingProblems[r.URL.Query().Get("billing")],
372 }, nil
373}
374
375// ---------------------------------------------------------------- owner
376
377func (p *AbonoPages) ownerOnly(next http.Handler) http.Handler {
378 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
379 if p.IsOwner == nil || !p.IsOwner(r) {
380 http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
381 return
382 }
383 next.ServeHTTP(w, r)
384 })
385}
386
387// abonoMember is one row of the members table.
388type abonoMember struct {
389 abono.Member
390 // Label is what to call the member (AbonoPages.Label).
391 Label string
392 // EndURL is the confirm page that ends a comp still granting; ""
393 // for anything else.
394 EndURL string
395}
396
397// abonoCompForm is the free-membership form as posted, and what was wrong
398// with it: message keys, by field.
399type abonoCompForm struct {
400 Member, Plan, Ends, Note string
401 Errors map[string]string
402}
403
404// abonoNotice is what a finished owner action says on the page it
405// returns to: Kind is sync, comp, end or adopt; N and Plan fill its
406// sentence.
407type abonoNotice struct {
408 Kind string
409 N int
410 Plan string
411}
412
413// abonoReadNotice reads the ?done= an owner action redirected with. A plan that
414// is not one of offers is dropped, so a link cannot put words on the page.
415func abonoReadNotice(r *http.Request, offers []abono.Offer) *abonoNotice {
416 q := r.URL.Query()
417 n := &abonoNotice{Kind: q.Get("done")}
418 switch n.Kind {
419 case "sync":
420 n.N, _ = strconv.Atoi(q.Get("n"))
421 case "comp", "adopt":
422 for _, o := range offers {
423 if o.Plan == q.Get("plan") {
424 n.Plan = o.Name
425 }
426 }
427 if n.Plan == "" {
428 return nil
429 }
430 case "end":
431 default:
432 return nil
433 }
434 return n
435}
436
437// GET /admin/members: every holding, sync now, and give a comp.
438func (p *AbonoPages) members(w http.ResponseWriter, r *http.Request) {
439 p.renderMembers(w, r, http.StatusOK, abonoCompForm{}, "")
440}
441
442func (p *AbonoPages) renderMembers(w http.ResponseWriter, r *http.Request, status int, form abonoCompForm, errKey string) {
443 ctx := r.Context()
444 ms, err := p.Service.Members(ctx)
445 if err != nil {
446 p.fail(w, r, "members", err)
447 return
448 }
449 offers, err := p.Service.Offers(ctx)
450 if err != nil {
451 p.fail(w, r, "offers", err)
452 return
453 }
454 var unknown []abonoUnrecognised
455 if p.Service.On() {
456 if unknown, err = p.unrecognised(ctx); err != nil {
457 p.fail(w, r, "unrecognised", err)
458 return
459 }
460 }
461 rows := make([]abonoMember, len(ms))
462 for i, m := range ms {
463 rows[i] = abonoMember{Member: m, Label: p.label(ctx, m.Subject)}
464 if m.Source == abono.SourceComp && m.Grants {
465 rows[i].EndURL = abonoMembersPath + "/comps/" + strconv.FormatInt(m.CompID, 10) + "/end"
466 }
467 }
468 p.render(w, r, status, "abono/owner-members", map[string]any{
469 "Title": "abono.owner.members_title",
470 "On": p.Service.On(),
471 "Members": rows,
472 "Unrecognised": unknown,
473 "Now": p.Service.Now(),
474 "Offers": offers,
475 "PlanOptions": abonoPlanOptions(offers, form.Plan),
476 "AdoptOptions": abonoPlanOptions(offers, ""),
477 "Form": form,
478 "SyncAction": abonoMembersPath + "/sync",
479 "CompAction": abonoMembersPath + "/comps",
480 "AdoptAction": abonoOffersPath + "/adopt",
481 "StripeURL": abonoStripePath,
482 "Done": abonoReadNotice(r, offers),
483 "Error": errKey,
484 })
485}
486
487// POST /admin/members/sync: Reconcile now. Reconcile keeps every
488// subscription it could sync when others fail, so the page says nothing
489// changed only when no subscription was synced.
490func (p *AbonoPages) sync(w http.ResponseWriter, r *http.Request) {
491 rep, err := p.Service.Reconcile(r.Context())
492 if err != nil {
493 slog.ErrorContext(r.Context(), "abono pages: sync", "err", err)
494 key := "abono.error.sync_failed"
495 if rep.Subscriptions > rep.Failed {
496 key = "abono.error.sync_partial"
497 }
498 p.renderMembers(w, r, http.StatusBadGateway, abonoCompForm{}, key)
499 return
500 }
501 http.Redirect(w, r, abonoMembersPath+"?done=sync&n="+strconv.Itoa(rep.Subscriptions), http.StatusSeeOther)
502}
503
504// POST /admin/members/comps: give the person with the posted address
505// a plan, until the posted day or for good.
506func (p *AbonoPages) comp(w http.ResponseWriter, r *http.Request) {
507 ctx := r.Context()
508 form := abonoCompForm{
509 Member: strings.TrimSpace(r.PostFormValue("member")),
510 Plan: r.PostFormValue("plan"),
511 Ends: strings.TrimSpace(r.PostFormValue("ends")),
512 Note: strings.TrimSpace(r.PostFormValue("note")),
513 Errors: map[string]string{},
514 }
515 var ends *time.Time
516 if form.Ends != "" {
517 t, err := time.Parse("2006-01-02", form.Ends)
518 if err != nil {
519 // A date field always posts this format; anything else is not a browser.
520 http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
521 return
522 }
523 ends = &t
524 }
525 offers, err := p.Service.Offers(ctx)
526 if err != nil {
527 p.fail(w, r, "offers", err)
528 return
529 }
530 if !abonoIsPlan(offers, form.Plan) {
531 form.Errors["plan"] = "abono.error.free_plan"
532 }
533 subject, found := "", false
534 if p.SubjectForEmail != nil && form.Member != "" {
535 subject, found = p.SubjectForEmail(ctx, form.Member)
536 }
537 if !found {
538 form.Errors["member"] = "abono.owner.free_no_member"
539 }
540 if len(form.Errors) > 0 {
541 p.renderMembers(w, r, http.StatusUnprocessableEntity, form, "")
542 return
543 }
544 if _, err := p.Service.Comp(ctx, subject, form.Plan, ends, form.Note); err != nil {
545 p.fail(w, r, "comp", err)
546 return
547 }
548 http.Redirect(w, r, abonoMembersPath+"?done=comp&plan="+url.QueryEscape(form.Plan), http.StatusSeeOther)
549}
550
551// abonoOption is one choice of a field-select.
552type abonoOption struct {
553 Value, Label string
554 Selected bool
555}
556
557// abonoPlanOptions is every offer as a choice, selected marked.
558func abonoPlanOptions(offers []abono.Offer, selected string) []abonoOption {
559 opts := make([]abonoOption, len(offers))
560 for i, o := range offers {
561 opts[i] = abonoOption{Value: o.Plan, Label: o.Name, Selected: o.Plan == selected}
562 }
563 return opts
564}
565
566func abonoIsPlan(offers []abono.Offer, plan string) bool {
567 for _, o := range offers {
568 if o.Plan == plan {
569 return true
570 }
571 }
572 return false
573}
574
575// compFor is the comp {id} names, while it still grants.
576func (p *AbonoPages) compFor(r *http.Request) (abono.Member, bool, error) {
577 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
578 if err != nil {
579 return abono.Member{}, false, nil
580 }
581 ms, err := p.Service.Members(r.Context())
582 if err != nil {
583 return abono.Member{}, false, err
584 }
585 for _, m := range ms {
586 if m.Source == abono.SourceComp && m.CompID == id && m.Grants {
587 return m, true, nil
588 }
589 }
590 return abono.Member{}, false, nil
591}
592
593// GET /admin/members/comps/{id}/end: are you sure?
594func (p *AbonoPages) endCompPage(w http.ResponseWriter, r *http.Request) {
595 m, ok, err := p.compFor(r)
596 switch {
597 case err != nil:
598 p.fail(w, r, "members", err)
599 return
600 case !ok:
601 http.NotFound(w, r)
602 return
603 }
604 offers, err := p.Service.Offers(r.Context())
605 if err != nil {
606 p.fail(w, r, "offers", err)
607 return
608 }
609 plan := m.Plan
610 for _, o := range offers {
611 if o.Plan == m.Plan {
612 plan = o.Name
613 }
614 }
615 p.render(w, r, http.StatusOK, "abono/owner-end", map[string]any{
616 "Title": "abono.owner.end_title",
617 "Member": m,
618 "Label": p.label(r.Context(), m.Subject),
619 "Plan": plan,
620 "Action": r.URL.Path,
621 "MembersURL": abonoMembersPath,
622 })
623}
624
625// POST /admin/members/comps/{id}/end: end it now.
626func (p *AbonoPages) endComp(w http.ResponseWriter, r *http.Request) {
627 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
628 if err != nil {
629 http.NotFound(w, r)
630 return
631 }
632 switch err := p.Service.EndComp(r.Context(), id); {
633 case errors.Is(err, abono.ErrNotFound):
634 http.NotFound(w, r)
635 case err != nil:
636 p.fail(w, r, "end comp", err)
637 default:
638 http.Redirect(w, r, abonoMembersPath+"?done=end", http.StatusSeeOther)
639 }
640}
641
642// abonoPriceForm is the add-a-price form as posted.
643type abonoPriceForm struct {
644 Plan, Price string
645 Sold bool
646}
647
648// GET /admin/offers: plans and their prices, a way to add an existing
649// Stripe price to a plan or put a legacy one back on sale, and paying
650// subscriptions no plan recognises, each with a way onto a plan.
651func (p *AbonoPages) offers(w http.ResponseWriter, r *http.Request) {
652 p.renderOffers(w, r, http.StatusOK, abonoPriceForm{}, "")
653}
654
655func (p *AbonoPages) renderOffers(w http.ResponseWriter, r *http.Request, status int, form abonoPriceForm, errKey string) {
656 ctx := r.Context()
657 offers, err := p.Service.Offers(ctx)
658 if err != nil {
659 p.fail(w, r, "offers", err)
660 return
661 }
662 var unknown []abonoUnrecognised
663 if p.Service.On() {
664 if unknown, err = p.unrecognised(ctx); err != nil {
665 p.fail(w, r, "unrecognised", err)
666 return
667 }
668 }
669 p.render(w, r, status, "abono/owner-offers", map[string]any{
670 "Title": "abono.owner.offers_title",
671 "On": p.Service.On(),
672 "Offers": offers,
673 "Unrecognised": unknown,
674 "PlanOptions": abonoPlanOptions(offers, ""),
675 "AdoptAction": abonoOffersPath + "/adopt",
676 "PriceOptions": abonoPlanOptions(offers, form.Plan),
677 "PriceForm": form,
678 "PriceAction": abonoOffersPath + "/prices",
679 "StripeURL": abonoStripePath,
680 "Done": abonoReadNotice(r, offers),
681 "Error": errKey,
682 })
683}
684
685// POST /admin/offers/prices: add an existing Stripe price to the posted
686// plan, on sale (sold) or as a legacy price whose subscribers keep the
687// plan; posting a price the plan already has changes whether it is on
688// sale, which is how a legacy price goes back on sale. A price put on
689// sale replaces the plan's sold price that bills the same way.
690func (p *AbonoPages) addPrice(w http.ResponseWriter, r *http.Request) {
691 ctx := r.Context()
692 form := abonoPriceForm{
693 Plan: r.PostFormValue("plan"),
694 Price: strings.TrimSpace(r.PostFormValue("price")),
695 Sold: r.PostFormValue("sold") != "",
696 }
697 offers, err := p.Service.Offers(ctx)
698 if err != nil {
699 p.fail(w, r, "offers", err)
700 return
701 }
702 switch {
703 case !abonoIsPlan(offers, form.Plan):
704 p.renderOffers(w, r, http.StatusUnprocessableEntity, form, "abono.error.unknown_plan")
705 return
706 case form.Price == "":
707 p.renderOffers(w, r, http.StatusUnprocessableEntity, form, "abono.error.add_price_failed")
708 return
709 }
710 if err := p.Service.AddPrice(ctx, form.Plan, form.Price, form.Sold); err != nil {
711 slog.ErrorContext(ctx, "abono pages: add price", "plan", form.Plan, "price", form.Price, "err", err)
712 p.renderOffers(w, r, http.StatusUnprocessableEntity, form, "abono.error.add_price_failed")
713 return
714 }
715 http.Redirect(w, r, abonoOffersPath, http.StatusSeeOther)
716}
717
718// POST /admin/offers/adopt: put an unrecognised subscription, and every
719// other on its price, onto the posted plan. Its holder is found as a sync
720// finds one. The form is on the members page and the offers page; a
721// posted from=members goes back to the first, anything else to the
722// second.
723func (p *AbonoPages) adopt(w http.ResponseWriter, r *http.Request) {
724 ctx := r.Context()
725 sub, plan := r.PostFormValue("subscription"), r.PostFormValue("plan")
726 back, again := abonoOffersPath, func(status int, errKey string) { p.renderOffers(w, r, status, abonoPriceForm{}, errKey) }
727 if r.PostFormValue("from") == "members" {
728 back, again = abonoMembersPath, func(status int, errKey string) { p.renderMembers(w, r, status, abonoCompForm{}, errKey) }
729 }
730 offers, err := p.Service.Offers(ctx)
731 if err != nil {
732 p.fail(w, r, "offers", err)
733 return
734 }
735 if !abonoIsPlan(offers, plan) {
736 again(http.StatusUnprocessableEntity, "abono.error.unknown_plan")
737 return
738 }
739 if err := p.Service.Adopt(ctx, sub, "", plan); err != nil {
740 slog.ErrorContext(ctx, "abono pages: adopt", "subscription", sub, "err", err)
741 again(http.StatusUnprocessableEntity, "abono.error.adopt_failed")
742 return
743 }
744 http.Redirect(w, r, back+"?done=adopt&plan="+url.QueryEscape(plan), http.StatusSeeOther)
745}
746
747// GET /admin/stripe: whether Stripe is connected, how, and the webhook's
748// address.
749func (p *AbonoPages) stripe(w http.ResponseWriter, r *http.Request) {
750 mode := "off"
751 switch {
752 case p.Service.Sandbox():
753 mode = "sandbox"
754 case p.Service.On() && p.Service.TestMode():
755 mode = "test"
756 case p.Service.On():
757 mode = "live"
758 }
759 p.render(w, r, http.StatusOK, "abono/owner-stripe", map[string]any{
760 "Title": "abono.owner.stripe_title",
761 "Mode": mode,
762 "WebhookURL": strings.TrimRight(p.Service.Origin(), "/") + abono.WebhookPath,
763 })
764}
765
766// ---------------------------------------------------------------- plumbing
767
768// abonoUnrecognised is a paying subscription no plan recognises, with
769// what to call its holder when it has one.
770type abonoUnrecognised struct {
771 abono.Unrecognised
772 Label string
773}
774
775func (p *AbonoPages) unrecognised(ctx context.Context) ([]abonoUnrecognised, error) {
776 us, err := p.Service.Unrecognised(ctx)
777 if err != nil {
778 return nil, err
779 }
780 out := make([]abonoUnrecognised, len(us))
781 for i, u := range us {
782 out[i] = abonoUnrecognised{Unrecognised: u}
783 if u.Subject != "" {
784 out[i].Label = p.label(ctx, u.Subject)
785 }
786 }
787 return out, nil
788}
789
790// label is what the owner's pages call subject.
791func (p *AbonoPages) label(ctx context.Context, subject string) string {
792 if p.Label != nil {
793 if l := p.Label(ctx, subject); l != "" {
794 return l
795 }
796 }
797 return subject
798}
799
800func (p *AbonoPages) subject(r *http.Request) (string, bool) {
801 if p.Subject == nil {
802 return "", false
803 }
804 s, ok := p.Subject(r)
805 return s, ok && s != ""
806}
807
808// render hands the page to Render, with the response's status set when it
809// is not 200.
810func (p *AbonoPages) render(w http.ResponseWriter, r *http.Request, status int, name string, data map[string]any) {
811 if status != http.StatusOK {
812 w = &abonoStatusWriter{ResponseWriter: w, status: status}
813 }
814 p.Render(w, r, name, data)
815}
816
817// fail is the answer to a failure no page explains: logged, and a 500.
818func (p *AbonoPages) fail(w http.ResponseWriter, r *http.Request, what string, err error) {
819 slog.ErrorContext(r.Context(), "abono pages: "+what, "err", err)
820 http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
821}
822
823// abonoStatusWriter writes status instead of the 200 a page's body would
824// imply, and lets any other status (a Render that fails with a 500) pass.
825type abonoStatusWriter struct {
826 http.ResponseWriter
827 status int
828 wrote bool
829}
830
831func (s *abonoStatusWriter) WriteHeader(code int) {
832 if s.wrote {
833 return
834 }
835 s.wrote = true
836 if code == http.StatusOK {
837 code = s.status
838 }
839 s.ResponseWriter.WriteHeader(code)
840}
841
842func (s *abonoStatusWriter) Write(b []byte) (int, error) {
843 if !s.wrote {
844 s.WriteHeader(http.StatusOK)
845 }
846 return s.ResponseWriter.Write(b)
847}
848
849func (s *abonoStatusWriter) Unwrap() http.ResponseWriter { return s.ResponseWriter }
850