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.
405 lines
12 KiB
Go
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()
|
|
}
|