diff --git a/internal/driver/stripe/client.go b/internal/driver/stripe/client.go index 2caa531..ea6e6b1 100644 --- a/internal/driver/stripe/client.go +++ b/internal/driver/stripe/client.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "strconv" "strings" "time" @@ -113,6 +114,66 @@ func (c *Client) ListPayments(ctx context.Context, params ListPaymentsParams) ([ return payments, nil } +// CreatePaymentLinkParams encapsulates the data required to generate a Stripe payment link. +type CreatePaymentLinkParams struct { + Amount float64 + Currency string + BookingID uint + Description string + PaymentMethodTypes []string +} + +// CreatePaymentLink creates a payment link for the provided booking metadata and amount. +func (c *Client) CreatePaymentLink(ctx context.Context, params CreatePaymentLinkParams) (string, error) { + if params.Amount <= 0 { + return "", errors.New("amount must be greater than zero") + } + currency := strings.ToLower(strings.TrimSpace(params.Currency)) + if currency == "" { + return "", errors.New("currency is required") + } + if params.BookingID == 0 { + return "", errors.New("booking id is required") + } + + amountCents := int64(math.Round(params.Amount * 100)) + + linkParams := &stripe.PaymentLinkCreateParams{ + LineItems: []*stripe.PaymentLinkCreateLineItemParams{ + { + PriceData: &stripe.PaymentLinkCreateLineItemPriceDataParams{ + Currency: stripe.String(currency), + ProductData: &stripe.PaymentLinkCreateLineItemPriceDataProductDataParams{ + Name: stripe.String(strings.TrimSpace(params.Description)), + }, + UnitAmount: stripe.Int64(amountCents), + }, + Quantity: stripe.Int64(1), + }, + }, + Metadata: map[string]string{ + "booking_id": strconv.FormatUint(uint64(params.BookingID), 10), + }, + } + + if linkParams.LineItems[0].PriceData.ProductData.Name == nil || *linkParams.LineItems[0].PriceData.ProductData.Name == "" { + linkParams.LineItems[0].PriceData.ProductData.Name = stripe.String(fmt.Sprintf("Booking %d", params.BookingID)) + } + + if len(params.PaymentMethodTypes) > 0 { + linkParams.PaymentMethodTypes = stripe.StringSlice(params.PaymentMethodTypes) + } + + linkParams.Context = ctx + + pl, err := c.api.V1PaymentLinks.Create(ctx, linkParams) + if err != nil { + return "", fmt.Errorf("failed to create payment link: %w", err) + } + + return pl.URL, nil +} + func deriveMethod(pi *stripe.PaymentIntent) string { if pi == nil { return "" diff --git a/internal/server/handle_bookings.go b/internal/server/handle_bookings.go index 0594d98..3c649a8 100644 --- a/internal/server/handle_bookings.go +++ b/internal/server/handle_bookings.go @@ -2,6 +2,7 @@ package server import ( "context" + "errors" "fmt" "io" "log/slog" @@ -142,21 +143,22 @@ func handleBookingPage(bs *booking.Service, hc *config.Host) echo.HandlerFunc { 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), + 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{ @@ -191,6 +193,32 @@ func handleBookingPage(bs *booking.Service, hc *config.Host) echo.HandlerFunc { } } +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 { diff --git a/internal/server/routes.go b/internal/server/routes.go index b107b4e..e8b6671 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -30,6 +30,7 @@ func (s Server) MountHandlers() { private.GET("/bookings/new", handleBookingCreatePage(s.hc)) private.POST("/bookings/new", handleBookingCreate(s.bs)) private.GET("/bookings/:id", handleBookingPage(s.bs, s.hc)) + private.POST("/bookings/:id/stripe/payment-link", handleBookingStripePaymentLink(s.bs)) private.PUT("/bookings/:id", handleBookingUpdate(s.bs, s.hc)) private.PATCH("/bookings/:id/cancel", handleBookingCancel(s.bs)) private.POST("/bookings/:id/items", handleCreateItem(s.bs, s.hc)) diff --git a/internal/server/server.go b/internal/server/server.go index 7683592..0346f2e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -79,19 +79,6 @@ func NewRouter(fs embed.FS, debug bool, secret string, origins []string) *echo.E // config e.HideBanner = !debug e.Debug = debug - e.HTTPErrorHandler = func(err error, c echo.Context) { - captureError(c, err) - - code := http.StatusInternalServerError - var he *echo.HTTPError - if errors.As(err, &he) { - code = he.Code - } - errorPage := fmt.Sprintf("assets/html/HTTP%d.html", code) - if err := c.File(errorPage); err != nil { - c.Logger().Error(err) - } - } // middlewares e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ Format: "${time_rfc3339} [${method}: ${status}] ${uri}; ip=${remote_ip}; ${latency_human}; ${user_agent}\n", diff --git a/internal/service/booking/service.go b/internal/service/booking/service.go index ccc7a4e..94b900d 100644 --- a/internal/service/booking/service.go +++ b/internal/service/booking/service.go @@ -35,6 +35,7 @@ type Store interface { type StripeClient interface { ListPayments(ctx context.Context, params stripeclient.ListPaymentsParams) ([]stripeclient.Payment, error) + CreatePaymentLink(ctx context.Context, params stripeclient.CreatePaymentLinkParams) (string, error) } type PdfClient interface { diff --git a/internal/service/booking/stripe_payment_link.go b/internal/service/booking/stripe_payment_link.go new file mode 100644 index 0000000..db3c87a --- /dev/null +++ b/internal/service/booking/stripe_payment_link.go @@ -0,0 +1,75 @@ +package booking + +import ( + "context" + "errors" + "fmt" + "math" + "strings" + + stripeclient "github.com/rjNemo/rentease/internal/driver/stripe" +) + +// ErrBookingNotFound indicates that a booking could not be retrieved from the datastore. +var ErrBookingNotFound = errors.New("booking not found") + +// ErrNoOutstandingBalance indicates that the booking has already been fully paid. +var ErrNoOutstandingBalance = errors.New("booking has no outstanding balance") + +// CreateStripePaymentLink generates a Stripe payment link for the outstanding balance of a booking. +func (bs Service) CreateStripePaymentLink(ctx context.Context, bookingID int) (string, error) { + if bs.stripe == nil { + return "", ErrStripeClientNotConfigured + } + + b := bs.store.Get(bookingID) + if b == nil || b.ID == 0 { + return "", ErrBookingNotFound + } + + outstanding := calculateOutstandingBalance(b) + if outstanding <= 0 { + return "", ErrNoOutstandingBalance + } + + description := fmt.Sprintf("Payment for booking %d", b.ID) + if name := strings.TrimSpace(b.Name); name != "" { + description = fmt.Sprintf("Payment for %s", name) + } + + url, err := bs.stripe.CreatePaymentLink(ctx, stripeclient.CreatePaymentLinkParams{ + Amount: outstanding, + Currency: "eur", + BookingID: uint(b.ID), + Description: description, + PaymentMethodTypes: []string{"card", "sepa_debit"}, + }) + if err != nil { + return "", err + } + + return url, nil +} + +func calculateOutstandingBalance(b *Booking) float64 { + if b == nil { + return 0 + } + + var total float64 + for _, item := range b.Items { + total += item.Price * float64(item.Quantity) + } + + var paid float64 + for _, payment := range b.Payments { + paid += payment.Amount + } + + outstanding := total - paid + outstanding = math.Round(outstanding*100) / 100 + if outstanding < 0 { + return 0 + } + return outstanding +} diff --git a/internal/service/booking/stripe_sync_test.go b/internal/service/booking/stripe_sync_test.go index 80f56d3..df21295 100644 --- a/internal/service/booking/stripe_sync_test.go +++ b/internal/service/booking/stripe_sync_test.go @@ -22,6 +22,10 @@ func (f *fakeStripeClient) ListPayments(ctx context.Context, params stripeclient return f.payments, f.err } +func (f *fakeStripeClient) CreatePaymentLink(ctx context.Context, params stripeclient.CreatePaymentLinkParams) (string, error) { + return "", nil +} + type mockStore struct { upserts []*Payment err error diff --git a/internal/view/booking_by_id.templ b/internal/view/booking_by_id.templ index 3dd9347..1a918c7 100644 --- a/internal/view/booking_by_id.templ +++ b/internal/view/booking_by_id.templ @@ -22,14 +22,6 @@ templ BookingById(booking *BookingViewModel) { > - - - if booking.Canceled { Canceled } else { @@ -43,13 +35,24 @@ templ BookingById(booking *BookingViewModel) { @BookingForm(*booking)
-
+

Line Items

- +
+ + +
@@ -123,6 +126,55 @@ templ BookingById(booking *BookingViewModel) { + @PaymentModal(booking.PaymentUrl) } } diff --git a/internal/view/booking_by_id_templ.go b/internal/view/booking_by_id_templ.go index fb673c5..2d3e7a7 100644 --- a/internal/view/booking_by_id_templ.go +++ b/internal/view/booking_by_id_templ.go @@ -85,7 +85,7 @@ func BookingById(booking *BookingViewModel) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" target=\"_blank\">Create PDF ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" target=\"_blank\">Create PDF ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -102,7 +102,7 @@ func BookingById(booking *BookingViewModel) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(booking.CancelUrl) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 36, Col: 66} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 28, Col: 66} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -121,7 +121,20 @@ func BookingById(booking *BookingViewModel) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

Line Items

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

Line Items

ItemQuantityPrice (€)Payment MethodSub-total (€)Actions
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -129,69 +142,69 @@ func BookingById(booking *BookingViewModel) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
ItemQuantityPrice (€)Payment MethodSub-total (€)Actions
Total:") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(booking.Total) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 70, Col: 56} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

Add New Line Item

Total:") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s/items", booking.Url)) + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(booking.Total) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 79, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 73, Col: 56} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" hx-target=\"#line-items\" hx-swap=\"afterend\" hx-on::after-request=\"if(event.detail.successful) this.reset()\" class=\"grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 items-end\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, item := range booking.ItemList { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -225,25 +238,25 @@ func PaymentModal(paymentUrl string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var11 := templ.GetChildren(ctx) - if templ_7745c5c3_Var11 == nil { - templ_7745c5c3_Var11 = templ.NopComponent + templ_7745c5c3_Var12 := templ.GetChildren(ctx) + if templ_7745c5c3_Var12 == nil { + templ_7745c5c3_Var12 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

Add Payment

Add Payment

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" hx-target=\"#payment-lines\" hx-swap=\"outerHTML\" hx-on::after-request=\"if(event.detail.successful) payment_modal.close()\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/internal/view/booking_viewmodel.go b/internal/view/booking_viewmodel.go index b9dadf0..d93dc74 100644 --- a/internal/view/booking_viewmodel.go +++ b/internal/view/booking_viewmodel.go @@ -6,26 +6,27 @@ import ( ) type BookingViewModel struct { - Id string - Name string - PhoneNumber string - CustomerNumber string - Email string - Canceled bool - From string - To string - Platform string - ExternalId string - Platforms []string - PlatformFees string - Items ItemListViewModel - ItemList []string - PaymentMethods []config.PaymentMethod - Url string - PdfUrl templ.SafeURL - PaymentUrl string - CancelUrl string - Total string + Id string + Name string + PhoneNumber string + CustomerNumber string + Email string + Canceled bool + From string + To string + Platform string + ExternalId string + Platforms []string + PlatformFees string + Items ItemListViewModel + ItemList []string + PaymentMethods []config.PaymentMethod + Url string + PdfUrl templ.SafeURL + PaymentUrl string + StripePaymentLinkUrl string + CancelUrl string + Total string } type PaymentViewModel struct {