camper/pkg/payment/public.go
jordi fita mas 72f8a329d2 Use pre-authorization to accept payment, rather than charge
Customer wants this because the booking is not automatically created,
thus it is possible to overbook.  They want to accept the payment of
those that they can actually book.
2024-03-24 22:06:59 +01:00

405 lines
12 KiB
Go

package payment
import (
"context"
"fmt"
ht "html/template"
"log"
"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"
StatusPreAuthenticated = "preauth"
)
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.reference
, payment.created_at
, to_price(total, decimal_digits)
, to_price(payment.down_payment, decimal_digits)
from payment
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.Reference, &payment.CreateTime, &payment.Total, &payment.DownPayment); err != nil {
return nil, err
}
return payment, nil
}
type Payment struct {
ID int
Slug string
Reference string
Total string
DownPayment string
CreateTime time.Time
}
func (payment *Payment) createRequest(r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) (*redsys.SignedRequest, error) {
baseURL := publicBaseURL(r, user, payment.Slug)
request := redsys.Request{
TransactionType: redsys.TransactionTypePreauth,
Amount: payment.DownPayment,
OrderNumber: payment.Reference,
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 publicBaseURL(r *http.Request, user *auth.User, slug string) string {
schema := httplib.Protocol(r)
authority := httplib.Host(r)
return fmt.Sprintf("%s://%s/%s/payments/%s", schema, authority, user.Locale.Language, slug)
}
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.Reference {
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 StatusPreAuthenticated:
if err := sendEmail(r, conn, payment, company, user.Locale); err != nil {
log.Println("Could not send email:", err)
}
default:
}
w.WriteHeader(http.StatusOK)
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
DownPayment string
CompanyAddress *address
}
type NotificationEmail struct {
CompanyName string
BaseURL string
Details *paymentDetails
}
type address struct {
TradeName string
Address string
PostalCode string
Province string
City string
Country string
}
func sendEmail(r *http.Request, conn *database.Conn, payment *Payment, company *auth.Company, locale *locale.Locale) error {
email := &CompletedEmail{
CurrentLocale: locale.Language.String(),
PaymentReference: payment.Reference,
Total: template.FormatPrice(payment.Total, locale.Language, locale.CurrencyPattern, company.DecimalDigits, company.CurrencySymbol),
DownPayment: template.FormatPrice(payment.DownPayment, locale.Language, locale.CurrencyPattern, company.DecimalDigits, company.CurrencySymbol),
CompanyAddress: &address{},
}
var fromAddress string
var toAddress string
if err := conn.QueryRow(r.Context(), `
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
}
details, notificationErr := fetchPaymentDetails(r.Context(), conn, payment.Slug, company.DefaultLocale())
if notificationErr == nil {
schema := httplib.Protocol(r)
authority := httplib.Host(r)
notification := &NotificationEmail{
CompanyName: email.CompanyAddress.TradeName,
BaseURL: fmt.Sprintf("%s://%s/admin/payments/%s", schema, authority, payment.Slug),
Details: details,
}
notificationErr = sendEmailTemplate(fromAddress, fromAddress, notification, company, "details.go", false, company.DefaultLocale())
}
if err := sendEmailTemplate(fromAddress, toAddress, email, company, "body.go", true, locale); err != nil {
return err
}
return notificationErr
}
func sendEmailTemplate(fromAddress string, toAddress string, data interface{}, company *auth.Company, baseTemplate string, addAlternativeHTML bool, locale *locale.Locale) error {
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"))
baseFilename := "web/templates/mail/payment/" + baseTemplate
body, err := tt.New(baseTemplate + "txt").Funcs(tt.FuncMap{
"gettext": locale.Get,
"pgettext": locale.GetC,
"formatPrice": func(price string) string {
return template.FormatPrice(price, locale.Language, locale.CurrencyPattern, company.DecimalDigits, company.CurrencySymbol)
},
}).ParseFiles(baseFilename + "txt")
if err != nil {
return err
}
if err := m.SetBodyTextTemplate(body, data); err != nil {
return err
}
if addAlternativeHTML {
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, data); err != nil {
return err
}
}
return m.WriteToSendmail()
}