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 - name: Checkout
uses: actions/checkout@v4 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 - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3

View file

@ -3,6 +3,7 @@ NAME ?= rentease
PORT ?= 8000 PORT ?= 8000
DB_USER ?= ruidy DB_USER ?= ruidy
DB_NAME ?= villafleurie 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) 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)) run: build ## Run the production container (port $(PORT))
docker run -p $(PORT):$(PORT) $(DOCKER_RUN_ENV) $(NAME) docker run -p $(PORT):$(PORT) $(DOCKER_RUN_ENV) $(NAME)
dev: ## Build and run the dev container with live reload (Air) dev: ## Start the local dev stack via docker compose
docker build -t $(NAME):dev -f Dockerfile.dev . docker compose -f $(DEV_COMPOSE) up --build
docker run -p $(PORT):$(PORT) --rm \
-v `pwd`:/app -v /app/tmp \
--name $(NAME) \
$(DOCKER_RUN_ENV) $(NAME):dev
test: ## Run Go tests inside the running dev container test: ## Run Go tests inside the running dev container
go test ./... go test ./...
@ -38,5 +35,5 @@ format: ## Generate templ files and format code locally
lint: ## Lint the code using golangci-lint locally lint: ## Lint the code using golangci-lint locally
golangci-lint run ./... golangci-lint run ./...
stop: ## Stop the dev container stop: ## Stop the dev stack
-@docker stop $(NAME) >/dev/null 2>&1 || true -@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 - [PostgreSQL](https://www.postgresql.org/):The database used to store all application
data. 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 ## Roadmap
See the [open issues](https://github.com/users/rjNemo/projects/2/views/1) for a full 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 module github.com/rjNemo/rentease
go 1.25.3 go 1.25.4
require ( require (
github.com/a-h/templ v0.3.960 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/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
github.com/gorilla/sessions v1.4.0 github.com/gorilla/sessions v1.4.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/rjNemo/underscore v0.8.0 github.com/rjNemo/underscore v0.10.0
github.com/stripe/stripe-go/v83 v83.1.0 github.com/stripe/stripe-go/v83 v83.2.1
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.0 gorm.io/gorm v1.31.1
) )
require ( require (
@ -33,9 +33,8 @@ require (
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/openai/openai-go v1.12.0 github.com/openai/openai-go v1.12.0
github.com/sethvargo/go-envconfig v1.3.0 github.com/sethvargo/go-envconfig v1.3.0
golang.org/x/crypto v0.43.0 // indirect golang.org/x/crypto v0.44.0 // indirect
golang.org/x/net v0.46.0 // indirect golang.org/x/sync v0.18.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect
golang.org/x/text v0.30.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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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.38.0 h1:S8Xui7gLeAvXINVLMOaX94HnsDf1GexnfXGSNC4+KQs=
github.com/getsentry/sentry-go v0.36.2/go.mod h1:p5Im24mJBeruET8Q4bbcMfCQ+F+Iadc4L48tB1apo2c= 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 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 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= 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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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.10.0 h1:f0WTiHXujG9mgbEt51VH06TqLMS5n4EUKtp5wzhBqQM=
github.com/rjNemo/underscore v0.8.0/go.mod h1:DVEYEX2dnZR79HfleURBPFE9paQxuGl6RlwXV+/szWY= 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 h1:gJs+Fuv8+f05omTpwWIu6KmuseFAXKrIaOZSh8RMt0U=
github.com/sethvargo/go-envconfig v1.3.0/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw= 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= 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.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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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.2.1 h1:8WPhpMjr8VyMWKUsCMoVvlWxYazuL5edajKX/RulfbA=
github.com/stripe/stripe-go/v83 v83.1.0/go.mod h1:nRyDcLrJtwPPQUnKAFs9Bt1NnQvNhNiF6V19XHmPISE= 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.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 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= 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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 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.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= 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 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= 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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= 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/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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= 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.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View file

@ -7,11 +7,12 @@ import (
"time" "time"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"github.com/rjNemo/rentease/internal/driver/pdf" "github.com/rjNemo/rentease/internal/driver/pdf"
"github.com/rjNemo/rentease/internal/repository/booking" "github.com/rjNemo/rentease/internal/repository/booking"
bookingService "github.com/rjNemo/rentease/internal/service/booking" bookingService "github.com/rjNemo/rentease/internal/service/booking"
"gorm.io/driver/postgres"
"gorm.io/gorm"
) )
func JobMonthlyBookingReport() error { func JobMonthlyBookingReport() error {
@ -29,7 +30,7 @@ func JobMonthlyBookingReport() error {
} }
store := booking.NewPgStore(db) store := booking.NewPgStore(db)
service, err := bookingService.NewService(nil, store, nil, ps, nil) service, err := bookingService.NewService(nil, store, nil, ps)
if err != nil { if err != nil {
return fmt.Errorf("error creating booking service: %w", err) 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/config"
"github.com/rjNemo/rentease/internal/driver/database" "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" "github.com/rjNemo/rentease/internal/repository/booking"
bookingservice "github.com/rjNemo/rentease/internal/service/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 // JobStripePaymentSync synchronises Stripe payments for the last 24 hours. It is
@ -40,17 +41,17 @@ func JobStripePaymentSync() error {
store := booking.NewPgStore(db) 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 { if err != nil {
return fmt.Errorf("error creating stripe client: %w", err) return fmt.Errorf("error creating stripe client: %w", err)
} }
logger := slog.Default() logger := slog.Default()
service, err := bookingservice.NewService(logger, store, nil, nil, client) service, err := payment.NewService(logger, store, client)
if err != nil { 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() to := time.Now().UTC()

View file

@ -20,6 +20,7 @@ import (
"github.com/rjNemo/rentease/internal/config" "github.com/rjNemo/rentease/internal/config"
"github.com/rjNemo/rentease/internal/constant" "github.com/rjNemo/rentease/internal/constant"
"github.com/rjNemo/rentease/internal/service/booking" "github.com/rjNemo/rentease/internal/service/booking"
"github.com/rjNemo/rentease/internal/service/payment"
"github.com/rjNemo/rentease/internal/view" "github.com/rjNemo/rentease/internal/view"
myTime "github.com/rjNemo/rentease/pkg/time" 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) { return func(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id") idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr) id, err := strconv.Atoi(idStr)
@ -243,14 +244,14 @@ func handleBookingStripePaymentLink(bs *booking.Service) http.HandlerFunc {
return return
} }
url, err := bs.CreateStripePaymentLink(r.Context(), id) url, err := ps.CreateStripePaymentLink(r.Context(), id)
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, booking.ErrStripeClientNotConfigured): case errors.Is(err, payment.ErrStripeClientNotConfigured):
http.Error(w, "stripe is not configured", http.StatusBadRequest) http.Error(w, "stripe is not configured", http.StatusBadRequest)
case errors.Is(err, booking.ErrBookingNotFound): case errors.Is(err, booking.ErrBookingNotFound):
http.Error(w, "booking not found", http.StatusNotFound) 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) http.Error(w, "booking has no outstanding balance", http.StatusBadRequest)
default: default:
http.Error(w, fmt.Sprintf("failed to create payment link: %v", err), http.StatusInternalServerError) 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 { func handleBookingCancel(bs *booking.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id") idStr := chi.URLParam(r, "id")

View file

@ -9,10 +9,11 @@ import (
"github.com/rjNemo/rentease/internal/config" "github.com/rjNemo/rentease/internal/config"
"github.com/rjNemo/rentease/internal/service/booking" "github.com/rjNemo/rentease/internal/service/booking"
"github.com/rjNemo/rentease/internal/service/payment"
"github.com/rjNemo/rentease/internal/view" "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) { return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
@ -39,7 +40,7 @@ func handleCreatePayment(bs *booking.Service, hc *config.Host) http.HandlerFunc
return 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) http.Error(w, err.Error(), http.StatusInternalServerError)
return 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) { return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id")) id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil { if err != nil {
@ -69,10 +70,49 @@ func handlePaymentForm(bs *booking.Service, hc *config.Host) http.HandlerFunc {
return 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)) form := view.PaymentForm(paymentViewModelFromBookingPayment(*p, hc.StripeAccountID))
if err := renderTempl(w, http.StatusOK, form); err != nil { if err := renderTempl(w, http.StatusOK, form); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} }
} }
} }
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" "net/http"
"time" "time"
"github.com/rjNemo/rentease/internal/service/booking" "github.com/rjNemo/rentease/internal/service/payment"
) )
type stripeSyncRequest struct { type stripeSyncRequest struct {
@ -53,7 +53,7 @@ func handleStripeSync(bs stripeSyncer) http.HandlerFunc {
} }
if err := bs.SyncStripePayments(r.Context(), from, to); err != nil { 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) http.Error(w, "stripe client not configured", http.StatusServiceUnavailable)
return return
} }

View file

@ -12,14 +12,14 @@ func (s *Server) MountHandlers() {
s.Router.Get("/healthz", handleHealthCheck()) s.Router.Get("/healthz", handleHealthCheck())
s.Router.Get("/", handleLoginPage()) s.Router.Get("/", handleLoginPage())
s.Router.Post("/", handleLogin(s.as)) 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) { s.Router.Route("/api", func(r chi.Router) {
r.Use(apiKeyMiddleware(s.as)) r.Use(apiKeyMiddleware(s.as))
r.Post("/sync", handleSync(s.bs)) r.Post("/sync", handleSync(s.bs))
r.Get("/bookings", handleBookingList(s.bs)) r.Get("/bookings", handleBookingList(s.bs))
r.Post("/bookings", handleCreateBooking(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) { s.Router.Group(func(r chi.Router) {
@ -29,7 +29,7 @@ func (s *Server) MountHandlers() {
r.Get("/bookings/new", handleBookingCreatePage(s.hc)) r.Get("/bookings/new", handleBookingCreatePage(s.hc))
r.Post("/bookings/new", handleBookingCreate(s.bs)) r.Post("/bookings/new", handleBookingCreate(s.bs))
r.Get("/bookings/{id}", handleBookingPage(s.bs, s.hc)) 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.Put("/bookings/{id}", handleBookingUpdate(s.bs, s.hc))
r.Patch("/bookings/{id}/cancel", handleBookingCancel(s.bs)) r.Patch("/bookings/{id}/cancel", handleBookingCancel(s.bs))
r.Post("/bookings/{id}/items", handleCreateItem(s.bs, s.hc)) 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/do", handleReportCompute(s.bs, s.hc))
r.Get("/reports/pdf", handlePdfCreateReport(s.bs)) r.Get("/reports/pdf", handlePdfCreateReport(s.bs))
r.Post("/payments/{id}", handleCreatePayment(s.bs, s.hc)) r.Post("/payments/{id}", handleCreatePayment(s.bs, s.ps, s.hc))
r.Put("/payments/{id}", handlePaymentUpdate(s.bs, s.hc)) r.Put("/payments/{id}", handlePaymentUpdate(s.ps, s.hc))
r.Get("/payments/{id}", handlePaymentForm(s.bs, 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/config"
"github.com/rjNemo/rentease/internal/service/auth" "github.com/rjNemo/rentease/internal/service/auth"
"github.com/rjNemo/rentease/internal/service/booking" "github.com/rjNemo/rentease/internal/service/booking"
"github.com/rjNemo/rentease/internal/service/payment"
) )
type Server struct { type Server struct {
Router *chi.Mux Router *chi.Mux
httpServer *http.Server httpServer *http.Server
bs *booking.Service bs *booking.Service
ps *payment.Service
as *auth.Service as *auth.Service
hc *config.Host hc *config.Host
addr string addr string
stripeWebhookSecret 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) option := new(options)
for _, opt := range opts { for _, opt := range opts {
if err := opt(option); err != nil { 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{ s := &Server{
Router: router, Router: router,
bs: bs, bs: bs,
ps: ps,
as: as, as: as,
hc: hc, hc: hc,
addr: fmt.Sprintf("0.0.0.0:%d", *option.port), 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 package booking
import ( import (
"context"
"errors" "errors"
"log/slog" "log/slog"
"time" "time"
@ -9,7 +8,6 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"github.com/rjNemo/rentease/internal/config" "github.com/rjNemo/rentease/internal/config"
stripeclient "github.com/rjNemo/rentease/internal/driver/stripe"
) )
type Store interface { type Store interface {
@ -27,18 +25,6 @@ type Store interface {
PayItem(id int) (*Item, error) PayItem(id int) (*Item, error)
GetItem(id int) (*Item, error) GetItem(id int) (*Item, error)
UpdateItem(id int, item string, paymentMethod string, paymentStatus string, qty int, price float64) (*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 { type PdfClient interface {
@ -59,10 +45,9 @@ type Service struct {
parser parserClient parser parserClient
pdf PdfClient pdf PdfClient
logger *slog.Logger 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 svcLogger := logger
if svcLogger == nil { if svcLogger == nil {
svcLogger = slog.Default() svcLogger = slog.Default()
@ -73,7 +58,6 @@ func NewService(logger *slog.Logger, store Store, parser parserClient, pdf PdfCl
store: store, store: store,
parser: parser, parser: parser,
pdf: pdf, pdf: pdf,
stripe: stripe,
}, nil }, 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 ( import (
"context" "context"
@ -10,19 +10,20 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"github.com/rjNemo/rentease/internal/config" "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 { type fakeStripeClient struct {
payments []stripeclient.Payment payments []stripe.Payment
err error 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 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 return "", nil
} }
@ -48,20 +49,7 @@ func (m *mockStore) record(p *Payment) (*Payment, error) {
return &cp, nil return &cp, nil
} }
func (m *mockStore) All() []*Line { return nil } func (m *mockStore) Get(int) (*booking.Booking, error) { return nil, 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) CreatePayment(*Payment) (*Payment, 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) GetPayment(int) (*Payment, error) { return nil, nil }
func (m *mockStore) UpdatePayment(int, float64, string) (*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) { func TestSyncStripePayments(t *testing.T) {
bookingID := uint(42) bookingID := uint(42)
stripePayments := []stripeclient.Payment{ stripePayments := []stripe.Payment{
{ {
ID: "pi_123", ID: "pi_123",
Amount: 120.50, Amount: 120.50,
@ -93,7 +81,7 @@ func TestSyncStripePayments(t *testing.T) {
stripe := &fakeStripeClient{payments: stripePayments} stripe := &fakeStripeClient{payments: stripePayments}
logger := slog.New(slog.DiscardHandler) logger := slog.New(slog.DiscardHandler)
svc, err := NewService(logger, store, nil, nil, stripe) svc, err := NewService(logger, store, stripe)
if err != nil { if err != nil {
t.Fatalf("NewService returned error: %v", err) t.Fatalf("NewService returned error: %v", err)
} }
@ -119,7 +107,7 @@ func TestSyncStripePayments(t *testing.T) {
} }
func TestSyncStripePaymentsSkipsMissingBooking(t *testing.T) { func TestSyncStripePaymentsSkipsMissingBooking(t *testing.T) {
stripePayments := []stripeclient.Payment{ stripePayments := []stripe.Payment{
{ID: "pi_123", Amount: 10}, {ID: "pi_123", Amount: 10},
} }
@ -127,7 +115,7 @@ func TestSyncStripePaymentsSkipsMissingBooking(t *testing.T) {
stripe := &fakeStripeClient{payments: stripePayments} stripe := &fakeStripeClient{payments: stripePayments}
logger := slog.New(slog.DiscardHandler) logger := slog.New(slog.DiscardHandler)
svc, err := NewService(logger, store, nil, nil, stripe) svc, err := NewService(logger, store, stripe)
if err != nil { if err != nil {
t.Fatalf("NewService returned error: %v", err) t.Fatalf("NewService returned error: %v", err)
} }
@ -143,7 +131,7 @@ func TestSyncStripePaymentsSkipsMissingBooking(t *testing.T) {
func TestSyncStripePaymentsReturnsAggregatedError(t *testing.T) { func TestSyncStripePaymentsReturnsAggregatedError(t *testing.T) {
bookingID := uint(7) bookingID := uint(7)
stripePayments := []stripeclient.Payment{ stripePayments := []stripe.Payment{
{ {
ID: "pi_err", ID: "pi_err",
Amount: 50, Amount: 50,
@ -157,7 +145,7 @@ func TestSyncStripePaymentsReturnsAggregatedError(t *testing.T) {
stripe := &fakeStripeClient{payments: stripePayments} stripe := &fakeStripeClient{payments: stripePayments}
logger := slog.New(slog.DiscardHandler) logger := slog.New(slog.DiscardHandler)
svc, err := NewService(logger, store, nil, nil, stripe) svc, err := NewService(logger, store, stripe)
if err != nil { if err != nil {
t.Fatalf("NewService returned error: %v", err) t.Fatalf("NewService returned error: %v", err)
} }

View file

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

View file

@ -1,4 +1,4 @@
package booking package payment
import ( import (
"context" "context"
@ -23,7 +23,7 @@ func TestHandleChargeRefundedUpdatesAmount(t *testing.T) {
StripeStatus: &status, 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 { if err != nil {
t.Fatalf("NewService returned error: %v", err) t.Fatalf("NewService returned error: %v", err)
} }
@ -54,7 +54,7 @@ func TestHandleChargeRefundedUpdatesAmount(t *testing.T) {
func TestHandleChargeRefundedUnknownPayment(t *testing.T) { func TestHandleChargeRefundedUnknownPayment(t *testing.T) {
store := &mockStore{} 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"}} charge := &stripe.Charge{PaymentIntent: &stripe.PaymentIntent{ID: "pi_missing"}}
@ -76,7 +76,7 @@ func TestHandleChargeRefundedStoreError(t *testing.T) {
}) })
store.err = errors.New("db error") 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}} charge := &stripe.Charge{PaymentIntent: &stripe.PaymentIntent{ID: stripeID}}
if err := svc.HandleChargeRefunded(context.Background(), charge); err == nil { 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/server"
"github.com/rjNemo/rentease/internal/service/auth" "github.com/rjNemo/rentease/internal/service/auth"
"github.com/rjNemo/rentease/internal/service/booking" "github.com/rjNemo/rentease/internal/service/booking"
"github.com/rjNemo/rentease/internal/service/payment"
) )
func main() { func main() {
@ -72,7 +73,7 @@ func run(ctx context.Context, appConfig *config.Config, appLogger *slog.Logger)
parsingClient := parser.NewBookingAgentParser() parsingClient := parser.NewBookingAgentParser()
var stripeClient booking.StripeClient var stripeClient payment.StripeClient
if appConfig.StripeSecretKey != "" { if appConfig.StripeSecretKey != "" {
opts := []stripeclient.Option{} opts := []stripeclient.Option{}
@ -83,11 +84,16 @@ func run(ctx context.Context, appConfig *config.Config, appLogger *slog.Logger)
stripeClient = client stripeClient = client
} }
bookingService, err := booking.NewService(appLogger, bookingStore, parsingClient, pc, stripeClient) bookingService, err := booking.NewService(appLogger, bookingStore, parsingClient, pc)
if err != nil { if err != nil {
return fmt.Errorf("error creating booking service: %w", err) 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 // build authentication service
as, err := auth.NewService( as, err := auth.NewService(
appConfig.SessionSecret, appConfig.SessionSecret,
@ -105,6 +111,7 @@ func run(ctx context.Context, appConfig *config.Config, appLogger *slog.Logger)
srv, err := server.New( srv, err := server.New(
bookingService, bookingService,
paymentService,
as, as,
config.NewHost(), // TODO: move to the database at some point config.NewHost(), // TODO: move to the database at some point
server.WithPort(port), 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" topic: "Stripe payment ingestion & webhook strategy"
tags: [research, payments, stripe] tags: [research, payments, stripe]
status: complete status: complete
last_updated: 2025-10-03 last_updated: 2025-10-05
last_updated_by: Codex last_updated_by: Codex
--- ---
@ -20,29 +20,24 @@ manual double-entry with webhook-driven updates?
## Summary ## Summary
Rentease currently captures payments through a manual UI that posts to `/payments/:id`, Rentease captures payments through a manual UI that posts to `/payments/:id`, with
persisting records with only amount and method metadata. Reporting features depend records now persisted by a dedicated payment service in `internal/service/payment`.
on these records, especially for card totals. Reporting features still depend on these rows—especially for card totals—so Stripe
There is no existing Stripe integration—configuration lacks API credentials, imports share the same storage path. Stripe connectivity is also centralised in
and no driver or service encapsulates Stripe access. Extending the system will that payment service, while configuration continues to supply the API/ webhook
require new infrastructure for Stripe clients, data models to track external IDs, secrets and `main.go` wires the Stripe client.
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).
## Detailed Findings ## Detailed Findings
### Manual Payment Entry Flow ### 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`). - 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>). - 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 ### 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>). - `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`). - 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 ## 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/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/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>) - `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>) - `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 ## 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/) ## Historical Context (from ./thoughts/)