feat(payments): add Stripe dashboard links for card payments
Some checks are pending
CI / checks (push) Waiting to run

- Add `APP_STRIPE_ACCOUNT_ID` to config and README.
- Pass Stripe account ID to payment view models.
- Show "View in Stripe" badge linking to the payment in Stripe dashboard
  for card payments when account ID and payment ID are present.
- Update Makefile to run format/lint locally instead of in container.
- Update templates and generated code to support new dashboard link.
This commit is contained in:
Ruidy 2025-11-16 18:02:13 +01:00
parent 4dc4d2f2b5
commit a0b7672e9e
No known key found for this signature in database
GPG key ID: 705C24D202990805
9 changed files with 100 additions and 71 deletions

View file

@ -25,19 +25,18 @@ dev: ## Build and run the dev container with live reload (Air)
$(DOCKER_RUN_ENV) $(NAME):dev $(DOCKER_RUN_ENV) $(NAME):dev
test: ## Run Go tests inside the running dev container test: ## Run Go tests inside the running dev container
docker exec $(NAME) go test ./... go test ./...
up-deps: ## Update Go dependencies on host up-deps: ## Update Go dependencies on host
go get -u ./... go get -u ./...
format: ## Generate templ files and format code (dev container must be running) format: ## Generate templ files and format code locally
docker exec $(NAME) templ generate internal/view templ generate internal/view
docker exec $(NAME) templ fmt . templ fmt .
docker exec $(NAME) go fmt ./... go fmt ./...
lint: ## Lint the code using golangci-lint (dev container must be running) lint: ## Lint the code using golangci-lint locally
docker exec $(NAME) golangci-lint run ./... golangci-lint run ./...
stop: ## Stop the dev container stop: ## Stop the dev container
-@docker stop $(NAME) >/dev/null 2>&1 || true -@docker stop $(NAME) >/dev/null 2>&1 || true

View file

@ -74,9 +74,10 @@ use a cloud alternative such as Railway, fly.io, _etc._
# Stripe configuration (optional until you enable automatic sync) # Stripe configuration (optional until you enable automatic sync)
APP_STRIPE_SECRET_KEY=sk_test_your_key APP_STRIPE_SECRET_KEY=sk_test_your_key
APP_STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret APP_STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
APP_STRIPE_ACCOUNT_ID=acct_your_account_id
``` ```
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). 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). Providing `APP_STRIPE_ACCOUNT_ID` also enables dashboard links for synchronized card payments.
5. Start the application 5. Start the application

View file

@ -3,17 +3,18 @@ package config
import "os" import "os"
type Host struct { type Host struct {
Items map[string]HostItem Items map[string]HostItem
Name string Name string
Address string Address string
City string City string
ZipCode string ZipCode string
PhoneNumber string PhoneNumber string
Email string Email string
InvoicePrefix string InvoicePrefix string
PaymentMethods []PaymentMethod PaymentMethods []PaymentMethod
Platforms []Platform Platforms []Platform
CustomerSeed int CustomerSeed int
StripeAccountID string
} }
type HostItem struct { type HostItem struct {
@ -30,16 +31,17 @@ type HostItem struct {
func NewHost() *Host { func NewHost() *Host {
return &Host{ return &Host{
Name: "VillaFleurie", Name: "VillaFleurie",
Address: "4 rue Gerty Archimede", Address: "4 rue Gerty Archimede",
City: "Le Gosier", City: "Le Gosier",
ZipCode: "97190", ZipCode: "97190",
PhoneNumber: "+590 690 44 15 30", PhoneNumber: "+590 690 44 15 30",
Email: "location.villafleurie@gmail.com", Email: "location.villafleurie@gmail.com",
CustomerSeed: 239, CustomerSeed: 239,
InvoicePrefix: "VFNI", InvoicePrefix: "VFNI",
PaymentMethods: []PaymentMethod{"Card", "Cash", "Cheque", "Transfer"}, // TODO: add to DB PaymentMethods: []PaymentMethod{"Card", "Cash", "Cheque", "Transfer"}, // TODO: add to DB
Platforms: []Platform{"Booking", "AirBnb", "TripAdvisor", "Other"}, // TODO: add to DB Platforms: []Platform{"Booking", "AirBnb", "TripAdvisor", "Other"}, // TODO: add to DB
StripeAccountID: os.Getenv("APP_STRIPE_ACCOUNT_ID"),
Items: map[string]HostItem{ // TODO: move to DB Items: map[string]HostItem{ // TODO: move to DB
"T2": { "T2": {
Name: "T2", Name: "T2",

View file

@ -63,17 +63,23 @@ func handleBookingListPage(bs *booking.Service, hc *config.Host) http.HandlerFun
} }
} }
func paymentViewModelFromBookingPayment(p booking.Payment) *view.PaymentViewModel { func paymentViewModelFromBookingPayment(p booking.Payment, stripeAccountID string) *view.PaymentViewModel {
stripeStatus := "" stripeStatus := ""
if p.StripeStatus != nil { if p.StripeStatus != nil {
stripeStatus = *p.StripeStatus stripeStatus = *p.StripeStatus
} }
var stripeDashboardURL string
if string(p.PaymentMethod) == "Card" && stripeAccountID != "" && p.StripePaymentID != nil && *p.StripePaymentID != "" {
stripeDashboardURL = fmt.Sprintf("https://dashboard.stripe.com/%s/payments/%s", stripeAccountID, *p.StripePaymentID)
}
return &view.PaymentViewModel{ return &view.PaymentViewModel{
Amount: strconv.FormatFloat(p.Amount, 'f', 2, 64), Amount: strconv.FormatFloat(p.Amount, 'f', 2, 64),
PaymentMethod: string(p.PaymentMethod), PaymentMethod: string(p.PaymentMethod),
PaymentUrl: fmt.Sprintf("%s/%d", constant.RoutePayment, p.ID), PaymentUrl: fmt.Sprintf("%s/%d", constant.RoutePayment, p.ID),
StripeStatus: stripeStatus, StripeStatus: stripeStatus,
StripeDashboardURL: stripeDashboardURL,
} }
} }
@ -199,7 +205,7 @@ func handleBookingPage(bs *booking.Service, hc *config.Host) http.HandlerFunc {
} }
}), }),
Payments: u.Map(b.Payments, func(p booking.Payment) view.PaymentViewModel { Payments: u.Map(b.Payments, func(p booking.Payment) view.PaymentViewModel {
return *paymentViewModelFromBookingPayment(p) return *paymentViewModelFromBookingPayment(p, hc.StripeAccountID)
}), }),
}, },
Total: strconv.FormatFloat(u.Reduce(b.Items, func(i booking.Item, sum float64) float64 { Total: strconv.FormatFloat(u.Reduce(b.Items, func(i booking.Item, sum float64) float64 {
@ -493,7 +499,7 @@ func handleItemUpdate(bs *booking.Service) http.HandlerFunc {
} }
} }
func handlePaymentUpdate(bs *booking.Service) http.HandlerFunc { func handlePaymentUpdate(bs *booking.Service, hc *config.Host) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
@ -517,7 +523,7 @@ func handlePaymentUpdate(bs *booking.Service) http.HandlerFunc {
p := bs.UpdatePayment(id, amount, r.FormValue("paymentMethod")) p := bs.UpdatePayment(id, amount, r.FormValue("paymentMethod"))
if err := renderTempl(w, http.StatusOK, view.PaymentLine(paymentViewModelFromBookingPayment(*p))); err != nil { if err := renderTempl(w, http.StatusOK, view.PaymentLine(paymentViewModelFromBookingPayment(*p, hc.StripeAccountID))); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
} }

View file

@ -7,11 +7,12 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
u "github.com/rjNemo/underscore" u "github.com/rjNemo/underscore"
"github.com/rjNemo/rentease/internal/config"
"github.com/rjNemo/rentease/internal/service/booking" "github.com/rjNemo/rentease/internal/service/booking"
"github.com/rjNemo/rentease/internal/view" "github.com/rjNemo/rentease/internal/view"
) )
func handleCreatePayment(bs *booking.Service) http.HandlerFunc { func handleCreatePayment(bs *booking.Service, hc *config.Host) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
@ -44,7 +45,7 @@ func handleCreatePayment(bs *booking.Service) http.HandlerFunc {
component := view.PaymentList( component := view.PaymentList(
u.Map(nb.Payments, func(p booking.Payment) *view.PaymentViewModel { u.Map(nb.Payments, func(p booking.Payment) *view.PaymentViewModel {
return paymentViewModelFromBookingPayment(p) return paymentViewModelFromBookingPayment(p, hc.StripeAccountID)
}), }),
) )
@ -54,7 +55,7 @@ func handleCreatePayment(bs *booking.Service) http.HandlerFunc {
} }
} }
func handlePaymentForm(bs *booking.Service) http.HandlerFunc { func handlePaymentForm(bs *booking.Service, hc *config.Host) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id")) id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil { if err != nil {
@ -63,7 +64,7 @@ func handlePaymentForm(bs *booking.Service) http.HandlerFunc {
} }
p := bs.OnePayment(id) p := bs.OnePayment(id)
form := view.PaymentForm(paymentViewModelFromBookingPayment(*p)) form := view.PaymentForm(paymentViewModelFromBookingPayment(*p, hc.StripeAccountID))
if err := renderTempl(w, http.StatusOK, form); err != nil { if err := renderTempl(w, http.StatusOK, form); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }

View file

@ -43,9 +43,9 @@ func (s *Server) MountHandlers() {
r.Get("/reports/do", handleReportCompute(s.bs, s.hc)) r.Get("/reports/do", handleReportCompute(s.bs, s.hc))
r.Get("/reports/pdf", handlePdfCreateReport(s.bs)) r.Get("/reports/pdf", handlePdfCreateReport(s.bs))
r.Post("/payments/{id}", handleCreatePayment(s.bs)) r.Post("/payments/{id}", handleCreatePayment(s.bs, s.hc))
r.Put("/payments/{id}", handlePaymentUpdate(s.bs)) r.Put("/payments/{id}", handlePaymentUpdate(s.bs, s.hc))
r.Get("/payments/{id}", handlePaymentForm(s.bs)) r.Get("/payments/{id}", handlePaymentForm(s.bs, s.hc))
}) })
} }

View file

@ -30,8 +30,9 @@ type BookingViewModel struct {
} }
type PaymentViewModel struct { type PaymentViewModel struct {
Amount string Amount string
PaymentMethod string PaymentMethod string
PaymentUrl string PaymentUrl string
StripeStatus string StripeStatus string
StripeDashboardURL string
} }

View file

@ -7,7 +7,16 @@ templ PaymentLine(payment *PaymentViewModel) {
<td>- { payment.Amount }</td> <td>- { payment.Amount }</td>
<td> <td>
{ payment.PaymentMethod } { payment.PaymentMethod }
<span class="badge badge-outline badge-sm ml-2">{ payment.StripeStatus }</span> if payment.StripeDashboardURL != "" {
<a
href={ payment.StripeDashboardURL }
target="_blank"
rel="noreferrer noopener"
class="badge badge-outline badge-sm ml-2"
>
View in Stripe
</a>
}
</td> </td>
<td></td> <td></td>
<td class="flex gap-2"> <td class="flex gap-2">

View file

@ -55,33 +55,43 @@ func PaymentLine(payment *PaymentViewModel) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " <span class=\"badge badge-outline badge-sm ml-2\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string if payment.StripeDashboardURL != "" {
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(payment.StripeStatus) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<a href=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 10, Col: 73} return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 templ.SafeURL
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(payment.StripeDashboardURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 12, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" target=\"_blank\" rel=\"noreferrer noopener\" class=\"badge badge-outline badge-sm ml-2\">View in Stripe</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</td><td></td><td class=\"flex gap-2\"><button class=\"btn btn-sm btn-outline\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</span></td><td></td><td class=\"flex gap-2\"><button class=\"btn btn-sm btn-outline\" hx-get=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(payment.PaymentUrl) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(payment.PaymentUrl)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 16, Col: 31} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 25, Col: 31}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" hx-target=\"closest tr\" hx-swap=\"outerHTML\">Edit</button></td></tr>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" hx-target=\"closest tr\" hx-swap=\"outerHTML\">Edit</button></td></tr>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -110,46 +120,46 @@ func PaymentForm(payment *PaymentViewModel) templ.Component {
templ_7745c5c3_Var6 = templ.NopComponent templ_7745c5c3_Var6 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<tr class=\"hover\"><form hx-put=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<tr class=\"hover\"><form hx-put=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(payment.PaymentUrl) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(payment.PaymentUrl)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 28, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 37, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" id=\"edit-payment\" hx-target=\"closest tr\" hx-swap=\"outerHTML\"><td></td><td></td><td><input class=\"input input-bordered input-sm w-full\" type=\"number\" inputmode=\"decimal\" step=\"0.01\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" id=\"edit-payment\" hx-target=\"closest tr\" hx-swap=\"outerHTML\"><td></td><td></td><td><input class=\"input input-bordered input-sm w-full\" type=\"number\" inputmode=\"decimal\" step=\"0.01\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(payment.Amount) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(payment.Amount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 37, Col: 27} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 46, Col: 27}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" name=\"amount\" form=\"edit-payment\"></td><td><input class=\"input input-bordered input-sm w-full\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" name=\"amount\" form=\"edit-payment\"></td><td><input class=\"input input-bordered input-sm w-full\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(payment.PaymentMethod) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(payment.PaymentMethod)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 45, Col: 34} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 54, Col: 34}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" name=\"paymentMethod\" form=\"edit-payment\"></td><td></td><td><button class=\"btn btn-sm btn-primary\" type=\"submit\" form=\"edit-payment\">Save</button></td></form></tr>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" name=\"paymentMethod\" form=\"edit-payment\"></td><td></td><td><button class=\"btn btn-sm btn-primary\" type=\"submit\" form=\"edit-payment\">Save</button></td></form></tr>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -178,7 +188,7 @@ func PaymentList(payments []*PaymentViewModel) templ.Component {
templ_7745c5c3_Var10 = templ.NopComponent templ_7745c5c3_Var10 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<tbody id=\"payment-lines\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<tbody id=\"payment-lines\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@ -190,7 +200,7 @@ func PaymentList(payments []*PaymentViewModel) templ.Component {
} }
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</tbody>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</tbody>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }