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()
|
|
}
|