taler-merchant-demos

Python-based Frontends for the Demonstration Web site
Log | Files | Refs | README | LICENSE

blog.go (10693B)


      1 package web
      2 
      3 import (
      4 	"errors"
      5 	"net/http"
      6 	"net/url"
      7 	"strings"
      8 	"time"
      9 
     10 	"git.taler.net/taler-merchant-demos/internal/backend"
     11 )
     12 
     13 type blogIndexContent struct {
     14 	Articles []Article
     15 	BankURL  string
     16 	Price    string
     17 }
     18 
     19 type articleContent struct {
     20 	Article    Article
     21 	Refundable bool
     22 	RefundURL  string
     23 }
     24 
     25 type confirmRefundContent struct {
     26 	ArticleName string
     27 	OrderID     string
     28 }
     29 
     30 type refundedContent struct {
     31 	ArticleName string
     32 	OrderID     string
     33 }
     34 
     35 func (a *App) registerBlogRoutes() {
     36 	a.handleGet("/{lang}/{$}", a.blogIndex)
     37 	a.handleGet("/{lang}/essay/{article}", a.article)
     38 	a.handleGet("/{lang}/essay/{article}/data/{file}", a.articleData)
     39 	a.handleGet("/{lang}/confirm-refund/{order_id}", a.confirmRefund)
     40 	a.handlePost("/{lang}/refund/{order_id}", a.refund)
     41 }
     42 
     43 func (a *App) blogLanguage(w http.ResponseWriter, r *http.Request) (string, bool) {
     44 	lang := r.PathValue("lang")
     45 	if !validLocale(lang) {
     46 		a.renderError(w, r, http.StatusNotFound, "en", "Page not found", nil)
     47 		return "", false
     48 	}
     49 	return lang, true
     50 }
     51 
     52 func (a *App) blogIndex(w http.ResponseWriter, r *http.Request) {
     53 	lang, ok := a.blogLanguage(w, r)
     54 	if !ok {
     55 		return
     56 	}
     57 	articles := sortedArticles(a.articles[lang])
     58 	content := blogIndexContent{
     59 		Articles: articles,
     60 		BankURL:  configuredURL(a.opts.PublicURLs.Bank),
     61 		Price:    a.opts.Currency + ":" + articlePriceUnits,
     62 	}
     63 	a.render(w, "blog-index", a.makePage(r, lang, "GNU Taler Demo: Essay Shop", content), http.StatusOK)
     64 }
     65 
     66 func (a *App) article(w http.ResponseWriter, r *http.Request) {
     67 	lang, ok := a.blogLanguage(w, r)
     68 	if !ok {
     69 		return
     70 	}
     71 	articleName := r.PathValue("article")
     72 	article, exists := a.articles[lang][articleName]
     73 	if !exists {
     74 		a.renderError(w, r, http.StatusNotFound, lang, "Page not found", nil)
     75 		return
     76 	}
     77 	session := sessionID(r)
     78 	expectState := r.URL.Query().Get("expect_state") == "yes"
     79 	if session == "" {
     80 		if expectState {
     81 			a.renderError(w, r, http.StatusPreconditionFailed, lang, "Please enable cookies.", nil)
     82 			return
     83 		}
     84 		var err error
     85 		session, err = newSessionID()
     86 		if err != nil {
     87 			a.renderError(w, r, http.StatusInternalServerError, lang, "Could not create a browser session", err)
     88 			return
     89 		}
     90 		setSessionCookie(w, r, session)
     91 		target := queryRedirect(r, func(query url.Values) { query.Set("expect_state", "yes") })
     92 		http.Redirect(w, r, target, http.StatusFound)
     93 		return
     94 	}
     95 	if expectState {
     96 		target := queryRedirect(r, func(query url.Values) { query.Del("expect_state") })
     97 		http.Redirect(w, r, target, http.StatusFound)
     98 		return
     99 	}
    100 
    101 	articleURL := externalURL(r)
    102 	currentOrderID := orderID(r)
    103 	if currentOrderID != "" {
    104 		var current backend.OrderStatusResponse
    105 		err := a.opts.BlogBackend.Get(r.Context(), "private/orders/"+url.PathEscape(currentOrderID), url.Values{"session_id": {session}}, &current)
    106 		if err == nil {
    107 			if current.OrderStatus == "paid" && !current.Refunded {
    108 				if current.ContractTerms.Extra.ArticleName != articleName {
    109 					a.renderError(w, r, http.StatusPaymentRequired, lang, "The paid order does not match this article", nil)
    110 					return
    111 				}
    112 				_, canRefund := a.refundAmount(current)
    113 				a.renderArticle(w, r, lang, article, currentOrderID, canRefund)
    114 				return
    115 			}
    116 			if current.AlreadyPaidOrderID != "" {
    117 				setOrderCookie(w, r, lang, articleName, current.AlreadyPaidOrderID)
    118 				http.Redirect(w, r, forwardedPrefix(r)+r.URL.Path, http.StatusFound)
    119 				return
    120 			}
    121 			if current.Refunded && r.URL.Query().Get("new_if_refunded") != "yes" {
    122 				content := refundedContent{ArticleName: articleName, OrderID: currentOrderID}
    123 				a.render(w, "blog-refunded", a.makePage(r, lang, "GNU Taler Demo: Refunded", content), http.StatusOK)
    124 				return
    125 			}
    126 		} else if !backendStatus(err, http.StatusNotFound) {
    127 			a.renderError(w, r, http.StatusBadGateway, lang, "Backend request failed", err)
    128 			return
    129 		}
    130 	}
    131 
    132 	var history backend.OrderHistory
    133 	if err := a.opts.BlogBackend.Get(r.Context(), "private/orders", url.Values{
    134 		"session_id": {session}, "fulfillment_url": {articleURL}, "refunded": {"no"},
    135 	}, &history); err != nil {
    136 		a.renderError(w, r, http.StatusBadGateway, lang, "Backend request failed", err)
    137 		return
    138 	}
    139 	for _, previous := range history.Orders {
    140 		if previous.Paid && previous.OrderID != "" {
    141 			setOrderCookie(w, r, lang, articleName, previous.OrderID)
    142 			http.Redirect(w, r, forwardedPrefix(r)+r.URL.Path, http.StatusFound)
    143 			return
    144 		}
    145 	}
    146 
    147 	created, err := a.postArticleOrder(r, article, articleURL, session, lang)
    148 	if err != nil {
    149 		a.renderError(w, r, http.StatusBadGateway, lang, "Backend could not create the order", err)
    150 		return
    151 	}
    152 	if created.OrderID == "" || created.Token == "" {
    153 		a.renderError(w, r, http.StatusBadGateway, lang, "Backend response did not contain an order ID and token", nil)
    154 		return
    155 	}
    156 	paymentURL := a.opts.BlogBackend.URL("orders/"+url.PathEscape(created.OrderID), url.Values{"token": {created.Token}, "session_id": {session}})
    157 	setOrderCookie(w, r, lang, articleName, created.OrderID)
    158 	http.Redirect(w, r, paymentURL, http.StatusFound)
    159 }
    160 
    161 func (a *App) postArticleOrder(r *http.Request, article Article, articleURL, session, lang string) (backend.PostOrderResponse, error) {
    162 	now := time.Now()
    163 	choices := []backend.OrderChoice{{
    164 		// Translators: Merchant order description shown in the customer's wallet.
    165 		Amount: a.opts.Currency + ":" + articlePriceUnits, Description: a.catalogs.translate(lang, "Buy an individual article"),
    166 	}}
    167 	if a.opts.EnableTokens {
    168 		slug := subscriptionPrefix + lang
    169 		choices = append(choices,
    170 			backend.OrderChoice{
    171 				// Translators: Merchant order description shown in the customer's wallet.
    172 				Amount: a.opts.Currency + ":" + subscriptionUnits, Description: a.catalogs.translate(lang, "Buy one month of unlimited access"),
    173 				Outputs: []backend.OrderOutput{{Type: "token", TokenFamilySlug: slug}},
    174 			},
    175 			backend.OrderChoice{
    176 				Amount:  a.opts.Currency + ":0",
    177 				Inputs:  []backend.OrderInput{{Type: "token", TokenFamilySlug: slug}},
    178 				Outputs: []backend.OrderOutput{{Type: "token", TokenFamilySlug: slug}},
    179 			},
    180 		)
    181 	}
    182 	request := backend.PostOrderRequest{
    183 		Order: backend.Order{
    184 			Version:          1,
    185 			Extra:            backend.OrderExtra{ArticleName: article.Slug},
    186 			FulfillmentURL:   articleURL,
    187 			PublicReorderURL: articleURL,
    188 			// Translators: Merchant order summary shown in the wallet; {title} is the essay title.
    189 			Summary:              formatNamed(a.catalogs.translate(lang, "Essay: {title}"), "title", article.Title),
    190 			Choices:              choices,
    191 			PayDeadline:          backend.Timestamp{Seconds: now.Add(5 * time.Minute).Unix()},
    192 			WireTransferDeadline: backend.Timestamp{Seconds: now.Add(2 * time.Hour).Unix()},
    193 		},
    194 		SessionID:   session,
    195 		CreateToken: true,
    196 		RefundDelay: backend.RelativeTime{Microseconds: time.Hour.Microseconds()},
    197 	}
    198 	var response backend.PostOrderResponse
    199 	err := a.opts.BlogBackend.Post(r.Context(), "private/orders", request, &response)
    200 	return response, err
    201 }
    202 
    203 func (a *App) renderArticle(w http.ResponseWriter, r *http.Request, lang string, article Article, requestedOrderID string, canRefund bool) {
    204 	content := articleContent{
    205 		Article: article, Refundable: canRefund,
    206 		RefundURL: forwardedPrefix(r) + "/" + url.PathEscape(lang) + "/confirm-refund/" + url.PathEscape(requestedOrderID),
    207 	}
    208 	a.render(w, "blog-article", a.makePage(r, lang, "GNU Taler Demo: Article", content), http.StatusOK)
    209 }
    210 
    211 func (a *App) confirmRefund(w http.ResponseWriter, r *http.Request) {
    212 	lang, ok := a.blogLanguage(w, r)
    213 	if !ok {
    214 		return
    215 	}
    216 	requestedOrderID := r.PathValue("order_id")
    217 	var order backend.OrderStatusResponse
    218 	if err := a.opts.BlogBackend.Get(r.Context(), "private/orders/"+url.PathEscape(requestedOrderID), url.Values{"session_id": {sessionID(r)}}, &order); err != nil {
    219 		a.renderError(w, r, http.StatusBadGateway, lang, "Backend request failed", err)
    220 		return
    221 	}
    222 	if order.OrderStatus != "paid" {
    223 		a.renderError(w, r, http.StatusBadRequest, lang, "Cannot refund unpaid article", nil)
    224 		return
    225 	}
    226 	if _, ok := a.refundAmount(order); !ok {
    227 		a.renderError(w, r, http.StatusForbidden, lang, "This article can no longer be refunded.", nil)
    228 		return
    229 	}
    230 	content := confirmRefundContent{ArticleName: order.ContractTerms.Extra.ArticleName, OrderID: requestedOrderID}
    231 	a.render(w, "blog-confirm-refund", a.makePage(r, lang, "GNU Taler Demo: Confirm refund", content), http.StatusOK)
    232 }
    233 
    234 func (a *App) refund(w http.ResponseWriter, r *http.Request) {
    235 	lang, ok := a.blogLanguage(w, r)
    236 	if !ok {
    237 		return
    238 	}
    239 	requestedOrderID := r.PathValue("order_id")
    240 	if requestedOrderID == "" {
    241 		a.renderError(w, r, http.StatusBadRequest, lang, "Aborting refund: order unknown", nil)
    242 		return
    243 	}
    244 	var order backend.OrderStatusResponse
    245 	if err := a.opts.BlogBackend.Get(r.Context(), "private/orders/"+url.PathEscape(requestedOrderID), url.Values{"session_id": {sessionID(r)}}, &order); err != nil {
    246 		a.renderError(w, r, http.StatusBadGateway, lang, "Backend request failed", err)
    247 		return
    248 	}
    249 	if order.OrderStatus != "paid" {
    250 		a.renderError(w, r, http.StatusPaymentRequired, lang, "You did not pay for this article (nice try!)", nil)
    251 		return
    252 	}
    253 	amount, ok := a.refundAmount(order)
    254 	if !ok {
    255 		a.renderError(w, r, http.StatusForbidden, lang, "This article can no longer be refunded.", nil)
    256 		return
    257 	}
    258 	if err := a.opts.BlogBackend.Post(r.Context(), "private/orders/"+url.PathEscape(requestedOrderID)+"/refund", backend.RefundRequest{
    259 		// Translators: Refund reason shown in the customer's wallet.
    260 		Reason: a.catalogs.translate(lang, "Demo reimbursement"), Refund: amount,
    261 	}, nil); err != nil {
    262 		a.renderError(w, r, http.StatusBadGateway, lang, "Backend could not refund the order", err)
    263 		return
    264 	}
    265 	if order.OrderStatusURL == "" {
    266 		a.renderError(w, r, http.StatusBadGateway, lang, "Backend response did not contain an order status URL", nil)
    267 		return
    268 	}
    269 	http.Redirect(w, r, order.OrderStatusURL, http.StatusFound)
    270 }
    271 
    272 func backendStatus(err error, status int) bool {
    273 	var backendError *backend.Error
    274 	return errors.As(err, &backendError) && backendError.Status == status
    275 }
    276 
    277 func (a *App) refundAmount(order backend.OrderStatusResponse) (string, bool) {
    278 	if order.Refunded || order.ContractTerms.RefundDeadline.Seconds <= time.Now().Unix() {
    279 		return "", false
    280 	}
    281 	amount := order.ContractTerms.Amount
    282 	if order.ContractTerms.Version == 1 {
    283 		if order.ChoiceIndex == nil || *order.ChoiceIndex < 0 || *order.ChoiceIndex >= len(order.ContractTerms.Choices) {
    284 			return "", false
    285 		}
    286 		amount = order.ContractTerms.Choices[*order.ChoiceIndex].Amount
    287 	}
    288 	value, ok := strings.CutPrefix(amount, a.opts.Currency+":")
    289 	if !ok || value == "" || strings.Trim(value, "0.") == "" {
    290 		return "", false
    291 	}
    292 	return amount, true
    293 }