Feat/stripe integration (#50)
Some checks are pending
CI / checks (push) Waiting to run

* feat(stripe): add Stripe payment sync and webhook support

Introduce Stripe integration for automatic payment ingestion and refund
tracking. Adds new fields to the payment model for Stripe IDs and
status,
Stripe client driver, sync service, cron job, manual API endpoint, and
public webhook handler for real-time updates. Includes tests and
documentation. Manual cash entry remains supported.

* chore(stripe): upgrade to stripe-go v83

Upgrade Stripe SDK from v79 to v83 across the codebase. Update all
imports to use github.com/stripe/stripe-go/v83 and refactor client usage
to match the new API, including changes to PaymentIntents listing.
Update documentation and plans to reference the new version. Remove
references to the old version from go.mod and go.sum.

* refactor(payment): extract payment logic to new service

Moves all payment-related logic (manual payments, Stripe sync, webhook
handling) from the booking service into a dedicated payment service
(`internal/service/payment`). Updates server, cron, and handler wiring
to
inject and use the new payment service. Adjusts tests, routes, and
documentation to reflect the new separation of concerns.

This improves cohesion, clarifies responsibilities, and prepares for
future payment features. No database schema changes are introduced.

* chore(ci): add Go and templ setup to CI workflow

This update enhances the CI workflow by adding steps to set up Go using
the version specified in go.mod, add the Go bin directory to the PATH,
and install the templ code generation tool. These additions ensure that
Go-based tooling is available for subsequent CI steps.
This commit is contained in:
Ruidy 2025-11-21 15:47:01 +01:00 committed by GitHub
parent afc61e02f1
commit 23f3ceec21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 685 additions and 354 deletions

View file

@ -20,6 +20,22 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Add Go bin to PATH
run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH
- name: Install templ
run: go install github.com/a-h/templ/cmd/templ@v0.3.960
- name: Install golangci-lint
run: |
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh \
| sh -s -- -b $(go env GOPATH)/bin latest
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

View file

@ -3,6 +3,7 @@ NAME ?= rentease
PORT ?= 8000
DB_USER ?= ruidy
DB_NAME ?= villafleurie
DEV_COMPOSE ?= docker-compose.dev.yml
DOCKER_RUN_ENV = -e DATABASE_URL="host=docker.for.mac.host.internal user=$(DB_USER) database=$(DB_NAME)" -e PORT=$(PORT)
@ -17,12 +18,8 @@ build: format lint ## Build the production Docker image
run: build ## Run the production container (port $(PORT))
docker run -p $(PORT):$(PORT) $(DOCKER_RUN_ENV) $(NAME)
dev: ## Build and run the dev container with live reload (Air)
docker build -t $(NAME):dev -f Dockerfile.dev .
docker run -p $(PORT):$(PORT) --rm \
-v `pwd`:/app -v /app/tmp \
--name $(NAME) \
$(DOCKER_RUN_ENV) $(NAME):dev
dev: ## Start the local dev stack via docker compose
docker compose -f $(DEV_COMPOSE) up --build
test: ## Run Go tests inside the running dev container
go test ./...
@ -38,5 +35,5 @@ format: ## Generate templ files and format code locally
lint: ## Lint the code using golangci-lint locally
golangci-lint run ./...
stop: ## Stop the dev container
-@docker stop $(NAME) >/dev/null 2>&1 || true
stop: ## Stop the dev stack
-@docker compose -f $(DEV_COMPOSE) down --remove-orphans >/dev/null 2>&1 || true

View file

@ -112,6 +112,11 @@ Rentease is built using the following technologies:
- [PostgreSQL](https://www.postgresql.org/):The database used to store all application
data.
### Service Layout
- `internal/service/booking`: booking lifecycle, items, reporting, and PDF generation.
- `internal/service/payment`: manual payments plus Stripe synchronisation and webhook handling.
## Roadmap
See the [open issues](https://github.com/users/rjNemo/projects/2/views/1) for a full

54
docker-compose.dev.yml Normal file
View file

@ -0,0 +1,54 @@
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
command: ["air", "-c", ".air.toml"]
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
environment:
APP_NAME: rentease
APP_DEBUG: "true"
APP_LOG_LEVEL: debug
APP_PORT: 8000
APP_ORIGINS: http://localhost:8000
APP_DATABASE_URL: postgres://rentease:rentease@db:5432/rentease?sslmode=disable
APP_ADMIN: admin@example.com
APP_ADMIN_SECRET: supersecret
APP_API_KEY: dev-api-key
APP_SECRET_KEY: dev-secret-key
APP_SESSION_SECRET: dev-session-secret
APP_STRIPE_SECRET_KEY: ""
APP_STRIPE_WEBHOOK_SECRET: ""
APP_STRIPE_ACCOUNT_ID: ""
APP_SENTRY_DSN: ""
volumes:
- .:/app
- tmp-data:/app/tmp
- go-build-cache:/root/.cache/go-build
- go-mod-cache:/go/pkg/mod
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: rentease
POSTGRES_USER: rentease
POSTGRES_PASSWORD: rentease
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
tmp-data:
go-build-cache:
go-mod-cache:

19
go.mod
View file

@ -1,18 +1,18 @@
module github.com/rjNemo/rentease
go 1.25.3
go 1.25.4
require (
github.com/a-h/templ v0.3.960
github.com/getsentry/sentry-go v0.36.2
github.com/getsentry/sentry-go v0.38.0
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
github.com/gorilla/sessions v1.4.0
github.com/joho/godotenv v1.5.1
github.com/rjNemo/underscore v0.8.0
github.com/stripe/stripe-go/v83 v83.1.0
github.com/rjNemo/underscore v0.10.0
github.com/stripe/stripe-go/v83 v83.2.1
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.0
gorm.io/gorm v1.31.1
)
require (
@ -33,9 +33,8 @@ require (
github.com/jinzhu/now v1.1.5 // indirect
github.com/openai/openai-go v1.12.0
github.com/sethvargo/go-envconfig v1.3.0
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/crypto v0.44.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
)

32
go.sum
View file

@ -3,8 +3,8 @@ github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/getsentry/sentry-go v0.36.2 h1:uhuxRPTrUy0dnSzTd0LrYXlBYygLkKY0hhlG5LXarzM=
github.com/getsentry/sentry-go v0.36.2/go.mod h1:p5Im24mJBeruET8Q4bbcMfCQ+F+Iadc4L48tB1apo2c=
github.com/getsentry/sentry-go v0.38.0 h1:S8Xui7gLeAvXINVLMOaX94HnsDf1GexnfXGSNC4+KQs=
github.com/getsentry/sentry-go v0.38.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
@ -41,8 +41,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rjNemo/underscore v0.8.0 h1:bvE/IAHwX2H2CnepOpij+A69p2uBqWO9cQ3NCTaTH0c=
github.com/rjNemo/underscore v0.8.0/go.mod h1:DVEYEX2dnZR79HfleURBPFE9paQxuGl6RlwXV+/szWY=
github.com/rjNemo/underscore v0.10.0 h1:f0WTiHXujG9mgbEt51VH06TqLMS5n4EUKtp5wzhBqQM=
github.com/rjNemo/underscore v0.10.0/go.mod h1:g2nURJw5INpBuh8ie0AjK1KLnsHSA+Tzc+oXiooq7ms=
github.com/sethvargo/go-envconfig v1.3.0 h1:gJs+Fuv8+f05omTpwWIu6KmuseFAXKrIaOZSh8RMt0U=
github.com/sethvargo/go-envconfig v1.3.0/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@ -50,8 +50,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stripe/stripe-go/v83 v83.1.0 h1:h6Wi8+dSUCmIdXDWObs1AirP9tQGWWI/4xP5oE5G6uQ=
github.com/stripe/stripe-go/v83 v83.1.0/go.mod h1:nRyDcLrJtwPPQUnKAFs9Bt1NnQvNhNiF6V19XHmPISE=
github.com/stripe/stripe-go/v83 v83.2.1 h1:8WPhpMjr8VyMWKUsCMoVvlWxYazuL5edajKX/RulfbA=
github.com/stripe/stripe-go/v83 v83.2.1/go.mod h1:nRyDcLrJtwPPQUnKAFs9Bt1NnQvNhNiF6V19XHmPISE=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@ -65,21 +65,21 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View file

@ -7,11 +7,12 @@ import (
"time"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"github.com/rjNemo/rentease/internal/driver/pdf"
"github.com/rjNemo/rentease/internal/repository/booking"
bookingService "github.com/rjNemo/rentease/internal/service/booking"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func JobMonthlyBookingReport() error {
@ -29,7 +30,7 @@ func JobMonthlyBookingReport() error {
}
store := booking.NewPgStore(db)
service, err := bookingService.NewService(nil, store, nil, ps, nil)
service, err := bookingService.NewService(nil, store, nil, ps)
if err != nil {
return fmt.Errorf("error creating booking service: %w", err)
}

View file

@ -8,9 +8,10 @@ import (
"github.com/rjNemo/rentease/internal/config"
"github.com/rjNemo/rentease/internal/driver/database"
stripeclient "github.com/rjNemo/rentease/internal/driver/stripe"
"github.com/rjNemo/rentease/internal/driver/stripe"
"github.com/rjNemo/rentease/internal/repository/booking"
bookingservice "github.com/rjNemo/rentease/internal/service/booking"
"github.com/rjNemo/rentease/internal/service/payment"
)
// JobStripePaymentSync synchronises Stripe payments for the last 24 hours. It is
@ -40,17 +41,17 @@ func JobStripePaymentSync() error {
store := booking.NewPgStore(db)
opts := []stripeclient.Option{}
opts := []stripe.Option{}
client, err := stripeclient.New(cfg.StripeSecretKey, opts...)
client, err := stripe.New(cfg.StripeSecretKey, opts...)
if err != nil {
return fmt.Errorf("error creating stripe client: %w", err)
}
logger := slog.Default()
service, err := bookingservice.NewService(logger, store, nil, nil, client)
service, err := payment.NewService(logger, store, client)
if err != nil {
return fmt.Errorf("error creating booking service: %w", err)
return fmt.Errorf("error creating payment service: %w", err)
}
to := time.Now().UTC()

View file

@ -20,6 +20,7 @@ import (
"github.com/rjNemo/rentease/internal/config"
"github.com/rjNemo/rentease/internal/constant"
"github.com/rjNemo/rentease/internal/service/booking"
"github.com/rjNemo/rentease/internal/service/payment"
"github.com/rjNemo/rentease/internal/view"
myTime "github.com/rjNemo/rentease/pkg/time"
)
@ -234,7 +235,7 @@ func handleBookingPage(bs *booking.Service, hc *config.Host) http.HandlerFunc {
}
}
func handleBookingStripePaymentLink(bs *booking.Service) http.HandlerFunc {
func handleBookingStripePaymentLink(ps *payment.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
@ -243,14 +244,14 @@ func handleBookingStripePaymentLink(bs *booking.Service) http.HandlerFunc {
return
}
url, err := bs.CreateStripePaymentLink(r.Context(), id)
url, err := ps.CreateStripePaymentLink(r.Context(), id)
if err != nil {
switch {
case errors.Is(err, booking.ErrStripeClientNotConfigured):
case errors.Is(err, payment.ErrStripeClientNotConfigured):
http.Error(w, "stripe is not configured", http.StatusBadRequest)
case errors.Is(err, booking.ErrBookingNotFound):
http.Error(w, "booking not found", http.StatusNotFound)
case errors.Is(err, booking.ErrNoOutstandingBalance):
case errors.Is(err, payment.ErrNoOutstandingBalance):
http.Error(w, "booking has no outstanding balance", http.StatusBadRequest)
default:
http.Error(w, fmt.Sprintf("failed to create payment link: %v", err), http.StatusInternalServerError)
@ -507,36 +508,6 @@ func handleItemUpdate(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)
return
}
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid payment id", http.StatusBadRequest)
return
}
amount := 0.0
if v := r.FormValue("amount"); v != "" {
amount, err = strconv.ParseFloat(v, 64)
if err != nil {
http.Error(w, "invalid amount", http.StatusBadRequest)
return
}
}
p := bs.UpdatePayment(id, amount, r.FormValue("paymentMethod"))
if err := renderTempl(w, http.StatusOK, view.PaymentLine(paymentViewModelFromBookingPayment(*p, hc.StripeAccountID))); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func handleBookingCancel(bs *booking.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")

View file

@ -9,10 +9,11 @@ import (
"github.com/rjNemo/rentease/internal/config"
"github.com/rjNemo/rentease/internal/service/booking"
"github.com/rjNemo/rentease/internal/service/payment"
"github.com/rjNemo/rentease/internal/view"
)
func handleCreatePayment(bs *booking.Service, hc *config.Host) http.HandlerFunc {
func handleCreatePayment(bs *booking.Service, ps *payment.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)
@ -39,7 +40,7 @@ func handleCreatePayment(bs *booking.Service, hc *config.Host) http.HandlerFunc
return
}
if _, err := bs.CreatePayment(b.ID, amount, r.FormValue("paymentMethod")); err != nil {
if _, err := ps.CreatePayment(b.ID, amount, r.FormValue("paymentMethod")); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@ -61,7 +62,7 @@ func handleCreatePayment(bs *booking.Service, hc *config.Host) http.HandlerFunc
}
}
func handlePaymentForm(bs *booking.Service, hc *config.Host) http.HandlerFunc {
func handlePaymentForm(ps *payment.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 {
@ -69,10 +70,49 @@ func handlePaymentForm(bs *booking.Service, hc *config.Host) http.HandlerFunc {
return
}
p := bs.OnePayment(id)
p, err := ps.Payment(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
form := view.PaymentForm(paymentViewModelFromBookingPayment(*p, hc.StripeAccountID))
if err := renderTempl(w, http.StatusOK, form); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func handlePaymentUpdate(ps *payment.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)
return
}
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid payment id", http.StatusBadRequest)
return
}
amount := 0.0
if v := r.FormValue("amount"); v != "" {
amount, err = strconv.ParseFloat(v, 64)
if err != nil {
http.Error(w, "invalid amount", http.StatusBadRequest)
return
}
}
p, err := ps.UpdatePayment(id, amount, r.FormValue("paymentMethod"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := renderTempl(w, http.StatusOK, view.PaymentLine(paymentViewModelFromBookingPayment(*p, hc.StripeAccountID))); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}

View file

@ -8,7 +8,7 @@ import (
"net/http"
"time"
"github.com/rjNemo/rentease/internal/service/booking"
"github.com/rjNemo/rentease/internal/service/payment"
)
type stripeSyncRequest struct {
@ -53,7 +53,7 @@ func handleStripeSync(bs stripeSyncer) http.HandlerFunc {
}
if err := bs.SyncStripePayments(r.Context(), from, to); err != nil {
if errors.Is(err, booking.ErrStripeClientNotConfigured) {
if errors.Is(err, payment.ErrStripeClientNotConfigured) {
http.Error(w, "stripe client not configured", http.StatusServiceUnavailable)
return
}

View file

@ -12,14 +12,14 @@ func (s *Server) MountHandlers() {
s.Router.Get("/healthz", handleHealthCheck())
s.Router.Get("/", handleLoginPage())
s.Router.Post("/", handleLogin(s.as))
s.Router.Post("/webhooks/stripe", handleStripeWebhook(s.bs, s.stripeWebhookSecret))
s.Router.Post("/webhooks/stripe", handleStripeWebhook(s.ps, s.stripeWebhookSecret))
s.Router.Route("/api", func(r chi.Router) {
r.Use(apiKeyMiddleware(s.as))
r.Post("/sync", handleSync(s.bs))
r.Get("/bookings", handleBookingList(s.bs))
r.Post("/bookings", handleCreateBooking(s.bs))
r.Post("/stripe/sync", handleStripeSync(s.bs))
r.Post("/stripe/sync", handleStripeSync(s.ps))
})
s.Router.Group(func(r chi.Router) {
@ -29,7 +29,7 @@ func (s *Server) MountHandlers() {
r.Get("/bookings/new", handleBookingCreatePage(s.hc))
r.Post("/bookings/new", handleBookingCreate(s.bs))
r.Get("/bookings/{id}", handleBookingPage(s.bs, s.hc))
r.Post("/bookings/{id}/stripe/payment-link", handleBookingStripePaymentLink(s.bs))
r.Post("/bookings/{id}/stripe/payment-link", handleBookingStripePaymentLink(s.ps))
r.Put("/bookings/{id}", handleBookingUpdate(s.bs, s.hc))
r.Patch("/bookings/{id}/cancel", handleBookingCancel(s.bs))
r.Post("/bookings/{id}/items", handleCreateItem(s.bs, s.hc))
@ -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, s.hc))
r.Put("/payments/{id}", handlePaymentUpdate(s.bs, s.hc))
r.Get("/payments/{id}", handlePaymentForm(s.bs, s.hc))
r.Post("/payments/{id}", handleCreatePayment(s.bs, s.ps, s.hc))
r.Put("/payments/{id}", handlePaymentUpdate(s.ps, s.hc))
r.Get("/payments/{id}", handlePaymentForm(s.ps, s.hc))
})
}

View file

@ -20,19 +20,21 @@ import (
"github.com/rjNemo/rentease/internal/config"
"github.com/rjNemo/rentease/internal/service/auth"
"github.com/rjNemo/rentease/internal/service/booking"
"github.com/rjNemo/rentease/internal/service/payment"
)
type Server struct {
Router *chi.Mux
httpServer *http.Server
bs *booking.Service
ps *payment.Service
as *auth.Service
hc *config.Host
addr string
stripeWebhookSecret string
}
func New(bs *booking.Service, as *auth.Service, hc *config.Host, opts ...Option) (*Server, error) {
func New(bs *booking.Service, ps *payment.Service, as *auth.Service, hc *config.Host, opts ...Option) (*Server, error) {
option := new(options)
for _, opt := range opts {
if err := opt(option); err != nil {
@ -48,6 +50,7 @@ func New(bs *booking.Service, as *auth.Service, hc *config.Host, opts ...Option)
s := &Server{
Router: router,
bs: bs,
ps: ps,
as: as,
hc: hc,
addr: fmt.Sprintf("0.0.0.0:%d", *option.port),

View file

@ -0,0 +1,6 @@
package booking
import "errors"
// ErrBookingNotFound indicates that a booking could not be retrieved from the datastore.
var ErrBookingNotFound = errors.New("booking not found")

View file

@ -1,45 +0,0 @@
package booking
import (
"log"
"github.com/rjNemo/rentease/internal/config"
)
func (bs Service) OnePayment(id int) *Payment {
p, err := bs.store.GetPayment(id)
if err != nil {
log.Println(err)
}
return p
}
func (bs Service) CreatePayment(bid int, amount float64, paymentMethod string) (*Payment, error) {
p, err := bs.store.CreatePayment(&Payment{
BookingID: uint(bid),
Amount: amount,
PaymentMethod: config.PaymentMethod(paymentMethod),
})
if err != nil {
return nil, err
}
return p, nil
}
func (bs Service) UpdatePayment(id int, amount float64, paymentMethod string) *Payment {
p, err := bs.store.UpdatePayment(id, amount, paymentMethod)
if err != nil {
log.Println(err)
}
return p
}
func (bs Service) UpsertStripePayment(p *Payment) (*Payment, error) {
sp, err := bs.store.UpsertStripePayment(p)
if err != nil {
log.Println(err)
return nil, err
}
return sp, nil
}

View file

@ -1,7 +1,6 @@
package booking
import (
"context"
"errors"
"log/slog"
"time"
@ -9,7 +8,6 @@ import (
"gorm.io/gorm"
"github.com/rjNemo/rentease/internal/config"
stripeclient "github.com/rjNemo/rentease/internal/driver/stripe"
)
type Store interface {
@ -27,18 +25,6 @@ type Store interface {
PayItem(id int) (*Item, error)
GetItem(id int) (*Item, error)
UpdateItem(id int, item string, paymentMethod string, paymentStatus string, qty int, price float64) (*Item, error)
// Payment methods
CreatePayment(p *Payment) (*Payment, error)
GetPayment(id int) (*Payment, error)
UpdatePayment(id int, amount float64, paymentMethod string) (*Payment, error)
UpsertStripePayment(p *Payment) (*Payment, error)
FindStripePayment(stripePaymentID string) (*Payment, error)
}
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 {
@ -59,10 +45,9 @@ type Service struct {
parser parserClient
pdf PdfClient
logger *slog.Logger
stripe StripeClient
}
func NewService(logger *slog.Logger, store Store, parser parserClient, pdf PdfClient, stripe StripeClient) (*Service, error) {
func NewService(logger *slog.Logger, store Store, parser parserClient, pdf PdfClient) (*Service, error) {
svcLogger := logger
if svcLogger == nil {
svcLogger = slog.Default()
@ -73,7 +58,6 @@ func NewService(logger *slog.Logger, store Store, parser parserClient, pdf PdfCl
store: store,
parser: parser,
pdf: pdf,
stripe: stripe,
}, nil
}

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

@ -1,70 +0,0 @@
package booking
import (
"context"
"errors"
"log/slog"
"strings"
"time"
"github.com/rjNemo/rentease/internal/config"
stripeclient "github.com/rjNemo/rentease/internal/driver/stripe"
)
// ErrStripeClientNotConfigured indicates the service was asked to run a Stripe operation without a configured client.
var ErrStripeClientNotConfigured = errors.New("stripe client not configured")
// SyncStripePayments pulls Stripe payments within the provided time window and
// upserts them into the local datastore. Payments lacking booking metadata are
// skipped to avoid incorrect associations.
func (bs Service) SyncStripePayments(ctx context.Context, from, to time.Time) error {
if bs.stripe == nil {
return ErrStripeClientNotConfigured
}
payments, err := bs.stripe.ListPayments(ctx, stripeclient.ListPaymentsParams{From: from, To: to})
if err != nil {
return err
}
var multi error
for _, payment := range payments {
if payment.BookingID == nil {
bs.logger.Warn("stripe payment missing booking metadata", slog.String("payment_id", payment.ID))
continue
}
bookingID := uint(*payment.BookingID)
stripeID := payment.ID
status := strings.ToLower(payment.Status)
_, err = bs.store.UpsertStripePayment(&Payment{
BookingID: bookingID,
Amount: payment.Amount,
PaymentMethod: mapStripeMethod(payment.PaymentMethod),
StripePaymentID: &stripeID,
StripeStatus: &status,
})
if err != nil {
multi = errors.Join(multi, err)
bs.logger.Error("failed to upsert stripe payment", slog.String("payment_id", payment.ID), slog.Any("error", err))
}
}
return multi
}
func mapStripeMethod(method string) config.PaymentMethod {
switch strings.ToLower(method) {
case "card", "link", "apple_pay", "google_pay", "cashapp":
return config.PaymentMethod("Card")
case "ach_credit_transfer", "ach_debit", "us_bank_account", "sepa_debit", "bank_transfer", "blik", "bancontact":
return config.PaymentMethod("Transfer")
case "cash":
return config.PaymentMethod("Cash")
case "check":
return config.PaymentMethod("Cheque")
default:
return config.PaymentMethod("Card")
}
}

View file

@ -0,0 +1,9 @@
package payment
import "errors"
// ErrStripeClientNotConfigured indicates the service was asked to run a Stripe operation without a configured client.
var ErrStripeClientNotConfigured = errors.New("stripe client not configured")
// ErrNoOutstandingBalance indicates that the booking has already been fully paid.
var ErrNoOutstandingBalance = errors.New("booking has no outstanding balance")

View file

@ -0,0 +1,22 @@
package payment
import (
"strings"
"github.com/rjNemo/rentease/internal/config"
)
func mapStripeMethod(method string) config.PaymentMethod {
switch strings.ToLower(method) {
case "card", "link", "apple_pay", "google_pay", "cashapp":
return config.PaymentMethod("Card")
case "ach_credit_transfer", "ach_debit", "us_bank_account", "sepa_debit", "bank_transfer", "blik", "bancontact":
return config.PaymentMethod("Transfer")
case "cash":
return config.PaymentMethod("Cash")
case "check":
return config.PaymentMethod("Cheque")
default:
return config.PaymentMethod("Card")
}
}

View file

@ -0,0 +1,146 @@
package payment
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"strings"
"time"
"github.com/rjNemo/rentease/internal/config"
"github.com/rjNemo/rentease/internal/driver/stripe"
"github.com/rjNemo/rentease/internal/service/booking"
)
// Payment fetches a payment by id.
func (ps Service) Payment(id int) (*Payment, error) {
p, err := ps.store.GetPayment(id)
if err != nil {
return nil, fmt.Errorf("fetch payment %d: %w", id, err)
}
return p, nil
}
// CreatePayment creates a manual payment for the provided booking id.
func (ps Service) CreatePayment(bid int, amount float64, paymentMethod string) (*Payment, error) {
p, err := ps.store.CreatePayment(&Payment{
BookingID: uint(bid),
Amount: amount,
PaymentMethod: config.PaymentMethod(paymentMethod),
})
if err != nil {
return nil, err
}
return p, nil
}
// UpdatePayment updates amount and method for the provided payment id.
func (ps Service) UpdatePayment(id int, amount float64, paymentMethod string) (*Payment, error) {
p, err := ps.store.UpdatePayment(id, amount, paymentMethod)
if err != nil {
return nil, err
}
return p, nil
}
// CreateStripePaymentLink generates a Stripe payment link for the outstanding balance of a booking.
func (ps Service) CreateStripePaymentLink(ctx context.Context, bookingID int) (string, error) {
if ps.stripe == nil {
return "", ErrStripeClientNotConfigured
}
b, err := ps.store.Get(bookingID)
if err != nil {
return "", err
}
if b == nil || b.ID == 0 {
return "", booking.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 := ps.stripe.CreatePaymentLink(ctx, stripe.CreatePaymentLinkParams{
Amount: outstanding,
Currency: "eur",
BookingID: uint(b.ID),
Description: description,
PaymentMethodTypes: []string{"card", "sepa_debit"},
})
if err != nil {
return "", err
}
return url, nil
}
// SyncStripePayments pulls Stripe payments within the provided time window and
// upserts them into the local datastore. Payments lacking booking metadata are skipped.
func (ps Service) SyncStripePayments(ctx context.Context, from, to time.Time) error {
if ps.stripe == nil {
return ErrStripeClientNotConfigured
}
payments, err := ps.stripe.ListPayments(ctx, stripe.ListPaymentsParams{From: from, To: to})
if err != nil {
return err
}
var multi error
for _, payment := range payments {
if payment.BookingID == nil {
ps.logger.Warn("stripe payment missing booking metadata", slog.String("payment_id", payment.ID))
continue
}
bookingID := uint(*payment.BookingID)
stripeID := payment.ID
status := strings.ToLower(payment.Status)
_, err = ps.store.UpsertStripePayment(&Payment{
BookingID: bookingID,
Amount: payment.Amount,
PaymentMethod: mapStripeMethod(payment.PaymentMethod),
StripePaymentID: &stripeID,
StripeStatus: &status,
})
if err != nil {
multi = errors.Join(multi, err)
ps.logger.Error("failed to upsert stripe payment", slog.String("payment_id", payment.ID), slog.Any("error", err))
}
}
return multi
}
func calculateOutstandingBalance(b *booking.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

@ -1,4 +1,4 @@
package booking
package payment
import (
"context"
@ -10,19 +10,20 @@ import (
"gorm.io/gorm"
"github.com/rjNemo/rentease/internal/config"
stripeclient "github.com/rjNemo/rentease/internal/driver/stripe"
"github.com/rjNemo/rentease/internal/driver/stripe"
"github.com/rjNemo/rentease/internal/service/booking"
)
type fakeStripeClient struct {
payments []stripeclient.Payment
payments []stripe.Payment
err error
}
func (f *fakeStripeClient) ListPayments(ctx context.Context, params stripeclient.ListPaymentsParams) ([]stripeclient.Payment, error) {
func (f *fakeStripeClient) ListPayments(ctx context.Context, params stripe.ListPaymentsParams) ([]stripe.Payment, error) {
return f.payments, f.err
}
func (f *fakeStripeClient) CreatePaymentLink(ctx context.Context, params stripeclient.CreatePaymentLinkParams) (string, error) {
func (f *fakeStripeClient) CreatePaymentLink(ctx context.Context, params stripe.CreatePaymentLinkParams) (string, error) {
return "", nil
}
@ -48,20 +49,7 @@ func (m *mockStore) record(p *Payment) (*Payment, error) {
return &cp, nil
}
func (m *mockStore) All() []*Line { return nil }
func (m *mockStore) Search(string) []*Line { return nil }
func (m *mockStore) List(time.Time, time.Time) ([]*Line, error) { return nil, nil }
func (m *mockStore) CardTotal(time.Time, time.Time) (float64, error) { return 0, nil }
func (m *mockStore) Get(int) (*Booking, error) { return nil, nil }
func (m *mockStore) Create(*Booking) error { return nil }
func (m *mockStore) Update(*Booking) error { return nil }
func (m *mockStore) Cancel(int) error { return nil }
func (m *mockStore) CreateItem(*Item) error { return nil }
func (m *mockStore) PayItem(int) (*Item, error) { return nil, nil }
func (m *mockStore) GetItem(int) (*Item, error) { return nil, nil }
func (m *mockStore) UpdateItem(int, string, string, string, int, float64) (*Item, error) {
return nil, nil
}
func (m *mockStore) Get(int) (*booking.Booking, error) { return nil, nil }
func (m *mockStore) CreatePayment(*Payment) (*Payment, error) { return nil, nil }
func (m *mockStore) GetPayment(int) (*Payment, error) { return nil, nil }
func (m *mockStore) UpdatePayment(int, float64, string) (*Payment, error) { return nil, nil }
@ -79,7 +67,7 @@ func (m *mockStore) FindStripePayment(id string) (*Payment, error) {
func TestSyncStripePayments(t *testing.T) {
bookingID := uint(42)
stripePayments := []stripeclient.Payment{
stripePayments := []stripe.Payment{
{
ID: "pi_123",
Amount: 120.50,
@ -93,7 +81,7 @@ func TestSyncStripePayments(t *testing.T) {
stripe := &fakeStripeClient{payments: stripePayments}
logger := slog.New(slog.DiscardHandler)
svc, err := NewService(logger, store, nil, nil, stripe)
svc, err := NewService(logger, store, stripe)
if err != nil {
t.Fatalf("NewService returned error: %v", err)
}
@ -119,7 +107,7 @@ func TestSyncStripePayments(t *testing.T) {
}
func TestSyncStripePaymentsSkipsMissingBooking(t *testing.T) {
stripePayments := []stripeclient.Payment{
stripePayments := []stripe.Payment{
{ID: "pi_123", Amount: 10},
}
@ -127,7 +115,7 @@ func TestSyncStripePaymentsSkipsMissingBooking(t *testing.T) {
stripe := &fakeStripeClient{payments: stripePayments}
logger := slog.New(slog.DiscardHandler)
svc, err := NewService(logger, store, nil, nil, stripe)
svc, err := NewService(logger, store, stripe)
if err != nil {
t.Fatalf("NewService returned error: %v", err)
}
@ -143,7 +131,7 @@ func TestSyncStripePaymentsSkipsMissingBooking(t *testing.T) {
func TestSyncStripePaymentsReturnsAggregatedError(t *testing.T) {
bookingID := uint(7)
stripePayments := []stripeclient.Payment{
stripePayments := []stripe.Payment{
{
ID: "pi_err",
Amount: 50,
@ -157,7 +145,7 @@ func TestSyncStripePaymentsReturnsAggregatedError(t *testing.T) {
stripe := &fakeStripeClient{payments: stripePayments}
logger := slog.New(slog.DiscardHandler)
svc, err := NewService(logger, store, nil, nil, stripe)
svc, err := NewService(logger, store, stripe)
if err != nil {
t.Fatalf("NewService returned error: %v", err)
}

View file

@ -1,4 +1,4 @@
package booking
package payment
import (
"context"
@ -14,7 +14,7 @@ import (
)
// HandlePaymentIntentSucceeded persists successful Stripe payment intents received via webhook.
func (bs Service) HandlePaymentIntentSucceeded(ctx context.Context, pi *stripe.PaymentIntent) error {
func (ps Service) HandlePaymentIntentSucceeded(ctx context.Context, pi *stripe.PaymentIntent) error {
if pi == nil {
return errors.New("payment intent payload is missing")
}
@ -25,7 +25,7 @@ func (bs Service) HandlePaymentIntentSucceeded(ctx context.Context, pi *stripe.P
}
if normalized.BookingID == nil {
bs.logger.Warn("stripe webhook payment missing booking metadata", slog.String("payment_intent", normalized.ID))
ps.logger.Warn("stripe webhook payment missing booking metadata", slog.String("payment_intent", normalized.ID))
return nil
}
@ -33,7 +33,7 @@ func (bs Service) HandlePaymentIntentSucceeded(ctx context.Context, pi *stripe.P
stripeID := normalized.ID
status := strings.ToLower(normalized.Status)
_, err := bs.store.UpsertStripePayment(&Payment{
_, err := ps.store.UpsertStripePayment(&Payment{
BookingID: bookingID,
Amount: normalized.Amount,
PaymentMethod: mapStripeMethod(normalized.PaymentMethod),
@ -44,25 +44,25 @@ func (bs Service) HandlePaymentIntentSucceeded(ctx context.Context, pi *stripe.P
return err
}
bs.logger.Info("stripe payment intent processed", slog.String("payment_intent", normalized.ID), slog.Int("booking_id", int(bookingID)))
ps.logger.Info("stripe payment intent processed", slog.String("payment_intent", normalized.ID), slog.Int("booking_id", int(bookingID)))
return nil
}
// HandleChargeRefunded updates an existing Stripe payment when a charge is refunded.
func (bs Service) HandleChargeRefunded(ctx context.Context, ch *stripe.Charge) error {
func (ps Service) HandleChargeRefunded(ctx context.Context, ch *stripe.Charge) error {
if ch == nil {
return errors.New("charge payload is missing")
}
if ch.PaymentIntent == nil || ch.PaymentIntent.ID == "" {
bs.logger.Warn("stripe refund missing payment intent", slog.String("charge", ch.ID))
ps.logger.Warn("stripe refund missing payment intent", slog.String("charge", ch.ID))
return nil
}
existing, err := bs.store.FindStripePayment(ch.PaymentIntent.ID)
existing, err := ps.store.FindStripePayment(ch.PaymentIntent.ID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
bs.logger.Warn("stripe refund received for unknown payment", slog.String("payment_intent", ch.PaymentIntent.ID))
ps.logger.Warn("stripe refund received for unknown payment", slog.String("payment_intent", ch.PaymentIntent.ID))
return nil
}
return err
@ -77,7 +77,7 @@ func (bs Service) HandleChargeRefunded(ctx context.Context, ch *stripe.Charge) e
status := "refunded"
stripeID := ch.PaymentIntent.ID
_, err = bs.store.UpsertStripePayment(&Payment{
_, err = ps.store.UpsertStripePayment(&Payment{
BookingID: existing.BookingID,
Amount: amount,
PaymentMethod: existing.PaymentMethod,
@ -88,6 +88,6 @@ func (bs Service) HandleChargeRefunded(ctx context.Context, ch *stripe.Charge) e
return err
}
bs.logger.Info("stripe charge refunded processed", slog.String("charge", ch.ID), slog.String("payment_intent", ch.PaymentIntent.ID))
ps.logger.Info("stripe charge refunded processed", slog.String("charge", ch.ID), slog.String("payment_intent", ch.PaymentIntent.ID))
return nil
}

View file

@ -1,4 +1,4 @@
package booking
package payment
import (
"context"
@ -23,7 +23,7 @@ func TestHandleChargeRefundedUpdatesAmount(t *testing.T) {
StripeStatus: &status,
})
svc, err := NewService(slog.New(slog.DiscardHandler), store, nil, nil, nil)
svc, err := NewService(slog.New(slog.DiscardHandler), store, nil)
if err != nil {
t.Fatalf("NewService returned error: %v", err)
}
@ -54,7 +54,7 @@ func TestHandleChargeRefundedUpdatesAmount(t *testing.T) {
func TestHandleChargeRefundedUnknownPayment(t *testing.T) {
store := &mockStore{}
svc, _ := NewService(slog.New(slog.DiscardHandler), store, nil, nil, nil)
svc, _ := NewService(slog.New(slog.DiscardHandler), store, nil)
charge := &stripe.Charge{PaymentIntent: &stripe.PaymentIntent{ID: "pi_missing"}}
@ -76,7 +76,7 @@ func TestHandleChargeRefundedStoreError(t *testing.T) {
})
store.err = errors.New("db error")
svc, _ := NewService(slog.New(slog.DiscardHandler), store, nil, nil, nil)
svc, _ := NewService(slog.New(slog.DiscardHandler), store, nil)
charge := &stripe.Charge{PaymentIntent: &stripe.PaymentIntent{ID: stripeID}}
if err := svc.HandleChargeRefunded(context.Background(), charge); err == nil {

View file

@ -0,0 +1,49 @@
package payment
import (
"context"
"log/slog"
"github.com/rjNemo/rentease/internal/driver/stripe"
"github.com/rjNemo/rentease/internal/service/booking"
)
// Payment re-exports the booking payment domain type until the models are split later.
type Payment = booking.Payment
// Store captures the datastore behaviour the payment service relies on.
type Store interface {
Get(id int) (*booking.Booking, error)
CreatePayment(p *booking.Payment) (*booking.Payment, error)
GetPayment(id int) (*booking.Payment, error)
UpdatePayment(id int, amount float64, paymentMethod string) (*booking.Payment, error)
UpsertStripePayment(p *booking.Payment) (*booking.Payment, error)
FindStripePayment(stripePaymentID string) (*booking.Payment, error)
}
// StripeClient exposes the subset of the Stripe driver used by the payment service.
type StripeClient interface {
ListPayments(ctx context.Context, params stripe.ListPaymentsParams) ([]stripe.Payment, error)
CreatePaymentLink(ctx context.Context, params stripe.CreatePaymentLinkParams) (string, error)
}
// Service handles payment flows such as manual payments and Stripe integrations.
type Service struct {
store Store
stripe StripeClient
logger *slog.Logger
}
// NewService builds a payment service with sane defaults for optional dependencies.
func NewService(logger *slog.Logger, store Store, stripe StripeClient) (*Service, error) {
l := logger
if l == nil {
l = slog.Default()
}
return &Service{
store: store,
stripe: stripe,
logger: l.With(slog.String("component", "payment_service")),
}, nil
}

11
main.go
View file

@ -20,6 +20,7 @@ import (
"github.com/rjNemo/rentease/internal/server"
"github.com/rjNemo/rentease/internal/service/auth"
"github.com/rjNemo/rentease/internal/service/booking"
"github.com/rjNemo/rentease/internal/service/payment"
)
func main() {
@ -72,7 +73,7 @@ func run(ctx context.Context, appConfig *config.Config, appLogger *slog.Logger)
parsingClient := parser.NewBookingAgentParser()
var stripeClient booking.StripeClient
var stripeClient payment.StripeClient
if appConfig.StripeSecretKey != "" {
opts := []stripeclient.Option{}
@ -83,11 +84,16 @@ func run(ctx context.Context, appConfig *config.Config, appLogger *slog.Logger)
stripeClient = client
}
bookingService, err := booking.NewService(appLogger, bookingStore, parsingClient, pc, stripeClient)
bookingService, err := booking.NewService(appLogger, bookingStore, parsingClient, pc)
if err != nil {
return fmt.Errorf("error creating booking service: %w", err)
}
paymentService, err := payment.NewService(appLogger, bookingStore, stripeClient)
if err != nil {
return fmt.Errorf("error creating payment service: %w", err)
}
// build authentication service
as, err := auth.NewService(
appConfig.SessionSecret,
@ -105,6 +111,7 @@ func run(ctx context.Context, appConfig *config.Config, appLogger *slog.Logger)
srv, err := server.New(
bookingService,
paymentService,
as,
config.NewHost(), // TODO: move to the database at some point
server.WithPort(port),

View file

@ -0,0 +1,228 @@
# Payment Service Extraction Implementation Plan
## Overview
Separate payment-related logic from the booking service into a dedicated payment service to improve cohesion, clarify responsibilities, and prepare for future payment features (Stripe sync, manual payments, refunds).
## Current State Analysis
- Booking service coordinates booking CRUD, manual payment creation, Stripe import, and webhook handling (`internal/service/booking/service.go:12`, `internal/service/booking/payment.go:8`).
- Repository store couples booking and payment persistence behind one interface (`internal/repository/booking/pg_store.go:18`).
- HTTP handlers, cron job, and Stripe webhook rely on booking service payment methods (`internal/server/handle_payments.go:14`, `internal/server/handle_stripe_webhook.go:13`, `internal/cron/job_stripe_sync.go:13`).
- Tests already cover Stripe sync and webhook flows under booking service namespace (`internal/service/booking/stripe_sync_test.go:14`, `internal/server/handle_stripe_webhook_test.go:15`).
## Desired End State
- Dedicated `payment.Service` encapsulates manual payments, Stripe sync, and webhook handling.
- Booking service exposes only booking-specific behaviour, delegating payment tasks via the new service.
- Server/cron wiring injects payment service alongside booking service.
- Tests and documentation reflect the new separation.
### Key Discoveries
- `booking.Service` constructor wires parser/pdf/stripe clients, making payment-only scenarios pass `nil` (`internal/service/booking/service.go:40`).
- `PgStore`s payment helpers already operate on `booking.Payment` records without booking-specific context (`internal/repository/booking/pg_store.go:150`).
- Server routes mount payment endpoints with booking service dependency injection (`internal/server/routes.go:30`).
## Out of Scope
- Database schema changes beyond necessary constructor renaming/ref wiring.
- UI redesign of payment components.
- Feature additions beyond reorganising existing behaviour.
## Implementation Approach
Incrementally extract payment logic while preserving behaviour: first introduce payment service and rehome logic, then switch entrypoints, and finally colocate tests/doc updates.
## Phase 1: Introduce Payment Service Layer
### Overview
Create a dedicated payment service and repository abstraction while keeping booking service focused on bookings.
### Changes Required
**File**: `internal/service/payment/service.go`
**Changes**: Add new payment service struct with dependencies (`store`, `stripe`, `logger`) and move manual payment CRUD + Stripe helpers.
**File**: `internal/service/payment/types.go`
**Changes**: Define interfaces (Store, StripeClient) mirroring existing booking counterparts; re-export `booking.Payment` until domain split later.
**File**: `internal/service/booking/service.go`
**Changes**: Remove payment-related fields (store methods, stripe client) and adjust constructor signature to drop payment deps.
**File**: `internal/service/booking/payment.go`
**Changes**: Delete or convert into thin wrappers invoking payment service (ultimately remove file).
**File**: `internal/service/booking/stripe_sync.go`
**Changes**: Move functionality into payment service; leave shim returning `booking.ErrStripeClientNotConfigured` if needed until call sites migrate.
**File**: `internal/service/booking/stripe_webhook.go`
**Changes**: Move to payment service with minimal adjustments (reuse mapStripeMethod helper via shared utility or move altogether).
**File**: `internal/service/payment/map.go`
**Changes**: Host Stripe method normalisation logic reused by sync/webhook.
**File**: `internal/service/payment/errors.go`
**Changes**: Rehome `ErrStripeClientNotConfigured`.
**File**: `internal/repository/payment/store.go`
**Changes**: Introduce payment repository interface implemented by existing PG store.
**File**: `internal/repository/booking/pg_store.go`
**Changes**: Implement new payment store interface while keeping booking-related methods.
**File**: `go.mod`
**Changes**: Update module references only if new packages require (likely no change).
### Success Criteria
#### Automated Verification
- [x] `go test ./internal/service/payment/...`
- [x] `go test ./internal/repository/...`
#### Manual Verification
- [x] Payment service constructor handles nil optional dependencies gracefully.
## Phase 2: Rewire Dependency Injection & Interfaces
### Overview
Inject payment service into HTTP server and cron job; adjust interfaces accordingly.
### Changes Required
**File**: `main.go`
**Changes**: Construct `payment.Service`, pass to server options; update booking service creation with new signature.
**File**: `internal/server/server.go`
**Changes**: Add payment service field; adjust constructor params/options.
**File**: `internal/server/routes.go`
**Changes**: Swap booking service usage for payment service in payment + Stripe routes; update API sync handler if necessary.
**File**: `internal/server/handle_payments.go`
**Changes**: Replace `booking.Service` dependency with `payment.Service` interface (new `PaymentService` interface capturing needed methods).
**File**: `internal/server/handle_bookings.go`
**Changes**: Adjust `paymentViewModelFromBookingPayment` to use `payment` types if moved; delegate payment updates through payment service.
**File**: `internal/server/handle_stripe_sync.go`
**Changes**: Accept payment service sync interface.
**File**: `internal/server/handle_stripe_webhook.go`
**Changes**: Inject payment service event handlers.
**File**: `internal/server/option.go`
**Changes**: Add option for payment service injection.
**File**: `cmd/cron/main.go`
**Changes**: Pass payment service job or separate constructor.
**File**: `internal/cron/job_stripe_sync.go`
**Changes**: Construct payment service instead of booking service; update logging context.
### Success Criteria
#### Automated Verification
- [x] `go test ./internal/server/...`
- [x] `go test ./cmd/cron/...`
#### Manual Verification
- [ ] Application starts without DI errors (`go run .`).
## Phase 3: Consolidate Stripe Sync/Webhook Logic Under Payment Service
### Overview
Ensure Stripe integrations use new service and repository, maintaining behaviour.
### Changes Required
**File**: `internal/service/payment/stripe_sync.go`
**Changes**: Move sync logic, adjust to new interfaces.
**File**: `internal/service/payment/stripe_webhook.go`
**Changes**: Host webhook handlers and reuse normalisation helpers.
**File**: `internal/service/payment/errors_test.go`
**Changes**: Add unit tests for error propagation, ensuring aggregated error handling preserved.
**File**: `internal/driver/stripe/client.go`
**Changes**: Update imports if package paths change; no functional modifications expected.
### Success Criteria
#### Automated Verification
- [x] `go test ./internal/service/payment -run Stripe`
#### Manual Verification
- [ ] Stripe webhook handler still logs/returns identical responses (simulate via existing tests).
## Phase 4: Move & Update Tests, Docs, and Views
### Overview
Relocate existing tests to the new service and clean up references; ensure docs and views compile.
### Changes Required
**File**: `internal/service/payment/stripe_sync_test.go`
**Changes**: Move current booking tests here; update package reference.
**File**: `internal/server/handle_payments_test.go`
**Changes**: Add/adjust tests if necessary to cover new payment service interface (create if missing).
**File**: `internal/service/booking/stripe_sync_test.go`
**Changes**: Remove or convert to thin wrappers if needed.
**File**: `internal/server/handle_stripe_webhook_test.go`
**Changes**: Update mocks to use payment service interface.
**File**: `README.md`
**Changes**: Reflect new architecture (mention payment service).
**File**: `docs/architecture.md` (if exists)
**Changes**: Update diagrams/descriptions; if absent, skip.
**File**: `internal/view/payment.templ`
**Changes**: Ensure imports compile referencing new payment view models.
### Success Criteria
#### Automated Verification
- [ ] `go test ./...` (or targeted packages excluding network-bound tests).
- [ ] `templ generate` (if required after view adjustments).
#### Manual Verification
- [ ] UI still displays payment list and badge with real data.
## Testing Strategy
- Run focused unit tests for payment service and server handlers (`go test ./internal/service/payment ./internal/server`).
- Execute integration tests for cron job if feasible (`go test ./internal/cron`).
- Run `templ generate` + `go build ./...` to ensure templates compile after refactor.
- Document skipping of network-dependent tests (`internal/driver/parser/parser_test.go`) when running full suite.
## Performance Considerations
- Payment service introduces no new database queries; ensure repository reuse avoids duplicate fetches.
- Confirm Stripe sync maintains pagination behaviour (existing list iterator already handles this).
## Migration Notes
- No schema changes; ensure auto-migrate still runs for payment model when payment service initialises.
- Update any environment variable references if moved (e.g., Stripe keys remain unchanged).
## References
- Research: `thoughts/shared/research/2025-10-03-stripe-payment-sync.md`
- Similar pattern: `internal/service/auth/service.go` for dedicated service struct.

View file

@ -7,7 +7,7 @@ repository: rentease
topic: "Stripe payment ingestion & webhook strategy"
tags: [research, payments, stripe]
status: complete
last_updated: 2025-10-03
last_updated: 2025-10-05
last_updated_by: Codex
---
@ -20,29 +20,24 @@ manual double-entry with webhook-driven updates?
## Summary
Rentease currently captures payments through a manual UI that posts to `/payments/:id`,
persisting records with only amount and method metadata. Reporting features depend
on these records, especially for card totals.
There is no existing Stripe integration—configuration lacks API credentials,
and no driver or service encapsulates Stripe access. Extending the system will
require new infrastructure for Stripe clients, data models to track external IDs,
background or on-demand sync routines, and unauthenticated webhook endpoints secured
by Stripe signatures. Key integration hooks include the booking service
(for upserting payments), the repository layer (for queries and uniqueness), the
server router (to host webhooks), and the cron command (for scheduled backfills).
The implementation relies on `github.com/stripe/stripe-go/v83` (currently pinned at v83.0.0).
Rentease captures payments through a manual UI that posts to `/payments/:id`, with
records now persisted by a dedicated payment service in `internal/service/payment`.
Reporting features still depend on these rows—especially for card totals—so Stripe
imports share the same storage path. Stripe connectivity is also centralised in
that payment service, while configuration continues to supply the API/ webhook
secrets and `main.go` wires the Stripe client.
## Detailed Findings
### Manual Payment Entry Flow
- Booking detail page exposes a modal that posts amount and method to the server (`internal/view/booking_by_id.templ:52`, `internal/view/booking_by_id.templ:130`).
- `/payments/:id` handler binds the form payload, calls `booking.Service.CreatePayment`, and re-renders the payment list (`internal/server/handle_payments.go:16`, permalink: <https://github.com/rjNemo/rentease/blob/ac94faedb0b491d710c36b04ac27b26890f4d062/internal/server/handle_payments.go#L16-L51>).
- `/payments/:id` handler binds the form payload, delegates to the payment service for creation, and re-renders the payment list (`internal/server/handle_payments.go`).
- Booking page view model preloads payments to display them alongside line items (`internal/server/handle_bookings.go:130`, permalink: <https://github.com/rjNemo/rentease/blob/ac94faedb0b491d710c36b04ac27b26890f4d062/internal/server/handle_bookings.go#L130-L179>).
### Payment Persistence & Reporting
- `booking.Service.CreatePayment` wraps repository insertions without de-duplication (`internal/service/booking/payment.go:17`, permalink: <https://github.com/rjNemo/rentease/blob/ac94faedb0b491d710c36b04ac27b26890f4d062/internal/service/booking/payment.go#L17-L35>).
- `payment.Service.CreatePayment` wraps repository inserts without additional de-duplication (`internal/service/payment/service.go`).
- `PgStore.CreatePayment` writes plain `amount` and `payment_method`; schema lacks external references for Stripe IDs (`internal/repository/booking/pg_store.go:150`, permalink: <https://github.com/rjNemo/rentease/blob/ac94faedb0b491d710c36b04ac27b26890f4d062/internal/repository/booking/pg_store.go#L150-L174>).
- Reports compute card totals by summing `payments` joined to bookings, so Stripe-imported transactions must populate this table to keep analytics accurate (`internal/repository/booking/pg_store.go:65`, `internal/service/booking/report.go:76`).
@ -65,14 +60,14 @@ The implementation relies on `github.com/stripe/stripe-go/v83` (currently pinned
## Code References
- `internal/server/handle_payments.go:16` manual payment creation flow (perm: <https://github.com/rjNemo/rentease/blob/ac94faedb0b491d710c36b04ac27b26890f4d062/internal/server/handle_payments.go#L16-L51>)
- `internal/service/booking/payment.go:17` service layer inserts payments (perm: <https://github.com/rjNemo/rentease/blob/ac94faedb0b491d710c36b04ac27b26890f4d062/internal/service/booking/payment.go#L17-L35>)
- `internal/service/payment/service.go` service layer inserts payments and delegates to the repository.
- `internal/repository/booking/pg_store.go:65` card totals depend on stored payments (perm: <https://github.com/rjNemo/rentease/blob/ac94faedb0b491d710c36b04ac27b26890f4d062/internal/repository/booking/pg_store.go#L65-L174>)
- `internal/server/routes.go:8` current route layout and auth groups (perm: <https://github.com/rjNemo/rentease/blob/ac94faedb0b491d710c36b04ac27b26890f4d062/internal/server/routes.go#L8-L46>)
- `cmd/cron/main.go:13` cron entrypoint for scheduled jobs (perm: <https://github.com/rjNemo/rentease/blob/ac94faedb0b491d710c36b04ac27b26890f4d062/cmd/cron/main.go#L13-L44>)
## Architecture Insights
Rentease centralises business logic in `booking.Service`, with storage handled through a repository abstraction and UI forms using htmx requests. External integrations live in `internal/driver` and are injected via `main.go`. Payment records are simple and lack idempotency safeguards, so any Stripe sync must extend the schema and service methods to avoid duplicates, enforce foreign keys, and reconcile amounts with existing booking items.
Rentease now splits business logic across `booking.Service` and `payment.Service`, with storage handled through a repository abstraction and UI forms using htmx requests. External integrations live in `internal/driver` and are injected via `main.go`. Payment records are simple and lack idempotency safeguards, so any Stripe sync must extend the schema and service methods to avoid duplicates, enforce foreign keys, and reconcile amounts with existing booking items.
## Historical Context (from ./thoughts/)