rentease/internal/server/handle_bookings.go
Ruidy 8384d85e3e
feat(stripe): add Stripe payment sync and webhook support
Introduce Stripe integration for automatic payment ingestion and refund
tracking. Adds new fields to the payment model for Stripe IDs and
status,
Stripe client driver, sync service, cron job, manual API endpoint, and
public webhook handler for real-time updates. Includes tests and
documentation. Manual cash entry remains supported.
2025-10-03 21:39:59 +02:00

412 lines
12 KiB
Go

package server
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/a-h/templ"
"github.com/labstack/echo/v4"
"github.com/labstack/gommon/log"
u "github.com/rjNemo/underscore"
"github.com/rjNemo/rentease/internal/config"
"github.com/rjNemo/rentease/internal/constant"
"github.com/rjNemo/rentease/internal/service/booking"
"github.com/rjNemo/rentease/internal/view"
myTime "github.com/rjNemo/rentease/pkg/time"
)
func handleBookingListPage(bs *booking.Service, hc *config.Host) echo.HandlerFunc {
return func(c echo.Context) error {
search := c.FormValue("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,
}
})
if hxRequest(c) && !hxBoosted(c) {
return renderTempl(c, http.StatusOK, view.BookingLines(bvm))
} else {
return renderTempl(c, http.StatusOK, view.ListBookings(bvm))
}
}
}
func paymentViewModelFromBookingPayment(p booking.Payment) *view.PaymentViewModel {
stripeStatus := ""
if p.StripeStatus != nil {
stripeStatus = *p.StripeStatus
}
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,
}
}
func handleBookingList(bs *booking.Service) echo.HandlerFunc {
return func(c echo.Context) error {
search := c.FormValue("search")
var bookings []*booking.Line
if search != "" {
bookings = bs.Search(search)
} else {
bookings = bs.All()
}
return c.JSON(http.StatusOK, bookings)
}
}
func handleBookingCreatePage(hc *config.Host) echo.HandlerFunc {
return func(c echo.Context) error {
return renderTempl(c, http.StatusOK, view.NewBooking(u.Map(hc.Platforms, func(p config.Platform) string {
return string(p)
})))
}
}
func handleBookingCreate(bs *booking.Service) echo.HandlerFunc {
return func(c echo.Context) error {
type NewBooking struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
ExternalId *string `form:"external_id"`
Name string `form:"name"`
PhoneNumber string `form:"phone_number"`
Email string `form:"email"`
Platform string `form:"platform"`
CustomerNumber int `form:"customer_number"`
PlatformFees float64 `form:"platform_fees"`
}
nb := new(NewBooking)
err := c.Bind(nb)
if err != nil {
log.Warn(err)
return err
}
ts, _ := myTime.ParseFromForm(c.FormValue("from"))
nb.From = ts
ts, _ = myTime.ParseFromForm(c.FormValue("to"))
nb.To = ts
if *nb.ExternalId == "" {
nb.ExternalId = nil
}
b := bs.Create(nb.From, nb.To, nb.Name, nb.PhoneNumber, nb.Email, nb.Platform, nb.CustomerNumber, nb.PlatformFees, nb.ExternalId)
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%d", constant.RouteBooking, b.ID))
}
}
func handleBookingPage(bs *booking.Service, hc *config.Host) echo.HandlerFunc {
return func(c echo.Context) error {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
return err
}
b := bs.One(id)
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),
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)
}),
},
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,
}
return renderTempl(c, http.StatusOK, view.BookingById(bvm))
}
}
func handleBookingUpdate(bs *booking.Service, hc *config.Host) echo.HandlerFunc {
return func(c echo.Context) error {
type UpdateBooking struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
ExternalId *string `form:"external_id"`
Name string `form:"name"`
PhoneNumber string `form:"phone_number"`
Email string `form:"email"`
Platform string `form:"platform"`
Id int `param:"id"`
CustomerNumber int `form:"customer_number"`
PlatformFees float64 `form:"platform_fees"`
}
nb := new(UpdateBooking)
err := c.Bind(nb)
if err != nil {
log.Warn(err)
return err
}
ts, _ := myTime.ParseFromForm(c.FormValue("from"))
nb.From = ts
ts, _ = myTime.ParseFromForm(c.FormValue("to"))
nb.To = ts
if *nb.ExternalId == "" {
nb.ExternalId = nil
}
b := bs.Update(nb.Id, nb.From, nb.To, nb.Name, nb.PhoneNumber, nb.Email, nb.Platform, nb.CustomerNumber, nb.PlatformFees, nb.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: *b.ExternalID,
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)),
})
return renderTempl(c, http.StatusOK, form)
}
}
func handleLineItemForm(bs *booking.Service) echo.HandlerFunc {
return func(c echo.Context) error {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
return err
}
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),
})
return renderTempl(c, http.StatusOK, form)
}
}
func handleCreateItem(bs *booking.Service, hc *config.Host) echo.HandlerFunc {
type NewItem struct {
Item string `form:"item"`
PaymentMethod string `form:"method"`
Quantity int `form:"quantity"`
Price float64 `form:"price"`
}
return func(c echo.Context) error {
bookingIdStr := c.Param("id")
bid, err := strconv.Atoi(bookingIdStr)
if err != nil {
return err
}
b := bs.One(bid)
ni := new(NewItem)
if err := c.Bind(ni); err != nil {
log.Warn(err)
return err
}
itm, ok := hc.Items[ni.Item]
if !ok {
return fmt.Errorf("invalid item name %q", ni.Item)
}
newItems := bs.CreateItem(b.ID, itm, ni.Quantity, ni.Price, ni.PaymentMethod, b.CustomerNumber, string(b.Platform))
// TODO: fix the calendar integration
// if err = cs.Create(
// itm.CalendarId,
// b.Name,
// fmt.Sprintf("Reservation: %s\n %d voyageur(s)\n", b.Name, b.CustomerNumber),
// b.From, b.To,
// ); err != nil {
// log.Warnf("could not create event: %s", err)
// captureError(c, err)
// }
for _, i := range newItems {
_ = renderTempl(c, http.StatusCreated, 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),
}))
}
return nil
}
}
func handleItemPay(bs *booking.Service) echo.HandlerFunc {
return func(c echo.Context) error {
itemIdStr := c.Param("id")
itemId, err := strconv.Atoi(itemIdStr)
if err != nil {
return err
}
i := bs.PayItem(itemId)
return renderTempl(c, 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),
}))
}
}
func handleItemUpdate(bs *booking.Service) echo.HandlerFunc {
return func(c echo.Context) error {
type updateItem struct {
Item string `form:"item"`
PaymentMethod string `form:"paymentMethod"`
PaymentStatus string `form:"paymentStatus"`
Id int `param:"id"`
Quantity int `form:"quantity"`
Price float64 `form:"price"`
}
ui := new(updateItem)
if err := c.Bind(ui); err != nil {
log.Warn(err)
return err
}
i := bs.UpdateItem(ui.Id, ui.Item, ui.Quantity, ui.Price, ui.PaymentMethod, ui.PaymentStatus)
return renderTempl(c, http.StatusCreated, view.LineItem(&view.ItemViewModel{
Id: strconv.Itoa(ui.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),
}))
}
}
func handlePaymentUpdate(bs *booking.Service) echo.HandlerFunc {
type updatePayment struct {
Id int `param:"id"`
Amount float64 `form:"amount"`
PaymentMethod string `form:"paymentMethod"`
}
return func(c echo.Context) error {
up := new(updatePayment)
if err := c.Bind(up); err != nil {
return fmt.Errorf("could not parse update payment request body: %w", err)
}
p := bs.UpdatePayment(up.Id, up.Amount, up.PaymentMethod)
return renderTempl(c, http.StatusOK, view.PaymentLine(paymentViewModelFromBookingPayment(*p)))
}
}
func handleBookingCancel(bs *booking.Service) echo.HandlerFunc {
return func(c echo.Context) error {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
return err
}
bs.Cancel(id)
return renderTempl(c, http.StatusOK, templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
_, err := io.WriteString(w, " <span>Canceled</span>")
return err
}))
}
}