feat(stripe): add payment link creation for bookings
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.
This commit is contained in:
Ruidy 2025-11-01 17:22:13 +01:00
parent 5cfe9b4169
commit 91a9a74750
No known key found for this signature in database
GPG key ID: 705C24D202990805
10 changed files with 327 additions and 104 deletions

View file

@ -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 ""

View file

@ -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 {

View file

@ -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))

View file

@ -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",

View file

@ -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 {

View file

@ -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
}

View file

@ -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

View file

@ -22,14 +22,6 @@ templ BookingById(booking *BookingViewModel) {
>
<img src="/static/icons/whatsapp.png" class="w-6 h-6"/>
</a>
<a
href="https://dashboard.stripe.com/payments/new"
target="_blank"
rel="noreferrer noopener"
class="btn btn-ghost btn-sm btn-square"
>
<img src="/static/icons/stripe.png" class="w-6 h-6"/>
</a>
if booking.Canceled {
<span class="badge badge-error">Canceled</span>
} else {
@ -43,13 +35,24 @@ templ BookingById(booking *BookingViewModel) {
@BookingForm(*booking)
</section>
<section class="card bg-base-100 shadow-md p-6 mb-8">
<hgroup class="flex justify-between">
<hgroup class="flex justify-between items-center">
<h2
class="text-xl font-semibold mb-4 border-b pb-2 border-base-content/10"
>
Line Items
</h2>
<button class="btn btn-secondary btn-sm " onclick="document.getElementById('payment_modal').showModal()">Add Payment</button>
<div class="flex items-center gap-2">
<button class="btn btn-secondary btn-sm " onclick="document.getElementById('payment_modal').showModal()">Add Payment</button>
<button
type="button"
class="btn btn-ghost btn-sm btn-square"
data-payment-link-url={ booking.StripePaymentLinkUrl }
aria-label="Create Stripe payment link"
onclick="createStripePaymentLink(event)"
>
<img src="/static/icons/stripe.png" class="w-6 h-6"/>
</button>
</div>
</hgroup>
<div class="overflow-x-auto mb-6">
<table class="table w-full">
@ -123,6 +126,55 @@ templ BookingById(booking *BookingViewModel) {
</div>
</form>
</div>
<script>
async function createStripePaymentLink(event) {
const button = event.currentTarget;
if (!button) {
return;
}
const endpoint = button.dataset.paymentLinkUrl;
if (!endpoint) {
return;
}
button.disabled = true;
button.classList.add("loading");
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Accept": "application/json",
},
});
if (!response.ok) {
const message = await extractStripePaymentLinkError(response);
alert(message);
return;
}
const data = await response.json();
if (data && data.url) {
window.open(data.url, "_blank", "noopener");
}
} catch (error) {
console.error("Failed to create Stripe payment link", error);
alert("Unable to create the Stripe payment link. Please try again.");
} finally {
button.disabled = false;
button.classList.remove("loading");
}
}
async function extractStripePaymentLinkError(response) {
try {
const payload = await response.json();
if (payload && payload.message) {
return payload.message;
}
} catch (_error) {
// ignore parsing errors
}
return response.statusText || "Unexpected error while creating payment link.";
}
</script>
@PaymentModal(booking.PaymentUrl)
}
}

View file

@ -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</a> <a href=\"https://web.whatsapp.com/\" target=\"_blank\" rel=\"noreferrer noopener\" class=\"btn btn-ghost btn-sm btn-square\"><img src=\"/static/icons/whatsapp.png\" class=\"w-6 h-6\"></a> <a href=\"https://dashboard.stripe.com/payments/new\" target=\"_blank\" rel=\"noreferrer noopener\" class=\"btn btn-ghost btn-sm btn-square\"><img src=\"/static/icons/stripe.png\" class=\"w-6 h-6\"></a> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" target=\"_blank\">Create PDF</a> <a href=\"https://web.whatsapp.com/\" target=\"_blank\" rel=\"noreferrer noopener\" class=\"btn btn-ghost btn-sm btn-square\"><img src=\"/static/icons/whatsapp.png\" class=\"w-6 h-6\"></a> ")
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, "</section><section class=\"card bg-base-100 shadow-md p-6 mb-8\"><hgroup class=\"flex justify-between\"><h2 class=\"text-xl font-semibold mb-4 border-b pb-2 border-base-content/10\">Line Items</h2><button class=\"btn btn-secondary btn-sm \" onclick=\"document.getElementById('payment_modal').showModal()\">Add Payment</button></hgroup><div class=\"overflow-x-auto mb-6\"><table class=\"table w-full\"><thead><tr><th>Item</th><th>Quantity</th><th>Price (€)</th><th>Payment Method</th><th>Sub-total (€)</th><th class=\"text-right\">Actions</th></tr></thead>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</section><section class=\"card bg-base-100 shadow-md p-6 mb-8\"><hgroup class=\"flex justify-between items-center\"><h2 class=\"text-xl font-semibold mb-4 border-b pb-2 border-base-content/10\">Line Items</h2><div class=\"flex items-center gap-2\"><button class=\"btn btn-secondary btn-sm \" onclick=\"document.getElementById('payment_modal').showModal()\">Add Payment</button> <button type=\"button\" class=\"btn btn-ghost btn-sm btn-square\" data-payment-link-url=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(booking.StripePaymentLinkUrl)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 49, Col: 58}
}
_, 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, 10, "\" aria-label=\"Create Stripe payment link\" onclick=\"createStripePaymentLink(event)\"><img src=\"/static/icons/stripe.png\" class=\"w-6 h-6\"></button></div></hgroup><div class=\"overflow-x-auto mb-6\"><table class=\"table w-full\"><thead><tr><th>Item</th><th>Quantity</th><th>Price (€)</th><th>Payment Method</th><th>Sub-total (€)</th><th class=\"text-right\">Actions</th></tr></thead>")
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, "<tfoot><tr><td colspan=\"4\" class=\"text-right font-bold\">Total:</td><td colspan=\"2\" class=\"font-bold\">")
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, "</td></tr></tfoot></table></div></section><div class=\"border-t pt-4 border-base-content/10\"><h3 class=\"text-lg font-semibold mb-3\">Add New Line Item</h3><form hx-post=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<tfoot><tr><td colspan=\"4\" class=\"text-right font-bold\">Total:</td><td colspan=\"2\" class=\"font-bold\">")
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\"><div class=\"form-control w-full\"><label class=\"label\" for=\"new-line-item\"><span class=\"label-text\">Item</span></label> <select class=\"select select-bordered w-full\" name=\"item\" id=\"new-line-item\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</td></tr></tfoot></table></div></section><div class=\"border-t pt-4 border-base-content/10\"><h3 class=\"text-lg font-semibold mb-3\">Add New Line Item</h3><form hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s/items", booking.Url))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 82, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\" 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\"><div class=\"form-control w-full\"><label class=\"label\" for=\"new-line-item\"><span class=\"label-text\">Item</span></label> <select class=\"select select-bordered w-full\" name=\"item\" id=\"new-line-item\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range booking.ItemList {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 91, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 91, Col: 36}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 94, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</option>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 94, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</select></div><div class=\"form-control w-full\"><label class=\"label\" for=\"new-line-quantity\"><span class=\"label-text\">Quantity</span></label> <input type=\"number\" name=\"quantity\" id=\"new-line-quantity\" required class=\"input input-bordered w-full\"></div><div class=\"form-control w-full\"><label class=\"label\" for=\"new-line-price\"><span class=\"label-text\">Price (€)</span></label> <input type=\"number\" name=\"price\" inputmode=\"decimal\" step=\"0.01\" id=\"new-line-price\" required class=\"input input-bordered w-full\"></div><div class=\"flex justify-end md:justify-start\"><button type=\"submit\" class=\"btn btn-secondary\">Add Line Item</button></div></form></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</select></div><div class=\"form-control w-full\"><label class=\"label\" for=\"new-line-quantity\"><span class=\"label-text\">Quantity</span></label> <input type=\"number\" name=\"quantity\" id=\"new-line-quantity\" required class=\"input input-bordered w-full\"></div><div class=\"form-control w-full\"><label class=\"label\" for=\"new-line-price\"><span class=\"label-text\">Price (€)</span></label> <input type=\"number\" name=\"price\" inputmode=\"decimal\" step=\"0.01\" id=\"new-line-price\" required class=\"input input-bordered w-full\"></div><div class=\"flex justify-end md:justify-start\"><button type=\"submit\" class=\"btn btn-secondary\">Add Line Item</button></div></form></div><script>\n\t\t\tasync function createStripePaymentLink(event) {\n\t\t\t\tconst button = event.currentTarget;\n\t\t\t\tif (!button) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst endpoint = button.dataset.paymentLinkUrl;\n\t\t\t\tif (!endpoint) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tbutton.disabled = true;\n\t\t\t\tbutton.classList.add(\"loading\");\n\t\t\t\ttry {\n\t\t\t\t\tconst response = await fetch(endpoint, {\n\t\t\t\t\t\tmethod: \"POST\",\n\t\t\t\t\t\theaders: {\n\t\t\t\t\t\t\t\"Accept\": \"application/json\",\n\t\t\t\t\t\t},\n\t\t\t\t\t});\n\t\t\t\t\tif (!response.ok) {\n\t\t\t\t\t\tconst message = await extractStripePaymentLinkError(response);\n\t\t\t\t\t\talert(message);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tconst data = await response.json();\n\t\t\t\t\tif (data && data.url) {\n\t\t\t\t\t\twindow.open(data.url, \"_blank\", \"noopener\");\n\t\t\t\t\t}\n\t\t\t\t} catch (error) {\n\t\t\t\t\tconsole.error(\"Failed to create Stripe payment link\", error);\n\t\t\t\t\talert(\"Unable to create the Stripe payment link. Please try again.\");\n\t\t\t\t} finally {\n\t\t\t\t\tbutton.disabled = false;\n\t\t\t\t\tbutton.classList.remove(\"loading\");\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tasync function extractStripePaymentLinkError(response) {\n\t\t\t\ttry {\n\t\t\t\t\tconst payload = await response.json();\n\t\t\t\t\tif (payload && payload.message) {\n\t\t\t\t\t\treturn payload.message;\n\t\t\t\t\t}\n\t\t\t\t} catch (_error) {\n\t\t\t\t\t// ignore parsing errors\n\t\t\t\t}\n\t\t\t\treturn response.statusText || \"Unexpected error while creating payment link.\";\n\t\t\t}\n\t\t</script> ")
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, "<dialog id=\"payment_modal\" class=\"modal\"><div class=\"modal-box\"><h3 class=\"text-lg font-bold\">Add Payment</h3><form class=\"py-4 space-y-4\" hx-post=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<dialog id=\"payment_modal\" class=\"modal\"><div class=\"modal-box\"><h3 class=\"text-lg font-bold\">Add Payment</h3><form class=\"py-4 space-y-4\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(paymentUrl)
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(paymentUrl)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 136, Col: 24}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 188, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" hx-target=\"#payment-lines\" hx-swap=\"outerHTML\" hx-on::after-request=\"if(event.detail.successful) payment_modal.close()\"><div class=\"form-control w-full\"><label class=\"label\"><span class=\"label-text\">Amount</span></label> <input type=\"number\" step=\"0.01\" name=\"amount\" class=\"input input-bordered w-full\" required autofocus></div><div class=\"form-control w-full\"><label class=\"label\"><span class=\"label-text\">Payment Method</span></label> <select name=\"paymentMethod\" class=\"select select-bordered w-full\" required><option value=\"\">Select payment method</option> <option value=\"Cash\">Cash</option> <option value=\"Card\">Card</option> <option value=\"Cheque\">Cheque</option> <option value=\"Transfer\">Bank Transfer</option></select></div><button type=\"submit\" class=\"btn btn-primary\">Add Payment </button></form></div></dialog>")
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()\"><div class=\"form-control w-full\"><label class=\"label\"><span class=\"label-text\">Amount</span></label> <input type=\"number\" step=\"0.01\" name=\"amount\" class=\"input input-bordered w-full\" required autofocus></div><div class=\"form-control w-full\"><label class=\"label\"><span class=\"label-text\">Payment Method</span></label> <select name=\"paymentMethod\" class=\"select select-bordered w-full\" required><option value=\"\">Select payment method</option> <option value=\"Cash\">Cash</option> <option value=\"Card\">Card</option> <option value=\"Cheque\">Cheque</option> <option value=\"Transfer\">Bank Transfer</option></select></div><button type=\"submit\" class=\"btn btn-primary\">Add Payment </button></form></div></dialog>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View file

@ -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 {