package server import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "strconv" "time" "github.com/a-h/templ" "github.com/go-chi/chi/v5" u "github.com/rjNemo/underscore" "github.com/rjNemo/rentease/assets" "github.com/rjNemo/rentease/internal/config" "github.com/rjNemo/rentease/internal/constant" "github.com/rjNemo/rentease/internal/service/booking" "github.com/rjNemo/rentease/internal/service/payment" "github.com/rjNemo/rentease/internal/view" myTime "github.com/rjNemo/rentease/pkg/time" ) func handleBookingListPage(bs *booking.Service, hc *config.Host) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { search := r.URL.Query().Get("search") var bookings []*booking.Line if search != "" { bookings = bs.Search(search) } else { bookings = bs.All() } slog.Info("serving bookings", slog.Int("bookings_length", len(bookings))) bvm := u.Map(bookings, func(b *booking.Line) *view.ListBookingsViewModel { return &view.ListBookingsViewModel{ Id: b.InvoiceNumber(hc), Url: templ.SafeURL(fmt.Sprintf("%s/%d", constant.RouteBooking, b.ID)), From: b.From.Format(time.DateOnly), To: b.To.Format(time.DateOnly), Platform: b.Platform, Name: b.CustomerName, Total: strconv.FormatFloat(b.Total, 'f', 2, 64), Canceled: b.Canceled, } }) var err error switch { case hxRequest(r) && !hxBoosted(r): err = renderTempl(w, r, http.StatusOK, view.BookingLines(bvm)) default: err = renderTempl(w, r, http.StatusOK, view.ListBookings(bvm)) } if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } } func paymentViewModelFromBookingPayment(p booking.Payment, stripeAccountID string) *view.PaymentViewModel { stripeStatus := "" if p.StripeStatus != nil { stripeStatus = *p.StripeStatus } var stripeDashboardURL string if string(p.PaymentMethod) == "Card" && stripeAccountID != "" && p.StripePaymentID != nil && *p.StripePaymentID != "" { stripeDashboardURL = fmt.Sprintf("https://dashboard.stripe.com/%s/payments/%s", stripeAccountID, *p.StripePaymentID) } return &view.PaymentViewModel{ Amount: strconv.FormatFloat(p.Amount, 'f', 2, 64), PaymentMethod: string(p.PaymentMethod), PaymentUrl: fmt.Sprintf("%s/%d", constant.RoutePayment, p.ID), StripeStatus: stripeStatus, StripeDashboardURL: stripeDashboardURL, } } func handleBookingList(bs *booking.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { search := r.URL.Query().Get("search") var bookings []*booking.Line if search != "" { bookings = bs.Search(search) } else { bookings = bs.All() } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(bookings); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } } func handleBookingCreatePage(hc *config.Host) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := renderTempl(w, r, http.StatusOK, view.NewBooking(u.Map(hc.Platforms, func(p config.Platform) string { return string(p) }))); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } } func handleBookingCreate(bs *booking.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } customerNumber, err := strconv.Atoi(r.FormValue("customer_number")) if err != nil { http.Error(w, "invalid customer number", http.StatusBadRequest) return } platformFees := 0.0 if v := r.FormValue("platform_fees"); v != "" { platformFees, err = strconv.ParseFloat(v, 64) if err != nil { http.Error(w, "invalid platform fees", http.StatusBadRequest) return } } externalID := r.FormValue("external_id") var externalPtr *string if externalID != "" { externalPtr = &externalID } from, _ := myTime.ParseFromForm(r.FormValue("from")) to, _ := myTime.ParseFromForm(r.FormValue("to")) b := bs.Create( from, to, r.FormValue("name"), r.FormValue("phone_number"), r.FormValue("email"), r.FormValue("platform"), customerNumber, platformFees, externalPtr, ) http.Redirect(w, r, fmt.Sprintf("%s/%d", constant.RouteBooking, b.ID), http.StatusSeeOther) } } func handleBookingPage(bs *booking.Service, hc *config.Host) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { idStr := chi.URLParam(r, "id") id, err := strconv.Atoi(idStr) if err != nil { http.Error(w, "invalid booking id", http.StatusBadRequest) return } b, err := bs.One(id) if err != nil { renderHTTPErrorPage(w, http.StatusNotFound) return } var eid string if b.ExternalID == nil { eid = "" } else { eid = *b.ExternalID } bvm := &view.BookingViewModel{ Id: b.InvoiceNumber(hc), Name: b.Name, PhoneNumber: b.PhoneNumber, CustomerNumber: strconv.Itoa(b.CustomerNumber), Email: b.Email, From: b.From.Format(time.DateOnly), To: b.To.Format(time.DateOnly), Platform: string(b.Platform), ExternalId: eid, Canceled: b.Canceled, PlatformFees: strconv.FormatFloat(b.PlatformFees, 'f', 2, 64), Url: templ.EscapeString(fmt.Sprintf("%s/%d", constant.RouteBooking, b.ID)), PdfUrl: templ.SafeURL(fmt.Sprintf("%s/pdf/%d", constant.RouteBooking, b.ID)), CancelUrl: fmt.Sprintf("%s/%d/cancel", constant.RouteBooking, b.ID), PaymentUrl: fmt.Sprintf("%s/%d", constant.RoutePayment, b.ID), StripePaymentLinkUrl: fmt.Sprintf("%s/%d/stripe/payment-link", constant.RouteBooking, b.ID), Items: view.ItemListViewModel{ Items: u.Map(b.Items, func(i booking.Item) view.ItemViewModel { return view.ItemViewModel{ Id: strconv.Itoa(i.ID), Item: i.Item, Quantity: strconv.Itoa(i.Quantity), Price: strconv.FormatFloat(i.Price, 'f', 2, 64), SubTotal: strconv.FormatFloat(i.Price*float64(i.Quantity), 'f', 2, 64), PaymentStatus: i.PaymentStatus, ItemUrl: fmt.Sprintf("%s/%d", constant.RouteItem, i.ID), } }), Payments: u.Map(b.Payments, func(p booking.Payment) view.PaymentViewModel { return *paymentViewModelFromBookingPayment(p, hc.StripeAccountID) }), }, Total: strconv.FormatFloat(u.Reduce(b.Items, func(i booking.Item, sum float64) float64 { return sum + i.Price*float64(i.Quantity) }, 0.0), 'f', 2, 64), Platforms: u.Map(hc.Platforms, func(p config.Platform) string { return string(p) }), ItemList: u.OrderBy(func(items map[string]config.HostItem) (out []string) { // TODO: return the full item to prefill the form for _, item := range items { out = append(out, item.Name) } return out }(hc.Items), func(l, r string) bool { return l > r }, ), PaymentMethods: hc.PaymentMethods, } if err := renderTempl(w, r, http.StatusOK, view.BookingById(bvm)); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } } func handleBookingStripePaymentLink(ps *payment.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { idStr := chi.URLParam(r, "id") id, err := strconv.Atoi(idStr) if err != nil { http.Error(w, "invalid booking id", http.StatusBadRequest) return } url, err := ps.CreateStripePaymentLink(r.Context(), id) if err != nil { switch { case errors.Is(err, payment.ErrStripeClientNotConfigured): http.Error(w, "stripe is not configured", http.StatusBadRequest) case errors.Is(err, booking.ErrBookingNotFound): http.Error(w, "booking not found", http.StatusNotFound) case errors.Is(err, payment.ErrNoOutstandingBalance): http.Error(w, "booking has no outstanding balance", http.StatusBadRequest) default: http.Error(w, fmt.Sprintf("failed to create payment link: %v", err), http.StatusInternalServerError) } return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(map[string]string{"url": url}); err != nil { slog.Error("failed to write stripe payment link response", slog.Any("error", err)) } } } func handleBookingUpdate(bs *booking.Service, hc *config.Host) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } id, err := strconv.Atoi(chi.URLParam(r, "id")) if err != nil { http.Error(w, "invalid booking id", http.StatusBadRequest) return } customerNumber, err := strconv.Atoi(r.FormValue("customer_number")) if err != nil { http.Error(w, "invalid customer number", http.StatusBadRequest) return } platformFees := 0.0 if v := r.FormValue("platform_fees"); v != "" { platformFees, err = strconv.ParseFloat(v, 64) if err != nil { http.Error(w, "invalid platform fees", http.StatusBadRequest) return } } externalID := r.FormValue("external_id") var externalPtr *string if externalID != "" { externalPtr = &externalID } from, _ := myTime.ParseFromForm(r.FormValue("from")) to, _ := myTime.ParseFromForm(r.FormValue("to")) b := bs.Update( id, from, to, r.FormValue("name"), r.FormValue("phone_number"), r.FormValue("email"), r.FormValue("platform"), customerNumber, platformFees, externalPtr, ) externalValue := "" if b.ExternalID != nil { externalValue = *b.ExternalID } form := view.BookingForm(view.BookingViewModel{ Id: b.InvoiceNumber(hc), Name: b.Name, PhoneNumber: b.PhoneNumber, CustomerNumber: strconv.Itoa(b.CustomerNumber), Email: b.Email, From: b.From.Format(time.DateOnly), To: b.To.Format(time.DateOnly), Canceled: b.Canceled, Platform: string(b.Platform), ExternalId: externalValue, Platforms: u.Map(hc.Platforms, func(p config.Platform) string { return string(p) }), PlatformFees: strconv.FormatFloat(b.PlatformFees, 'f', 2, 64), PaymentMethods: hc.PaymentMethods, Url: templ.EscapeString(fmt.Sprintf("%s/%d", constant.RouteBooking, b.ID)), PdfUrl: templ.SafeURL(fmt.Sprintf("%s/pdf/%d", constant.RouteBooking, b.ID)), }) if err := renderTempl(w, r, http.StatusOK, form); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } } func handleLineItemForm(bs *booking.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { idStr := chi.URLParam(r, "id") id, err := strconv.Atoi(idStr) if err != nil { http.Error(w, "invalid item id", http.StatusBadRequest) return } i := bs.OneItem(id) form := view.LineItemForm(&view.ItemViewModel{ Id: strconv.Itoa(i.ID), Item: i.Item, Quantity: strconv.Itoa(i.Quantity), Price: strconv.FormatFloat(i.Price, 'f', 2, 64), PaymentStatus: i.PaymentStatus, SubTotal: strconv.FormatFloat(i.Price*float64(i.Quantity), 'f', 2, 64), ItemUrl: fmt.Sprintf("%s/%d", constant.RouteItem, i.ID), }) if err := renderTempl(w, r, http.StatusOK, form); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } } func handleCreateItem(bs *booking.Service, hc *config.Host) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { bookingIdStr := chi.URLParam(r, "id") bid, err := strconv.Atoi(bookingIdStr) if err != nil { http.Error(w, "invalid booking id", http.StatusBadRequest) return } if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } b, err := bs.One(bid) if bookingLookupFailed(w, err) { return } itemName := r.FormValue("item") itm, ok := hc.Items[itemName] if !ok { http.Error(w, fmt.Sprintf("invalid item name %q", itemName), http.StatusBadRequest) return } quantity, err := strconv.Atoi(r.FormValue("quantity")) if err != nil { http.Error(w, "invalid quantity", http.StatusBadRequest) return } price := 0.0 if v := r.FormValue("price"); v != "" { price, err = strconv.ParseFloat(v, 64) if err != nil { http.Error(w, "invalid price", http.StatusBadRequest) return } } newItems := bs.CreateItem(b.ID, itm, quantity, price, r.FormValue("method"), b.CustomerNumber, string(b.Platform)) var buf bytes.Buffer for _, i := range newItems { component := view.LineItem(&view.ItemViewModel{ Id: strconv.Itoa(i.ID), Item: i.Item, Quantity: strconv.Itoa(i.Quantity), Price: strconv.FormatFloat(i.Price, 'f', 2, 64), PaymentStatus: i.PaymentStatus, SubTotal: strconv.FormatFloat(i.Price*float64(i.Quantity), 'f', 2, 64), ItemUrl: fmt.Sprintf("%s/%d", constant.RouteItem, i.ID), }) if err := component.Render(context.Background(), &buf); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusCreated) if _, err := buf.WriteTo(w); err != nil { slog.Error("failed to write item response", slog.Any("error", err)) } } } func handleItemPay(bs *booking.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { itemIdStr := chi.URLParam(r, "id") itemId, err := strconv.Atoi(itemIdStr) if err != nil { http.Error(w, "invalid item id", http.StatusBadRequest) return } i := bs.PayItem(itemId) if err := renderTempl(w, r, http.StatusOK, view.LineItem(&view.ItemViewModel{ Id: itemIdStr, Item: i.Item, Quantity: strconv.Itoa(i.Quantity), Price: strconv.FormatFloat(i.Price, 'f', 2, 64), PaymentStatus: i.PaymentStatus, SubTotal: strconv.FormatFloat(i.Price*float64(i.Quantity), 'f', 2, 64), ItemUrl: fmt.Sprintf("%s/%d", constant.RouteItem, i.ID), })); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } } func handleItemUpdate(bs *booking.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } id, err := strconv.Atoi(chi.URLParam(r, "id")) if err != nil { http.Error(w, "invalid item id", http.StatusBadRequest) return } quantity, err := strconv.Atoi(r.FormValue("quantity")) if err != nil { http.Error(w, "invalid quantity", http.StatusBadRequest) return } price := 0.0 if v := r.FormValue("price"); v != "" { price, err = strconv.ParseFloat(v, 64) if err != nil { http.Error(w, "invalid price", http.StatusBadRequest) return } } i := bs.UpdateItem(id, r.FormValue("item"), quantity, price, r.FormValue("paymentMethod"), r.FormValue("paymentStatus")) if err := renderTempl(w, r, http.StatusCreated, view.LineItem(&view.ItemViewModel{ Id: strconv.Itoa(id), Item: i.Item, Quantity: strconv.Itoa(i.Quantity), Price: strconv.FormatFloat(i.Price, 'f', 2, 64), PaymentStatus: i.PaymentStatus, SubTotal: strconv.FormatFloat(i.Price*float64(i.Quantity), 'f', 2, 64), ItemUrl: fmt.Sprintf("%s/%d", constant.RouteItem, i.ID), })); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } } func handleBookingCancel(bs *booking.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { idStr := chi.URLParam(r, "id") id, err := strconv.Atoi(idStr) if err != nil { http.Error(w, "invalid booking id", http.StatusBadRequest) return } bs.Cancel(id) component := templ.ComponentFunc(func(ctx context.Context, writer io.Writer) error { _, err := io.WriteString(writer, " Canceled") return err }) if err := renderTempl(w, r, http.StatusOK, component); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } } func bookingLookupFailed(w http.ResponseWriter, err error) bool { if err == nil { return false } if errors.Is(err, booking.ErrBookingNotFound) { renderHTTPErrorPage(w, http.StatusNotFound) return true } renderHTTPErrorPage(w, http.StatusInternalServerError) return true } func renderHTTPErrorPage(w http.ResponseWriter, status int) { pagePath := fmt.Sprintf("assets/html/HTTP%d.html", status) page, err := assets.Static.ReadFile(pagePath) if err != nil { http.Error(w, http.StatusText(status), status) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) if _, err := w.Write(page); err != nil { slog.Error("failed to write error page", slog.Any("path", pagePath), slog.Any("error", err)) } }