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) } // TODO: use email to send marshalled payment if _, err := conn.Exec(r.Context(), "select unmarshal_booking(marshal_payment($1))", payment.ID); err != nil { log.Println("Could not marshal payment:", 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() }