mirror of
https://github.com/rjNemo/rentease.git
synced 2026-06-10 04:36:50 +00:00
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.
412 lines
12 KiB
Go
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
|
|
}))
|
|
}
|
|
}
|