mirror of
https://github.com/rjNemo/rentease.git
synced 2026-06-12 13:46:51 +00:00
Some checks are pending
CI / checks (push) Waiting to run
Introduce backend and frontend support for generating Stripe payment links for outstanding booking balances. Adds a new POST endpoint to create payment links, updates booking view to include a Stripe button, and integrates error handling and feedback for payment link creation. Refactors view models and templates to support the new feature.
440 lines
14 KiB
Go
440 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"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),
|
|
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)
|
|
}),
|
|
},
|
|
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 handleBookingStripePaymentLink(bs *booking.Service) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
idStr := c.Param("id")
|
|
id, err := strconv.Atoi(idStr)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "invalid booking id")
|
|
}
|
|
|
|
url, err := bs.CreateStripePaymentLink(c.Request().Context(), id)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, booking.ErrStripeClientNotConfigured):
|
|
return echo.NewHTTPError(http.StatusBadRequest, "stripe is not configured")
|
|
case errors.Is(err, booking.ErrBookingNotFound):
|
|
return echo.NewHTTPError(http.StatusNotFound, "booking not found")
|
|
case errors.Is(err, booking.ErrNoOutstandingBalance):
|
|
return echo.NewHTTPError(http.StatusBadRequest, "booking has no outstanding balance")
|
|
default:
|
|
return fmt.Errorf("failed to create payment link: %w", err)
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusCreated, map[string]string{"url": url})
|
|
}
|
|
}
|
|
|
|
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
|
|
}))
|
|
}
|
|
}
|