mirror of
https://github.com/rjNemo/rentease.git
synced 2026-06-06 02:36:49 +00:00
feat/stripe integration (#48)
This commit is contained in:
parent
5d42a5aefe
commit
aa9f46a222
45 changed files with 1619 additions and 129 deletions
44
.air.toml
44
.air.toml
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
19
go.mod
|
|
@ -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
20
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
68
internal/cron/job_stripe_sync.go
Normal file
68
internal/cron/job_stripe_sync.go
Normal 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
|
||||
}
|
||||
169
internal/driver/stripe/client.go
Normal file
169
internal/driver/stripe/client.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
63
internal/server/handle_stripe_sync.go
Normal file
63
internal/server/handle_stripe_sync.go
Normal 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"})
|
||||
}
|
||||
}
|
||||
75
internal/server/handle_stripe_sync_test.go
Normal file
75
internal/server/handle_stripe_sync_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
65
internal/server/handle_stripe_webhook.go
Normal file
65
internal/server/handle_stripe_webhook.go
Normal 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)
|
||||
}
|
||||
}
|
||||
150
internal/server/handle_stripe_webhook_test.go
Normal file
150
internal/server/handle_stripe_webhook_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
70
internal/service/booking/stripe_sync.go
Normal file
70
internal/service/booking/stripe_sync.go
Normal 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")
|
||||
}
|
||||
}
|
||||
165
internal/service/booking/stripe_sync_test.go
Normal file
165
internal/service/booking/stripe_sync_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
93
internal/service/booking/stripe_webhook.go
Normal file
93
internal/service/booking/stripe_webhook.go
Normal 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
|
||||
}
|
||||
85
internal/service/booking/stripe_webhook_test.go
Normal file
85
internal/service/booking/stripe_webhook_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -32,4 +32,5 @@ type PaymentViewModel struct {
|
|||
Amount string
|
||||
PaymentMethod string
|
||||
PaymentUrl string
|
||||
StripeStatus string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
22
main.go
|
|
@ -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)
|
||||
|
|
|
|||
234
thoughts/shared/plans/2025-10-03-stripe-payment-sync.md
Normal file
234
thoughts/shared/plans/2025-10-03-stripe-payment-sync.md
Normal 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 Stripe’s 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`
|
||||
90
thoughts/shared/research/2025-10-03-stripe-payment-sync.md
Normal file
90
thoughts/shared/research/2025-10-03-stripe-payment-sync.md
Normal 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)?
|
||||
Loading…
Reference in a new issue