diff --git a/Makefile b/Makefile index 234d824..155b905 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index aac68a4..3ed8334 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..e87fc4a --- /dev/null +++ b/docker-compose.dev.yml @@ -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: diff --git a/go.mod b/go.mod index 93e7008..c60fcc6 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 86d62cf..31bc023 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/cron/job_report.go b/internal/cron/job_report.go index 71ac2c7..537f107 100644 --- a/internal/cron/job_report.go +++ b/internal/cron/job_report.go @@ -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) } diff --git a/internal/cron/job_stripe_sync.go b/internal/cron/job_stripe_sync.go index 3ff25ad..4dada0c 100644 --- a/internal/cron/job_stripe_sync.go +++ b/internal/cron/job_stripe_sync.go @@ -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() diff --git a/internal/server/handle_bookings.go b/internal/server/handle_bookings.go index 1120038..ffd63de 100644 --- a/internal/server/handle_bookings.go +++ b/internal/server/handle_bookings.go @@ -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") diff --git a/internal/server/handle_payments.go b/internal/server/handle_payments.go index 7417fe7..ff371e4 100644 --- a/internal/server/handle_payments.go +++ b/internal/server/handle_payments.go @@ -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) + } + } +} diff --git a/internal/server/handle_stripe_sync.go b/internal/server/handle_stripe_sync.go index dcaf11c..c529a07 100644 --- a/internal/server/handle_stripe_sync.go +++ b/internal/server/handle_stripe_sync.go @@ -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 } diff --git a/internal/server/handle_stripe_webhook_test.go b/internal/server/handle_stripe_webhook_test.go index 9e9f038..8472bda 100644 --- a/internal/server/handle_stripe_webhook_test.go +++ b/internal/server/handle_stripe_webhook_test.go @@ -2,9 +2,7 @@ package server import ( "context" - "encoding/hex" "encoding/json" - "fmt" "net/http" "net/http/httptest" "strings" diff --git a/internal/server/routes.go b/internal/server/routes.go index 4bfd4b8..d31784c 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -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)) }) } diff --git a/internal/server/server.go b/internal/server/server.go index 745ff5f..ee8c5f5 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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), diff --git a/internal/service/booking/errors.go b/internal/service/booking/errors.go new file mode 100644 index 0000000..8f32598 --- /dev/null +++ b/internal/service/booking/errors.go @@ -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") diff --git a/internal/service/booking/payment.go b/internal/service/booking/payment.go deleted file mode 100644 index 2529e04..0000000 --- a/internal/service/booking/payment.go +++ /dev/null @@ -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 -} diff --git a/internal/service/booking/service.go b/internal/service/booking/service.go index 46ee888..e16fc61 100644 --- a/internal/service/booking/service.go +++ b/internal/service/booking/service.go @@ -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 } diff --git a/internal/service/booking/stripe_payment_link.go b/internal/service/booking/stripe_payment_link.go deleted file mode 100644 index 05a758c..0000000 --- a/internal/service/booking/stripe_payment_link.go +++ /dev/null @@ -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 -} diff --git a/internal/service/booking/stripe_sync.go b/internal/service/booking/stripe_sync.go deleted file mode 100644 index 9b5a6e7..0000000 --- a/internal/service/booking/stripe_sync.go +++ /dev/null @@ -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") - } -} diff --git a/internal/service/payment/errors.go b/internal/service/payment/errors.go new file mode 100644 index 0000000..c2c4bd7 --- /dev/null +++ b/internal/service/payment/errors.go @@ -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") diff --git a/internal/service/payment/map.go b/internal/service/payment/map.go new file mode 100644 index 0000000..26c3a27 --- /dev/null +++ b/internal/service/payment/map.go @@ -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") + } +} diff --git a/internal/service/payment/service.go b/internal/service/payment/service.go new file mode 100644 index 0000000..927863b --- /dev/null +++ b/internal/service/payment/service.go @@ -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 +} diff --git a/internal/service/booking/stripe_sync_test.go b/internal/service/payment/stripe_sync_test.go similarity index 69% rename from internal/service/booking/stripe_sync_test.go rename to internal/service/payment/stripe_sync_test.go index dc270ad..e2aea47 100644 --- a/internal/service/booking/stripe_sync_test.go +++ b/internal/service/payment/stripe_sync_test.go @@ -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) } diff --git a/internal/service/booking/stripe_webhook.go b/internal/service/payment/stripe_webhook.go similarity index 75% rename from internal/service/booking/stripe_webhook.go rename to internal/service/payment/stripe_webhook.go index 6c30028..1f4f09d 100644 --- a/internal/service/booking/stripe_webhook.go +++ b/internal/service/payment/stripe_webhook.go @@ -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 } diff --git a/internal/service/booking/stripe_webhook_test.go b/internal/service/payment/stripe_webhook_test.go similarity index 92% rename from internal/service/booking/stripe_webhook_test.go rename to internal/service/payment/stripe_webhook_test.go index c9a89bc..2260e9b 100644 --- a/internal/service/booking/stripe_webhook_test.go +++ b/internal/service/payment/stripe_webhook_test.go @@ -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 { diff --git a/internal/service/payment/types.go b/internal/service/payment/types.go new file mode 100644 index 0000000..e45373d --- /dev/null +++ b/internal/service/payment/types.go @@ -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 +} diff --git a/main.go b/main.go index 215cc1a..b1789c1 100644 --- a/main.go +++ b/main.go @@ -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), diff --git a/thoughts/shared/plans/2025-10-05-payment-service-extraction.md b/thoughts/shared/plans/2025-10-05-payment-service-extraction.md new file mode 100644 index 0000000..1d00ab2 --- /dev/null +++ b/thoughts/shared/plans/2025-10-05-payment-service-extraction.md @@ -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. diff --git a/thoughts/shared/research/2025-10-03-stripe-payment-sync.md b/thoughts/shared/research/2025-10-03-stripe-payment-sync.md index 1a80f01..6592ed9 100644 --- a/thoughts/shared/research/2025-10-03-stripe-payment-sync.md +++ b/thoughts/shared/research/2025-10-03-stripe-payment-sync.md @@ -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: ). +- `/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: ). ### Payment Persistence & Reporting -- `booking.Service.CreatePayment` wraps repository insertions without de-duplication (`internal/service/booking/payment.go:17`, permalink: ). +- `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: ). - 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: ) -- `internal/service/booking/payment.go:17` – service layer inserts payments (perm: ) +- `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: ) - `internal/server/routes.go:8` – current route layout and auth groups (perm: ) - `cmd/cron/main.go:13` – cron entrypoint for scheduled jobs (perm: ) ## 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/)