camper/pkg/payment/public.go

369 lines
10 KiB
Go
Raw Normal View History

package payment
import (
"context"
"fmt"
ht "html/template"
"net/http"
tt "text/template"
"time"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/mail"
"dev.tandem.ws/tandem/camper/pkg/redsys"
"dev.tandem.ws/tandem/camper/pkg/template"
"dev.tandem.ws/tandem/camper/pkg/uuid"
)
const (
StatusCompleted = "completed"
)
type PublicHandler struct {
}
func NewPublicHandler() *PublicHandler {
return &PublicHandler{}
}
func (h *PublicHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var paymentSlug string
paymentSlug, r.URL.Path = httplib.ShiftPath(r.URL.Path)
if !uuid.Valid(paymentSlug) {
http.NotFound(w, r)
return
}
payment, err := fetchPayment(r.Context(), conn, paymentSlug)
if err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodGet:
page := newPaymentPage(payment)
page.MustRender(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
case "success":
handleSuccessfulPayment(w, r, user, company, conn, payment)
case "failure":
handleFailedPayment(w, r, user, company, conn, payment)
case "notification":
handleNotification(w, r, user, company, conn, payment)
default:
http.NotFound(w, r)
}
})
}
func fetchPayment(ctx context.Context, conn *database.Conn, paymentSlug string) (*Payment, error) {
row := conn.QueryRow(ctx, `
select payment_id
, payment.slug::text
, payment.created_at
, to_price(total, decimal_digits)
from payment
join company using (company_id)
join currency using (currency_code)
where payment.slug = $1
and payment_status <> 'draft'
`, paymentSlug)
payment := &Payment{}
if err := row.Scan(&payment.ID, &payment.Slug, &payment.CreateTime, &payment.Total); err != nil {
return nil, err
}
return payment, nil
}
type Payment struct {
ID int
Slug string
Total string
CreateTime time.Time
}
func (payment *Payment) createRequest(r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) (*redsys.SignedRequest, error) {
schema := httplib.Protocol(r)
authority := httplib.Host(r)
baseURL := fmt.Sprintf("%s://%s/%s/payments/%s", schema, authority, user.Locale.Language, payment.Slug)
request := redsys.Request{
TransactionType: redsys.TransactionTypeCharge,
Amount: payment.Total,
OrderNumber: payment.OrderNumber(),
Product: user.Locale.Pgettext("Campsite Booking", "order product name"),
SuccessURL: fmt.Sprintf("%s/success", baseURL),
FailureURL: fmt.Sprintf("%s/failure", baseURL),
NotificationURL: fmt.Sprintf("%s/notification", baseURL),
ConsumerLanguage: user.Locale.Language,
}
return request.Sign(r.Context(), conn, company)
}
func (payment *Payment) OrderNumber() string {
return fmt.Sprintf("%08d%s", payment.ID, payment.Slug[:4])
}
type paymentPage struct {
*template.PublicPage
Environment string
Payment *Payment
Request *redsys.SignedRequest
}
func newPaymentPage(payment *Payment) *paymentPage {
return &paymentPage{
PublicPage: template.NewPublicPage(),
Payment: payment,
}
}
func (p *paymentPage) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
p.Setup(r, user, company, conn)
request, err := p.Payment.createRequest(r, user, company, conn)
if err != nil {
panic(err)
}
p.Request = request
if err := conn.QueryRow(r.Context(), "select environment from redsys where company_id = $1", company.ID).Scan(&p.Environment); err != nil && !database.ErrorIsNotFound(err) {
panic(err)
}
template.MustRenderPublicFiles(w, r, user, company, p, "payment/request.gohtml", "payment/details.gohtml")
}
func handleSuccessfulPayment(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, payment *Payment) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodGet:
page := newSuccessfulPaymentPage(payment)
page.MustRender(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
default:
http.NotFound(w, r)
}
}
type successfulPaymentPage struct {
*template.PublicPage
Payment *Payment
}
func newSuccessfulPaymentPage(payment *Payment) *successfulPaymentPage {
return &successfulPaymentPage{
PublicPage: template.NewPublicPage(),
Payment: payment,
}
}
func (p *successfulPaymentPage) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
p.Setup(r, user, company, conn)
template.MustRenderPublicFiles(w, r, user, company, p, "payment/success.gohtml", "payment/details.gohtml")
}
func handleFailedPayment(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, payment *Payment) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodGet:
page := newFailedPaymentPage(payment)
page.MustRender(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
default:
http.NotFound(w, r)
}
}
type failedPaymentPage struct {
*template.PublicPage
Payment *Payment
}
func newFailedPaymentPage(payment *Payment) *failedPaymentPage {
return &failedPaymentPage{
PublicPage: template.NewPublicPage(),
Payment: payment,
}
}
func (p *failedPaymentPage) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
p.Setup(r, user, company, conn)
template.MustRenderPublicFiles(w, r, user, company, p, "payment/failure.gohtml", "payment/details.gohtml")
}
func handleNotification(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, payment *Payment) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodPost:
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
signed := redsys.SignedResponse{
MerchantParameters: r.Form.Get("Ds_MerchantParameters"),
Signature: r.Form.Get("Ds_Signature"),
SignatureVersion: r.Form.Get("Ds_SignatureVersion"),
}
response, err := signed.Decode(r.Context(), conn, company)
if err != nil {
panic(err)
}
if response == nil {
http.Error(w, "Invalid response", http.StatusBadRequest)
return
}
if response.OrderNumber != payment.OrderNumber() {
http.Error(w, "Response for a different payment", http.StatusBadRequest)
return
}
status, err := response.Process(r.Context(), conn, payment.Slug)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
switch status {
case StatusCompleted:
_ = sendEmail(r.Context(), conn, payment, company, user.Locale) /* shrug */
default:
}
w.WriteHeader(http.StatusNoContent)
default:
httplib.MethodNotAllowed(w, r, http.MethodPost)
}
default:
http.NotFound(w, r)
}
}
type CompletedEmail struct {
CurrentLocale string
CustomerFullName string
PaymentReference string
AccommodationName string
ArrivalDate string
DepartureDate string
Total string
CompanyAddress *address
}
type address struct {
TradeName string
Address string
PostalCode string
Province string
City string
Country string
}
func sendEmail(ctx context.Context, conn *database.Conn, payment *Payment, company *auth.Company, locale *locale.Locale) error {
email := &CompletedEmail{
CurrentLocale: locale.Language.String(),
PaymentReference: payment.OrderNumber(),
Total: template.FormatPrice(payment.Total, locale.Language, locale.CurrencyPattern, company.DecimalDigits, company.CurrencySymbol),
CompanyAddress: &address{},
}
var fromAddress string
var toAddress string
if err := conn.QueryRow(ctx, `
select company.email::text
, customer.email::text
, customer.full_name
, coalesce(i18n.name, campsite_type.name)
, to_char(arrival_date, 'DD/MM/YYYY')
, to_char(departure_date, 'DD/MM/YYYY')
, company.trade_name
, company.address
, company.postal_code
, company.province
, company.city
, coalesce(country_i18n.name, country.name) as country_name
from payment
join payment_customer as customer using (payment_id)
join campsite_type using (campsite_type_id)
left join campsite_type_i18n as i18n on i18n.campsite_type_id = campsite_type.campsite_type_id and i18n.lang_tag = $2
join company on company.company_id = payment.company_id
join country on country.country_code = company.country_code
left join country_i18n on country.country_code = country_i18n.country_code and country_i18n.lang_tag = $2
where payment_id = $1
`, payment.ID, locale.Language).Scan(&fromAddress,
&toAddress,
&email.CustomerFullName,
&email.AccommodationName,
&email.ArrivalDate,
&email.DepartureDate,
&email.CompanyAddress.TradeName,
&email.CompanyAddress.Address,
&email.CompanyAddress.PostalCode,
&email.CompanyAddress.Province,
&email.CompanyAddress.City,
&email.CompanyAddress.Country,
); err != nil {
return err
}
m := mail.NewMsg()
if err := m.From(fromAddress); err != nil {
return err
}
if err := m.ReplyTo(fromAddress); err != nil {
return err
}
if err := m.To(toAddress); err != nil {
return err
}
m.Subject(locale.Pgettext("Booking payment successfully received", "subject"))
baseTemplate := "body.go"
baseFilename := "web/templates/mail/payment/" + baseTemplate
body, err := tt.New(baseTemplate + "txt").Funcs(tt.FuncMap{
"gettext": locale.Get,
"pgettext": locale.GetC,
}).ParseFiles(baseFilename + "txt")
if err != nil {
return err
}
if err := m.SetBodyTextTemplate(body, email); err != nil {
return err
}
alternative, err := ht.New(baseTemplate + "html").Funcs(tt.FuncMap{
"gettext": locale.Get,
"pgettext": locale.GetC,
"raw": func(s string) ht.HTML { return ht.HTML(s) },
}).ParseFiles(baseFilename + "html")
if err != nil {
return err
}
if err := m.AddAlternativeHTMLTemplate(alternative, email); err != nil {
return err
}
return m.WriteToSendmail()
}