rentease/internal/server/handle_bookings.go
Ruidy 584d81f7bd
Some checks failed
CI / checks (push) Has been cancelled
feat(i18n): add language toggle and localize views
2026-01-09 16:09:20 -04:00

560 lines
17 KiB
Go

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, " <span>Canceled</span>")
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))
}
}