feat/stripe integration (#48)

This commit is contained in:
Ruidy 2025-10-19 15:48:59 +02:00 committed by GitHub
parent 5d42a5aefe
commit aa9f46a222
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 1619 additions and 129 deletions

View file

@ -2,31 +2,31 @@ root = "."
tmp_dir = "tmp"
[build]
bin = "./tmp/main"
cmd = "templ generate && go build -o ./tmp/main ."
delay = 1000
exclude_dir = ["assets", "tmp", "vendor"]
exclude_file = []
exclude_regex = [".*_templ.go", "VFNI*html"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "templ", "html"]
kill_delay = "0s"
log = "build-errors.log"
send_interrupt = false
stop_on_error = true
bin = "./tmp/main"
cmd = "templ generate && go build -o ./tmp/main ."
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "thoughts"]
exclude_file = []
exclude_regex = [".*_templ.go", "VFNI*html"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "templ", "html"]
kill_delay = "0s"
log = "build-errors.log"
send_interrupt = false
stop_on_error = true
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
time = false
time = false
[misc]
clean_on_exit = false
clean_on_exit = false

View file

@ -71,8 +71,14 @@ use a cloud alternative such as Railway, fly.io, _etc._
DB_USER=your_db_username
DB_PASSWORD=your_db_password
DB_NAME=rentease
# Stripe configuration (optional until you enable automatic sync)
APP_STRIPE_SECRET_KEY=sk_test_your_key
APP_STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
APP_STRIPE_CONNECT_ACCOUNT=acct_your_connect_account # optional
```
Leave the Stripe variables blank to continue using manual cash entry only. When set, Rentease will pull payments from Stripe, process webhooks sent to `/webhooks/stripe`, and expose a manual sync endpoint at `POST /api/stripe/sync` (protected by the existing API key middleware).
5. Start the application
```sh

View file

@ -7,6 +7,8 @@ import (
"syscall"
"github.com/rjNemo/rentease/pkg/cron"
internalcron "github.com/rjNemo/rentease/internal/cron"
)
func main() {
@ -15,8 +17,13 @@ func main() {
scheduler.AddJob(cron.Job{
Name: "Monthly Booking Report",
Schedule: "minute",
// Schedule: "monthly",
// Action: cron.JobMonthlyBookingReport,
Action: internalcron.JobMonthlyBookingReport,
})
scheduler.AddJob(cron.Job{
Name: "Stripe Payment Sync",
Schedule: "daily",
Action: internalcron.JobStripePaymentSync,
})
go scheduler.Start()

19
go.mod
View file

@ -1,17 +1,18 @@
module github.com/rjNemo/rentease
go 1.25.1
go 1.25.2
require (
github.com/a-h/templ v0.3.943
github.com/getsentry/sentry-go v0.35.3
github.com/getsentry/sentry-go/echo v0.35.3
github.com/a-h/templ v0.3.960
github.com/getsentry/sentry-go v0.36.0
github.com/getsentry/sentry-go/echo v0.36.0
github.com/gorilla/sessions v1.4.0
github.com/joho/godotenv v1.5.1
github.com/labstack/echo-contrib v0.17.4
github.com/labstack/echo/v4 v4.13.4
github.com/labstack/gommon v0.4.2
github.com/rjNemo/underscore v0.8.0
github.com/stripe/stripe-go/v83 v83.0.1
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.0
)
@ -39,10 +40,10 @@ require (
github.com/sethvargo/go-envconfig v1.3.0
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/net v0.44.0 // indirect
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.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/time v0.13.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.14.0 // indirect
)

20
go.sum
View file

@ -1,12 +1,18 @@
github.com/a-h/templ v0.3.943 h1:o+mT/4yqhZ33F3ootBiHwaY4HM5EVaOJfIshvd5UNTY=
github.com/a-h/templ v0.3.943/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
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.35.3 h1:u5IJaEqZyPdWqe/hKlBKBBnMTSxB/HenCqF3QLabeds=
github.com/getsentry/sentry-go v0.35.3/go.mod h1:mdL49ixwT2yi57k5eh7mpnDyPybixPzlzEJFu0Z76QA=
github.com/getsentry/sentry-go v0.36.0 h1:UkCk0zV28PiGf+2YIONSSYiYhxwlERE5Li3JPpZqEns=
github.com/getsentry/sentry-go v0.36.0/go.mod h1:p5Im24mJBeruET8Q4bbcMfCQ+F+Iadc4L48tB1apo2c=
github.com/getsentry/sentry-go/echo v0.35.3 h1:aJ0e4kGuH7T1ggAd3LOYwAyQV0bq37AX36vNPr6JYnM=
github.com/getsentry/sentry-go/echo v0.35.3/go.mod h1:zQn5wNGqJUwIlA6z/pi7CFeXiUGrWkzue28C0Mfbz/Q=
github.com/getsentry/sentry-go/echo v0.36.0 h1:PimJIxiH2O/nS+jegFLxx52RMpVY2ciAIvVkk8miVeM=
github.com/getsentry/sentry-go/echo v0.36.0/go.mod h1:Z4Q44b9OWBO18lFcC1yfCqOVex00nz2WPSH1AuUUC5I=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@ -60,6 +66,10 @@ 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.0.0 h1:00HYu/n80zH6ugy88bWI5sBLbJZ7WmhCXCRQ1N1tuqI=
github.com/stripe/stripe-go/v83 v83.0.0/go.mod h1:nRyDcLrJtwPPQUnKAFs9Bt1NnQvNhNiF6V19XHmPISE=
github.com/stripe/stripe-go/v83 v83.0.1 h1:HvUXOw0AcjYJ9zUTN5XW+k7HvkM1AY9zxbpOFN9bhRA=
github.com/stripe/stripe-go/v83 v83.0.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=
@ -79,17 +89,27 @@ 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.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
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/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
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=

View file

@ -36,6 +36,12 @@ type Config struct {
SecretKey string `env:"SECRET_KEY, required"`
// SessionSecret is the secret key used for session signing
SessionSecret string `env:"SESSION_SECRET, required"`
// StripeSecretKey is the API key used to authenticate with Stripe
StripeSecretKey string `env:"STRIPE_SECRET_KEY"`
// StripeWebhookSecret is the signing secret for validating Stripe webhooks
StripeWebhookSecret string `env:"STRIPE_WEBHOOK_SECRET"`
// StripeConnectAccount is the connected account ID when using Stripe Connect (optional)
StripeConnectAccount string `env:"STRIPE_CONNECT_ACCOUNT"`
}
// New creates a [Config] struct. It first parses the environment variables. You can use a .env file.

View file

@ -29,7 +29,7 @@ func JobMonthlyBookingReport() error {
}
store := booking.NewPgStore(db)
service, err := bookingService.NewService(nil, store, nil, ps)
service, err := bookingService.NewService(nil, store, nil, ps, nil)
if err != nil {
return fmt.Errorf("error creating booking service: %w", err)
}

View file

@ -0,0 +1,68 @@
package cron
import (
"context"
"fmt"
"log/slog"
"time"
"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/repository/booking"
bookingservice "github.com/rjNemo/rentease/internal/service/booking"
)
// JobStripePaymentSync synchronises Stripe payments for the last 24 hours. It is
// safe to run multiple times thanks to the repository upsert semantics.
func JobStripePaymentSync() error {
ctx := context.Background()
cfg, err := config.New(ctx)
if err != nil {
return fmt.Errorf("error loading config: %w", err)
}
if cfg.StripeSecretKey == "" {
slog.Default().Warn("stripe secret key missing; skipping stripe sync job")
return nil
}
db, err := database.New(cfg.DatabaseURL)
if err != nil {
return fmt.Errorf("error connecting to database: %w", err)
}
// Auto-migrate payment schema if necessary.
if err := database.Migrate(db, &bookingservice.Booking{}, &bookingservice.Item{}, &bookingservice.Payment{}); err != nil {
return fmt.Errorf("error migrating database: %w", err)
}
store := booking.NewPgStore(db)
opts := []stripeclient.Option{}
if cfg.StripeConnectAccount != "" {
opts = append(opts, stripeclient.WithAccount(cfg.StripeConnectAccount))
}
client, err := stripeclient.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)
if err != nil {
return fmt.Errorf("error creating booking service: %w", err)
}
to := time.Now().UTC()
from := to.Add(-24 * time.Hour)
if err := service.SyncStripePayments(ctx, from, to); err != nil {
return fmt.Errorf("error syncing stripe payments: %w", err)
}
slog.Default().Info("stripe payment sync job completed", slog.Time("from", from), slog.Time("to", to))
return nil
}

View file

@ -0,0 +1,169 @@
package stripe
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/stripe/stripe-go/v83"
)
// Option configures a Client instance.
type Option func(*Client)
// WithAccount sets the Stripe connected account identifier used for requests.
func WithAccount(account string) Option {
return func(c *Client) {
c.account = strings.TrimSpace(account)
}
}
// Client wraps Stripe's SDK to expose the subset of functionality needed by the
// application while keeping the rest of the codebase decoupled from the SDK.
type Client struct {
api *stripe.Client
account string
}
// New constructs a Client using the provided secret key. The key must not be empty.
func New(secretKey string, opts ...Option) (*Client, error) {
trimmed := strings.TrimSpace(secretKey)
if trimmed == "" {
return nil, errors.New("stripe secret key is required")
}
api := stripe.NewClient(trimmed)
client := &Client{api: api}
for _, opt := range opts {
opt(client)
}
return client, nil
}
// Payment represents the subset of payment intent data consumed by the booking service.
type Payment struct {
ID string
Amount float64
Currency string
Status string
PaymentMethod string
BookingID *uint
Created time.Time
}
// NormalizePaymentIntent converts a Stripe payment intent into the simplified Payment structure used
// by the application. Fields that are absent default to their zero values.
func NormalizePaymentIntent(pi *stripe.PaymentIntent) Payment {
if pi == nil {
return Payment{}
}
amount := float64(pi.AmountReceived) / 100.0
if amount == 0 {
amount = float64(pi.Amount) / 100.0
}
return Payment{
ID: pi.ID,
Amount: amount,
Currency: strings.ToUpper(string(pi.Currency)),
Status: string(pi.Status),
PaymentMethod: deriveMethod(pi),
BookingID: extractBookingID(pi.Metadata),
Created: time.Unix(int64(pi.Created), 0),
}
}
// ListPaymentsParams defines the time boundaries used when fetching Stripe payments.
type ListPaymentsParams struct {
From time.Time
To time.Time
}
// ListPayments fetches payment intents created within the provided time range. The
// results are normalised into Payment structs suitable for downstream processing.
func (c *Client) ListPayments(ctx context.Context, params ListPaymentsParams) ([]Payment, error) {
listParams := &stripe.PaymentIntentListParams{}
listParams.Context = ctx
listParams.AddExpand("data.latest_charge")
listParams.AddExpand("data.payment_method")
if !params.From.IsZero() {
listParams.Filters.AddFilter("created", "gte", strconv.FormatInt(params.From.Unix(), 10))
}
if !params.To.IsZero() {
listParams.Filters.AddFilter("created", "lte", strconv.FormatInt(params.To.Unix(), 10))
}
if c.account != "" {
listParams.SetStripeAccount(c.account)
}
seq := c.api.V1PaymentIntents.List(ctx, listParams)
payments := make([]Payment, 0)
var listErr error
seq(func(pi *stripe.PaymentIntent, err error) bool {
if err != nil {
listErr = err
return false
}
if pi == nil {
return true
}
payments = append(payments, NormalizePaymentIntent(pi))
return true
})
if listErr != nil {
return nil, fmt.Errorf("stripe payment intents iteration failed: %w", listErr)
}
return payments, nil
}
func deriveMethod(pi *stripe.PaymentIntent) string {
if pi == nil {
return ""
}
if pi.LatestCharge != nil && pi.LatestCharge.PaymentMethodDetails != nil {
typ := pi.LatestCharge.PaymentMethodDetails.Type
if typ != "" {
return string(typ)
}
}
if pi.PaymentMethod != nil && pi.PaymentMethod.Type != "" {
return string(pi.PaymentMethod.Type)
}
if len(pi.PaymentMethodTypes) > 0 {
return pi.PaymentMethodTypes[0]
}
return ""
}
func extractBookingID(metadata map[string]string) *uint {
if len(metadata) == 0 {
return nil
}
keys := []string{"booking_id", "bookingId", "bookingID"}
for _, key := range keys {
if raw, ok := metadata[key]; ok {
if raw == "" {
continue
}
if id, err := strconv.ParseUint(raw, 10, 32); err == nil {
value := uint(id)
return &value
}
}
}
return nil
}

View file

@ -1,6 +1,7 @@
package booking
import (
"errors"
"fmt"
"time"
@ -172,3 +173,49 @@ func (ps *PgStore) UpdatePayment(id int, amount float64, paymentMethod string) (
Error
return p, err
}
func (ps *PgStore) UpsertStripePayment(p *booking.Payment) (*booking.Payment, error) {
if p.StripePaymentID == nil || *p.StripePaymentID == "" {
return nil, fmt.Errorf("stripe payment id is required")
}
existing := new(booking.Payment)
stripeID := *p.StripePaymentID
if err := ps.db.Where("stripe_payment_id = ?", stripeID).First(existing).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
if err := ps.db.Create(p).Error; err != nil {
return nil, fmt.Errorf("failed to create stripe payment: %w", err)
}
return p, nil
}
return nil, fmt.Errorf("failed to lookup stripe payment: %w", err)
}
updates := map[string]any{
"amount": p.Amount,
"payment_method": p.PaymentMethod,
"stripe_status": p.StripeStatus,
"booking_id": p.BookingID,
}
if err := ps.db.Model(existing).
Clauses(clause.Returning{}).
Updates(updates).
Error; err != nil {
return nil, fmt.Errorf("failed to update stripe payment: %w", err)
}
return existing, nil
}
func (ps *PgStore) FindStripePayment(stripePaymentID string) (*booking.Payment, error) {
if stripePaymentID == "" {
return nil, fmt.Errorf("stripe payment id is required")
}
p := new(booking.Payment)
if err := ps.db.Where("stripe_payment_id = ?", stripePaymentID).First(p).Error; err != nil {
return nil, err
}
return p, nil
}

View file

@ -55,6 +55,20 @@ func handleBookingListPage(bs *booking.Service, hc *config.Host) echo.HandlerFun
}
}
func paymentViewModelFromBookingPayment(p booking.Payment) *view.PaymentViewModel {
stripeStatus := ""
if p.StripeStatus != nil {
stripeStatus = *p.StripeStatus
}
return &view.PaymentViewModel{
Amount: strconv.FormatFloat(p.Amount, 'f', 2, 64),
PaymentMethod: string(p.PaymentMethod),
PaymentUrl: fmt.Sprintf("%s/%d", constant.RoutePayment, p.ID),
StripeStatus: stripeStatus,
}
}
func handleBookingList(bs *booking.Service) echo.HandlerFunc {
return func(c echo.Context) error {
search := c.FormValue("search")
@ -156,11 +170,7 @@ func handleBookingPage(bs *booking.Service, hc *config.Host) echo.HandlerFunc {
}
}),
Payments: u.Map(b.Payments, func(p booking.Payment) view.PaymentViewModel {
return view.PaymentViewModel{
Amount: strconv.FormatFloat(p.Amount, 'f', 2, 64),
PaymentMethod: string(p.PaymentMethod),
PaymentUrl: fmt.Sprintf("%s/%d", constant.RoutePayment, p.ID),
}
return *paymentViewModelFromBookingPayment(p)
}),
},
Total: strconv.FormatFloat(u.Reduce(b.Items, func(i booking.Item, sum float64) float64 {
@ -380,11 +390,7 @@ func handlePaymentUpdate(bs *booking.Service) echo.HandlerFunc {
p := bs.UpdatePayment(up.Id, up.Amount, up.PaymentMethod)
return renderTempl(c, http.StatusOK, view.PaymentLine(&view.PaymentViewModel{
Amount: strconv.FormatFloat(p.Amount, 'f', 2, 64),
PaymentMethod: string(p.PaymentMethod),
PaymentUrl: fmt.Sprintf("%s/%d", constant.RoutePayment, p.ID),
}))
return renderTempl(c, http.StatusOK, view.PaymentLine(paymentViewModelFromBookingPayment(*p)))
}
}

View file

@ -1,14 +1,12 @@
package server
import (
"fmt"
"net/http"
"strconv"
"github.com/labstack/echo/v4"
u "github.com/rjNemo/underscore"
"github.com/rjNemo/rentease/internal/constant"
"github.com/rjNemo/rentease/internal/service/booking"
"github.com/rjNemo/rentease/internal/view"
)
@ -42,11 +40,7 @@ func handleCreatePayment(bs *booking.Service) echo.HandlerFunc {
return renderTempl(c, http.StatusOK, view.PaymentList(
u.Map(nb.Payments, func(p booking.Payment) *view.PaymentViewModel {
return &view.PaymentViewModel{
Amount: strconv.FormatFloat(p.Amount, 'f', 2, 64),
PaymentMethod: string(p.PaymentMethod),
PaymentUrl: fmt.Sprintf("%s/%d", constant.RoutePayment, p.ID),
}
return paymentViewModelFromBookingPayment(p)
}),
))
}
@ -61,11 +55,7 @@ func handlePaymentForm(bs *booking.Service) echo.HandlerFunc {
}
p := bs.OnePayment(id)
form := view.PaymentForm(&view.PaymentViewModel{
Amount: strconv.FormatFloat(p.Amount, 'f', 2, 64),
PaymentMethod: string(p.PaymentMethod),
PaymentUrl: fmt.Sprintf("%s/%d", constant.RoutePayment, p.ID),
})
form := view.PaymentForm(paymentViewModelFromBookingPayment(*p))
return renderTempl(c, http.StatusOK, form)
}
}

View file

@ -0,0 +1,63 @@
package server
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"time"
"github.com/labstack/echo/v4"
"github.com/rjNemo/rentease/internal/service/booking"
)
type stripeSyncRequest struct {
From string `json:"from"`
To string `json:"to"`
}
type stripeSyncer interface {
SyncStripePayments(ctx context.Context, from, to time.Time) error
}
func handleStripeSync(bs stripeSyncer) echo.HandlerFunc {
return func(c echo.Context) error {
req := new(stripeSyncRequest)
if err := json.NewDecoder(c.Request().Body).Decode(req); err != nil {
if !errors.Is(err, io.EOF) {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request payload")
}
}
now := time.Now().UTC()
from := now.Add(-24 * time.Hour)
to := now
if req.From != "" {
parsed, err := time.Parse(time.RFC3339, req.From)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid 'from' timestamp, expected RFC3339")
}
from = parsed
}
if req.To != "" {
parsed, err := time.Parse(time.RFC3339, req.To)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid 'to' timestamp, expected RFC3339")
}
to = parsed
}
if err := bs.SyncStripePayments(c.Request().Context(), from, to); err != nil {
if errors.Is(err, booking.ErrStripeClientNotConfigured) {
return echo.NewHTTPError(http.StatusServiceUnavailable, "stripe client not configured")
}
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
}
}

View file

@ -0,0 +1,75 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/labstack/echo/v4"
)
type stubStripeSyncer struct {
from time.Time
to time.Time
err error
}
func (s *stubStripeSyncer) SyncStripePayments(ctx context.Context, from, to time.Time) error {
s.from = from
s.to = to
return s.err
}
func TestHandleStripeSyncSuccess(t *testing.T) {
syncer := &stubStripeSyncer{}
handler := handleStripeSync(syncer)
now := time.Now().UTC().Truncate(time.Second)
from := now.Add(-2 * time.Hour).Format(time.RFC3339)
to := now.Format(time.RFC3339)
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/api/stripe/sync", strings.NewReader(`{"from":"`+from+`","to":"`+to+`"}`))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := handler(c); err != nil {
t.Fatalf("handler returned error: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
if syncer.from.IsZero() || syncer.to.IsZero() {
t.Fatal("expected syncer to receive time bounds")
}
}
func TestHandleStripeSyncInvalidTimestamp(t *testing.T) {
syncer := &stubStripeSyncer{}
handler := handleStripeSync(syncer)
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/api/stripe/sync", strings.NewReader(`{"from":"not-a-date"}`))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
err := handler(c)
if err == nil {
t.Fatal("expected error for invalid timestamp")
}
httpErr, ok := err.(*echo.HTTPError)
if !ok {
t.Fatalf("expected HTTPError, got %T", err)
}
if httpErr.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", httpErr.Code)
}
}

View file

@ -0,0 +1,65 @@
package server
import (
"context"
"encoding/json"
"io"
"net/http"
"github.com/labstack/echo/v4"
"github.com/stripe/stripe-go/v83"
"github.com/stripe/stripe-go/v83/webhook"
)
type stripeEventService interface {
HandlePaymentIntentSucceeded(ctx context.Context, pi *stripe.PaymentIntent) error
HandleChargeRefunded(ctx context.Context, ch *stripe.Charge) error
}
func handleStripeWebhook(bs stripeEventService, secret string) echo.HandlerFunc {
return func(c echo.Context) error {
if secret == "" {
return echo.NewHTTPError(http.StatusServiceUnavailable, "stripe webhook secret not configured")
}
payload, err := io.ReadAll(c.Request().Body)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "unable to read request body")
}
sig := c.Request().Header.Get("Stripe-Signature")
if sig == "" {
return echo.NewHTTPError(http.StatusBadRequest, "missing Stripe-Signature header")
}
event, err := webhook.ConstructEvent(payload, sig, secret)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid webhook signature")
}
switch event.Type {
case stripe.EventTypePaymentIntentSucceeded:
var pi stripe.PaymentIntent
if err := json.Unmarshal(event.Data.Raw, &pi); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid payment intent payload")
}
if err := bs.HandlePaymentIntentSucceeded(c.Request().Context(), &pi); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
case stripe.EventTypeChargeRefunded:
var ch stripe.Charge
if err := json.Unmarshal(event.Data.Raw, &ch); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid charge payload")
}
if err := bs.HandleChargeRefunded(c.Request().Context(), &ch); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
default:
// Acknowledge events we don't actively process.
}
return c.NoContent(http.StatusOK)
}
}

View file

@ -0,0 +1,150 @@
package server
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/labstack/echo/v4"
"github.com/stripe/stripe-go/v83"
"github.com/stripe/stripe-go/v83/webhook"
)
type stubStripeEventService struct {
intentCalled bool
chargeCalled bool
err error
}
func (s *stubStripeEventService) HandlePaymentIntentSucceeded(ctx context.Context, pi *stripe.PaymentIntent) error {
s.intentCalled = true
return s.err
}
func (s *stubStripeEventService) HandleChargeRefunded(ctx context.Context, ch *stripe.Charge) error {
s.chargeCalled = true
return s.err
}
func TestHandleStripeWebhookPaymentIntent(t *testing.T) {
secret := "whsec_test"
payload := map[string]any{
"id": "evt_test",
"type": "payment_intent.succeeded",
"api_version": stripe.APIVersion,
"data": map[string]any{
"object": map[string]any{
"id": "pi_123",
"amount": 10000,
"amount_received": 10000,
"currency": "eur",
"status": "succeeded",
"metadata": map[string]string{"booking_id": "42"},
"payment_method_types": []string{"card"},
},
},
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
t.Fatalf("failed to marshal payload: %v", err)
}
ts := time.Now()
sig := webhook.ComputeSignature(ts, payloadBytes, secret)
sigHeader := fmt.Sprintf("t=%d,v1=%s", ts.Unix(), hex.EncodeToString(sig))
if _, err := webhook.ConstructEvent(payloadBytes, sigHeader, secret); err != nil {
t.Fatalf("signature validation failed in setup: %v", err)
}
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/webhooks/stripe", strings.NewReader(string(payloadBytes)))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Set("Stripe-Signature", sigHeader)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
service := &stubStripeEventService{}
handler := handleStripeWebhook(service, secret)
if err := handler(c); err != nil {
t.Fatalf("handler returned error: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
if !service.intentCalled {
t.Fatalf("expected payment intent handler to be called")
}
}
func TestHandleStripeWebhookChargeRefunded(t *testing.T) {
secret := "whsec_test"
payload := map[string]any{
"id": "evt_charge",
"type": "charge.refunded",
"api_version": stripe.APIVersion,
"data": map[string]any{
"object": map[string]any{
"id": "ch_123",
"amount": 5000,
"amount_refunded": 5000,
"payment_intent": map[string]any{
"id": "pi_123",
},
},
},
}
payloadBytes, _ := json.Marshal(payload)
ts := time.Now()
sig := webhook.ComputeSignature(ts, payloadBytes, secret)
sigHeader := fmt.Sprintf("t=%d,v1=%s", ts.Unix(), hex.EncodeToString(sig))
if _, err := webhook.ConstructEvent(payloadBytes, sigHeader, secret); err != nil {
t.Fatalf("signature validation failed in setup: %v", err)
}
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/webhooks/stripe", strings.NewReader(string(payloadBytes)))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Set("Stripe-Signature", sigHeader)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
service := &stubStripeEventService{}
handler := handleStripeWebhook(service, secret)
if err := handler(c); err != nil {
t.Fatalf("handler returned error: %v", err)
}
if !service.chargeCalled {
t.Fatalf("expected charge handler to be called")
}
}
func TestHandleStripeWebhookInvalidSignature(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/webhooks/stripe", strings.NewReader("{}"))
req.Header.Set("Stripe-Signature", "invalid")
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := handleStripeWebhook(&stubStripeEventService{}, "secret")
err := handler(c)
if err == nil {
t.Fatal("expected error for invalid signature")
}
if httpErr, ok := err.(*echo.HTTPError); !ok || httpErr.Code != http.StatusBadRequest {
t.Fatalf("expected 400 HTTP error, got %v", err)
}
}

View file

@ -6,11 +6,12 @@ import (
)
type options struct {
port *int
fs *embed.FS
debug *bool
secretKey *string
origins []string
port *int
fs *embed.FS
debug *bool
secretKey *string
origins []string
stripeWebhookSecret *string
}
type Option func(*options) error
@ -52,3 +53,13 @@ func WithOrigins(origins []string) Option {
return nil
}
}
func WithStripeWebhookSecret(secret string) Option {
return func(o *options) error {
if secret == "" {
return nil
}
o.stripeWebhookSecret = &secret
return nil
}
}

View file

@ -9,6 +9,7 @@ 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))
api := s.Router.Group("/api")
api.Use(middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{
@ -20,6 +21,7 @@ func (s Server) MountHandlers() {
api.POST("/sync", handleSync(s.bs))
api.GET("/bookings", handleBookingList(s.bs))
api.POST("/bookings", handleCreateBooking(s.bs))
api.POST("/stripe/sync", handleStripeSync(s.bs))
private := s.Router.Group("")
private.Use(MakeAuthMiddleware(s.as))

View file

@ -23,11 +23,12 @@ import (
)
type Server struct {
Router *echo.Echo
bs *booking.Service
as *auth.Service
hc *config.Host
addr string
Router *echo.Echo
bs *booking.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) {
@ -40,11 +41,16 @@ func New(bs *booking.Service, as *auth.Service, hc *config.Host, opts ...Option)
}
s := &Server{
Router: NewRouter(*option.fs, *option.debug, *option.secretKey, option.origins),
bs: bs,
as: as,
hc: hc,
addr: fmt.Sprintf("0.0.0.0:%d", *option.port),
Router: NewRouter(*option.fs, *option.debug, *option.secretKey, option.origins),
bs: bs,
as: as,
hc: hc,
addr: fmt.Sprintf("0.0.0.0:%d", *option.port),
stripeWebhookSecret: "",
}
if option.stripeWebhookSecret != nil {
s.stripeWebhookSecret = *option.stripeWebhookSecret
}
s.MountHandlers()

View file

@ -126,8 +126,10 @@ func (i Item) ToFrench() string {
type Payment struct {
gorm.Model
BookingID uint `gorm:"not null;index"`
Booking Booking `gorm:"foreignKey:BookingID;constraint:OnDelete:CASCADE"`
Amount float64
PaymentMethod config.PaymentMethod
BookingID uint `gorm:"not null;index"`
Booking Booking `gorm:"foreignKey:BookingID;constraint:OnDelete:CASCADE"`
Amount float64
PaymentMethod config.PaymentMethod
StripePaymentID *string `gorm:"size:255;uniqueIndex"`
StripeStatus *string `gorm:"size:32"`
}

View file

@ -34,3 +34,12 @@ func (bs Service) UpdatePayment(id int, amount float64, paymentMethod string) *P
}
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,10 +1,12 @@
package booking
import (
"context"
"log/slog"
"time"
"github.com/rjNemo/rentease/internal/config"
stripeclient "github.com/rjNemo/rentease/internal/driver/stripe"
)
type Store interface {
@ -27,6 +29,12 @@ type Store interface {
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)
}
type PdfClient interface {
@ -47,14 +55,21 @@ type Service struct {
parser parserClient
pdf PdfClient
logger *slog.Logger
stripe StripeClient
}
func NewService(logger *slog.Logger, store Store, parser parserClient, pdf PdfClient) (*Service, error) {
func NewService(logger *slog.Logger, store Store, parser parserClient, pdf PdfClient, stripe StripeClient) (*Service, error) {
svcLogger := logger
if svcLogger == nil {
svcLogger = slog.Default()
}
return &Service{
logger: logger.With(slog.String("component", "booking_service")),
logger: svcLogger.With(slog.String("component", "booking_service")),
store: store,
parser: parser,
pdf: pdf,
stripe: stripe,
}, nil
}

View file

@ -0,0 +1,70 @@
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,165 @@
package booking
import (
"context"
"errors"
"log/slog"
"testing"
"time"
"gorm.io/gorm"
"github.com/rjNemo/rentease/internal/config"
stripeclient "github.com/rjNemo/rentease/internal/driver/stripe"
)
type fakeStripeClient struct {
payments []stripeclient.Payment
err error
}
func (f *fakeStripeClient) ListPayments(ctx context.Context, params stripeclient.ListPaymentsParams) ([]stripeclient.Payment, error) {
return f.payments, f.err
}
type mockStore struct {
upserts []*Payment
err error
byStripeID map[string]*Payment
}
func (m *mockStore) record(p *Payment) (*Payment, error) {
cp := *p
m.upserts = append(m.upserts, &cp)
if cp.StripePaymentID != nil {
if m.byStripeID == nil {
m.byStripeID = make(map[string]*Payment)
}
clone := cp
m.byStripeID[*cp.StripePaymentID] = &clone
}
if m.err != nil {
return nil, m.err
}
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 { return 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) GetPayment(int) (*Payment, error) { return nil, nil }
func (m *mockStore) UpdatePayment(int, float64, string) (*Payment, error) { return nil, nil }
func (m *mockStore) UpsertStripePayment(p *Payment) (*Payment, error) { return m.record(p) }
func (m *mockStore) FindStripePayment(id string) (*Payment, error) {
if m.byStripeID == nil {
return nil, gorm.ErrRecordNotFound
}
if p, ok := m.byStripeID[id]; ok {
clone := *p
return &clone, nil
}
return nil, gorm.ErrRecordNotFound
}
func TestSyncStripePayments(t *testing.T) {
bookingID := uint(42)
stripePayments := []stripeclient.Payment{
{
ID: "pi_123",
Amount: 120.50,
PaymentMethod: "card",
Status: "succeeded",
BookingID: &bookingID,
},
}
store := &mockStore{}
stripe := &fakeStripeClient{payments: stripePayments}
logger := slog.New(slog.DiscardHandler)
svc, err := NewService(logger, store, nil, nil, stripe)
if err != nil {
t.Fatalf("NewService returned error: %v", err)
}
if err := svc.SyncStripePayments(context.Background(), time.Now().Add(-time.Hour), time.Now()); err != nil {
t.Fatalf("SyncStripePayments returned error: %v", err)
}
if len(store.upserts) != 1 {
t.Fatalf("expected 1 upsert, got %d", len(store.upserts))
}
upsert := store.upserts[0]
if upsert.Amount != 120.50 {
t.Errorf("unexpected amount: %v", upsert.Amount)
}
if upsert.PaymentMethod != config.PaymentMethod("Card") {
t.Errorf("unexpected payment method: %v", upsert.PaymentMethod)
}
if upsert.StripePaymentID == nil || *upsert.StripePaymentID != "pi_123" {
t.Errorf("stripe payment id not set correctly: %v", upsert.StripePaymentID)
}
}
func TestSyncStripePaymentsSkipsMissingBooking(t *testing.T) {
stripePayments := []stripeclient.Payment{
{ID: "pi_123", Amount: 10},
}
store := &mockStore{}
stripe := &fakeStripeClient{payments: stripePayments}
logger := slog.New(slog.DiscardHandler)
svc, err := NewService(logger, store, nil, nil, stripe)
if err != nil {
t.Fatalf("NewService returned error: %v", err)
}
if err := svc.SyncStripePayments(context.Background(), time.Now().Add(-time.Hour), time.Now()); err != nil {
t.Fatalf("SyncStripePayments returned error: %v", err)
}
if len(store.upserts) != 0 {
t.Fatalf("expected 0 upserts, got %d", len(store.upserts))
}
}
func TestSyncStripePaymentsReturnsAggregatedError(t *testing.T) {
bookingID := uint(7)
stripePayments := []stripeclient.Payment{
{
ID: "pi_err",
Amount: 50,
PaymentMethod: "card",
Status: "succeeded",
BookingID: &bookingID,
},
}
store := &mockStore{err: errors.New("db failure")}
stripe := &fakeStripeClient{payments: stripePayments}
logger := slog.New(slog.DiscardHandler)
svc, err := NewService(logger, store, nil, nil, stripe)
if err != nil {
t.Fatalf("NewService returned error: %v", err)
}
err = svc.SyncStripePayments(context.Background(), time.Now().Add(-time.Hour), time.Now())
if err == nil {
t.Fatalf("expected error, got nil")
}
}

View file

@ -0,0 +1,93 @@
package booking
import (
"context"
"errors"
"log/slog"
"math"
"strings"
"github.com/stripe/stripe-go/v83"
"gorm.io/gorm"
stripeclient "github.com/rjNemo/rentease/internal/driver/stripe"
)
// HandlePaymentIntentSucceeded persists successful Stripe payment intents received via webhook.
func (bs Service) HandlePaymentIntentSucceeded(ctx context.Context, pi *stripe.PaymentIntent) error {
if pi == nil {
return errors.New("payment intent payload is missing")
}
normalized := stripeclient.NormalizePaymentIntent(pi)
if normalized.ID == "" {
return errors.New("payment intent missing id")
}
if normalized.BookingID == nil {
bs.logger.Warn("stripe webhook payment missing booking metadata", slog.String("payment_intent", normalized.ID))
return nil
}
bookingID := uint(*normalized.BookingID)
stripeID := normalized.ID
status := strings.ToLower(normalized.Status)
_, err := bs.store.UpsertStripePayment(&Payment{
BookingID: bookingID,
Amount: normalized.Amount,
PaymentMethod: mapStripeMethod(normalized.PaymentMethod),
StripePaymentID: &stripeID,
StripeStatus: &status,
})
if err != nil {
return err
}
bs.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 {
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))
return nil
}
existing, err := bs.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))
return nil
}
return err
}
amount := existing.Amount
if ch.AmountRefunded > 0 {
net := float64(ch.Amount-ch.AmountRefunded) / 100.0
amount = math.Max(net, 0)
}
status := "refunded"
stripeID := ch.PaymentIntent.ID
_, err = bs.store.UpsertStripePayment(&Payment{
BookingID: existing.BookingID,
Amount: amount,
PaymentMethod: existing.PaymentMethod,
StripePaymentID: &stripeID,
StripeStatus: &status,
})
if err != nil {
return err
}
bs.logger.Info("stripe charge refunded processed", slog.String("charge", ch.ID), slog.String("payment_intent", ch.PaymentIntent.ID))
return nil
}

View file

@ -0,0 +1,85 @@
package booking
import (
"context"
"errors"
"log/slog"
"testing"
"github.com/stripe/stripe-go/v83"
"github.com/rjNemo/rentease/internal/config"
)
func TestHandleChargeRefundedUpdatesAmount(t *testing.T) {
store := &mockStore{}
stripeID := "pi_123"
status := "succeeded"
_, _ = store.UpsertStripePayment(&Payment{
BookingID: 42,
Amount: 100,
PaymentMethod: config.PaymentMethod("Card"),
StripePaymentID: &stripeID,
StripeStatus: &status,
})
svc, err := NewService(slog.New(slog.DiscardHandler), store, nil, nil, nil)
if err != nil {
t.Fatalf("NewService returned error: %v", err)
}
charge := &stripe.Charge{
ID: "ch_123",
Amount: 10000,
AmountRefunded: 2500,
PaymentIntent: &stripe.PaymentIntent{ID: stripeID},
}
if err := svc.HandleChargeRefunded(context.Background(), charge); err != nil {
t.Fatalf("HandleChargeRefunded returned error: %v", err)
}
updated, err := store.FindStripePayment(stripeID)
if err != nil {
t.Fatalf("expected payment to be present: %v", err)
}
if updated.Amount != 75 {
t.Fatalf("expected amount 75, got %v", updated.Amount)
}
if updated.StripeStatus == nil || *updated.StripeStatus != "refunded" {
t.Fatalf("expected status refunded, got %v", updated.StripeStatus)
}
}
func TestHandleChargeRefundedUnknownPayment(t *testing.T) {
store := &mockStore{}
svc, _ := NewService(slog.New(slog.DiscardHandler), store, nil, nil, nil)
charge := &stripe.Charge{PaymentIntent: &stripe.PaymentIntent{ID: "pi_missing"}}
if err := svc.HandleChargeRefunded(context.Background(), charge); err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestHandleChargeRefundedStoreError(t *testing.T) {
store := &mockStore{}
stripeID := "pi_321"
status := "succeeded"
_, _ = store.UpsertStripePayment(&Payment{
BookingID: 1,
Amount: 10,
PaymentMethod: config.PaymentMethod("Card"),
StripePaymentID: &stripeID,
StripeStatus: &status,
})
store.err = errors.New("db error")
svc, _ := NewService(slog.New(slog.DiscardHandler), store, nil, nil, nil)
charge := &stripe.Charge{PaymentIntent: &stripe.PaymentIntent{ID: stripeID}}
if err := svc.HandleChargeRefunded(context.Background(), charge); err == nil {
t.Fatalf("expected error when store fails")
}
}

View file

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package view
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View file

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package view
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View file

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package view
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View file

@ -32,4 +32,5 @@ type PaymentViewModel struct {
Amount string
PaymentMethod string
PaymentUrl string
StripeStatus string
}

View file

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package view
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View file

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package view
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View file

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package view
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View file

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package layout
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View file

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package view
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View file

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package view
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View file

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package view
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View file

@ -5,7 +5,10 @@ templ PaymentLine(payment *PaymentViewModel) {
<td></td>
<td></td>
<td>- { payment.Amount }</td>
<td>{ payment.PaymentMethod }</td>
<td>
{ payment.PaymentMethod }
<span class="badge badge-outline badge-sm ml-2">{ payment.StripeStatus }</span>
</td>
<td></td>
<td class="flex gap-2">
<button
@ -13,7 +16,9 @@ templ PaymentLine(payment *PaymentViewModel) {
hx-get={ payment.PaymentUrl }
hx-target="closest tr"
hx-swap="outerHTML"
>Edit</button>
>
Edit
</button>
</td>
</tr>
}

View file

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package view
//lint:file-ignore SA4006 This context is only used if a nested component is present.
@ -49,26 +49,39 @@ func PaymentLine(payment *PaymentViewModel) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(payment.PaymentMethod)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 8, Col: 29}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 9, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</td><td></td><td class=\"flex gap-2\"><button class=\"btn btn-sm btn-outline\" hx-get=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " <span class=\"badge badge-outline badge-sm ml-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(payment.PaymentUrl)
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(payment.StripeStatus)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 13, Col: 31}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 10, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" hx-target=\"closest tr\" hx-swap=\"outerHTML\">Edit</button></td></tr>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</span></td><td></td><td class=\"flex gap-2\"><button class=\"btn btn-sm btn-outline\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(payment.PaymentUrl)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 16, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" hx-target=\"closest tr\" hx-swap=\"outerHTML\">Edit</button></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -92,51 +105,51 @@ func PaymentForm(payment *PaymentViewModel) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
if templ_7745c5c3_Var5 == nil {
templ_7745c5c3_Var5 = templ.NopComponent
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
if templ_7745c5c3_Var6 == nil {
templ_7745c5c3_Var6 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<tr class=\"hover\"><form hx-put=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(payment.PaymentUrl)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 23, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" id=\"edit-payment\" hx-target=\"closest tr\" hx-swap=\"outerHTML\"><td></td><td></td><td><input class=\"input input-bordered input-sm w-full\" type=\"number\" inputmode=\"decimal\" step=\"0.01\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<tr class=\"hover\"><form hx-put=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(payment.Amount)
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(payment.PaymentUrl)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 32, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 28, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" name=\"amount\" form=\"edit-payment\"></td><td><input class=\"input input-bordered input-sm w-full\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" id=\"edit-payment\" hx-target=\"closest tr\" hx-swap=\"outerHTML\"><td></td><td></td><td><input class=\"input input-bordered input-sm w-full\" type=\"number\" inputmode=\"decimal\" step=\"0.01\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(payment.PaymentMethod)
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(payment.Amount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 40, Col: 34}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 37, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" name=\"paymentMethod\" form=\"edit-payment\"></td><td></td><td><button class=\"btn btn-sm btn-primary\" type=\"submit\" form=\"edit-payment\">Save</button></td></form></tr>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" name=\"amount\" form=\"edit-payment\"></td><td><input class=\"input input-bordered input-sm w-full\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(payment.PaymentMethod)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/payment.templ`, Line: 45, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" name=\"paymentMethod\" form=\"edit-payment\"></td><td></td><td><button class=\"btn btn-sm btn-primary\" type=\"submit\" form=\"edit-payment\">Save</button></td></form></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -160,12 +173,12 @@ func PaymentList(payments []*PaymentViewModel) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
if templ_7745c5c3_Var9 == nil {
templ_7745c5c3_Var9 = templ.NopComponent
templ_7745c5c3_Var10 := templ.GetChildren(ctx)
if templ_7745c5c3_Var10 == nil {
templ_7745c5c3_Var10 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<tbody id=\"payment-lines\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<tbody id=\"payment-lines\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@ -177,7 +190,7 @@ func PaymentList(payments []*PaymentViewModel) templ.Component {
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</tbody>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View file

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package view
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View file

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package view
//lint:file-ignore SA4006 This context is only used if a nested component is present.

View file

@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
// templ: version: v0.3.960
package view
//lint:file-ignore SA4006 This context is only used if a nested component is present.

22
main.go
View file

@ -15,6 +15,7 @@ import (
"github.com/rjNemo/rentease/internal/driver/logger"
"github.com/rjNemo/rentease/internal/driver/parser"
"github.com/rjNemo/rentease/internal/driver/pdf"
stripeclient "github.com/rjNemo/rentease/internal/driver/stripe"
bookingRepo "github.com/rjNemo/rentease/internal/repository/booking"
"github.com/rjNemo/rentease/internal/server"
"github.com/rjNemo/rentease/internal/service/auth"
@ -35,13 +36,13 @@ func main() {
appLogger := logger.New(appConfig.LogLevel)
slog.SetDefault(appLogger)
if err := run(appConfig, appLogger); err != nil {
if err := run(ctx, appConfig, appLogger); err != nil {
appLogger.Error("server exited", slog.Any("error", err))
os.Exit(1)
}
}
func run(appConfig *config.Config, appLogger *slog.Logger) error {
func run(ctx context.Context, appConfig *config.Config, appLogger *slog.Logger) error {
// init sentry
if err := sentry.Init(sentry.ClientOptions{
Dsn: appConfig.SentryDsn,
@ -71,7 +72,21 @@ func run(appConfig *config.Config, appLogger *slog.Logger) error {
parsingClient := parser.NewBookingAgentParser()
bookingService, err := booking.NewService(appLogger, bookingStore, parsingClient, pc)
var stripeClient booking.StripeClient
if appConfig.StripeSecretKey != "" {
opts := []stripeclient.Option{}
if appConfig.StripeConnectAccount != "" {
opts = append(opts, stripeclient.WithAccount(appConfig.StripeConnectAccount))
}
client, err := stripeclient.New(appConfig.StripeSecretKey, opts...)
if err != nil {
return fmt.Errorf("error creating stripe client: %w", err)
}
stripeClient = client
}
bookingService, err := booking.NewService(appLogger, bookingStore, parsingClient, pc, stripeClient)
if err != nil {
return fmt.Errorf("error creating booking service: %w", err)
}
@ -100,6 +115,7 @@ func run(appConfig *config.Config, appLogger *slog.Logger) error {
server.WithDebug(appConfig.Debug),
server.WithSecretKey(appConfig.SecretKey),
server.WithOrigins(origins),
server.WithStripeWebhookSecret(appConfig.StripeWebhookSecret),
)
if err != nil {
return fmt.Errorf("error starting server %w", err)

View file

@ -0,0 +1,234 @@
# Stripe Payment Sync Implementation Plan
## Overview
Enable Stripe-backed payment ingestion so card transactions flow automatically into Rentease while preserving manual cash entry. The system will store Stripe identifiers, support manual/cron backfills, and react to Stripe webhooks for real-time updates and refunds.
## Current State Analysis
- Booking payments are entered manually via `/payments/:id`, persisting `amount` and `payment_method` only; no external identifiers exist (`internal/server/handle_payments.go:16`, `internal/service/booking/payment.go:17`).
- Reports and booking summaries rely on totals from the `payments` table, especially card totals computed via SQL joins (`internal/repository/booking/pg_store.go:65`, `internal/service/booking/report.go:76`).
- No Stripe configuration or drivers; `config.Config` lacks keys, and `main.go` wires only database, parser, pdf clients (`internal/config/config.go:11`, `main.go:52`).
- Routes bundle payment handlers inside auth-protected groups, meaning Stripe webhooks need a dedicated public endpoint (`internal/server/routes.go:8`).
- Cron binary exists for scheduled jobs but has no payment sync job (`pkg/cron/cron.go:9`, `cmd/cron/main.go:13`).
## Desired End State
- `payments` records include Stripe IDs and status for card transactions, allowing idempotent updates and refund tracking.
- Configurable Stripe client (secret key, webhook secret) powers both range-based syncs and webhook ingestion.
- Manual sync endpoint/job triggers Stripe List APIs for a given time window and upserts records; the job can be run via API or mounted in the cron scheduler.
- Public webhook endpoint verifies Stripe signatures and handles `payment_intent.succeeded` and `charge.refunded`, updating local payments accordingly.
- Manual payment modal remains for cash/cheque/transfer; card payments can now be imported automatically.
### Key Discoveries
- Manual payment creation flow is htmx-based and fully server-side (`internal/view/booking_by_id.templ:130`).
- Repository lacks idempotent upsert capability; new helper needed to match on Stripe ID (`internal/repository/booking/pg_store.go:150`).
- `config.Host.PaymentMethods` already enumerates methods shown in UI; Stripe integration must normalise to these values (`internal/config/host.go:41`).
## Out of Scope
- Customer-level Stripe data (customer objects, saved cards).
- UI changes for displaying Stripe-specific metadata beyond status/method.
- Handling non-card Stripe events (disputes, payouts) beyond refunds.
## Implementation Approach
Augment the data model with Stripe identifiers, introduce a Stripe driver/service for syncing, expose both manual and scheduled sync triggers, and add a webhook for near real-time updates. Ensure idempotency via unique Stripe IDs and keep manual cash entry unaffected.
## Phase 1: Data Model & Repository Extensions
### Overview
Add fields to persist Stripe metadata and repository logic for idempotent upserts based on Stripe IDs.
### Changes Required
**File**: `internal/service/booking/models.go`
**Changes**: Add `StripePaymentID *string` and `StripeStatus *string` fields to `Payment` (nullable to support manual entries) with unique index on Stripe ID. Ensure `gorm` tags reflect uniqueness and index requirements.
**File**: `internal/repository/booking/pg_store.go`
**Changes**:
- Update `CreatePayment` to respect new fields.
- Add `UpsertStripePayment` method: find by `stripe_payment_id`, update status/amount/method if exists; otherwise create.
- Update `GetPayment`/`UpdatePayment` logic to preload new fields.
**File**: `main.go`
**Changes**: Trigger `database.Migrate` after model changes to add new columns (AutoMigrate will handle column additions).
**File**: `scripts/payment_migration.sql`
**Changes**: (Optional) Document SQL backfill to populate Stripe IDs later if needed.
```go
// Payment model sketch
type Payment struct {
gorm.Model
BookingID uint `gorm:"not null;index"`
Booking Booking `gorm:"foreignKey:BookingID;constraint:OnDelete:CASCADE"`
Amount float64
PaymentMethod config.PaymentMethod
StripePaymentID *string `gorm:"uniqueIndex"`
StripeStatus *string
}
```
### Success Criteria
#### Automated Verification
- [x] `go test ./internal/service/booking/...`
- [x] `go test ./internal/repository/booking/...`
- [x] `golangci-lint run`
#### Manual Verification
- [ ] Run application; AutoMigrate adds new columns without data loss.
- [ ] Existing manual payment entry still works and shows in UI.
## Phase 2: Stripe Client & Sync Service
### Overview
Introduce Stripe SDK usage, configuration, and a service method to fetch payments for a time range, process them, and upsert into storage. Expose a job callable via API and cron.
### Changes Required
**File**: `go.mod`
**Changes**: Add `github.com/stripe/stripe-go/v83` dependency (or latest version) plus `go mod tidy`.
**File**: `internal/config/config.go`
**Changes**: Add fields `StripeSecretKey`, `StripeWebhookSecret`, optional `StripeConnectAccount` (if needed). Update env tags and documentation.
**File**: `internal/driver/stripe/client.go` (new)
**Changes**: Implement wrapper around Stripe client to list `PaymentIntent` (or `Charge`) objects within a `Created` range, retrieving status, amount, currency, and metadata/booking reference if available.
**File**: `internal/service/booking/payment.go`
**Changes**: Add `SyncStripePayments(ctx, params)` method that:
- Calls driver to list payments between `from`/`to` timestamps.
- Maps Stripe payment method/status to internal values.
- Resolves booking association (e.g., via metadata `booking_id` or manual mapping; define behaviour if not found).
- Calls repository `UpsertStripePayment` (with fallback logging if booking unknown).
**File**: `internal/service/booking/service.go`
**Changes**: Extend interface with `UpsertStripePayment` and register driver in constructor (add parameter for Stripe client).
**File**: `cmd/cron/main.go`
**Changes**: Add job (e.g., `StripePaymentSync`) that invokes service with last-run timestamp stored persistently (file/db) or via parameter. For initial implementation, fetch daily by default.
**File**: `pkg/cron/cron.go`
**Changes**: Optionally enhance scheduler to accept context cancellation or job-specific config; ensure job invocation errors bubble through channels.
**File**: `internal/server/handle_api_sync.go`
**Changes**: Add new handler `handleStripeSync(bs *booking.Service)` bound to `POST /api/stripe/sync` expecting payload (from, to). Validate API key via existing middleware and trigger sync job synchronously (or enqueue background routine).
### Success Criteria
#### Automated Verification
- [x] Unit tests mocking Stripe client to validate `SyncStripePayments` mapping and repository calls.
- [x] Route handler tests confirming 401 protection and sync invocation (can use Echo context test helpers).
- [x] `go test ./cmd/cron` for scheduler wiring.
#### Manual Verification
- [ ] Configure Stripe test key; run API sync for a test window; verify payments appear in booking detail.
- [ ] Cron job logs success when run with shortened schedule in dev.
## Phase 3: Webhook Endpoint & Event Processing
### Overview
Accept Stripe webhook notifications to keep payment status current (successes and refunds) with signature verification.
### Changes Required
**File**: `internal/server/routes.go`
**Changes**: Register `POST /webhooks/stripe` prior to auth middleware so Stripe can reach it; ensure CORS is acceptable.
**File**: `internal/server/handle_stripe_webhook.go` (new)
**Changes**:
- Parse body, verify signature using `StripeWebhookSecret` via Stripe SDK helper.
- Handle `payment_intent.succeeded`: locate booking association (metadata); call service upsert with status `Succeeded`.
- Handle `charge.refunded` (and partial refunds) by updating payment amount/status (set `StripeStatus = "refunded"`, adjust amounts if partial, record refund metadata).
- Return 2xx quickly; log and capture errors.
**File**: `internal/service/booking/payment.go`
**Changes**: Add helper `ApplyStripeEvent` to centralise webhook logic, reuse upsert.
**File**: `internal/config/config.go`
**Changes**: Document required webhook secret env var.
**File**: `main.go`
**Changes**: Inject Stripe client + webhook secret into server (extend `server.New` options or add to booking service).
### Success Criteria
#### Automated Verification
- [x] Unit tests for webhook handler verifying signature failure -> 400, success -> upsert call.
- [x] Service tests ensuring refunds update amounts correctly.
#### Manual Verification
- [ ] Use Stripe CLI `stripe listen` to forward events locally; confirm succeeded and refund events update bookings.
- [ ] Invalid signature events rejected (check logs).
## Phase 4: Admin Controls, UI Confirmation, and Regression Testing
### Overview
Expose manual triggers, ensure UI reflects new automatic payments, and validate the mixed workflow.
### Changes Required
**File**: `internal/view/booking_by_id.templ`
**Changes**: Optionally add indicator for Stripe-synced payments (e.g., status badge) while keeping manual modal intact.
**File**: `internal/server/handle_payments.go`
**Changes**: Ensure manual flow still sets `StripePaymentID`/`StripeStatus` nil. Consider preventing duplicate manual card entries by clarifying methods.
**File**: `docs/` (new doc)
**Changes**: Document Stripe setup, env vars, webhook configuration, manual sync API usage, cron schedule.
**File**: `internal/server/handle_reports.go`
**Changes**: Confirm card totals include Stripe payments; adjust if status filtering needed (e.g., exclude refunded amounts from revenue).
### Success Criteria
#### Automated Verification
- [ ] Snapshot or template tests (if available) confirm payment list includes expected fields.
- [ ] `go test ./internal/view/...` (if templ tests) or run `templ generate` to ensure templates compile.
#### Manual Verification
- [ ] Booking page shows Stripe-imported payment with correct method/status.
- [ ] Manual cash entry unaffected.
- [x] Documentation reviewed for completeness.
## Testing Strategy
- Unit tests mocking Stripe client and repository behaviour to ensure idempotency and status handling.
- Integration test hitting `/api/stripe/sync` with fake Stripe driver to verify end-to-end upsert (could use in-memory driver for tests).
- Webhook handler tests using Stripes official test helpers for signature verification.
- Manual QA in Stripe test mode: create payment intent with metadata linking to booking ID, trigger success/refund, verify UI/report updates.
## Performance Considerations
- Stripe list API pagination: ensure sync job handles pagination and rate limits (backoff on errors).
- Webhook handler should be lightweight; consider asynchronous processing (goroutine or queue) if high volume, though initial volume likely low.
## Migration Notes
- AutoMigrate adds nullable columns; ensure production deploy runs with Stripe env vars set (or feature toggled off until configured).
- Consider backfilling existing Stripe payments manually post-deploy using the new API sync endpoint.
## References
- Research doc: `thoughts/shared/research/2025-10-03-stripe-payment-sync.md`
- Manual payment handler: `internal/server/handle_payments.go:16`
- Booking service payment logic: `internal/service/booking/payment.go:17`
- Reports card totals: `internal/repository/booking/pg_store.go:65`

View file

@ -0,0 +1,90 @@
---
date: 2025-10-03T20:02:01+0200
researcher: Codex
git_commit: ac94faedb0b491d710c36b04ac27b26890f4d062
branch: main
repository: rentease
topic: "Stripe payment ingestion & webhook strategy"
tags: [research, payments, stripe]
status: complete
last_updated: 2025-10-03
last_updated_by: Codex
---
# Research: Stripe payment ingestion & webhook strategy
## Research Question
What changes are needed to fetch Stripe payment data for a given period and replace
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).
## Detailed Findings
### Manual Payment Entry Flow
- Booking detail page exposes a modal that posts amount and method to the server (`internal/view/booking_by_id.templ:52`, `internal/view/booking_by_id.templ:130`).
- `/payments/:id` handler binds the form payload, calls `booking.Service.CreatePayment`, and re-renders the payment list (`internal/server/handle_payments.go:16`, permalink: <https://github.com/rjNemo/rentease/blob/ac94faedb0b491d710c36b04ac27b26890f4d062/internal/server/handle_payments.go#L16-L51>).
- Booking page view model preloads payments to display them alongside line items (`internal/server/handle_bookings.go:130`, permalink: <https://github.com/rjNemo/rentease/blob/ac94faedb0b491d710c36b04ac27b26890f4d062/internal/server/handle_bookings.go#L130-L179>).
### Payment Persistence & Reporting
- `booking.Service.CreatePayment` wraps repository insertions without de-duplication (`internal/service/booking/payment.go:17`, permalink: <https://github.com/rjNemo/rentease/blob/ac94faedb0b491d710c36b04ac27b26890f4d062/internal/service/booking/payment.go#L17-L35>).
- `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`).
### Integration Hooks for Stripe API Fetch
- Configuration currently has no Stripe credentials, so `internal/config/config.go` must gain `StripeSecretKey`, `StripeWebhookSecret`, etc., and `main.go` should wire a Stripe client (`internal/config/config.go:11`, `main.go:52`).
- Extraction logic can mirror existing external service patterns (e.g., the OpenAI parser driver) by adding a `internal/driver/stripe` client and a `booking` service method to request Stripe payments and upsert records (`internal/service/booking/sync.go:7`).
- The cron binary is designed to host scheduled jobs and can trigger periodic Stripe backfills once a job runner function exists (`pkg/cron/cron.go:9`, `cmd/cron/main.go:13`).
### Webhook Considerations
- Routes under `private` require authentication, so Stripe webhooks need a new public endpoint (e.g., `/webhooks/stripe`) mounted before auth middleware in `internal/server/routes.go:8`.
- Handlers should verify Stripe signatures, deserialize events, and, for `payment_intent.succeeded` or `charge.refunded`, call into a service layer that ensures idempotent upserts (likely by storing Stripe IDs in the `payments` table).
- Payment view models already display method strings from config; mapping Stripe payment methods (card, bank redirect) to existing enums may require normalisation in the webhook handler before storing (`internal/view/item_list.templ:19`, `internal/config/host.go:41`).
### External Integration Patterns
- The OpenAI booking parser demonstrates how the app encapsulates third-party clients behind drivers and service methods, including error handling and data persistence hooks (`internal/driver/parser/client.go:17`, `internal/service/booking/sync.go:7`). This pattern can guide the Stripe integration for consistency.
## Code References
- `internal/server/handle_payments.go:16` manual payment creation flow (perm: <https://github.com/rjNemo/rentease/blob/ac94faedb0b491d710c36b04ac27b26890f4d062/internal/server/handle_payments.go#L16-L51>)
- `internal/service/booking/payment.go:17` service layer inserts payments (perm: <https://github.com/rjNemo/rentease/blob/ac94faedb0b491d710c36b04ac27b26890f4d062/internal/service/booking/payment.go#L17-L35>)
- `internal/repository/booking/pg_store.go:65` card totals depend on stored payments (perm: <https://github.com/rjNemo/rentease/blob/ac94faedb0b491d710c36b04ac27b26890f4d062/internal/repository/booking/pg_store.go#L65-L174>)
- `internal/server/routes.go:8` current route layout and auth groups (perm: <https://github.com/rjNemo/rentease/blob/ac94faedb0b491d710c36b04ac27b26890f4d062/internal/server/routes.go#L8-L46>)
- `cmd/cron/main.go:13` cron entrypoint for scheduled jobs (perm: <https://github.com/rjNemo/rentease/blob/ac94faedb0b491d710c36b04ac27b26890f4d062/cmd/cron/main.go#L13-L44>)
## Architecture Insights
Rentease centralises business logic in `booking.Service`, with storage handled through a repository abstraction and UI forms using htmx requests. External integrations live in `internal/driver` and are injected via `main.go`. Payment records are simple and lack idempotency safeguards, so any Stripe sync must extend the schema and service methods to avoid duplicates, enforce foreign keys, and reconcile amounts with existing booking items.
## Historical Context (from ./thoughts/)
- No prior research documents found in `./thoughts` related to payments or Stripe.
## Related Research
- None available.
## Open Questions
- How will Stripe payments be matched to internal bookings (metadata, external IDs, or manual association)?
- Should the `payments` table store Stripe identifiers and status fields to enable idempotent upserts and refunds?
- What is the preferred flow for backfilling historical payments—ad hoc command, scheduled cron job, or admin-triggered API?
- Which Stripe event types should the webhook handle beyond successful payments (refunds, disputes, payouts)?