diff --git a/README.md b/README.md index da2eef5..199bc8b 100644 --- a/README.md +++ b/README.md @@ -87,11 +87,20 @@ APP_OPENAI_MODEL=gpt-5-nano APP_STRIPE_SECRET_KEY= APP_STRIPE_WEBHOOK_SECRET= APP_SENTRY_DSN= +APP_MINIO_ENDPOINT= +APP_MINIO_ACCESS_KEY= +APP_MINIO_SECRET_KEY= +APP_MINIO_BUCKET= +APP_MINIO_USE_SSL=false +APP_INVOICE_SHARE_URL_TTL=168h ``` Stripe values can be left blank for manual payment entry only. When provided, webhooks are received at `/webhooks/stripe` and a manual sync is available at `POST /api/stripe/sync` (API key protected). +MinIO values are optional unless you want invoice generation; when configured, +invoice PDFs are uploaded to the configured bucket and a presigned share URL is +stored in the database. ## Usage diff --git a/go.mod b/go.mod index 4025fa7..2de363b 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/rjNemo/rentease go 1.26 require ( - github.com/a-h/templ v0.3.977 - github.com/getsentry/sentry-go v0.42.0 + github.com/a-h/templ v0.3.1001 + github.com/getsentry/sentry-go v0.43.0 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 github.com/gorilla/sessions v1.4.0 @@ -17,12 +17,27 @@ require ( ) require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-ini/ini v1.67.0 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jung-kurt/gofpdf/v2 v2.17.3 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/minio/minio-go/v7 v7.0.99 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/rs/xid v1.6.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect - golang.org/x/net v0.47.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect ) require ( @@ -35,7 +50,7 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/openai/openai-go v1.12.0 github.com/sethvargo/go-envconfig v1.3.0 - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 ) diff --git a/go.sum b/go.sum index 551cca4..8fd026b 100644 --- a/go.sum +++ b/go.sum @@ -1,22 +1,28 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg= -github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= +github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY= +github.com/a-h/templ v0.3.1001/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.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8= -github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4= +github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= 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/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= @@ -35,10 +41,27 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jung-kurt/gofpdf/v2 v2.17.3 h1:otZXZby2gXJ7uU6pzprXHq/R57lsHLi0WtH79VabWxY= +github.com/jung-kurt/gofpdf/v2 v2.17.3/go.mod h1:Qx8ZNg4cNsO5i6uLDiBngnm+ii/FjtAqjRNO6drsoYU= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.99 h1:2vH/byrwUkIpFQFOilvTfaUpvAX3fEFhEzO+DR3DlCE= +github.com/minio/minio-go/v7 v7.0.99/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw= github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0= github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -47,6 +70,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rjNemo/underscore v0.10.0 h1:f0WTiHXujG9mgbEt51VH06TqLMS5n4EUKtp5wzhBqQM= github.com/rjNemo/underscore v0.10.0/go.mod h1:g2nURJw5INpBuh8ie0AjK1KLnsHSA+Tzc+oXiooq7ms= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/sethvargo/go-envconfig v1.3.0 h1:gJs+Fuv8+f05omTpwWIu6KmuseFAXKrIaOZSh8RMt0U= github.com/sethvargo/go-envconfig v1.3.0/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -67,18 +92,24 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= 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= diff --git a/internal/config/config.go b/internal/config/config.go index c149884..f3f87a4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "context" "fmt" + "time" "github.com/joho/godotenv" "github.com/sethvargo/go-envconfig" @@ -42,6 +43,18 @@ type Config struct { StripeSecretKey string `env:"STRIPE_SECRET_KEY"` // StripeWebhookSecret is the signing secret for validating Stripe webhooks StripeWebhookSecret string `env:"STRIPE_WEBHOOK_SECRET"` + // MinIOEndpoint is the S3-compatible endpoint used to store generated invoices. + MinIOEndpoint string `env:"MINIO_ENDPOINT"` + // MinIOAccessKey is the access key used for MinIO authentication. + MinIOAccessKey string `env:"MINIO_ACCESS_KEY"` + // MinIOSecretKey is the secret key used for MinIO authentication. + MinIOSecretKey string `env:"MINIO_SECRET_KEY"` + // MinIOBucket is the bucket used to persist invoice PDFs. + MinIOBucket string `env:"MINIO_BUCKET"` + // MinIOUseSSL toggles HTTPS for the MinIO connection. + MinIOUseSSL bool `env:"MINIO_USE_SSL, default=false"` + // InvoiceShareURLTTL controls how long invoice share links remain valid. + InvoiceShareURLTTL time.Duration `env:"INVOICE_SHARE_URL_TTL, default=168h"` } // New creates a [Config] struct. It first parses the environment variables. You can use a .env file. diff --git a/internal/cron/job_report.go b/internal/cron/job_report.go index 537f107..96713f6 100644 --- a/internal/cron/job_report.go +++ b/internal/cron/job_report.go @@ -30,7 +30,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, 0) if err != nil { return fmt.Errorf("error creating booking service: %w", err) } diff --git a/internal/driver/pdf/html.go b/internal/driver/pdf/html.go index 7ad3cc5..6d11b2a 100644 --- a/internal/driver/pdf/html.go +++ b/internal/driver/pdf/html.go @@ -7,6 +7,8 @@ import ( "os" "text/template" + "github.com/jung-kurt/gofpdf/v2" + "github.com/rjNemo/rentease/assets" "github.com/rjNemo/rentease/internal/service/booking" ) @@ -17,24 +19,83 @@ func NewPdfClient() (*HTMLPdfClient, error) { return &HTMLPdfClient{}, nil } -func (pc *HTMLPdfClient) BuildInvoice(data booking.Invoice) (string, error) { - tmpl, err := template.ParseFS(assets.Static, "assets/html/invoice.html") - if err != nil { - return "", fmt.Errorf("error parsing template: %v", err) +func (pc *HTMLPdfClient) BuildInvoice(data booking.Invoice) (*booking.GeneratedFile, error) { + pdf := gofpdf.New("P", "mm", "A4", "") + pdf.SetMargins(16, 16, 16) + pdf.SetAutoPageBreak(true, 16) + pdf.AddPage() + + tr := pdf.UnicodeTranslatorFromDescriptor("") + writeLine := func(fontStyle string, fontSize float64, text string) { + pdf.SetFont("Helvetica", fontStyle, fontSize) + pdf.CellFormat(0, 7, tr(text), "", 1, "", false, 0, "") } - // Create a buffer to hold the rendered HTML. + writeLine("B", 20, fmt.Sprintf("Invoice %s", data.ID)) + pdf.Ln(2) + writeLine("", 11, data.Host.Name) + writeLine("", 11, data.Host.Address) + writeLine("", 11, fmt.Sprintf("%s %s", data.Host.ZipCode, data.Host.City)) + writeLine("", 11, data.Host.PhoneNumber) + writeLine("", 11, data.Host.Email) + + pdf.Ln(4) + writeLine("B", 14, "Guest") + writeLine("", 11, data.Name) + if data.PhoneNumber != "" { + writeLine("", 11, data.PhoneNumber) + } + writeLine("", 11, fmt.Sprintf("Stay: %s - %s", data.From, data.To)) + writeLine("", 11, fmt.Sprintf("Platform: %s", data.Platform)) + writeLine("", 11, fmt.Sprintf("Guests: %d", data.CustomersNumber)) + + pdf.Ln(4) + writeLine("B", 14, "Charges") + pdf.SetFont("Helvetica", "B", 11) + pdf.CellFormat(90, 8, tr("Item"), "1", 0, "", false, 0, "") + pdf.CellFormat(25, 8, tr("Qty"), "1", 0, "C", false, 0, "") + pdf.CellFormat(35, 8, tr("Unit"), "1", 0, "R", false, 0, "") + pdf.CellFormat(25, 8, tr("Total"), "1", 1, "R", false, 0, "") + pdf.SetFont("Helvetica", "", 11) + for _, line := range data.Lines { + pdf.CellFormat(90, 8, tr(line.Name), "1", 0, "", false, 0, "") + pdf.CellFormat(25, 8, fmt.Sprintf("%d", line.Quantity), "1", 0, "C", false, 0, "") + pdf.CellFormat(35, 8, fmt.Sprintf("%s EUR", line.Price), "1", 0, "R", false, 0, "") + pdf.CellFormat(25, 8, fmt.Sprintf("%s EUR", line.Total), "1", 1, "R", false, 0, "") + } + + pdf.Ln(4) + writeLine("B", 14, "Payments") + if len(data.Payments) == 0 { + writeLine("", 11, "No payment recorded") + } else { + pdf.SetFont("Helvetica", "B", 11) + pdf.CellFormat(45, 8, tr("Date"), "1", 0, "", false, 0, "") + pdf.CellFormat(80, 8, tr("Method"), "1", 0, "", false, 0, "") + pdf.CellFormat(50, 8, tr("Amount"), "1", 1, "R", false, 0, "") + pdf.SetFont("Helvetica", "", 11) + for _, payment := range data.Payments { + pdf.CellFormat(45, 8, tr(payment.Date), "1", 0, "", false, 0, "") + pdf.CellFormat(80, 8, tr(payment.Method), "1", 0, "", false, 0, "") + pdf.CellFormat(50, 8, fmt.Sprintf("%s EUR", payment.Amount), "1", 1, "R", false, 0, "") + } + } + + pdf.Ln(4) + writeLine("B", 12, fmt.Sprintf("Total: %s EUR", data.Total)) + writeLine("", 12, fmt.Sprintf("Paid: %s EUR", data.AmountPaid)) + writeLine("B", 12, fmt.Sprintf("Balance due: %s EUR", data.BalanceDue)) + var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return "", fmt.Errorf("error executing template: %v", err) + if err := pdf.Output(&buf); err != nil { + return nil, fmt.Errorf("error writing PDF file: %v", err) } - outputPath := fmt.Sprintf("%s.html", data.ID) - if err := os.WriteFile(outputPath, buf.Bytes(), 0o644); err != nil { - return "", fmt.Errorf("error writing HTML file: %v", err) - } - - return outputPath, nil + return &booking.GeneratedFile{ + Name: fmt.Sprintf("%s.pdf", data.ID), + ContentType: "application/pdf", + Data: buf.Bytes(), + }, nil } func (pc *HTMLPdfClient) BuildReport(report booking.ReportData, period string, month int, year int) (string, error) { diff --git a/internal/driver/pdf/html_test.go b/internal/driver/pdf/html_test.go new file mode 100644 index 0000000..b2de871 --- /dev/null +++ b/internal/driver/pdf/html_test.go @@ -0,0 +1,54 @@ +package pdf + +import ( + "bytes" + "testing" + + "github.com/rjNemo/rentease/internal/config" + "github.com/rjNemo/rentease/internal/service/booking" +) + +func TestBuildInvoice_ReturnsPDFBytes(t *testing.T) { + client, err := NewPdfClient() + if err != nil { + t.Fatalf("unexpected error creating pdf client: %v", err) + } + + file, err := client.BuildInvoice(booking.Invoice{ + Host: *config.NewHost(), + Name: "Jane Doe", + PhoneNumber: "+590690441530", + CustomersNumber: 2, + Platform: "Booking.com", + ID: "VFNI0240", + From: "20/03/2026", + To: "24/03/2026", + Total: "250.00", + AmountPaid: "100.00", + BalanceDue: "150.00", + Lines: []booking.InvoiceLine{{ + Name: "T2", + Quantity: 4, + Price: "59.00", + Total: "236.00", + }}, + Payments: []booking.PaymentLine{{ + Date: "20/03/2026", + Method: "Card", + Amount: "100.00", + }}, + }) + if err != nil { + t.Fatalf("unexpected error building invoice PDF: %v", err) + } + + if file.ContentType != "application/pdf" { + t.Fatalf("expected PDF content type, got %q", file.ContentType) + } + if file.Name != "VFNI0240.pdf" { + t.Fatalf("expected invoice file name, got %q", file.Name) + } + if !bytes.HasPrefix(file.Data, []byte("%PDF")) { + t.Fatalf("expected PDF bytes, got %q", string(file.Data[:4])) + } +} diff --git a/internal/driver/storage/minio.go b/internal/driver/storage/minio.go new file mode 100644 index 0000000..141b1a3 --- /dev/null +++ b/internal/driver/storage/minio.go @@ -0,0 +1,101 @@ +package storage + +import ( + "bytes" + "context" + "fmt" + "time" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" + + "github.com/rjNemo/rentease/internal/config" + "github.com/rjNemo/rentease/internal/service/booking" +) + +type MinioInvoiceStorage struct { + bucket string + client *minio.Client +} + +func NewMinioInvoiceStorage(cfg *config.Config) (*MinioInvoiceStorage, error) { + if cfg.MinIOEndpoint == "" && cfg.MinIOAccessKey == "" && cfg.MinIOSecretKey == "" && cfg.MinIOBucket == "" { + return nil, nil + } + + switch { + case cfg.MinIOEndpoint == "": + return nil, fmt.Errorf("minio endpoint is required") + case cfg.MinIOAccessKey == "": + return nil, fmt.Errorf("minio access key is required") + case cfg.MinIOSecretKey == "": + return nil, fmt.Errorf("minio secret key is required") + case cfg.MinIOBucket == "": + return nil, fmt.Errorf("minio bucket is required") + } + + client, err := minio.New(cfg.MinIOEndpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.MinIOAccessKey, cfg.MinIOSecretKey, ""), + Secure: cfg.MinIOUseSSL, + }) + if err != nil { + return nil, fmt.Errorf("create minio client: %w", err) + } + + return &MinioInvoiceStorage{ + bucket: cfg.MinIOBucket, + client: client, + }, nil +} + +func (s *MinioInvoiceStorage) StoreInvoice( + ctx context.Context, + objectKey string, + file booking.GeneratedFile, + shareURLTTL time.Duration, +) (*booking.StoredInvoiceFile, error) { + if err := s.ensureBucket(ctx); err != nil { + return nil, err + } + + _, err := s.client.PutObject( + ctx, + s.bucket, + objectKey, + bytes.NewReader(file.Data), + int64(len(file.Data)), + minio.PutObjectOptions{ + ContentType: file.ContentType, + }, + ) + if err != nil { + return nil, fmt.Errorf("upload object %q: %w", objectKey, err) + } + + shareURL, err := s.client.PresignedGetObject(ctx, s.bucket, objectKey, shareURLTTL, nil) + if err != nil { + return nil, fmt.Errorf("presign object %q: %w", objectKey, err) + } + + return &booking.StoredInvoiceFile{ + ObjectKey: objectKey, + ShareURL: shareURL.String(), + ShareURLExpiresAt: time.Now().UTC().Add(shareURLTTL), + }, nil +} + +func (s *MinioInvoiceStorage) ensureBucket(ctx context.Context) error { + exists, err := s.client.BucketExists(ctx, s.bucket) + if err != nil { + return fmt.Errorf("check bucket %q: %w", s.bucket, err) + } + if exists { + return nil + } + + if err := s.client.MakeBucket(ctx, s.bucket, minio.MakeBucketOptions{}); err != nil { + return fmt.Errorf("create bucket %q: %w", s.bucket, err) + } + + return nil +} diff --git a/internal/repository/booking/pg_store.go b/internal/repository/booking/pg_store.go index 9483440..5430335 100644 --- a/internal/repository/booking/pg_store.go +++ b/internal/repository/booking/pg_store.go @@ -112,6 +112,24 @@ func (ps *PgStore) Cancel(id int) error { return ps.db.Model(&booking.Booking{}).Where("id = ?", id).Update("canceled", true).Error } +func (ps *PgStore) UpsertInvoiceDocument(doc *booking.InvoiceDocument) error { + existing := new(booking.InvoiceDocument) + err := ps.db.Where("booking_id = ?", doc.BookingID).First(existing).Error + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + return ps.db.Create(doc).Error + case err != nil: + return fmt.Errorf("failed to lookup invoice document: %w", err) + default: + existing.ObjectKey = doc.ObjectKey + existing.FileName = doc.FileName + existing.ContentType = doc.ContentType + existing.ShareURL = doc.ShareURL + existing.ShareURLExpiresAt = doc.ShareURLExpiresAt + return ps.db.Save(existing).Error + } +} + // Item methods func (ps *PgStore) CreateItem(i *booking.Item) error { return ps.db.Create(i).Error diff --git a/internal/server/handle_pdf.go b/internal/server/handle_pdf.go index 0d11dc9..71439b1 100644 --- a/internal/server/handle_pdf.go +++ b/internal/server/handle_pdf.go @@ -1,6 +1,7 @@ package server import ( + "errors" "fmt" "log/slog" "net/http" @@ -27,14 +28,18 @@ func handlePdfCreateInvoice(bs *booking.Service, hc *config.Host) http.HandlerFu return } - filePath, err := bs.BuildInvoice(b, hc) + doc, err := bs.CreateInvoice(r.Context(), b, hc) if err != nil { + if errors.Is(err, booking.ErrInvoiceStorageNotConfigured) { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } slog.Error("failed to build invoice", slog.Any("error", err)) http.Error(w, err.Error(), http.StatusInternalServerError) return } - http.ServeFile(w, r, filePath) + http.Redirect(w, r, doc.ShareURL, http.StatusTemporaryRedirect) } } diff --git a/internal/service/booking/errors.go b/internal/service/booking/errors.go index 8f32598..7075476 100644 --- a/internal/service/booking/errors.go +++ b/internal/service/booking/errors.go @@ -4,3 +4,6 @@ import "errors" // ErrBookingNotFound indicates that a booking could not be retrieved from the datastore. var ErrBookingNotFound = errors.New("booking not found") + +// ErrInvoiceStorageNotConfigured indicates that invoice storage is unavailable. +var ErrInvoiceStorageNotConfigured = errors.New("invoice storage not configured") diff --git a/internal/service/booking/invoice_document.go b/internal/service/booking/invoice_document.go new file mode 100644 index 0000000..bb1c843 --- /dev/null +++ b/internal/service/booking/invoice_document.go @@ -0,0 +1,30 @@ +package booking + +import ( + "time" + + "gorm.io/gorm" +) + +type GeneratedFile struct { + Name string + ContentType string + Data []byte +} + +type StoredInvoiceFile struct { + ObjectKey string + ShareURL string + ShareURLExpiresAt time.Time +} + +type InvoiceDocument struct { + gorm.Model + BookingID int `gorm:"not null;uniqueIndex"` + ID int + ObjectKey string `gorm:"not null"` + FileName string `gorm:"not null"` + ContentType string `gorm:"not null"` + ShareURL string `gorm:"type:text;not null"` + ShareURLExpiresAt time.Time `gorm:"not null"` +} diff --git a/internal/service/booking/invoice_service_test.go b/internal/service/booking/invoice_service_test.go new file mode 100644 index 0000000..94bff54 --- /dev/null +++ b/internal/service/booking/invoice_service_test.go @@ -0,0 +1,126 @@ +package booking + +import ( + "context" + "errors" + "log/slog" + "testing" + "time" + + "github.com/rjNemo/rentease/internal/config" +) + +func TestCreateInvoice_StoresInvoicePDFAndPersistsShareURL(t *testing.T) { + store := newStubStore() + pdfClient := stubInvoicePDF{ + file: &GeneratedFile{ + Name: "VFNI0240.pdf", + ContentType: "application/pdf", + Data: []byte("%PDF-1.4 test invoice"), + }, + } + storage := &stubInvoiceStorage{ + stored: &StoredInvoiceFile{ + ShareURL: "https://minio.local/invoices/1/VFNI0240.pdf?X-Amz-Signature=test", + ShareURLExpiresAt: time.Date(2026, time.March, 21, 10, 0, 0, 0, time.UTC), + }, + } + + svc, err := NewService(slog.Default(), store, nil, pdfClient, storage, 2*time.Hour) + if err != nil { + t.Fatalf("unexpected error creating service: %v", err) + } + + booking := NewBooking( + time.Date(2026, time.March, 20, 0, 0, 0, 0, time.UTC), + time.Date(2026, time.March, 24, 0, 0, 0, 0, time.UTC), + "Jane Doe", + "+590690441530", + "jane@example.com", + "Booking", + 2, + 15, + nil, + ).WithID(1) + + doc, err := svc.CreateInvoice(context.Background(), booking, config.NewHost()) + if err != nil { + t.Fatalf("unexpected error creating invoice: %v", err) + } + + if storage.objectKey != "invoices/1/VFNI0240.pdf" { + t.Fatalf("expected invoice object key to include booking folder, got %q", storage.objectKey) + } + if storage.shareURLTTL != 2*time.Hour { + t.Fatalf("expected configured share URL TTL to be forwarded, got %s", storage.shareURLTTL) + } + if string(storage.file.Data) != "%PDF-1.4 test invoice" { + t.Fatalf("expected generated PDF bytes to be uploaded, got %q", string(storage.file.Data)) + } + if doc.ShareURL != storage.stored.ShareURL { + t.Fatalf("expected returned document share URL %q, got %q", storage.stored.ShareURL, doc.ShareURL) + } + if store.invoiceDocument == nil { + t.Fatal("expected invoice document to be persisted") + } + if store.invoiceDocument.ObjectKey != "invoices/1/VFNI0240.pdf" { + t.Fatalf("expected persisted object key, got %q", store.invoiceDocument.ObjectKey) + } + if store.invoiceDocument.ShareURL != storage.stored.ShareURL { + t.Fatalf("expected persisted share URL %q, got %q", storage.stored.ShareURL, store.invoiceDocument.ShareURL) + } +} + +func TestCreateInvoice_ReturnsConfigurationErrorWhenStorageIsMissing(t *testing.T) { + svc, err := NewService(slog.Default(), newStubStore(), nil, noopPDF{}, nil, time.Hour) + if err != nil { + t.Fatalf("unexpected error creating service: %v", err) + } + + booking := NewBooking(time.Now(), time.Now().Add(24*time.Hour), "Jane Doe", "", "", "Booking", 1, 0, nil).WithID(1) + + _, err = svc.CreateInvoice(context.Background(), booking, config.NewHost()) + if !errors.Is(err, ErrInvoiceStorageNotConfigured) { + t.Fatalf("expected ErrInvoiceStorageNotConfigured, got %v", err) + } +} + +type stubInvoicePDF struct { + file *GeneratedFile + err error +} + +func (s stubInvoicePDF) BuildInvoice(invoice Invoice) (*GeneratedFile, error) { + if s.err != nil { + return nil, s.err + } + return s.file, nil +} + +func (stubInvoicePDF) BuildReport(report ReportData, period string, month, year int) (string, error) { + return "", nil +} + +type stubInvoiceStorage struct { + file GeneratedFile + objectKey string + shareURLTTL time.Duration + stored *StoredInvoiceFile + err error +} + +func (s *stubInvoiceStorage) StoreInvoice(ctx context.Context, objectKey string, file GeneratedFile, shareURLTTL time.Duration) (*StoredInvoiceFile, error) { + if s.err != nil { + return nil, s.err + } + + s.objectKey = objectKey + s.file = file + s.shareURLTTL = shareURLTTL + + stored := *s.stored + if stored.ObjectKey == "" { + stored.ObjectKey = objectKey + } + return &stored, nil +} diff --git a/internal/service/booking/service.go b/internal/service/booking/service.go index e16fc61..bbb3b80 100644 --- a/internal/service/booking/service.go +++ b/internal/service/booking/service.go @@ -1,8 +1,11 @@ package booking import ( + "context" "errors" + "fmt" "log/slog" + "path" "time" "gorm.io/gorm" @@ -19,6 +22,7 @@ type Store interface { Create(b *Booking) error Update(b *Booking) error Cancel(id int) error + UpsertInvoiceDocument(doc *InvoiceDocument) error // Item methods CreateItem(i *Item) error @@ -28,10 +32,14 @@ type Store interface { } type PdfClient interface { - BuildInvoice(invoice Invoice) (string, error) + BuildInvoice(invoice Invoice) (*GeneratedFile, error) BuildReport(report ReportData, period string, month, year int) (string, error) } +type InvoiceStorage interface { + StoreInvoice(ctx context.Context, objectKey string, file GeneratedFile, shareURLTTL time.Duration) (*StoredInvoiceFile, error) +} + type CalendarClient interface { Create(calendarID, name, description string, from, to time.Time) error } @@ -41,23 +49,37 @@ type parserClient interface { } type Service struct { - store Store - parser parserClient - pdf PdfClient - logger *slog.Logger + store Store + parser parserClient + pdf PdfClient + storage InvoiceStorage + logger *slog.Logger + invoiceShareURLTTL time.Duration } -func NewService(logger *slog.Logger, store Store, parser parserClient, pdf PdfClient) (*Service, error) { +func NewService( + logger *slog.Logger, + store Store, + parser parserClient, + pdf PdfClient, + storage InvoiceStorage, + invoiceShareURLTTL time.Duration, +) (*Service, error) { svcLogger := logger if svcLogger == nil { svcLogger = slog.Default() } + if invoiceShareURLTTL <= 0 { + invoiceShareURLTTL = 7 * 24 * time.Hour + } return &Service{ - logger: svcLogger.With(slog.String("component", "booking_service")), - store: store, - parser: parser, - pdf: pdf, + logger: svcLogger.With(slog.String("component", "booking_service")), + store: store, + parser: parser, + pdf: pdf, + storage: storage, + invoiceShareURLTTL: invoiceShareURLTTL, }, nil } @@ -111,6 +133,33 @@ func (bs Service) Cancel(id int) { } } -func (bs Service) BuildInvoice(b *Booking, hc *config.Host) (string, error) { - return bs.pdf.BuildInvoice(b.ToInvoice(hc)) +func (bs Service) CreateInvoice(ctx context.Context, b *Booking, hc *config.Host) (*InvoiceDocument, error) { + if bs.storage == nil { + return nil, ErrInvoiceStorageNotConfigured + } + + file, err := bs.pdf.BuildInvoice(b.ToInvoice(hc)) + if err != nil { + return nil, fmt.Errorf("build invoice pdf: %w", err) + } + + objectKey := path.Join("invoices", fmt.Sprintf("%d", b.ID), file.Name) + stored, err := bs.storage.StoreInvoice(ctx, objectKey, *file, bs.invoiceShareURLTTL) + if err != nil { + return nil, fmt.Errorf("store invoice pdf: %w", err) + } + + doc := &InvoiceDocument{ + BookingID: b.ID, + ObjectKey: stored.ObjectKey, + FileName: file.Name, + ContentType: file.ContentType, + ShareURL: stored.ShareURL, + ShareURLExpiresAt: stored.ShareURLExpiresAt, + } + if err := bs.store.UpsertInvoiceDocument(doc); err != nil { + return nil, fmt.Errorf("persist invoice document: %w", err) + } + + return doc, nil } diff --git a/internal/service/booking/sync_test.go b/internal/service/booking/sync_test.go index 2afe214..fe5e691 100644 --- a/internal/service/booking/sync_test.go +++ b/internal/service/booking/sync_test.go @@ -30,7 +30,7 @@ func TestParseFromAPI_CreatesItemsForBookingSync(t *testing.T) { }, } - svc, err := NewService(slog.Default(), store, parser, noopPDF{}) + svc, err := NewService(slog.Default(), store, parser, noopPDF{}, nil, 0) if err != nil { t.Fatalf("unexpected error creating service: %v", err) } @@ -77,7 +77,7 @@ func TestParseFromAPI_DoesNotCreateTaxesForNonBookingPlatform(t *testing.T) { }, } - svc, err := NewService(slog.Default(), store, parser, noopPDF{}) + svc, err := NewService(slog.Default(), store, parser, noopPDF{}, nil, 0) if err != nil { t.Fatalf("unexpected error creating service: %v", err) } @@ -114,7 +114,7 @@ func TestParseFromAPI_CreatesFallbackItemWhenUnknown(t *testing.T) { }, } - svc, err := NewService(slog.Default(), store, parser, noopPDF{}) + svc, err := NewService(slog.Default(), store, parser, noopPDF{}, nil, 0) if err != nil { t.Fatalf("unexpected error creating service: %v", err) } @@ -152,7 +152,7 @@ func TestParseFromAPI_NormalizesVerboseItemNameToHostItem(t *testing.T) { }, } - svc, err := NewService(slog.Default(), store, parser, noopPDF{}) + svc, err := NewService(slog.Default(), store, parser, noopPDF{}, nil, 0) if err != nil { t.Fatalf("unexpected error creating service: %v", err) } @@ -185,8 +185,10 @@ func (p stubParser) Parse(rawContent string) (*Booking, error) { } type stubStore struct { - bookings []*Booking - items []*Item + bookings []*Booking + items []*Item + invoiceDocument *InvoiceDocument + upsertInvoiceErr error } func newStubStore() *stubStore { @@ -230,6 +232,16 @@ func (s *stubStore) Cancel(id int) error { return nil } +func (s *stubStore) UpsertInvoiceDocument(doc *InvoiceDocument) error { + if s.upsertInvoiceErr != nil { + return s.upsertInvoiceErr + } + + cp := *doc + s.invoiceDocument = &cp + return nil +} + func (s *stubStore) CreateItem(i *Item) error { i.ID = len(s.items) + 1 s.items = append(s.items, i) @@ -250,8 +262,8 @@ func (s *stubStore) UpdateItem(id int, item string, paymentMethod string, paymen type noopPDF struct{} -func (noopPDF) BuildInvoice(invoice Invoice) (string, error) { - return "", nil +func (noopPDF) BuildInvoice(invoice Invoice) (*GeneratedFile, error) { + return &GeneratedFile{Name: "invoice.pdf", ContentType: "application/pdf"}, nil } func (noopPDF) BuildReport(report ReportData, period string, month, year int) (string, error) { diff --git a/main.go b/main.go index 90cdb80..8fa1c84 100644 --- a/main.go +++ b/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" + "github.com/rjNemo/rentease/internal/driver/storage" stripeclient "github.com/rjNemo/rentease/internal/driver/stripe" bookingRepo "github.com/rjNemo/rentease/internal/repository/booking" "github.com/rjNemo/rentease/internal/server" @@ -59,7 +60,7 @@ func run(ctx context.Context, appConfig *config.Config, appLogger *slog.Logger) return fmt.Errorf("error connecting to the database %w", err) } - if err = database.Migrate(db, &booking.Booking{}, &booking.Item{}, &booking.Payment{}); err != nil { + if err = database.Migrate(db, &booking.Booking{}, &booking.Item{}, &booking.Payment{}, &booking.InvoiceDocument{}); err != nil { return fmt.Errorf("error migrating the database %w", err) } @@ -71,6 +72,11 @@ func run(ctx context.Context, appConfig *config.Config, appLogger *slog.Logger) return fmt.Errorf("error starting pdf client %w", err) } + invoiceStorage, err := storage.NewMinioInvoiceStorage(appConfig) + if err != nil { + return fmt.Errorf("error starting invoice storage %w", err) + } + parsingClient := parser.NewBookingAgentParser(appConfig.OpenAIModel) var stripeClient payment.StripeClient @@ -84,7 +90,7 @@ func run(ctx context.Context, appConfig *config.Config, appLogger *slog.Logger) stripeClient = client } - bookingService, err := booking.NewService(appLogger, bookingStore, parsingClient, pc) + bookingService, err := booking.NewService(appLogger, bookingStore, parsingClient, pc, invoiceStorage, appConfig.InvoiceShareURLTTL) if err != nil { return fmt.Errorf("error creating booking service: %w", err) }