Compare commits

..

No commits in common. "91a9a747502ace1b9ad18c58b93103620999a6de" and "1c5e3ecb6eba20a6dd5d21aabd89451e4eb9b64b" have entirely different histories.

13 changed files with 123 additions and 328 deletions

View file

@ -74,6 +74,7 @@ use a cloud alternative such as Railway, fly.io, _etc._
# Stripe configuration (optional until you enable automatic sync)
APP_STRIPE_SECRET_KEY=sk_test_your_key
APP_STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
APP_STRIPE_CONNECT_ACCOUNT=acct_your_connect_account # optional
```
Leave the Stripe variables blank to continue using manual cash entry only. When set, Rentease will pull payments from Stripe, process webhooks sent to `/webhooks/stripe`, and expose a manual sync endpoint at `POST /api/stripe/sync` (protected by the existing API key middleware).

View file

@ -40,6 +40,8 @@ type Config struct {
StripeSecretKey string `env:"STRIPE_SECRET_KEY"`
// StripeWebhookSecret is the signing secret for validating Stripe webhooks
StripeWebhookSecret string `env:"STRIPE_WEBHOOK_SECRET"`
// StripeConnectAccount is the connected account ID when using Stripe Connect (optional)
StripeConnectAccount string `env:"STRIPE_CONNECT_ACCOUNT"`
}
// New creates a [Config] struct. It first parses the environment variables. You can use a .env file.

View file

@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
@ -15,10 +14,18 @@ import (
// Option configures a Client instance.
type Option func(*Client)
// WithAccount sets the Stripe connected account identifier used for requests.
func WithAccount(account string) Option {
return func(c *Client) {
c.account = strings.TrimSpace(account)
}
}
// Client wraps Stripe's SDK to expose the subset of functionality needed by the
// application while keeping the rest of the codebase decoupled from the SDK.
type Client struct {
api *stripe.Client
account string
}
// New constructs a Client using the provided secret key. The key must not be empty.
@ -91,6 +98,10 @@ func (c *Client) ListPayments(ctx context.Context, params ListPaymentsParams) ([
listParams.Filters.AddFilter("created", "lte", strconv.FormatInt(params.To.Unix(), 10))
}
if c.account != "" {
listParams.SetStripeAccount(c.account)
}
seq := c.api.V1PaymentIntents.List(ctx, listParams)
payments := make([]Payment, 0)
var listErr error
@ -114,66 +125,6 @@ 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,7 +2,6 @@ package server
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
@ -158,7 +157,6 @@ func handleBookingPage(bs *booking.Service, hc *config.Host) echo.HandlerFunc {
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{
@ -193,32 +191,6 @@ 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,7 +30,6 @@ 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,6 +79,19 @@ 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,7 +35,6 @@ 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

@ -1,75 +0,0 @@
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,10 +22,6 @@ 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,6 +22,14 @@ 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 {
@ -35,24 +43,13 @@ templ BookingById(booking *BookingViewModel) {
@BookingForm(*booking)
</section>
<section class="card bg-base-100 shadow-md p-6 mb-8">
<hgroup class="flex justify-between items-center">
<hgroup class="flex justify-between">
<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={ 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">
@ -126,55 +123,6 @@ 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> ")
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> ")
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: 28, Col: 66}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 36, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@ -121,20 +121,7 @@ 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 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>")
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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -142,69 +129,69 @@ func BookingById(booking *BookingViewModel) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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\">")
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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(booking.Total)
templ_7745c5c3_Var8, 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: 73, Col: 56}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 79, Col: 50}
}
_, 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, "</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, 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\">")
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(fmt.Sprintf("%s/items", booking.Url))
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: 82, Col: 50}
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, 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, 14, "<option value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">")
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: 94, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 91, Col: 36}
}
_, 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, "\">")
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>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
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> ")
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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -238,25 +225,25 @@ func PaymentModal(paymentUrl string) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var12 := templ.GetChildren(ctx)
if templ_7745c5c3_Var12 == nil {
templ_7745c5c3_Var12 = templ.NopComponent
templ_7745c5c3_Var11 := templ.GetChildren(ctx)
if templ_7745c5c3_Var11 == nil {
templ_7745c5c3_Var11 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
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=\"")
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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(paymentUrl)
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, 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: 188, Col: 24}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 136, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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>")
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>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View file

@ -24,7 +24,6 @@ type BookingViewModel struct {
Url string
PdfUrl templ.SafeURL
PaymentUrl string
StripePaymentLinkUrl string
CancelUrl string
Total string
}

View file

@ -75,6 +75,9 @@ func run(ctx context.Context, appConfig *config.Config, appLogger *slog.Logger)
var stripeClient booking.StripeClient
if appConfig.StripeSecretKey != "" {
opts := []stripeclient.Option{}
if appConfig.StripeConnectAccount != "" {
opts = append(opts, stripeclient.WithAccount(appConfig.StripeConnectAccount))
}
client, err := stripeclient.New(appConfig.StripeSecretKey, opts...)
if err != nil {