From a0b7672e9eda3ac3ae03f416e2e51b2c4e921a0c Mon Sep 17 00:00:00 2001 From: Ruidy Date: Sun, 16 Nov 2025 18:02:13 +0100 Subject: [PATCH] feat(payments): add Stripe dashboard links for card payments - 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. --- Makefile | 15 ++++----- README.md | 3 +- internal/config/host.go | 44 +++++++++++++------------ internal/server/handle_bookings.go | 22 ++++++++----- internal/server/handle_payments.go | 9 +++--- internal/server/routes.go | 6 ++-- internal/view/booking_viewmodel.go | 9 +++--- internal/view/payment.templ | 11 ++++++- internal/view/payment_templ.go | 52 ++++++++++++++++++------------ 9 files changed, 100 insertions(+), 71 deletions(-) diff --git a/Makefile b/Makefile index 6abaa0e..234d824 100644 --- a/Makefile +++ b/Makefile @@ -25,19 +25,18 @@ dev: ## Build and run the dev container with live reload (Air) $(DOCKER_RUN_ENV) $(NAME):dev test: ## Run Go tests inside the running dev container - docker exec $(NAME) go test ./... + go test ./... up-deps: ## Update Go dependencies on host go get -u ./... -format: ## Generate templ files and format code (dev container must be running) - docker exec $(NAME) templ generate internal/view - docker exec $(NAME) templ fmt . - docker exec $(NAME) go fmt ./... +format: ## Generate templ files and format code locally + templ generate internal/view + templ fmt . + go fmt ./... -lint: ## Lint the code using golangci-lint (dev container must be running) - docker exec $(NAME) golangci-lint run ./... +lint: ## Lint the code using golangci-lint locally + golangci-lint run ./... stop: ## Stop the dev container -@docker stop $(NAME) >/dev/null 2>&1 || true - diff --git a/README.md b/README.md index 36f9051..aac68a4 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,10 @@ 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_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 diff --git a/internal/config/host.go b/internal/config/host.go index 3477cf4..a82d3cc 100644 --- a/internal/config/host.go +++ b/internal/config/host.go @@ -3,17 +3,18 @@ package config import "os" type Host struct { - Items map[string]HostItem - Name string - Address string - City string - ZipCode string - PhoneNumber string - Email string - InvoicePrefix string - PaymentMethods []PaymentMethod - Platforms []Platform - CustomerSeed int + Items map[string]HostItem + Name string + Address string + City string + ZipCode string + PhoneNumber string + Email string + InvoicePrefix string + PaymentMethods []PaymentMethod + Platforms []Platform + CustomerSeed int + StripeAccountID string } type HostItem struct { @@ -30,16 +31,17 @@ type HostItem struct { func NewHost() *Host { return &Host{ - Name: "VillaFleurie", - Address: "4 rue Gerty Archimede", - City: "Le Gosier", - ZipCode: "97190", - PhoneNumber: "+590 690 44 15 30", - Email: "location.villafleurie@gmail.com", - CustomerSeed: 239, - InvoicePrefix: "VFNI", - PaymentMethods: []PaymentMethod{"Card", "Cash", "Cheque", "Transfer"}, // TODO: add to DB - Platforms: []Platform{"Booking", "AirBnb", "TripAdvisor", "Other"}, // TODO: add to DB + Name: "VillaFleurie", + Address: "4 rue Gerty Archimede", + City: "Le Gosier", + ZipCode: "97190", + PhoneNumber: "+590 690 44 15 30", + Email: "location.villafleurie@gmail.com", + CustomerSeed: 239, + InvoicePrefix: "VFNI", + PaymentMethods: []PaymentMethod{"Card", "Cash", "Cheque", "Transfer"}, // 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 "T2": { Name: "T2", diff --git a/internal/server/handle_bookings.go b/internal/server/handle_bookings.go index 8b28fdf..093d1e6 100644 --- a/internal/server/handle_bookings.go +++ b/internal/server/handle_bookings.go @@ -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 := "" if p.StripeStatus != nil { 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{ - Amount: strconv.FormatFloat(p.Amount, 'f', 2, 64), - PaymentMethod: string(p.PaymentMethod), - PaymentUrl: fmt.Sprintf("%s/%d", constant.RoutePayment, p.ID), - StripeStatus: stripeStatus, + Amount: strconv.FormatFloat(p.Amount, 'f', 2, 64), + PaymentMethod: string(p.PaymentMethod), + PaymentUrl: fmt.Sprintf("%s/%d", constant.RoutePayment, p.ID), + 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 { - return *paymentViewModelFromBookingPayment(p) + return *paymentViewModelFromBookingPayment(p, hc.StripeAccountID) }), }, 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) { if err := r.ParseForm(); err != nil { 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")) - 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) } } diff --git a/internal/server/handle_payments.go b/internal/server/handle_payments.go index 84accd6..de7bc2a 100644 --- a/internal/server/handle_payments.go +++ b/internal/server/handle_payments.go @@ -7,11 +7,12 @@ import ( "github.com/go-chi/chi/v5" u "github.com/rjNemo/underscore" + "github.com/rjNemo/rentease/internal/config" "github.com/rjNemo/rentease/internal/service/booking" "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) { if err := r.ParseForm(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -44,7 +45,7 @@ func handleCreatePayment(bs *booking.Service) http.HandlerFunc { component := view.PaymentList( 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) { id, err := strconv.Atoi(chi.URLParam(r, "id")) if err != nil { @@ -63,7 +64,7 @@ func handlePaymentForm(bs *booking.Service) http.HandlerFunc { } 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 { http.Error(w, err.Error(), http.StatusInternalServerError) } diff --git a/internal/server/routes.go b/internal/server/routes.go index 6969060..4bfd4b8 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -43,9 +43,9 @@ func (s *Server) MountHandlers() { r.Get("/reports/do", handleReportCompute(s.bs, s.hc)) r.Get("/reports/pdf", handlePdfCreateReport(s.bs)) - r.Post("/payments/{id}", handleCreatePayment(s.bs)) - r.Put("/payments/{id}", handlePaymentUpdate(s.bs)) - r.Get("/payments/{id}", handlePaymentForm(s.bs)) + r.Post("/payments/{id}", handleCreatePayment(s.bs, s.hc)) + r.Put("/payments/{id}", handlePaymentUpdate(s.bs, s.hc)) + r.Get("/payments/{id}", handlePaymentForm(s.bs, s.hc)) }) } diff --git a/internal/view/booking_viewmodel.go b/internal/view/booking_viewmodel.go index d93dc74..ec01ab4 100644 --- a/internal/view/booking_viewmodel.go +++ b/internal/view/booking_viewmodel.go @@ -30,8 +30,9 @@ type BookingViewModel struct { } type PaymentViewModel struct { - Amount string - PaymentMethod string - PaymentUrl string - StripeStatus string + Amount string + PaymentMethod string + PaymentUrl string + StripeStatus string + StripeDashboardURL string } diff --git a/internal/view/payment.templ b/internal/view/payment.templ index 258779a..e4266a5 100644 --- a/internal/view/payment.templ +++ b/internal/view/payment.templ @@ -7,7 +7,16 @@ templ PaymentLine(payment *PaymentViewModel) { - { payment.Amount } { payment.PaymentMethod } - { payment.StripeStatus } + if payment.StripeDashboardURL != "" { + + View in Stripe + + } diff --git a/internal/view/payment_templ.go b/internal/view/payment_templ.go index ba7f3e5..bff03bc 100644 --- a/internal/view/payment_templ.go +++ b/internal/view/payment_templ.go @@ -55,33 +55,43 @@ func PaymentLine(payment *PaymentViewModel) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(payment.StripeStatus) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 10, Col: 73} + if payment.StripeDashboardURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "View in Stripe") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - _, 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, 4, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" hx-target=\"closest tr\" hx-swap=\"outerHTML\">Edit") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -110,46 +120,46 @@ func PaymentForm(payment *PaymentViewModel) templ.Component { templ_7745c5c3_Var6 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" name=\"paymentMethod\" form=\"edit-payment\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -178,7 +188,7 @@ func PaymentList(payments []*PaymentViewModel) templ.Component { templ_7745c5c3_Var10 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -190,7 +200,7 @@ func PaymentList(payments []*PaymentViewModel) templ.Component { } } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }