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

View file

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

View file

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

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 := ""
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)
}
}

View file

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

View file

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

View file

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

View file

@ -7,7 +7,16 @@ templ PaymentLine(payment *PaymentViewModel) {
<td>- { payment.Amount }</td>
<td>
{ 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 class="flex gap-2">

View file

@ -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, " <span class=\"badge badge-outline badge-sm ml-2\">")
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, "<a href=\"")
if templ_7745c5c3_Err != nil {
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))
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=\"")
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
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(payment.PaymentUrl)
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))
if templ_7745c5c3_Err != nil {
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 {
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, "<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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(payment.PaymentUrl)
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))
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(payment.Amount)
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))
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(payment.PaymentMethod)
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))
if templ_7745c5c3_Err != nil {
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 {
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, "<tbody id=\"payment-lines\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<tbody id=\"payment-lines\">")
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, "</tbody>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}